Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feedback-preference-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": minor
---

Read account feedback preferences and stop automatic retries when feedback is not enabled for the account.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,17 @@ Agents should check the saved account preference before sending feedback on thei
own. If enabled, they may send without asking again, subject to their host's
controls. If off, unset or unavailable, show the proposed report and ask first.
Approval for one report does not change the preference. Never enable it
automatically. This CLI version has no preference-read command, so ask first
unless an authenticated preference read is available through another supported
tool. An explicit user request to send the report needs no additional preference
automatically. Use `nansen feedback preferences --json` for a fresh account read
before each new automatic report. Without `--json`, it prints a human summary.
Reads use no credits or report quota. Change the lasting choice in Superapp settings.
An explicit user request to send the report needs no additional preference
check. The submit command sends the report without an interactive prompt.

HTTP 403 with `reason: feedback_not_enabled` means feedback is not enabled for
this account. Stop automatic retries. Approval or an enabled preference does
not override rollout. Keep the ID and unchanged report for recovery; an earlier
uncertain attempt remains uncertain. Never switch accounts or pay to bypass denial.

## Auth

Plain `nansen login` requests fresh browser approval and saves a session automatically. No API-key copying or PEM setup is needed. Selected sessions renew automatically; rejected or uncertain renewal requires fresh login.
Expand Down
17 changes: 12 additions & 5 deletions skills/nansen-agent-guide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,16 @@ Browser nansen:api grants have API-key-equivalent account permissions, subject t

When a Nansen task exposes a useful product gap, draft a concise report without
secrets or unnecessary conversation details. Before sending on your own, read
the current account's saved feedback preference through an available
authenticated preference reader. If enabled, you may send without asking again,
the current account's saved feedback preference with
`nansen feedback preferences --json`. Use a fresh read for each new report. If enabled, you may send without asking again,
subject to the host's controls. If off, unset or unavailable, show the proposed
report and ask the user. If they decline or you cannot ask, do not send.

This CLI version does not include a preference-read command. If no supported
preference reader is available, ask first. An explicit user request or approval
If the preference read fails, ask first. An explicit user request or approval
permits that report; it does not change the saved preference. Never enable the
preference automatically or add approval fields to the report.
preference automatically or add approval fields to the report. Direct users to
Superapp settings for a lasting choice. Host or local-deny controls still apply
to an enabled preference and to one-report approval.

After the user requests the report or the preference permits it, submit with
`nansen feedback submit --file feedback.json --submission-id <new-id> --json`.
Expand All @@ -97,3 +98,9 @@ do not keep retrying or ask for approval again in the same session. Preserve
the ID and unchanged report for a later retry. If this CLI version lacks the
feedback command, report that it is unavailable and stop. Accepted-ID replays
and receipt reads remain available during a pause.

A 403 with `reason: feedback_not_enabled` means feedback is not enabled for
this account. Stop automatic retries and tell the user. Approval and an enabled
preference do not override rollout. Keep the saved ID and unchanged report.
An earlier uncertain attempt remains uncertain; recover it with the same ID
after availability is restored. Never switch accounts or pay to bypass denial.
16 changes: 16 additions & 0 deletions src/__tests__/feedback-recovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,19 @@ it.skipIf(process.platform === 'win32').each(['json', 'human'])('reports quota r
expect(new Set(posts(h).map(req => req.headers['idempotency-key']))).toEqual(new Set([saved.submission_id]));
expect(h.read()).toMatchObject({ state: 'confirmed', submission_id: saved.submission_id });
});

it('preserves the saved ID on rollout denial and distinguishes prior uncertainty', async () => {
for (const prior of [false, true]) {
const h = await harness();
if (prior) await killedPending(h);
h.post((_req, res) => reply(res, 403, { code: 'forbidden', reason: 'feedback_not_enabled', message: 'Not enabled for account.' }));
const result = await h.run(prior ? ['resume', '--json'] : h.submit);
const error = JSON.parse(result.stdout);
expect(error.details).toMatchObject({ reason: 'feedback_not_enabled', outcome: prior ? 'unknown' : 'rejected', submission_id: h.read().submission_id });
expect(result.stdout).toContain('Stop automatic retries'); expect(error.details.retry_command).toBeUndefined();
const saved = h.read(); expect(saved.state).toBe('pending'); expect(saved.body).toBe(h.body);
h.post((req, res) => reply(res, 200, receipt(req, { replayed: prior })));
expect((await h.run(['resume', '--json'])).code).toBe(0);
expect(h.read().submission_id).toBe(saved.submission_id);
}
});
77 changes: 77 additions & 0 deletions src/__tests__/feedback.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,80 @@ it('uses the saved browser account on retries and preserves structured quota err
expect(fetch.mock.calls[0][1].body).toBe(fetch.mock.calls[1][1].body);
for (const secret of [bundle.accessToken, bundle.refreshToken, bundle.privateJwk.d]) expect(JSON.stringify(error)).not.toContain(secret);
});

