Skip to content

Commit e78d3e2

Browse files
authored
feat(acp): prefix extension methods with _nanocoder/ and advertise them (#1909)
* feat(acp): prefix extension methods with _nanocoder/ and advertise them ACP reserves the _ prefix for custom methods. Answer renameSession, timeline/list, timeline/revert and retryTurn as _nanocoder/<name>, keep the bare names as deprecated aliases that log a one-time notice, and list the exact method and notification names under agentCapabilities._meta.nanocoder. An unrecognised custom request now fails with Method not found (-32601). The VS Code extension uses the advertised names and falls back to the bare ones. Closes #1893 * fix(acp): share the notification name and prefix, and harden the new tests Export the session title notification name and use it both where it is sent and in the advertised list, copy the advertised notification list like the method list, and have the VS Code client build the names it looks up from the shared prefix and method list instead of repeating the literals. Tests: run the logger-patching deprecation test serially so the bare-name calls in other tests cannot land in its window, reuse makeHandshakenClient in the VS Code spec, and cover an agent that advertises only some of the extension methods.
1 parent 5b36bcc commit e78d3e2

8 files changed

Lines changed: 268 additions & 13 deletions

File tree

‎.changeset/acp-ext-method-names.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nanocollective/nanocoder": minor
3+
---
4+
5+
ACP extension requests now use the `_` prefix ACP reserves for custom methods: `_nanocoder/renameSession`, `_nanocoder/timeline/list`, `_nanocoder/timeline/revert` and `_nanocoder/retryTurn`. The bare names still work as deprecated aliases and log a one-time notice, and will be removed in a later release. `initialize` now lists the exact method and notification names under `agentCapabilities._meta.nanocoder`, so a client can detect support instead of guessing from the version. A custom request the agent doesn't recognise now fails with the standard "Method not found" (`-32601`) instead of a plain error. The VS Code extension uses the advertised names and falls back to the bare ones for an older agent.

‎docs/features/acp.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,34 @@ With ACP the **client is the UI**: the agent runs headless and everything (strea
3535
- **`ask_user`** — when the agent asks a clarifying question, the options appear as selectable buttons in the editor. (Selection only; a free-form typed answer is not available over ACP.)
3636
- **`@`-mentioned files** — files you reference in the editor are read and included in the prompt, using the editor's live buffer (including unsaved edits) when available.
3737
- **Session persistence** - ACP sessions are saved to disk like TUI sessions (see [Session Management](session-management.md)). Loading or resuming a thread replays its history, even after the editor and agent restart. Loading or resuming a session ID that is neither in memory nor on disk fails with `Session not found on disk: <id>` instead of creating an empty session; only `session/new` creates one.
38-
- **Session management** - clients can list, resume, close and delete sessions. Nanocoder also answers a `renameSession` extension method (`sessionId`, `title`), and new sessions get a generated title from the first message.
38+
- **Session management** - clients can list, resume, close and delete sessions. Nanocoder also answers a `_nanocoder/renameSession` extension method (`sessionId`, `title`; see [Extension methods](#extension-methods)), and new sessions get a generated title from the first message.
3939
- **Images** - PNG, JPEG, GIF and WebP attachments are sent to the model as images.
4040

41+
## Extension methods
42+
43+
ACP reserves method names starting with `_` for custom methods. Nanocoder's are:
44+
45+
| Method | Params | What it does |
46+
|--------|--------|--------------|
47+
| `_nanocoder/renameSession` | `sessionId`, `title` | Sets a session's title |
48+
| `_nanocoder/timeline/list` | `sessionId` | Lists the session's file-change checkpoints |
49+
| `_nanocoder/timeline/revert` | `sessionId`, `checkpointId` | Reverts the files and history to before a checkpoint |
50+
| `_nanocoder/retryTurn` | `sessionId`, optional `promptText` | Removes the last turn so the prompt can be sent again |
51+
52+
The agent also sends the `_nanocoder/sessionTitleChanged` notification when it titles a session in the background.
53+
54+
**Capability detection.** `initialize` lists the exact names under `agentCapabilities._meta.nanocoder`:
55+
56+
```json
57+
{"nanocoder": {"extMethods": ["_nanocoder/renameSession", "..."], "extNotifications": ["_nanocoder/sessionTitleChanged"]}}
58+
```
59+
60+
Take the names from that list as they are rather than building them from a prefix. An agent that doesn't list a method doesn't support it under that name.
61+
62+
**Deprecated aliases.** The same methods are still answered under their old bare names (`renameSession`, `timeline/list`, `timeline/revert`, `retryTurn`) for clients that predate the prefix. The first use of each logs a deprecation notice. The aliases will be removed in a later release, announced in the changelog.
63+
64+
**Unknown methods.** A custom request the agent doesn't recognise fails with the standard JSON-RPC "Method not found" error (`-32601`), so "not supported" can be told apart from "failed".
65+
4166
## Setup in Zed
4267

4368
[Zed](https://zed.dev) is the reference ACP client. Register Nanocoder as a custom agent in Zed's `settings.json` (`Cmd+,`, or **zed: open settings** from the command palette):

‎plugins/vscode/src/acp-client.spec.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ test('NanocoderAcpClient - newChat clears pending permissions', async (t) => {
9797
async function makeHandshakenClient(
9898
agentCapabilities: unknown,
9999
closeSession: (params: {sessionId: string}) => Promise<unknown>,
100+
extMethod?: (method: string, params: unknown) => Promise<unknown>,
100101
) {
101102
const lines: string[] = [];
102103
const client = new NanocoderAcpClient(
@@ -106,6 +107,7 @@ async function makeHandshakenClient(
106107
client.setConnection({
107108
initialize: async () => ({agentInfo: {version: '1.32.0'}, agentCapabilities}),
108109
closeSession,
110+
extMethod,
109111
} as any);
110112
await client.initializeHandshake();
111113
(client as any)._sessionId = 'session-old';
@@ -453,3 +455,66 @@ test('NanocoderAcpClient - a creation still in flight does not overwrite a resum
453455
t.is(await pending, 'session-resumed');
454456
t.is((client as any)._sessionId, 'session-resumed');
455457
});
458+
459+
function recordExtCalls() {
460+
const calls: Array<{method: string; params: unknown}> = [];
461+
const extMethod = async (method: string, params: unknown) => {
462+
calls.push({method, params});
463+
return {};
464+
};
465+
return {calls, extMethod};
466+
}
467+
468+
async function renameAndRetry(agentCapabilities: unknown) {
469+
const {calls, extMethod} = recordExtCalls();
470+
const {client} = await makeHandshakenClient(
471+
agentCapabilities,
472+
async () => ({}),
473+
extMethod,
474+
);
475+
await client.renameSession('session-old', 'New title');
476+
await client.retryTurn('again');
477+
return {client, calls};
478+
}
479+
480+
test('NanocoderAcpClient - rename and retry use the extension names the agent advertises', async (t) => {
481+
const {calls} = await renameAndRetry({
482+
_meta: {
483+
nanocoder: {
484+
extMethods: ['_nanocoder/renameSession', '_nanocoder/retryTurn'],
485+
},
486+
},
487+
});
488+
489+
t.deepEqual(
490+
calls.map((call) => call.method),
491+
['_nanocoder/renameSession', '_nanocoder/retryTurn'],
492+
);
493+
t.deepEqual(calls[0].params, {sessionId: 'session-old', title: 'New title'});
494+
});
495+
496+
test('NanocoderAcpClient - rename and retry fall back to the bare names for an agent that advertises none', async (t) => {
497+
for (const agentCapabilities of [
498+
{},
499+
{_meta: {nanocoder: {}}},
500+
{_meta: {nanocoder: {extMethods: 'x'}}},
501+
]) {
502+
const {calls} = await renameAndRetry(agentCapabilities);
503+
504+
t.deepEqual(
505+
calls.map((call) => call.method),
506+
['renameSession', 'retryTurn'],
507+
);
508+
}
509+
});
510+
511+
test('NanocoderAcpClient - each extension method resolves on its own against what the agent advertises', async (t) => {
512+
const {calls} = await renameAndRetry({
513+
_meta: {nanocoder: {extMethods: ['_nanocoder/renameSession']}},
514+
});
515+
516+
t.deepEqual(
517+
calls.map((call) => call.method),
518+
['_nanocoder/renameSession', 'retryTurn'],
519+
);
520+
});

‎plugins/vscode/src/acp-client.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as vscode from 'vscode';
22
import {ClientSideConnection} from '@agentclientprotocol/sdk';
3+
import {ACP_EXT_PREFIX, type ACP_EXT_METHODS} from '../../../source/acp/acp-capabilities';
34
import {AcpStateManager, ACPStatus} from './acp-state';
45
import {PromptAttempt} from './prompt-attempt';
56
import { resolveExtensionCwd } from './workspace-utils';
@@ -55,6 +56,8 @@ export class NanocoderAcpClient {
5556
private _handshakeComplete = false;
5657
/** Whether the agent advertised `sessionCapabilities.close` at initialize. */
5758
private _supportsSessionClose = false;
59+
/** Extension request names the agent advertised at initialize. */
60+
private _extMethods: readonly string[] = [];
5861

5962
get isHandshakeComplete(): boolean {
6063
return this._handshakeComplete && this.connection != null;
@@ -119,6 +122,7 @@ export class NanocoderAcpClient {
119122
this._clearPendingPermissions();
120123
this._handshakeComplete = false;
121124
this._supportsSessionClose = false;
125+
this._extMethods = [];
122126
}
123127

124128
/** Drop any in-flight session creation; see {@link _sessionGeneration}. */
@@ -208,6 +212,10 @@ export class NanocoderAcpClient {
208212

209213
this._supportsSessionClose =
210214
initResult.agentCapabilities?.sessionCapabilities?.close != null;
215+
const advertised = (initResult.agentCapabilities?._meta?.nanocoder as
216+
| { extMethods?: unknown }
217+
| undefined)?.extMethods;
218+
this._extMethods = Array.isArray(advertised) ? advertised : [];
211219

212220
// Complete handshake
213221
this._handshakeComplete = true;
@@ -507,10 +515,22 @@ export class NanocoderAcpClient {
507515
}
508516
}
509517

518+
/**
519+
* The advertised name when the agent lists it, else the bare name an agent
520+
* from before the `_nanocoder/` prefix understands.
521+
*/
522+
private extMethodName(method: (typeof ACP_EXT_METHODS)[number]): string {
523+
const advertised = `${ACP_EXT_PREFIX}${method}`;
524+
return this._extMethods.includes(advertised) ? advertised : method;
525+
}
526+
510527
async renameSession(sessionId: string, title: string): Promise<void> {
511528
if (!this.connection) return;
512529
try {
513-
await this.connection.extMethod('renameSession', {sessionId, title});
530+
await this.connection.extMethod(
531+
this.extMethodName('renameSession'),
532+
{sessionId, title},
533+
);
514534
} catch (error) {
515535
this.outputChannel.appendLine(`renameSession failed: ${error}`);
516536
vscode.window.showErrorMessage(`Failed to rename session: ${error}`);
@@ -559,10 +579,13 @@ export class NanocoderAcpClient {
559579
return;
560580
}
561581
try {
562-
await this.connection.extMethod('retryTurn', {
563-
sessionId: this._sessionId,
564-
promptText,
565-
});
582+
await this.connection.extMethod(
583+
this.extMethodName('retryTurn'),
584+
{
585+
sessionId: this._sessionId,
586+
promptText,
587+
},
588+
);
566589
} catch (error) {
567590
this.outputChannel.appendLine(`retryTurn warning: ${error}`);
568591
}

‎source/acp/acp-agent.spec.ts‎

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '@/message-handler';
2020
import {convertToModelMessages} from '@/ai-sdk-client/converters/message-converter';
2121
import {sessionManager} from '@/session/session-manager';
22+
import {getLogger} from '@/utils/logging';
2223
import {SemanticMemoryManager} from '@/memory/semantic-memory-manager';
2324
import {readFileTool} from '@/tools/read-file';
2425
import type {Message} from '@/types/core';
@@ -1207,11 +1208,86 @@ test.serial(
12071208
},
12081209
);
12091210

1210-
test('AcpAgent.extMethod - throws for an unknown method', async t => {
1211+
test('AcpAgent.extMethod - an unknown method is "Method not found"', async t => {
12111212
const {agent} = createAgent();
1212-
await t.throwsAsync(agent.extMethod('bogus', {}), {
1213-
message: 'Unknown extension method: bogus',
1214-
});
1213+
for (const method of ['bogus', '_nanocoder/bogus', '_other/renameSession']) {
1214+
const error = await t.throwsAsync(agent.extMethod(method, {}));
1215+
t.is((error as {code?: number}).code, -32601, method);
1216+
t.regex(error?.message ?? '', /Method not found/);
1217+
}
1218+
});
1219+
1220+
test('AcpAgent.extMethod - answers on the prefixed name and on the bare alias', async t => {
1221+
const {agent} = createAgent();
1222+
const session = await agent.newSession({cwd: '/tmp'});
1223+
try {
1224+
await agent.prompt({
1225+
sessionId: session.sessionId,
1226+
prompt: [{type: 'text', text: 'hello'}],
1227+
});
1228+
for (const name of ['timeline/list', 'retryTurn', 'renameSession']) {
1229+
const params =
1230+
name === 'renameSession'
1231+
? {sessionId: session.sessionId, title: 'Same title'}
1232+
: {sessionId: session.sessionId};
1233+
const prefixed = await agent.extMethod(`_nanocoder/${name}`, params);
1234+
const bare = await agent.extMethod(name, params);
1235+
t.deepEqual(prefixed, bare, name);
1236+
}
1237+
// The same input errors identically under both names.
1238+
await t.throwsAsync(
1239+
agent.extMethod('_nanocoder/timeline/revert', {sessionId: 1}),
1240+
{message: 'timeline/revert requires string sessionId and checkpointId'},
1241+
);
1242+
await t.throwsAsync(
1243+
agent.extMethod('timeline/revert', {sessionId: 1}),
1244+
{message: 'timeline/revert requires string sessionId and checkpointId'},
1245+
);
1246+
} finally {
1247+
await agent.deleteSession({sessionId: session.sessionId});
1248+
}
1249+
});
1250+
1251+
// Serial: it patches the process-wide logger, and every bare-name call in the
1252+
// other tests would land in the patch window as a stray notice.
1253+
test.serial('AcpAgent.extMethod - a bare name logs one deprecation notice, a prefixed name none', async t => {
1254+
const {agent} = createAgent();
1255+
const logger = getLogger();
1256+
const original = logger.warn;
1257+
const warnings: string[] = [];
1258+
logger.warn = ((message: string) => {
1259+
warnings.push(message);
1260+
}) as typeof logger.warn;
1261+
try {
1262+
const params = {sessionId: 'nope'};
1263+
await t.throwsAsync(agent.extMethod('_nanocoder/timeline/list', params));
1264+
t.deepEqual(warnings, []);
1265+
1266+
await t.throwsAsync(agent.extMethod('timeline/list', params));
1267+
await t.throwsAsync(agent.extMethod('timeline/list', params));
1268+
await t.throwsAsync(agent.extMethod('retryTurn', params));
1269+
t.is(warnings.length, 2);
1270+
t.regex(warnings[0], /"timeline\/list" is deprecated; use "_nanocoder\/timeline\/list"/);
1271+
t.regex(warnings[1], /"retryTurn" is deprecated/);
1272+
} finally {
1273+
logger.warn = original;
1274+
}
1275+
});
1276+
1277+
test('AcpAgent.initialize - advertises the extension methods, and each one is handled', async t => {
1278+
const {agent} = createAgent();
1279+
const {agentCapabilities} = await agent.initialize({protocolVersion: 1});
1280+
const advertised = agentCapabilities?._meta?.nanocoder as {
1281+
extMethods: string[];
1282+
extNotifications: string[];
1283+
};
1284+
t.deepEqual(advertised.extNotifications, ['_nanocoder/sessionTitleChanged']);
1285+
t.true(advertised.extMethods.length > 0);
1286+
for (const method of advertised.extMethods) {
1287+
// Bad params fail inside the handler; an unsupported name would be -32601.
1288+
const error = await t.throwsAsync(agent.extMethod(method, {}));
1289+
t.not((error as {code?: number}).code, -32601, method);
1290+
}
12151291
});
12161292

12171293
test('AcpAgent.extMethod - renameSession throws on non-string sessionId/title', async t => {

‎source/acp/acp-agent.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ import type {
3131
SetSessionModeRequest,
3232
SetSessionModeResponse,
3333
} from '@agentclientprotocol/sdk';
34+
import {RequestError} from '@agentclientprotocol/sdk';
3435
import {
36+
ACP_EXT_METHODS,
37+
ACP_EXT_PREFIX,
38+
ACP_SESSION_TITLE_NOTIFICATION,
3539
acpModeToDevelopmentMode,
3640
developmentModeToAcpMode,
3741
getAgentCapabilities,
@@ -102,6 +106,8 @@ export class AcpAgent implements Agent {
102106
private conn: AgentSideConnection;
103107
private appVersion: string;
104108
private clientCapabilities?: ClientCapabilities;
109+
/** Bare extension method names already reported as deprecated. */
110+
private warnedExtAliases = new Set<string>();
105111

106112
constructor(
107113
initContext: AcpInitContext,
@@ -489,7 +495,7 @@ export class AcpAgent implements Agent {
489495
// Lands after the turn went idle, so the client may already be
490496
// gone; an unhandled rejection here would kill the agent.
491497
void this.conn
492-
.notify('_nanocoder/sessionTitleChanged', {
498+
.notify(ACP_SESSION_TITLE_NOTIFICATION, {
493499
sessionId: session.sessionId,
494500
title,
495501
})
@@ -710,11 +716,28 @@ export class AcpAgent implements Agent {
710716
* ACP's core methods don't include a way for the client to rename a
711717
* session (title is otherwise agent-driven, from the latest message).
712718
* `extMethod` is the protocol's sanctioned escape hatch for exactly this.
719+
*
720+
* Methods are named `_nanocoder/<name>`. The bare `<name>` still works as a
721+
* deprecated alias; anything else is "Method not found" so a client can
722+
* tell an unsupported method from a failed one.
713723
*/
714724
async extMethod(
715-
method: string,
725+
requested: string,
716726
params: Record<string, unknown>,
717727
): Promise<Record<string, unknown>> {
728+
const method = requested.startsWith(ACP_EXT_PREFIX)
729+
? requested.slice(ACP_EXT_PREFIX.length)
730+
: requested;
731+
if (!(ACP_EXT_METHODS as readonly string[]).includes(method)) {
732+
throw RequestError.methodNotFound(requested);
733+
}
734+
if (method === requested && !this.warnedExtAliases.has(method)) {
735+
this.warnedExtAliases.add(method);
736+
logger.warn(
737+
`ACP extMethod "${method}" is deprecated; use "${ACP_EXT_PREFIX}${method}"`,
738+
);
739+
}
740+
718741
if (method === 'renameSession') {
719742
const sessionId = params.sessionId;
720743
const title = params.title;
@@ -828,7 +851,7 @@ export class AcpAgent implements Agent {
828851
return {ok: true, messagesCount: session.messages.length};
829852
}
830853

831-
throw new Error(`Unknown extension method: ${method}`);
854+
throw RequestError.methodNotFound(requested);
832855
}
833856

834857
async unstable_listProviders(

‎source/acp/acp-capabilities.spec.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ test('getAgentCapabilities - returns session capabilities with close', t => {
2323
list: {},
2424
resume: {},
2525
},
26+
_meta: {
27+
nanocoder: {
28+
extMethods: [
29+
'_nanocoder/renameSession',
30+
'_nanocoder/timeline/list',
31+
'_nanocoder/timeline/revert',
32+
'_nanocoder/retryTurn',
33+
],
34+
extNotifications: ['_nanocoder/sessionTitleChanged'],
35+
},
36+
},
2637
});
2738
});
2839

0 commit comments

Comments
 (0)