@@ -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+ } )
0 commit comments