Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
39 changes: 23 additions & 16 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 @@ -2392,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) {
Expand All @@ -2404,11 +2405,14 @@ export function registerSessionRoutes(
});
return;
}
const toolCallId = req.params['toolCallId'];
if (!toolCallId || toolCallId.length > 500) {
const subagentRef = req.params['subagentRef'];
if (
!subagentRef ||
subagentRef.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH
Comment on lines +2410 to +2411

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] The route-level 400 guard this PR rewrote (constant-based cap + new invalid_subagent_ref code) has zero test coverage — the only route tests are the two happy-path it.each cases below. Probe-verified: removing this guard flips an oversized ref's response from 400 to 500 (Virtual subagent session ids require valid id parts), because the ref reaches resolve() and suffix-matches a long task id. The cancel route's copy (~line 2465) is equally untested. — Failure scenario: with nothing pinning the 400 contract, a follow-up refactor (e.g. deduplicating this guard into request-helpers.ts) that drops or mis-bounds the length check ships green, letting >500-char refs reach resolve() — reintroducing via the back door the HTTP-500 failure class this PR exists to fix.

Suggested test alongside the happy-path cases, following this file's request(app) + Host/Bearer scaffolding:

it('rejects an oversized subagentRef with a 400', async () => {
  const oversized = 'a'.repeat(MAX_VIRTUAL_SESSION_ID_PART_LENGTH + 1);
  const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' };
  const app = createServeApp(
    { ...tokenOpts, workspace: WS_BOUND },
    undefined,
    { bridge: fakeBridge() },
  );
  const resolveRes = await request(app)
    .get(`/session/s-1/subagents/${oversized}`)
    .set('Host', `127.0.0.1:${tokenOpts.port}`)
    .set('Authorization', 'Bearer secret');
  const cancelRes = await request(app)
    .post(`/session/s-1/subagents/${oversized}/cancel`)
    .set('Host', `127.0.0.1:${tokenOpts.port}`)
    .set('Authorization', 'Bearer secret');
  expect(resolveRes.status).toBe(400);
  expect(resolveRes.body).toMatchObject({ code: 'invalid_subagent_ref' });
  expect(cancelRes.status).toBe(400);
  expect(cancelRes.body).toMatchObject({ code: 'invalid_subagent_ref' });
});
中文说明

[建议] 本 PR 重写的路由级 400 守卫(基于常量的上限 + 新的 invalid_subagent_ref code)没有任何测试覆盖 —— 路由测试只有下面两个 happy-path it.each 用例。已用探针验证:移除该守卫后,超长 ref 的响应会从 400 变为 500(Virtual subagent session ids require valid id parts),因为 ref 会进入 resolve() 并与长 task id 发生后缀匹配。cancel 路由中的副本(约第 2465 行)同样没有测试。—— 失败场景:在没有任何测试钉住 400 契约的情况下,后续的 refactor(比如把这个守卫去重提取到 request-helpers.ts)如果漏掉或写错长度检查,仍然能全绿合入,让超过 500 字符的 ref 进入 resolve() —— 从后门重新引入本 PR 要修复的 HTTP-500 失败类别。

建议在 happy-path 用例旁补充测试,沿用本文件 request(app) + Host/Bearer 的既有写法(示例代码见上)。

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

) {
Comment on lines +2408 to +2412

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] The subagentRef extraction + length validation + coded-400 payload is duplicated verbatim in the two subagent routes this PR rewrites (GET resolve and POST cancel). server/request-helpers.ts is the established home for exactly this shape — requireSessionId and parseClientIdHeader (extraction + length cap + coded 400) — and this file already imports from it. — Failure scenario: any future change to this validation must be applied to both copies in lockstep — this very diff had to edit both copies identically twice (the rename and the constant). One missed copy silently diverges the resolve and cancel routes' acceptance boundaries, so a ref accepted by resolve is rejected by cancel (or vice versa).

Extract a shared helper called from both handlers, e.g. beside requireSessionId:

function requireSubagentRef(req: Request, res: Response): string | null {
  const subagentRef = req.params['subagentRef'];
  if (
    !subagentRef ||
    subagentRef.length > MAX_VIRTUAL_SESSION_ID_PART_LENGTH
  ) {
    res.status(400).json({
      error: '`subagentRef` must be a non-empty subagent reference',
      code: 'invalid_subagent_ref',
    });
    return null;
  }
  return subagentRef;
}
中文说明

