From 860a3349c9bd04986b7fb9fbdccf3d26aea7bd92 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 10:41:13 +0800 Subject: [PATCH 01/17] test(serve): reproduce encoded subagent id failure --- .../serve/virtual-subagent-sessions.test.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 6e612c0511e..b1549e8b331 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -79,15 +79,30 @@ function activeTarget(sessions: VirtualSubagentSessions): { } describe('VirtualSubagentSessions', () => { - it('rejects id parts that the parser cannot accept', () => { + it('rejects invalid parent session ids and empty agent ids', () => { expect(() => createVirtualSubagentSessionId('parent session', 'agent-1'), ).toThrow('valid id parts'); - expect(() => - createVirtualSubagentSessionId('parent-session', 'agent/1'), - ).toThrow('valid id parts'); + expect(() => createVirtualSubagentSessionId('parent-session', '')).toThrow( + 'valid id parts', + ); }); + it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( + 'round-trips an existing agent id containing reserved characters: %s', + (agentId) => { + const sessionId = createVirtualSubagentSessionId( + 'parent-session', + agentId, + ); + + expect(parseVirtualSubagentSessionId(sessionId)).toEqual({ + parentSessionId: 'parent-session', + agentId, + }); + }, + ); + it('resolves an out-of-band fork by agent task id', async () => { const runtime = { workspaceId: 'workspace-1', From 6df743f4c2a4ca50d83d2d080c11aa0c0aa7164a Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 10:43:09 +0800 Subject: [PATCH 02/17] fix(serve): support encoded subagent task ids --- packages/cli/src/serve/server.test.ts | 23 ++++++++++++------- .../src/serve/virtual-subagent-sessions.ts | 14 +++++++---- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 4e48e2e5aa3..aa77ccf0f13 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -8166,8 +8166,11 @@ describe('createServeApp', () => { const resolveSpy = vi .spyOn(VirtualSubagentSessions.prototype, 'resolve') .mockResolvedValue({ - sessionId: createVirtualSubagentSessionId('s-1', 'agent-1'), - taskId: 'agent-1', + sessionId: createVirtualSubagentSessionId( + 's-1', + 'general-purpose-agent:8', + ), + taskId: 'general-purpose-agent:8', title: 'Investigate', status: 'running', }); @@ -8180,27 +8183,31 @@ describe('createServeApp', () => { try { const resolveRes = await request(app) - .get('/session/s-1/subagents/tool-1') + .get('/session/s-1/subagents/agent%3A8') .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret'); const cancelRes = await request(app) - .post('/session/s-1/subagents/tool-1/cancel') + .post('/session/s-1/subagents/agent%3A8/cancel') .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret'); expect(resolveRes.status).toBe(200); expect(resolveRes.headers['cache-control']).toBe('no-store'); expect(resolveRes.body).toMatchObject({ - taskId: 'agent-1', + taskId: 'general-purpose-agent:8', status: 'running', }); expect(cancelRes.status).toBe(200); expect(cancelRes.body).toEqual({ cancelled: true }); expect(resolveSpy).toHaveBeenCalledTimes(2); - expect(resolveSpy.mock.calls[0]?.slice(1)).toEqual(['s-1', 'tool-1']); - expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual(['s-1', 'tool-1']); + expect(resolveSpy.mock.calls[0]?.slice(1)).toEqual(['s-1', 'agent:8']); + expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual(['s-1', 'agent:8']); expect(bridge.cancelSessionTaskCalls).toEqual([ - { sessionId: 's-1', taskId: 'agent-1', taskKind: 'agent' }, + { + sessionId: 's-1', + taskId: 'general-purpose-agent:8', + taskKind: 'agent', + }, ]); } finally { resolveSpy.mockRestore(); diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index fa09dc47491..6fb304ed8b5 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -204,17 +204,21 @@ function decodePart(value: string): string | undefined { } } -function isValidVirtualSessionPart(value: string): boolean { +function isValidVirtualParentSessionId(value: string): boolean { return /^[a-zA-Z0-9_-]{1,500}$/.test(value); } +function isValidVirtualAgentId(value: string): boolean { + return value.length > 0 && value.length <= 500; +} + export function createVirtualSubagentSessionId( parentSessionId: string, agentId: string, ): string { if ( - !isValidVirtualSessionPart(parentSessionId) || - !isValidVirtualSessionPart(agentId) + !isValidVirtualParentSessionId(parentSessionId) || + !isValidVirtualAgentId(agentId) ) { throw new Error('Virtual subagent session ids require valid id parts'); } @@ -234,8 +238,8 @@ export function parseVirtualSubagentSessionId( if ( !parentSessionId || !agentId || - !isValidVirtualSessionPart(parentSessionId) || - !isValidVirtualSessionPart(agentId) + !isValidVirtualParentSessionId(parentSessionId) || + !isValidVirtualAgentId(agentId) ) { return undefined; } From 1713e9a7b14b2ccd6db8891ac9f7df90a9a74ec9 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 10:49:40 +0800 Subject: [PATCH 03/17] test(serve): cover oversized encoded subagent ids --- packages/cli/src/serve/virtual-subagent-sessions.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index b1549e8b331..24940b8035d 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -86,6 +86,9 @@ describe('VirtualSubagentSessions', () => { expect(() => createVirtualSubagentSessionId('parent-session', '')).toThrow( 'valid id parts', ); + expect(() => + createVirtualSubagentSessionId('parent-session', '界'.repeat(500)), + ).toThrow('valid id parts'); }); it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( From 2ba6d37249c6ade3f00f66f0e6995aced8402076 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 10:50:18 +0800 Subject: [PATCH 04/17] fix(serve): bound encoded virtual session ids --- packages/cli/src/serve/virtual-subagent-sessions.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 6fb304ed8b5..d830e9db535 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -27,6 +27,7 @@ import { replayTranscriptRecordPage } from '../acp-integration/session/history-r import type { WorkspaceRuntime } from './workspace-registry.js'; const PREFIX = 'subagent.'; +const MAX_VIRTUAL_SESSION_ID_LENGTH = 2_000; const POLL_INTERVAL_MS = 250; const TARGET_RETENTION_MS = 60_000; @@ -222,13 +223,20 @@ export function createVirtualSubagentSessionId( ) { throw new Error('Virtual subagent session ids require valid id parts'); } - return `${PREFIX}${encodePart(parentSessionId)}.${encodePart(agentId)}`; + const sessionId = `${PREFIX}${encodePart(parentSessionId)}.${encodePart(agentId)}`; + if (sessionId.length > MAX_VIRTUAL_SESSION_ID_LENGTH) { + throw new Error('Virtual subagent session ids require valid id parts'); + } + return sessionId; } export function parseVirtualSubagentSessionId( sessionId: string, ): VirtualSubagentSessionKey | undefined { - if (!sessionId.startsWith(PREFIX) || sessionId.length > 2_000) { + if ( + !sessionId.startsWith(PREFIX) || + sessionId.length > MAX_VIRTUAL_SESSION_ID_LENGTH + ) { return undefined; } const parts = sessionId.slice(PREFIX.length).split('.'); From 0945d22434ec4ffc00e5641d8f7820450f017ce2 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 10:51:47 +0800 Subject: [PATCH 05/17] test(serve): reject lossy virtual agent ids --- packages/cli/src/serve/virtual-subagent-sessions.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 24940b8035d..4dbd9995a79 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -89,6 +89,9 @@ describe('VirtualSubagentSessions', () => { expect(() => createVirtualSubagentSessionId('parent-session', '界'.repeat(500)), ).toThrow('valid id parts'); + expect(() => + createVirtualSubagentSessionId('parent-session', '\ud800'), + ).toThrow('valid id parts'); }); it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( From 977e6d8556ec38a2ff1c324775a3b945ee532669 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 10:52:21 +0800 Subject: [PATCH 06/17] fix(serve): preserve virtual agent id round trips --- packages/cli/src/serve/virtual-subagent-sessions.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index d830e9db535..3c4e8eded13 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -210,7 +210,11 @@ function isValidVirtualParentSessionId(value: string): boolean { } function isValidVirtualAgentId(value: string): boolean { - return value.length > 0 && value.length <= 500; + return ( + value.length > 0 && + value.length <= 500 && + decodePart(encodePart(value)) === value + ); } export function createVirtualSubagentSessionId( From b69cf27eeac05554443a2ab2d3cee7fe2fdd3221 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sat, 8 Aug 2026 11:42:19 +0800 Subject: [PATCH 07/17] test(serve): cover virtual agent id length limit --- packages/cli/src/serve/virtual-subagent-sessions.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 4dbd9995a79..43f9a8fead5 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -79,13 +79,16 @@ function activeTarget(sessions: VirtualSubagentSessions): { } describe('VirtualSubagentSessions', () => { - it('rejects invalid parent session ids and empty agent ids', () => { + it('rejects invalid, oversized, or non-round-trippable id parts', () => { expect(() => createVirtualSubagentSessionId('parent session', 'agent-1'), ).toThrow('valid id parts'); expect(() => createVirtualSubagentSessionId('parent-session', '')).toThrow( 'valid id parts', ); + expect(() => + createVirtualSubagentSessionId('parent-session', 'a'.repeat(501)), + ).toThrow('valid id parts'); expect(() => createVirtualSubagentSessionId('parent-session', '界'.repeat(500)), ).toThrow('valid id parts'); From 2ba3bbc9b3e64741b6c5d4682ec24a7d0bdc4de9 Mon Sep 17 00:00:00 2001 From: carffuca Date: Sun, 9 Aug 2026 16:31:40 +0800 Subject: [PATCH 08/17] test(serve): cover virtual session id boundaries --- .../serve/virtual-subagent-sessions.test.ts | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 43f9a8fead5..4cfe64bee82 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -90,13 +90,58 @@ describe('VirtualSubagentSessions', () => { createVirtualSubagentSessionId('parent-session', 'a'.repeat(501)), ).toThrow('valid id parts'); expect(() => - createVirtualSubagentSessionId('parent-session', '界'.repeat(500)), - ).toThrow('valid id parts'); + createVirtualSubagentSessionId('parent-session', '界'.repeat(493)), + ).toThrow('exceeds 2000 characters'); expect(() => createVirtualSubagentSessionId('parent-session', '\ud800'), ).toThrow('valid id parts'); }); + it.each(['a'.repeat(500), '界'.repeat(492)])( + 'round-trips an agent id at an accepted length boundary', + (agentId) => { + const sessionId = createVirtualSubagentSessionId( + 'parent-session', + agentId, + ); + + expect(parseVirtualSubagentSessionId(sessionId)).toEqual({ + parentSessionId: 'parent-session', + agentId, + }); + }, + ); + + it.each([ + ['non-canonical parent encoding', true, false], + ['non-canonical agent encoding', false, true], + ])('rejects %s', (_name, padParent, padAgent) => { + const sessionId = createVirtualSubagentSessionId( + 'parent-session', + 'agent-1', + ); + const [parentPart, agentPart] = sessionId + .slice('subagent.'.length) + .split('.'); + const nonCanonicalSessionId = `subagent.${parentPart}${padParent ? '=' : ''}.${agentPart}${padAgent ? '=' : ''}`; + + expect( + parseVirtualSubagentSessionId(nonCanonicalSessionId), + ).toBeUndefined(); + }); + + it('rejects a non-canonical agent encoding that decodes as garbage', () => { + const sessionId = createVirtualSubagentSessionId( + 'parent-session', + 'agent-1', + ); + const [parentPart] = sessionId.slice('subagent.'.length).split('.'); + + expect( + parseVirtualSubagentSessionId(`subagent.${parentPart}.garbage`), + ).toBeUndefined(); + }); + it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( 'round-trips an existing agent id containing reserved characters: %s', (agentId) => { From 4c2c4211db04df1f6b22acf37d3880a276bfe41a Mon Sep 17 00:00:00 2001 From: carffuca Date: Sun, 9 Aug 2026 16:32:26 +0800 Subject: [PATCH 09/17] fix(serve): validate canonical virtual session ids --- packages/cli/src/serve/virtual-subagent-sessions.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 3c4e8eded13..0a7af803158 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -229,7 +229,7 @@ export function createVirtualSubagentSessionId( } const sessionId = `${PREFIX}${encodePart(parentSessionId)}.${encodePart(agentId)}`; if (sessionId.length > MAX_VIRTUAL_SESSION_ID_LENGTH) { - throw new Error('Virtual subagent session ids require valid id parts'); + throw new Error('Virtual subagent session id exceeds 2000 characters'); } return sessionId; } @@ -245,13 +245,17 @@ export function parseVirtualSubagentSessionId( } const parts = sessionId.slice(PREFIX.length).split('.'); if (parts.length !== 2) return undefined; - const parentSessionId = decodePart(parts[0]!); - const agentId = decodePart(parts[1]!); + const parentPart = parts[0]!; + const agentPart = parts[1]!; + const parentSessionId = decodePart(parentPart); + const agentId = decodePart(agentPart); if ( !parentSessionId || !agentId || !isValidVirtualParentSessionId(parentSessionId) || - !isValidVirtualAgentId(agentId) + !isValidVirtualAgentId(agentId) || + encodePart(parentSessionId) !== parentPart || + encodePart(agentId) !== agentPart ) { return undefined; } From 9750746e90e3ba065c9c0c03207728e8b8a250eb Mon Sep 17 00:00:00 2001 From: carffuca Date: Sun, 9 Aug 2026 16:36:13 +0800 Subject: [PATCH 10/17] test(serve): pin virtual session id length limit --- packages/cli/src/serve/virtual-subagent-sessions.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 4cfe64bee82..cc1735eb459 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -97,14 +97,18 @@ describe('VirtualSubagentSessions', () => { ).toThrow('valid id parts'); }); - it.each(['a'.repeat(500), '界'.repeat(492)])( + it.each([ + ['a'.repeat(500), 696], + [`${'界'.repeat(492)}aa`, 2_000], + ])( 'round-trips an agent id at an accepted length boundary', - (agentId) => { + (agentId, expectedSessionIdLength) => { const sessionId = createVirtualSubagentSessionId( 'parent-session', agentId, ); + expect(sessionId).toHaveLength(expectedSessionIdLength); expect(parseVirtualSubagentSessionId(sessionId)).toEqual({ parentSessionId: 'parent-session', agentId, From a4c7df2bd26556f749683cd475dad64505152a0e Mon Sep 17 00:00:00 2001 From: carffuca Date: Mon, 10 Aug 2026 09:07:54 +0800 Subject: [PATCH 11/17] test(serve): cover reserved ids through resolve --- .../serve/virtual-subagent-sessions.test.ts | 81 ++++++++++--------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index cc1735eb459..9219654d959 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -161,45 +161,52 @@ describe('VirtualSubagentSessions', () => { }, ); - it('resolves an out-of-band fork by agent task id', async () => { - const runtime = { - workspaceId: 'workspace-1', - workspaceCwd: '/workspace', - env: { mode: 'parent-process', overlayKeys: [] }, - bridge: { - getSessionTasksStatus: async () => ({ - v: 1 as const, - sessionId: 'parent-session', - now: Date.now(), - tasks: [ - { - kind: 'agent' as const, - id: 'fork-agent-1', - label: 'Review current changes', - description: 'Review current changes', - status: 'running' as const, - startTime: Date.now(), - runtimeMs: 1, - outputFile: '/tmp/fork-agent-1.jsonl', - isBackgrounded: true, - }, - ], - }), - }, - } as unknown as WorkspaceRuntime; + it.each(['fork-agent-1', 'general-purpose-agent:8'])( + 'resolves an out-of-band task by agent task id: %s', + async (taskId) => { + const runtime = { + workspaceId: 'workspace-1', + workspaceCwd: '/workspace', + env: { mode: 'parent-process', overlayKeys: [] }, + bridge: { + getSessionTasksStatus: async () => ({ + v: 1 as const, + sessionId: 'parent-session', + now: Date.now(), + tasks: [ + { + kind: 'agent' as const, + id: taskId, + label: 'Review current changes', + description: 'Review current changes', + status: 'running' as const, + startTime: Date.now(), + runtimeMs: 1, + outputFile: `/tmp/${taskId}.jsonl`, + isBackgrounded: true, + }, + ], + }), + }, + } as unknown as WorkspaceRuntime; - const resolved = await new VirtualSubagentSessions().resolve( - runtime, - 'parent-session', - 'fork-agent-1', - ); + const resolved = await new VirtualSubagentSessions().resolve( + runtime, + 'parent-session', + taskId, + ); - expect(resolved).toMatchObject({ - taskId: 'fork-agent-1', - title: 'Review current changes', - status: 'running', - }); - }); + expect(resolved).toMatchObject({ + taskId, + title: 'Review current changes', + status: 'running', + }); + expect(parseVirtualSubagentSessionId(resolved!.sessionId)).toEqual({ + parentSessionId: 'parent-session', + agentId: taskId, + }); + }, + ); it('resolves, fully loads, and independently streams an agent transcript', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-subagent-')); From 1c2791490a9cb165731e67456b3cc24b4c308de4 Mon Sep 17 00:00:00 2001 From: carffuca Date: Mon, 10 Aug 2026 09:08:47 +0800 Subject: [PATCH 12/17] refactor(serve): share virtual id part length limit --- packages/cli/src/serve/virtual-subagent-sessions.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 0a7af803158..93476ecb8c3 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -27,6 +27,7 @@ import { replayTranscriptRecordPage } from '../acp-integration/session/history-r import type { WorkspaceRuntime } from './workspace-registry.js'; const PREFIX = 'subagent.'; +const MAX_VIRTUAL_SESSION_ID_PART_LENGTH = 500; const MAX_VIRTUAL_SESSION_ID_LENGTH = 2_000; const POLL_INTERVAL_MS = 250; const TARGET_RETENTION_MS = 60_000; @@ -206,13 +207,17 @@ function decodePart(value: string): string | undefined { } function isValidVirtualParentSessionId(value: string): boolean { - return /^[a-zA-Z0-9_-]{1,500}$/.test(value); + return ( + value.length > 0 && + value.length <= MAX_VIRTUAL_SESSION_ID_PART_LENGTH && + /^[a-zA-Z0-9_-]+$/.test(value) + ); } function isValidVirtualAgentId(value: string): boolean { return ( value.length > 0 && - value.length <= 500 && + value.length <= MAX_VIRTUAL_SESSION_ID_PART_LENGTH && decodePart(encodePart(value)) === value ); } From 6688f96baa6a3e6aebc36bdc6a32f741b8bac563 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 19:56:49 +0000 Subject: [PATCH 13/17] test(serve): pin parent charset and encoded slash coverage --- packages/cli/src/serve/server.test.ts | 112 ++++++++++-------- .../serve/virtual-subagent-sessions.test.ts | 18 +++ 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 81ee23939fe..b495d922fbb 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -8785,60 +8785,70 @@ describe('createServeApp', () => { ]); }); - it('resolves and cancels a virtual subagent through its routes', async () => { - const bridge = fakeBridge({ - cancelSessionTaskImpl: async () => ({ cancelled: true }), - }); - const resolveSpy = vi - .spyOn(VirtualSubagentSessions.prototype, 'resolve') - .mockResolvedValue({ - sessionId: createVirtualSubagentSessionId( - 's-1', - 'general-purpose-agent:8', - ), - taskId: 'general-purpose-agent:8', - title: 'Investigate', - status: 'running', + it.each([ + ['agent%3A8', 'agent:8'], + ['agent%2F8', 'agent/8'], + ])( + 'resolves and cancels a virtual subagent through its routes: %s', + async (encodedTaskId, toolCallId) => { + const taskId = `general-purpose-${toolCallId}`; + const bridge = fakeBridge({ + cancelSessionTaskImpl: async () => ({ cancelled: true }), }); - const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; - const app = createServeApp( - { ...tokenOpts, workspace: WS_BOUND }, - undefined, - { bridge }, - ); + const resolveSpy = vi + .spyOn(VirtualSubagentSessions.prototype, 'resolve') + .mockResolvedValue({ + sessionId: createVirtualSubagentSessionId('s-1', taskId), + taskId, + title: 'Investigate', + status: 'running', + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); - try { - const resolveRes = await request(app) - .get('/session/s-1/subagents/agent%3A8') - .set('Host', `127.0.0.1:${tokenOpts.port}`) - .set('Authorization', 'Bearer secret'); - const cancelRes = await request(app) - .post('/session/s-1/subagents/agent%3A8/cancel') - .set('Host', `127.0.0.1:${tokenOpts.port}`) - .set('Authorization', 'Bearer secret'); + try { + const resolveRes = await request(app) + .get(`/session/s-1/subagents/${encodedTaskId}`) + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + const cancelRes = await request(app) + .post(`/session/s-1/subagents/${encodedTaskId}/cancel`) + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); - expect(resolveRes.status).toBe(200); - expect(resolveRes.headers['cache-control']).toBe('no-store'); - expect(resolveRes.body).toMatchObject({ - taskId: 'general-purpose-agent:8', - status: 'running', - }); - expect(cancelRes.status).toBe(200); - expect(cancelRes.body).toEqual({ cancelled: true }); - expect(resolveSpy).toHaveBeenCalledTimes(2); - expect(resolveSpy.mock.calls[0]?.slice(1)).toEqual(['s-1', 'agent:8']); - expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual(['s-1', 'agent:8']); - expect(bridge.cancelSessionTaskCalls).toEqual([ - { - sessionId: 's-1', - taskId: 'general-purpose-agent:8', - taskKind: 'agent', - }, - ]); - } finally { - resolveSpy.mockRestore(); - } - }); + expect(resolveRes.status).toBe(200); + expect(resolveRes.headers['cache-control']).toBe('no-store'); + expect(resolveRes.body).toMatchObject({ + taskId, + status: 'running', + }); + expect(cancelRes.status).toBe(200); + expect(cancelRes.body).toEqual({ cancelled: true }); + expect(resolveSpy).toHaveBeenCalledTimes(2); + expect(resolveSpy.mock.calls[0]?.slice(1)).toEqual([ + 's-1', + toolCallId, + ]); + expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual([ + 's-1', + toolCallId, + ]); + expect(bridge.cancelSessionTaskCalls).toEqual([ + { + sessionId: 's-1', + taskId, + taskKind: 'agent', + }, + ]); + } finally { + resolveSpy.mockRestore(); + } + }, + ); it('requires the parent runtime for virtual heartbeat and detach', async () => { const primaryBridge = fakeBridge(); diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 9219654d959..960c382420d 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -83,6 +83,15 @@ describe('VirtualSubagentSessions', () => { expect(() => createVirtualSubagentSessionId('parent session', 'agent-1'), ).toThrow('valid id parts'); + expect(() => + createVirtualSubagentSessionId('parent/session', 'agent-1'), + ).toThrow('valid id parts'); + expect(() => + createVirtualSubagentSessionId('parent:session', 'agent-1'), + ).toThrow('valid id parts'); + expect(() => + createVirtualSubagentSessionId('parent.session', 'agent-1'), + ).toThrow('valid id parts'); expect(() => createVirtualSubagentSessionId('parent-session', '')).toThrow( 'valid id parts', ); @@ -146,6 +155,15 @@ describe('VirtualSubagentSessions', () => { ).toBeUndefined(); }); + it('rejects a parent part outside the strict charset', () => { + const parentPart = Buffer.from('../foo', 'utf8').toString('base64url'); + const agentPart = Buffer.from('agent-1', 'utf8').toString('base64url'); + + expect( + parseVirtualSubagentSessionId(`subagent.${parentPart}.${agentPart}`), + ).toBeUndefined(); + }); + it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( 'round-trips an existing agent id containing reserved characters: %s', (agentId) => { From 53265f299e3e0a2149485475e29a9184014d2c3a Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 21:03:57 +0000 Subject: [PATCH 14/17] refactor(serve): sync virtual subagent id length limits (#8717) Wire MAX_VIRTUAL_SESSION_ID_PART_LENGTH into the two subagent route guards and interpolate MAX_VIRTUAL_SESSION_ID_LENGTH into the oversized session id error so each limit keeps a single source of truth. Pin the parent part's empty and length rejections in tests and document the lone-surrogate round-trip guard. --- packages/cli/src/serve/routes/session.ts | 8 ++++++-- packages/cli/src/serve/virtual-subagent-sessions.test.ts | 6 ++++++ packages/cli/src/serve/virtual-subagent-sessions.ts | 8 ++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 184985a7cd1..83e7ceb8107 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -108,6 +108,7 @@ import { deleteBranch, } from '../server/git-branch-ops.js'; import { + MAX_VIRTUAL_SESSION_ID_PART_LENGTH, parseVirtualSubagentSessionId, type VirtualSubagentSessions, } from '../virtual-subagent-sessions.js'; @@ -2405,7 +2406,7 @@ export function registerSessionRoutes( return; } const toolCallId = req.params['toolCallId']; - if (!toolCallId || toolCallId.length > 500) { + if (!toolCallId || toolCallId.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH) { res.status(400).json({ error: '`toolCallId` must be a non-empty tool call id', code: 'invalid_tool_call_id', @@ -2457,7 +2458,10 @@ export function registerSessionRoutes( return; } const toolCallId = req.params['toolCallId']; - if (!toolCallId || toolCallId.length > 500) { + if ( + !toolCallId || + toolCallId.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH + ) { res.status(400).json({ error: '`toolCallId` must be a non-empty tool call id', code: 'invalid_tool_call_id', diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 960c382420d..60e88b725a9 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -92,6 +92,12 @@ describe('VirtualSubagentSessions', () => { expect(() => createVirtualSubagentSessionId('parent.session', 'agent-1'), ).toThrow('valid id parts'); + expect(() => createVirtualSubagentSessionId('', 'agent-1')).toThrow( + 'valid id parts', + ); + expect(() => + createVirtualSubagentSessionId('a'.repeat(501), 'agent-1'), + ).toThrow('valid id parts'); expect(() => createVirtualSubagentSessionId('parent-session', '')).toThrow( 'valid id parts', ); diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 93476ecb8c3..0125ea1df0f 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -27,7 +27,7 @@ import { replayTranscriptRecordPage } from '../acp-integration/session/history-r import type { WorkspaceRuntime } from './workspace-registry.js'; const PREFIX = 'subagent.'; -const MAX_VIRTUAL_SESSION_ID_PART_LENGTH = 500; +export const MAX_VIRTUAL_SESSION_ID_PART_LENGTH = 500; const MAX_VIRTUAL_SESSION_ID_LENGTH = 2_000; const POLL_INTERVAL_MS = 250; const TARGET_RETENTION_MS = 60_000; @@ -218,6 +218,8 @@ function isValidVirtualAgentId(value: string): boolean { return ( value.length > 0 && value.length <= MAX_VIRTUAL_SESSION_ID_PART_LENGTH && + // Round-trip rejects lone surrogates: UTF-8 maps them to U+FFFD, so two + // distinct agent ids would otherwise encode to the same session id. decodePart(encodePart(value)) === value ); } @@ -234,7 +236,9 @@ export function createVirtualSubagentSessionId( } const sessionId = `${PREFIX}${encodePart(parentSessionId)}.${encodePart(agentId)}`; if (sessionId.length > MAX_VIRTUAL_SESSION_ID_LENGTH) { - throw new Error('Virtual subagent session id exceeds 2000 characters'); + throw new Error( + `Virtual subagent session id exceeds ${MAX_VIRTUAL_SESSION_ID_LENGTH} characters`, + ); } return sessionId; } From a91880c658b8d225a858bbbb4dbc0cb52df778ce Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 22:01:49 +0000 Subject: [PATCH 15/17] test(serve): pin parse-side parent id length limit (#8717) --- .../cli/src/serve/virtual-subagent-sessions.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 60e88b725a9..179db181830 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -170,6 +170,17 @@ describe('VirtualSubagentSessions', () => { ).toBeUndefined(); }); + it('rejects an oversized parent part under the total session id cap', () => { + const parentPart = Buffer.from('a'.repeat(600), 'utf8').toString( + 'base64url', + ); + const agentPart = Buffer.from('agent-1', 'utf8').toString('base64url'); + + expect( + parseVirtualSubagentSessionId(`subagent.${parentPart}.${agentPart}`), + ).toBeUndefined(); + }); + it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( 'round-trips an existing agent id containing reserved characters: %s', (agentId) => { From 3d0f6e008a25f41ea5e975d23426e29dc404c1dd Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Tue, 11 Aug 2026 23:28:26 +0000 Subject: [PATCH 16/17] refactor(serve): rename subagent route param to subagentRef (#8717) --- packages/cli/src/serve/routes/session.ts | 37 ++++++++++--------- packages/cli/src/serve/server.test.ts | 12 +++--- packages/cli/src/serve/server/telemetry.ts | 8 ++-- .../sdk-typescript/src/daemon/DaemonClient.ts | 12 +++--- 4 files changed, 36 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 83e7ceb8107..57f6f323d20 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -2393,8 +2393,8 @@ export function registerSessionRoutes( app.post('/session/:id/load', mutate(), restoreSessionHandler('load')); app.post('/session/:id/resume', mutate(), restoreSessionHandler('resume')); - app.get('/session/:id/subagents/:toolCallId', async (req, res) => { - const route = 'GET /session/:id/subagents/:toolCallId'; + app.get('/session/:id/subagents/:subagentRef', async (req, res) => { + const route = 'GET /session/:id/subagents/:subagentRef'; const sessionId = requireSessionId(req, res); if (!sessionId) return; if (!virtualSubagentSessions) { @@ -2405,11 +2405,14 @@ export function registerSessionRoutes( }); return; } - const toolCallId = req.params['toolCallId']; - if (!toolCallId || toolCallId.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH) { + const subagentRef = req.params['subagentRef']; + if ( + !subagentRef || + subagentRef.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH + ) { res.status(400).json({ - error: '`toolCallId` must be a non-empty tool call id', - code: 'invalid_tool_call_id', + error: '`subagentRef` must be a non-empty subagent reference', + code: 'invalid_subagent_ref', }); return; } @@ -2425,14 +2428,14 @@ export function registerSessionRoutes( const resolved = await virtualSubagentSessions.resolve( runtime, sessionId, - toolCallId, + subagentRef, ); if (!resolved) { res.status(404).json({ error: 'Subagent session not found', code: 'session_not_found', sessionId, - toolCallId, + subagentRef, }); return; } @@ -2443,10 +2446,10 @@ export function registerSessionRoutes( }); app.post( - '/session/:id/subagents/:toolCallId/cancel', + '/session/:id/subagents/:subagentRef/cancel', mutate(), async (req, res) => { - const route = 'POST /session/:id/subagents/:toolCallId/cancel'; + const route = 'POST /session/:id/subagents/:subagentRef/cancel'; const sessionId = requireSessionId(req, res); if (!sessionId) return; if (!virtualSubagentSessions) { @@ -2457,14 +2460,14 @@ export function registerSessionRoutes( }); return; } - const toolCallId = req.params['toolCallId']; + const subagentRef = req.params['subagentRef']; if ( - !toolCallId || - toolCallId.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH + !subagentRef || + subagentRef.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH ) { res.status(400).json({ - error: '`toolCallId` must be a non-empty tool call id', - code: 'invalid_tool_call_id', + error: '`subagentRef` must be a non-empty subagent reference', + code: 'invalid_subagent_ref', }); return; } @@ -2480,14 +2483,14 @@ export function registerSessionRoutes( const resolved = await virtualSubagentSessions.resolve( runtime, sessionId, - toolCallId, + subagentRef, ); if (!resolved) { res.status(404).json({ error: 'Subagent session not found', code: 'session_not_found', sessionId, - toolCallId, + subagentRef, }); return; } diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index b495d922fbb..55e80c4c301 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -8790,8 +8790,8 @@ describe('createServeApp', () => { ['agent%2F8', 'agent/8'], ])( 'resolves and cancels a virtual subagent through its routes: %s', - async (encodedTaskId, toolCallId) => { - const taskId = `general-purpose-${toolCallId}`; + async (encodedSubagentRef, subagentRef) => { + const taskId = `general-purpose-${subagentRef}`; const bridge = fakeBridge({ cancelSessionTaskImpl: async () => ({ cancelled: true }), }); @@ -8812,11 +8812,11 @@ describe('createServeApp', () => { try { const resolveRes = await request(app) - .get(`/session/s-1/subagents/${encodedTaskId}`) + .get(`/session/s-1/subagents/${encodedSubagentRef}`) .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret'); const cancelRes = await request(app) - .post(`/session/s-1/subagents/${encodedTaskId}/cancel`) + .post(`/session/s-1/subagents/${encodedSubagentRef}/cancel`) .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret'); @@ -8831,11 +8831,11 @@ describe('createServeApp', () => { expect(resolveSpy).toHaveBeenCalledTimes(2); expect(resolveSpy.mock.calls[0]?.slice(1)).toEqual([ 's-1', - toolCallId, + subagentRef, ]); expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual([ 's-1', - toolCallId, + subagentRef, ]); expect(bridge.cancelSessionTaskCalls).toEqual([ { diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 76640738364..f146cd2bf39 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -121,15 +121,15 @@ export const legacySessionTelemetryRoutes = [ }, { method: 'GET', - path: '/session/:id/subagents/:toolCallId', + path: '/session/:id/subagents/:subagentRef', attribution: 'handler_resolved', - route: 'GET /session/:id/subagents/:toolCallId', + route: 'GET /session/:id/subagents/:subagentRef', }, { method: 'POST', - path: '/session/:id/subagents/:toolCallId/cancel', + path: '/session/:id/subagents/:subagentRef/cancel', attribution: 'handler_resolved', - route: 'POST /session/:id/subagents/:toolCallId/cancel', + route: 'POST /session/:id/subagents/:subagentRef/cancel', }, { method: 'GET', diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index b6c9d1d3ec1..1843b300653 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -2532,24 +2532,24 @@ export class DaemonClient { async resolveSubagentSession( sessionId: string, - toolCallId: string, + subagentRef: string, clientId?: string, ): Promise { return await this.jsonRequest( - `/session/${urlEncode(sessionId)}/subagents/${urlEncode(toolCallId)}`, - 'GET /session/:id/subagents/:toolCallId', + `/session/${urlEncode(sessionId)}/subagents/${urlEncode(subagentRef)}`, + 'GET /session/:id/subagents/:subagentRef', { clientId, mode: 'rest' }, ); } async cancelSubagentSession( sessionId: string, - toolCallId: string, + subagentRef: string, clientId?: string, ): Promise<{ cancelled: boolean }> { return await this.jsonRequest<{ cancelled: boolean }>( - `/session/${urlEncode(sessionId)}/subagents/${urlEncode(toolCallId)}/cancel`, - 'POST /session/:id/subagents/:toolCallId/cancel', + `/session/${urlEncode(sessionId)}/subagents/${urlEncode(subagentRef)}/cancel`, + 'POST /session/:id/subagents/:subagentRef/cancel', { clientId, mode: 'rest', method: 'POST' }, ); } From 44f0e4c56d707057a0d4083a23c5cb58f8dcb27c Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Wed, 12 Aug 2026 00:19:15 +0000 Subject: [PATCH 17/17] test(serve): pin parse-side total session id cap (#8717) Add a decisive parse-side test for the 2,000-char total length cap where both decoded parts sit at the part length cap, rename the mislabeled part-cap test, and document why the parent validator keeps the strict charset while the agent validator only requires round-tripping. --- .../src/serve/virtual-subagent-sessions.test.ts | 17 ++++++++++++++++- .../cli/src/serve/virtual-subagent-sessions.ts | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/serve/virtual-subagent-sessions.test.ts b/packages/cli/src/serve/virtual-subagent-sessions.test.ts index 179db181830..f4479b36772 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.test.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.test.ts @@ -170,7 +170,7 @@ describe('VirtualSubagentSessions', () => { ).toBeUndefined(); }); - it('rejects an oversized parent part under the total session id cap', () => { + it('rejects an oversized parent part above the part length cap', () => { const parentPart = Buffer.from('a'.repeat(600), 'utf8').toString( 'base64url', ); @@ -181,6 +181,21 @@ describe('VirtualSubagentSessions', () => { ).toBeUndefined(); }); + it('rejects an oversized session id at the parse-side total length cap', () => { + // Both decoded parts sit exactly at the part length cap, so only the + // total length check can reject this id. + const parentPart = Buffer.from('a'.repeat(500), 'utf8').toString( + 'base64url', + ); + const agentPart = Buffer.from('界'.repeat(500), 'utf8').toString( + 'base64url', + ); + const sessionId = `subagent.${parentPart}.${agentPart}`; + + expect(sessionId).toHaveLength(2677); + expect(parseVirtualSubagentSessionId(sessionId)).toBeUndefined(); + }); + it.each(['general-purpose-agent:8', 'general-purpose-agent/8'])( 'round-trips an existing agent id containing reserved characters: %s', (agentId) => { diff --git a/packages/cli/src/serve/virtual-subagent-sessions.ts b/packages/cli/src/serve/virtual-subagent-sessions.ts index 0125ea1df0f..6286efab3b0 100644 --- a/packages/cli/src/serve/virtual-subagent-sessions.ts +++ b/packages/cli/src/serve/virtual-subagent-sessions.ts @@ -206,6 +206,8 @@ function decodePart(value: string): string | undefined { } } +// Parent ids reach filesystem paths, so they keep the strict charset; +// agent ids are comparison-only and may use the round-trippable space. function isValidVirtualParentSessionId(value: string): boolean { return ( value.length > 0 &&