Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
860a334
test(serve): reproduce encoded subagent id failure
carffuca Aug 8, 2026
6df743f
fix(serve): support encoded subagent task ids
carffuca Aug 8, 2026
1713e9a
test(serve): cover oversized encoded subagent ids
carffuca Aug 8, 2026
2ba6d37
fix(serve): bound encoded virtual session ids
carffuca Aug 8, 2026
0945d22
test(serve): reject lossy virtual agent ids
carffuca Aug 8, 2026
977e6d8
fix(serve): preserve virtual agent id round trips
carffuca Aug 8, 2026
b69cf27
test(serve): cover virtual agent id length limit
carffuca Aug 8, 2026
2ba3bbc
test(serve): cover virtual session id boundaries
carffuca Aug 9, 2026
4c2c421
fix(serve): validate canonical virtual session ids
carffuca Aug 9, 2026
9750746
test(serve): pin virtual session id length limit
carffuca Aug 9, 2026
a4c7df2
test(serve): cover reserved ids through resolve
carffuca Aug 10, 2026
1c27914
refactor(serve): share virtual id part length limit
carffuca Aug 10, 2026
d425d1f
Merge branch 'main' into fix/virtual-subagent-session-id-parts
qwen-code-dev-bot Aug 11, 2026
6688f96
test(serve): pin parent charset and encoded slash coverage
qwen-code-dev-bot Aug 11, 2026
53265f2
refactor(serve): sync virtual subagent id length limits (#8717)
qwen-code-dev-bot Aug 11, 2026
a91880c
test(serve): pin parse-side parent id length limit (#8717)
qwen-code-dev-bot Aug 11, 2026
3d0f6e0
refactor(serve): rename subagent route param to subagentRef (#8717)
qwen-code-dev-bot Aug 11, 2026
44f0e4c
test(serve): pin parse-side total session id cap (#8717)
qwen-code-dev-bot Aug 12, 2026
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
8 changes: 6 additions & 2 deletions packages/cli/src/serve/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Route parameter name :toolCallId is misleading since it also accepts agent task IDs — Failure scenario: A maintainer sees the route /session/:id/subagents/:toolCallId and the error message "toolCallId must be a non-empty tool call id", reasonably assumes the value is always a tool call ID, and adds a regex guard that rejects : and /. The feature that resolves agent task IDs with these characters would silently return 400 errors.

中文说明

路由参数名 :toolCallId 具有误导性,因为它同时也接受 agent task ID——当维护者看到 /session/:id/subagents/:toolCallId 路由时,会合理认为该值始终是工具调用 ID,可能会添加拒绝 : 和 / 的正则校验,导致该功能静默返回 400 错误。建议将路由参数重命名为 :taskRef 或 :subagentRef,并更新相关的错误消息。

— deepseek-v4-flash via Qwen Code /review (v0.21.8)

res.status(400).json({
error: '`toolCallId` must be a non-empty tool call id',
code: 'invalid_tool_call_id',
Expand Down Expand Up @@ -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',
Expand Down
105 changes: 61 additions & 44 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8785,53 +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', 'agent-1'),
taskId: 'agent-1',
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/tool-1')
.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')
.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: 'agent-1',
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(bridge.cancelSessionTaskCalls).toEqual([
{ sessionId: 's-1', taskId: 'agent-1', 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();
Expand Down
187 changes: 151 additions & 36 deletions packages/cli/src/serve/virtual-subagent-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,55 +79,170 @@ function activeTarget(sessions: VirtualSubagentSessions): {
}

describe('VirtualSubagentSessions', () => {
it('rejects id parts that the parser cannot accept', () => {
it('rejects invalid, oversized, or non-round-trippable id parts', () => {
expect(() =>
createVirtualSubagentSessionId('parent session', 'agent-1'),
).toThrow('valid id parts');
expect(() =>
createVirtualSubagentSessionId('parent-session', 'agent/1'),
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('', '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',
);
expect(() =>
createVirtualSubagentSessionId('parent-session', 'a'.repeat(501)),
).toThrow('valid id parts');
expect(() =>
createVirtualSubagentSessionId('parent-session', '界'.repeat(493)),
).toThrow('exceeds 2000 characters');
expect(() =>
createVirtualSubagentSessionId('parent-session', '\ud800'),
).toThrow('valid id parts');
});

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([
['a'.repeat(500), 696],
[`${'界'.repeat(492)}aa`, 2_000],
])(
'round-trips an agent id at an accepted length boundary',
(agentId, expectedSessionIdLength) => {
const sessionId = createVirtualSubagentSessionId(
'parent-session',
agentId,
);

const resolved = await new VirtualSubagentSessions().resolve(
runtime,
expect(sessionId).toHaveLength(expectedSessionIdLength);
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',
'fork-agent-1',
'agent-1',
);
const [parentPart, agentPart] = sessionId
.slice('subagent.'.length)
.split('.');
const nonCanonicalSessionId = `subagent.${parentPart}${padParent ? '=' : ''}.${agentPart}${padAgent ? '=' : ''}`;

expect(resolved).toMatchObject({
taskId: 'fork-agent-1',
title: 'Review current changes',
status: 'running',
});
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('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('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',
Comment on lines +199 to +200

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-1: No test chains a reserved-character task ID through the real VirtualSubagentSessions.resolve() — the reserved-char round-trip tests below call createVirtualSubagentSessionId/parseVirtualSubagentSessionId directly, the server route test mocks resolve, and every real-resolve unit test uses plain IDs only (fork-agent-1, general-purpose-call-1, ...). — Failure scenario: a future change at the resolve() call site that re-restricts or transforms task.id before ID creation (e.g. reintroducing a per-part charset filter) silently ships this PR's exact bug — HTTP 500 for agent:8-style provider IDs — while every current test stays green (verified by probe: a strict charset gate injected on task.id left all existing tests green; adding the missing chain test failed with the original valid id parts error).

Suggested fix — add one real-resolve case with a reserved-char id, e.g. a sibling of 'resolves an out-of-band fork by agent task id' where the bridge task's id is general-purpose-agent:8, asserting the resolved sessionId parses back to that agent ID:

it('resolves an out-of-band task whose id contains reserved characters', async () => {
  // same scaffold as the fork test, bridge task id 'general-purpose-agent:8'
  const target = await sessions.resolve(runtime, 'parent-1', 'general-purpose-agent:8');
  expect(parseVirtualSubagentSessionId(target.sessionId)).toMatchObject({
    agentId: 'general-purpose-agent:8',
  });
});
中文说明

没有测试把含保留字符的 task ID 经由真实的 VirtualSubagentSessions.resolve() 串到 createVirtualSubagentSessionId:下方的保留字符往返测试直接调用 create/parse,server 路由测试 mock 了 resolve,而所有真实 resolve 单测只用普通 ID(fork-agent-1、general-purpose-call-1 等)。— 失败场景:未来在 resolve() 调用点对 task.id 重新收紧或变换(例如重新引入按部件的字符集过滤),会在所有现有测试保持绿色的情况下悄悄复发本 PR 修复的 bug——agent:8 这类 provider ID 返回 HTTP 500(已用探针验证:在 task.id 上注入严格字符集门槛后全部现有测试仍为绿色;补上缺失的链路测试则以原始的 valid id parts 报错失败)。

建议修复——为真实 resolve 补一个含保留字符 ID 的用例,例如在 'resolves an out-of-band fork by agent task id' 旁新增一个 bridge task id 为 general-purpose-agent:8 的用例,断言解析出的 sessionId 能 parse 回该 agent ID。

— qwen3.8-max via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a4c7df2. The existing out-of-band resolve test is now parameterized with general-purpose-agent:8 and asserts that the sessionId returned by the real VirtualSubagentSessions.resolve() path parses back to the same task ID. I also verified the test's efficacy by temporarily reintroducing a strict charset gate at the resolve call site: only the new reserved-ID case failed with the original valid id parts error. The focused suite passes 19/19 on the final code.

(agentId) => {
const sessionId = createVirtualSubagentSessionId(
'parent-session',
agentId,
);

expect(parseVirtualSubagentSessionId(sessionId)).toEqual({
parentSessionId: 'parent-session',
agentId,
});
},
);

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',
taskId,
);

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-'));
tempDirs.push(dir);
Expand Down
Loading
Loading