[建议] 本 PR 重写的两个 subagent 路由(GET resolve 和 POST cancel)中,subagentRef 的提取 + 长度校验 + 带 code 的 400 响应体是逐字重复的。server/request-helpers.ts 正是这类逻辑的既定归属 —— requireSessionIdparseClientIdHeader(提取 + 长度上限 + 带 code 的 400)—— 而且本文件已经从该模块导入。—— 失败场景:未来对这个校验的任何修改都必须同步应用到两处 —— 本 diff 自己就不得不两次同步修改这两份副本(重命名和常量)。漏改一处会悄悄让 resolve 与 cancel 路由的接受边界产生分歧,导致 resolve 接受的 ref 被 cancel 拒绝(反之亦然)。

提取一个两个 handler 共用的辅助函数,例如放在 requireSessionId 旁边(示例代码见上)。

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

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',
Comment on lines +2414 to +2415

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] The 400 message only mentions the non-empty constraint, but this diff's guard also rejects refs longer than MAX_VIRTUAL_SESSION_ID_PART_LENGTH (500) — and an Express :subagentRef segment can never be empty for a matched route, so the length bound is the only practically reachable trigger. The sibling parseClientIdHeader in server/request-helpers.ts states both constraints ("must be a non-empty token of 128 characters or fewer"). The identical block in the cancel route (~line 2469) has the same issue. — Failure scenario: a client sends a 501-character subagentRef (a long provider task id); the daemon answers 400 with a message implying the ref was empty, so the debugging engineer investigates the wrong property.

Suggested change
error: '`subagentRef` must be a non-empty subagent reference',
code: 'invalid_subagent_ref',
error: `\`subagentRef\` must be a non-empty subagent reference of ${MAX_VIRTUAL_SESSION_ID_PART_LENGTH} characters or fewer`,
code: 'invalid_subagent_ref',
中文说明

[建议] 这个 400 错误信息只提到了非空约束,但本次改动的守卫还会拒绝超过 MAX_VIRTUAL_SESSION_ID_PART_LENGTH(500)的 ref —— 而 Express 的 :subagentRef 段在匹配到路由时不可能为空,所以长度上限才是实际唯一可能触发的条件。同目录的 server/request-helpers.tsparseClientIdHeader 会同时声明两个约束("must be a non-empty token of 128 characters or fewer")。cancel 路由中相同的代码块(约第 2469 行)存在同样的问题。—— 失败场景:客户端发送 501 个字符的 subagentRef(比如一个很长的 provider task id);daemon 返回 400 并提示 ref 为空,调试的工程师会因此排查错误的方向。

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

});
return;
}
Expand All @@ -2424,14 +2428,14 @@ export function registerSessionRoutes(
const resolved = await virtualSubagentSessions.resolve(
runtime,
sessionId,
toolCallId,
subagentRef,
);
Comment on lines 2428 to 2432

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] The toolCallIdsubagentRef rename stops one layer short: VirtualSubagentSessions.resolve()'s third parameter is still named toolCallId, although after this change the value flowing in is routinely a provider task id like agent:8 rather than a tool-call id — the PR's own tests pass task ids here. — Failure scenario: a maintainer tracing subagentRef from the route into resolve(runtime, parentSessionId, toolCallId) is told the wrong contract at the seam and may reason the ref must be a transcript tool-call id when analyzing the candidate.id === toolCallId || candidate.toolUseId === toolCallId || candidate.id.endsWith(...) matching; a grep for subagentRef also misses the layer where matching actually happens.

The fix belongs in virtual-subagent-sessions.ts (not at this call site): rename resolve()'s third parameter to subagentRef. The transcript-level helpers (findLegacyTaskByToolCall, readParentToolCallMetrics) genuinely compare transcript tool-call ids and can keep their names.

中文说明