const preference = { automatic_reporting: 'enabled', configured: true, version: 1, updated_at: '2026-10-15T12:00:00Z', can_manage: true };
it('reads fresh account preferences in human and JSON output without caching, payments, or submission', async () => {
const disabled = { ...preference, automatic_reporting: 'disabled', version: 2 };
const h = await harness([{ status: 200, body: preference }, { status: 200, body: disabled }]);
const first = await h.run(['preferences', '--json', '--cache', '--paginate']);
expect(first.code).toBe(0); expect(JSON.parse(first.stdout).data).toEqual(preference);
const next = await h.run(['preferences']);
expect(next.code).toBe(0); expect(next.stdout).toContain('disabled'); expect(next.stdout).toContain('ask before sending');
expect(h.requests).toHaveLength(2);
for (const req of h.requests) {
expect(req).toMatchObject({ path: '/api/v1/feedback/preferences', method: 'GET', body: '' });
expect(req.headers.apikey).toBe('synthetic-key'); expect(req.headers['idempotency-key']).toBeUndefined();
expect(req.headers['payment-signature']).toBeUndefined();
}
expect(fs.existsSync(path.join(h.home, '.nansen/cache'))).toBe(false);
expect(fs.existsSync(path.join(h.home, '.nansen/feedback'))).toBe(false);
});
it.each([
{ automatic_reporting: 'disabled', configured: false, version: 0, updated_at: null, can_manage: false },
{ ...preference, automatic_reporting: 'disabled' },
])('reports disabled or unconfigured preferences without submitting', async body => {
const h = await harness([{ status: 200, body }]);
const r = await h.run(['preferences']); expect(r.code).toBe(0); expect(r.stdout).toContain('ask before sending');
expect(h.requests).toHaveLength(1);
});
it.each([
{ status: 200, body: {} }, { status: 200, body: { ...preference, configured: false } },
{ status: 200, body: { ...preference, automatic_reporting: 'unknown' } },
{ status: 503, body: { code: 'upstream_unavailable', reason: 'preference_store_unavailable' } },
{ status: 404, body: { code: 'not_found' } },
])('fails closed on unavailable or invalid preference $status', async response => {
const h = await harness([response]); const r = await h.run(['preferences', '--json', '--no-retry']);
expect(r.code).toBe(1); expect(JSON.parse(r.stdout).success).toBe(false);
expect(h.requests).toHaveLength(1); expect(h.requests[0].method).toBe('GET');
});
it.each([['preferences', 'enable'], ['preferences', '--file', 'report.json'], ['preferences', '--new']])('rejects preference mutations %j before sending', async args => {
const h = await harness([{ status: 200, body: preference }]);
expect((await h.run([...args, '--json'])).code).toBe(1); expect(h.requests).toHaveLength(0);
});
it('stops on account rollout denial and preserves an earlier lost-response outcome', async () => {
const denial = { status: 403, body: { code: 'forbidden', reason: 'feedback_not_enabled', message: 'Feedback is not enabled.' } };
for (const lost of [false, true]) {
const h = await harness(lost ? ['disconnect', denial] : [denial]);
const r = await h.run([...h.submit, '--json']); const error = JSON.parse(r.stdout);
expect(error.code).toBe(lost ? 'submission_outcome_unknown' : 'forbidden');
expect(error.details).toMatchObject({ reason: 'feedback_not_enabled', outcome: lost ? 'unknown' : 'rejected' });
expect(r.stdout).toContain('Stop automatic retries'); expect(h.requests).toHaveLength(lost ? 2 : 1);
}
});
it('keeps existing telemetry accurate for preference reads without recording preference values', async () => {
const h = await harness([{ status: 200, body: preference }]);
await h.run(['preferences', '--json', '--cache'], { NANSEN_NO_TELEMETRY: '0', NODE_OPTIONS: `--require=${h.capture}` });
const event = JSON.parse(fs.readFileSync(h.eventFile, 'utf8').trim());
expect(event).toMatchObject({ event: 'cli_command_succeeded', path: '/feedback', properties: { from_cache: false } });
expect(JSON.stringify(event)).not.toContain('automatic_reporting');
});

it('rejects a preference response when reading a receipt with the reserved ID', async () => {
const h = await harness([{ status: 200, body: preference }]);
for (const mode of [[], ['--json']]) {
const r = await h.run(['get', 'preferences', '--no-retry', ...mode]);
expect(r.code).toBe(1); expect(r.stdout).toContain('Receipt lookup failed');
expect(r.stdout).not.toContain('undefined'); expect(r.stdout).not.toContain('automatic_reporting');
}
expect(h.requests).toHaveLength(2);
expect(h.requests.every(r => r.method === 'GET' && r.path === '/api/v1/feedback/preferences')).toBe(true);
});
it('keeps the human uncertainty warning and original ID after a rollout denial on a retry', async () => {
const h = await harness(['disconnect', { status: 403, body: { code: 'forbidden', reason: 'feedback_not_enabled' } }]);
const r = await h.run(h.submit);
expect(r.code).toBe(1); expect(r.stdout).toContain('Stop automatic retries');
expect(r.stdout).toContain('may have been saved'); expect(r.stdout).toContain('unchanged --file');
expect(r.stdout).toContain(examples.accepted.body.submission_id);
expect(h.requests).toHaveLength(2);
expect(h.requests[0].headers['idempotency-key']).toBe(h.requests[1].headers['idempotency-key']);
});
2 changes: 1 addition & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,7 @@ COMMANDS:
wallet ${WALLET_SUBCOMMANDS.join(', ')}
agent Ask the Nansen AI research agent (fast/expert modes)
alerts list, create, update, toggle, delete
feedback submit, resume, forget local data, or get a receipt (--json for structured output)
feedback preferences, submit, resume, forget local data, or get a receipt (--json for structured output)
web search, fetch
mcp install/uninstall/verify the Nansen MCP server
account Check the effective credential, plan, and remaining credits (free)
Expand Down
Loading
Loading