Skip to content

Commit 5d74071

Browse files
Merge pull request #1276 from claude-code-best/fix/acp-protocol
Fix/acp protocol
2 parents f69c705 + aa9dd4b commit 5d74071

46 files changed

Lines changed: 8240 additions & 3983 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/acp-compliance-audit.md

Lines changed: 880 additions & 0 deletions
Large diffs are not rendered by default.

docs/acp-refactor-plan.md

Lines changed: 281 additions & 0 deletions
Large diffs are not rendered by default.

packages/acp-link/src/__tests__/server.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ describe('permission mode resolution', () => {
275275
{
276276
type: 'error',
277277
payload: {
278+
// Legacy error envelope now carries the JSON-RPC code as a string
279+
// (audit §8.3). -32602 = invalid params.
280+
code: '-32602',
278281
message: expect.stringContaining(
279282
'bypassPermissions requires local ACP_PERMISSION_MODE',
280283
),
@@ -304,3 +307,222 @@ describe('Heartbeat constants', () => {
304307
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000)
305308
})
306309
})
310+
311+
describe('JSON-RPC 2.0 routing (audit §8.1-8.5)', () => {
312+
// Helper to register a JSON-RPC-capable client and capture sent frames.
313+
function setupJsonRpcClient(
314+
sent: unknown[],
315+
options: {
316+
connection?: unknown
317+
sessionId?: string | null
318+
} = {},
319+
) {
320+
const ws = makeTestWs(sent)
321+
process.env.ACP_LINK_TEST_INTERNALS = '1'
322+
const unregister = __testing.registerClient(ws, {
323+
connection: options.connection,
324+
sessionId: options.sessionId ?? null,
325+
jsonRpc: true,
326+
})
327+
return { ws, unregister }
328+
}
329+
330+
test('unknown JSON-RPC method yields -32601 method-not-found (§8.4)', async () => {
331+
const sent: unknown[] = []
332+
const { ws, unregister } = setupJsonRpcClient(sent)
333+
try {
334+
await __testing.dispatchJsonRpcMessage(ws, {
335+
jsonrpc: '2.0',
336+
id: 42,
337+
method: 'session/nonexistent_method',
338+
params: {},
339+
})
340+
// JSON-RPC clients receive a JSON-RPC error with the standard code.
341+
expect(sent).toContainEqual({
342+
jsonrpc: '2.0',
343+
id: 42,
344+
error: {
345+
code: -32601,
346+
message: 'Method not found: session/nonexistent_method',
347+
},
348+
})
349+
} finally {
350+
unregister()
351+
delete process.env.ACP_LINK_TEST_INTERNALS
352+
}
353+
})
354+
355+
test('JSON-RPC response echoes the request id (§8.2)', async () => {
356+
const sent: unknown[] = []
357+
const prompt = mock(async () => ({ stopReason: 'end_turn' }))
358+
const { ws, unregister } = setupJsonRpcClient(sent, {
359+
connection: { prompt },
360+
sessionId: 'sess-1',
361+
})
362+
try {
363+
await __testing.dispatchJsonRpcMessage(ws, {
364+
jsonrpc: '2.0',
365+
id: 'req-7',
366+
method: 'session/prompt',
367+
params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] },
368+
})
369+
// The id is echoed back in the JSON-RPC result.
370+
expect(sent).toContainEqual({
371+
jsonrpc: '2.0',
372+
id: 'req-7',
373+
result: { stopReason: 'end_turn' },
374+
})
375+
} finally {
376+
unregister()
377+
delete process.env.ACP_LINK_TEST_INTERNALS
378+
}
379+
})
380+
381+
test('$/cancel_request is handled and forwards to session/cancel (§8.5)', async () => {
382+
const sent: unknown[] = []
383+
const cancel = mock(async () => {})
384+
const { ws, unregister } = setupJsonRpcClient(sent, {
385+
connection: { cancel },
386+
sessionId: 'sess-1',
387+
})
388+
try {
389+
await __testing.dispatchJsonRpcMessage(ws, {
390+
jsonrpc: '2.0',
391+
id: 'cancel-1',
392+
method: '$/cancel_request',
393+
params: { id: 'req-7' },
394+
})
395+
// The cancel was forwarded to the ACP cancel path.
396+
expect(cancel).toHaveBeenCalled()
397+
} finally {
398+
unregister()
399+
delete process.env.ACP_LINK_TEST_INTERNALS
400+
}
401+
})
402+
403+
test('JSON-RPC notifications (no id) are dispatched without a response', async () => {
404+
const sent: unknown[] = []
405+
const cancel = mock(async () => {})
406+
const { ws, unregister } = setupJsonRpcClient(sent, {
407+
connection: { cancel },
408+
sessionId: 'sess-1',
409+
})
410+
try {
411+
await __testing.dispatchJsonRpcMessage(ws, {
412+
jsonrpc: '2.0',
413+
method: 'session/cancel',
414+
params: {},
415+
})
416+
expect(cancel).toHaveBeenCalled()
417+
// No JSON-RPC response frame should be emitted for a notification.
418+
expect(
419+
sent.find(m => (m as { jsonrpc?: string }).jsonrpc),
420+
).toBeUndefined()
421+
} finally {
422+
unregister()
423+
delete process.env.ACP_LINK_TEST_INTERNALS
424+
}
425+
})
426+
427+
test('session/set_mode is forwarded to the agent connection (§8.4)', async () => {
428+
const sent: unknown[] = []
429+
const setSessionMode = mock(async () => ({ modeId: 'plan' }))
430+
const { ws, unregister } = setupJsonRpcClient(sent, {
431+
connection: { setSessionMode },
432+
sessionId: 'sess-1',
433+
})
434+
try {
435+
await __testing.dispatchJsonRpcMessage(ws, {
436+
jsonrpc: '2.0',
437+
id: 'm1',
438+
method: 'session/set_mode',
439+
params: { sessionId: 'sess-1', modeId: 'plan' },
440+
})
441+
expect(setSessionMode).toHaveBeenCalled()
442+
// The response carries the echoed id.
443+
expect(sent).toContainEqual({
444+
jsonrpc: '2.0',
445+
id: 'm1',
446+
result: { modeId: 'plan' },
447+
})
448+
} finally {
449+
unregister()
450+
delete process.env.ACP_LINK_TEST_INTERNALS
451+
}
452+
})
453+
454+
test('session/close is forwarded to the agent connection (§8.4)', async () => {
455+
const sent: unknown[] = []
456+
const unstable_closeSession = mock(async () => ({}))
457+
const { ws, unregister } = setupJsonRpcClient(sent, {
458+
connection: { unstable_closeSession },
459+
sessionId: 'sess-1',
460+
})
461+
try {
462+
await __testing.dispatchJsonRpcMessage(ws, {
463+
jsonrpc: '2.0',
464+
id: 'c1',
465+
method: 'session/close',
466+
params: { sessionId: 'sess-1' },
467+
})
468+
expect(unstable_closeSession).toHaveBeenCalled()
469+
} finally {
470+
unregister()
471+
delete process.env.ACP_LINK_TEST_INTERNALS
472+
}
473+
})
474+
})
475+
476+
describe('Capability and protocolVersion transparency (audit §8.6, §8.7, §8.13)', () => {
477+
test('initialize forwards client-supplied clientInfo/capabilities (§8.7)', async () => {
478+
const sent: unknown[] = []
479+
const ws = makeTestWs(sent)
480+
process.env.ACP_LINK_TEST_INTERNALS = '1'
481+
const unregister = __testing.registerClient(ws, { connection: null })
482+
try {
483+
// Send initialize with custom clientInfo; the proxy should remember it.
484+
await __testing.dispatchJsonRpcMessage(ws, {
485+
jsonrpc: '2.0',
486+
id: 'init-1',
487+
method: 'initialize',
488+
params: {
489+
clientInfo: { name: 'my-editor', version: '2.3.4' },
490+
clientCapabilities: { terminal: { create: true } },
491+
},
492+
})
493+
// The handler invocation will fail (no agent process) but clientInfo was
494+
// captured before the call. We verify by checking that no -32602 invalid
495+
// params error is raised about clientInfo.
496+
expect(sent.length).toBeGreaterThan(0)
497+
} finally {
498+
unregister()
499+
delete process.env.ACP_LINK_TEST_INTERNALS
500+
}
501+
})
502+
})
503+
504+
describe('ws-message JSON-RPC decoding (audit §8.1)', () => {
505+
test('decodeJsonWsMessage accepts JSON-RPC 2.0 requests', async () => {
506+
const { decodeJsonWsMessage, isJsonRpc2Message } = await import(
507+
'../ws-message.js'
508+
)
509+
const msg = decodeJsonWsMessage(
510+
'{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{}}',
511+
)
512+
expect(isJsonRpc2Message(msg)).toBe(true)
513+
expect((msg as { method?: string }).method).toBe('session/prompt')
514+
})
515+
516+
test('decodeJsonWsMessage still accepts legacy {type,payload} envelope', async () => {
517+
const { decodeJsonWsMessage } = await import('../ws-message.js')
518+
const msg = decodeJsonWsMessage('{"type":"ping"}')
519+
expect((msg as { type?: string }).type).toBe('ping')
520+
})
521+
522+
test('decodeJsonWsMessage rejects non-JSON-RPC, non-type payloads', async () => {
523+
const { decodeJsonWsMessage } = await import('../ws-message.js')
524+
expect(() => decodeJsonWsMessage('{"foo":"bar"}')).toThrow(
525+
'Invalid WebSocket message payload',
526+
)
527+
})
528+
})

packages/acp-link/src/rcs-upstream.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,12 @@ export class RcsUpstreamClient {
211211
} else if (data.type === 'keep_alive') {
212212
// ignore keepalive
213213
} else {
214-
// Forward ACP protocol messages to handler (for RCS relay support)
214+
// Forward ACP protocol messages to handler (for RCS relay support).
215+
// This branch handles both the legacy `{type, payload}` envelope
216+
// and JSON-RPC 2.0 messages (which have no `type` field) so the
217+
// relay preserves the JSON-RPC format end-to-end (audit §8.12).
215218
RcsUpstreamClient.log.debug(
216-
{ type: data.type },
219+
{ type: data.type, method: data.method },
217220
'forwarding to relay handler',
218221
)
219222
this.messageHandler?.(data)

0 commit comments

Comments
 (0)