[建议] toolCallIdsubagentRef 的重命名还差一层:VirtualSubagentSessions.resolve() 的第三个参数仍然叫 toolCallId,而本次改动之后,流入这个参数的值通常是 agent:8 这样的 provider task id,而不是工具调用 id —— 本 PR 自己的测试也是在这里传入 task id。—— 失败场景:维护者从路由追踪 subagentRef 进入 resolve(runtime, parentSessionId, toolCallId) 时,会在这个接缝处得到错误的契约,在分析 candidate.id === toolCallId || candidate.toolUseId === toolCallId || candidate.id.endsWith(...) 匹配时可能误以为 ref 必须是 transcript 工具调用 id;grep subagentRef 也会漏掉真正发生匹配的这一层。

修复位置在 virtual-subagent-sessions.ts(而不是这个调用点):把 resolve() 的第三个参数重命名为 subagentRef。transcript 层的辅助函数(findLegacyTaskByToolCallreadParentToolCallMetrics)确实是在比较 transcript 的工具调用 id,可以保持不变。

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

if (!resolved) {
res.status(404).json({
error: 'Subagent session not found',
code: 'session_not_found',
sessionId,
toolCallId,
subagentRef,
});
return;
}
Expand All @@ -2442,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) {
Expand All @@ -2456,11 +2460,14 @@ export function registerSessionRoutes(
});
return;
}
const toolCallId = req.params['toolCallId'];
if (!toolCallId || toolCallId.length > 500) {
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;
}
Expand All @@ -2476,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;
}
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'],
])(
Comment on lines +8788 to +8791

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] Both added route cases decode to forms containing no %, so the tests cannot pin that the router decodes subagentRef exactly once (the plain unencoded case the replaced test had was also dropped). Mutation-probe verified: layering decodeURIComponent(req.params['subagentRef']!) on top of Express's built-in decode — a plausible "fix" in exactly this code area — passes both existing cases green, because decodeURIComponent('agent:8') === 'agent:8'. — Failure scenario: the relaxed validator accepts any round-trippable UTF-8 agent id, which includes %: for a task whose literal id is agent%3A8, the SDK sends agent%253A8, Express decodes once to agent%3A8, and a double-decoding handler resolves/cancels agent:8 instead — the wrong subagent session (or 404 on a valid one).

Suggested change
it.each([
['agent%3A8', 'agent:8'],
['agent%2F8', 'agent/8'],
])(
it.each([
['agent%3A8', 'agent:8'],
['agent%2F8', 'agent/8'],
['agent%253A8', 'agent%3A8'],
])(

The added case passes today and fails under the double-decode mutant; optionally also restore the pre-diff plain scenario with ['tool-1', 'tool-1'].

中文说明

[建议] 新增的两个路由用例解码后都不含 %,因此测试无法钉住"路由器只对 subagentRef 解码一次"这一点(被替换的旧测试中的纯文本未编码用例也被删掉了)。已用变异探针验证:在 Express 内建解码之上再叠一层 decodeURIComponent(req.params['subagentRef']!) —— 在这段代码区域是一个很"合理"的"修复" —— 两个现有用例仍然全绿,因为 decodeURIComponent('agent:8') === 'agent:8'。—— 失败场景:放宽后的校验器接受任何可无损往返的 UTF-8 agent id,其中包括含 % 的:若某个 task 的字面 id 是 agent%3A8,SDK 会发送 agent%253A8,Express 解码一次得到 agent%3A8,而二次解码的 handler 会解析/取消 agent:8 —— 命中错误的 subagent 会话(或对一个有效会话返回 404)。

新增的 ['agent%253A8', 'agent%3A8'] 用例在当前代码下通过,在二次解码变异体下失败;也可以选择用 ['tool-1', 'tool-1'] 恢复改动前的纯文本场景。

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

'resolves and cancels a virtual subagent through its routes: %s',
async (encodedSubagentRef, subagentRef) => {
const taskId = `general-purpose-${subagentRef}`;
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/${encodedSubagentRef}`)
.set('Host', `127.0.0.1:${tokenOpts.port}`)
.set('Authorization', 'Bearer secret');
const cancelRes = await request(app)
.post(`/session/s-1/subagents/${encodedSubagentRef}/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',
subagentRef,
]);
expect(resolveSpy.mock.calls[1]?.slice(1)).toEqual([
's-1',
subagentRef,
]);
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
8 changes: 4 additions & 4 deletions packages/cli/src/serve/server/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading