authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-30 23:02:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 23:02:12-07:00
log7ab800a43c770b2c48a2d9306c89b6177f61ad4a
tree4c90ad288bf164ab009dcf263869f11ba4d186e3
parent783ccf0fdde0d20824ad87af10a870fc28005835
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude subagents with their own model no longer show the parent's model (#14540)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 103 insertions(+), 49 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+23-13
...@@ -3991,7 +3991,7 @@ describe("ClaudeAdapterLive", () => {...@@ -3991,7 +3991,7 @@ describe("ClaudeAdapterLive", () => {
39913991
3992 const taskEventsFiber = yield* adapter.streamEvents.pipe(3992 const taskEventsFiber = yield* adapter.streamEvents.pipe(
3993 Stream.filter((event) => event.type.startsWith("task.")),3993 Stream.filter((event) => event.type.startsWith("task.")),
3994 Stream.take(2),3994 Stream.take(3),
3995 Stream.runCollect,3995 Stream.runCollect,
3996 Effect.forkChild,3996 Effect.forkChild,
3997 );3997 );
...@@ -4025,24 +4025,27 @@ describe("ClaudeAdapterLive", () => {...@@ -4025,24 +4025,27 @@ describe("ClaudeAdapterLive", () => {
4025 session_id: "sdk-session",4025 session_id: "sdk-session",
4026 } as unknown as SDKMessage);4026 } as unknown as SDKMessage);
4027 // The subagent's assistant snapshot carries the authoritative API4027 // The subagent's assistant snapshot carries the authoritative API
4028 // model id, which refines the linkage on later rows.4028 // model id. The correction is pushed at once, not on the next task row.
4029 // Its tool calls arrive only here, so a task one of them launches must
4030 // still resolve to this subagent as owner.
4029 harness.query.emit({4031 harness.query.emit({
4030 type: "assistant",4032 type: "assistant",
4031 parent_tool_use_id: "toolu_agent_m",4033 parent_tool_use_id: "toolu_agent_m",
4032 message: {4034 message: {
4033 model: SYNTHETIC_SUBAGENT_MODEL,4035 model: SYNTHETIC_SUBAGENT_MODEL,
4034 content: [],4036 content: [{ type: "tool_use", id: "toolu_nested", name: "Skill", input: {} }],
4035 },4037 },
4036 uuid: "subagent-snapshot-uuid",4038 uuid: "subagent-snapshot-uuid",
4037 session_id: "sdk-session",4039 session_id: "sdk-session",
4038 } as unknown as SDKMessage);4040 } as unknown as SDKMessage);
4039 harness.query.emit({4041 harness.query.emit({
4040 type: "system",4042 type: "system",
4041 subtype: "task_progress",4043 subtype: "task_started",
4042 task_id: "task-model",4044 task_id: "task-nested",
4043 description: "Agent M",4045 description: "Nested",
4044 usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 },4046 task_type: "local_agent",
4045 uuid: "task-model-progress-uuid",4047 tool_use_id: "toolu_nested",
4048 uuid: "task-nested-uuid",
4046 session_id: "sdk-session",4049 session_id: "sdk-session",
4047 } as unknown as SDKMessage);4050 } as unknown as SDKMessage);
40484051
...@@ -4053,11 +4056,18 @@ describe("ClaudeAdapterLive", () => {...@@ -4053,11 +4056,18 @@ describe("ClaudeAdapterLive", () => {
4053 assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL);4056 assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
4054 assert.equal(started.payload.effort, "max");4057 assert.equal(started.payload.effort, "max");
4055 }4058 }
4056 const progress = taskEvents[1];4059 const refined = taskEvents[1];
4057 assert.equal(progress?.type, "task.progress");4060 assert.equal(refined?.type, "task.updated");
4058 if (progress?.type === "task.progress") {4061 if (refined?.type === "task.updated") {
4059 assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);4062 assert.equal(refined.payload.taskId, "task-model");
4060 assert.equal(progress.payload.effort, "max");4063 assert.equal(refined.payload.model, SYNTHETIC_SUBAGENT_MODEL);
4064 assert.equal(refined.payload.status, undefined);
4065 }
4066 const nested = taskEvents[2];
4067 assert.equal(nested?.type, "task.started");
4068 if (nested?.type === "task.started") {
4069 assert.equal(nested.payload.agentId, "task-model");
4070 assert.equal(nested.payload.model, SYNTHETIC_SUBAGENT_MODEL);
4061 }4071 }
4062 }).pipe(4072 }).pipe(
4063 Effect.provideService(Random.Random, makeDeterministicRandomService()),4073 Effect.provideService(Random.Random, makeDeterministicRandomService()),
apps/server/src/provider/Layers/ClaudeAdapter.ts+80-36
...@@ -380,26 +380,26 @@ interface ClaudeTaskAgentState {...@@ -380,26 +380,26 @@ interface ClaudeTaskAgentState {
380}380}
381381
382/**382/**
383 * How many racing snapshot models to buffer per session. A snapshot whose383 * How many entries each task_started lookup map buffers per session. An entry
384 * task_started never arrives would otherwise pin its entry for the session's384 * whose task_started never arrives would otherwise pin it for the session's
385 * lifetime; oldest entries evict first.385 * lifetime; oldest entries evict first.
386 */386 */
387const PENDING_TASK_MODEL_CAP = 64;387const PENDING_TASK_ENTRY_CAP = 256;
388/** How long Stop waits for Claude to abort a turn before killing the process. */388/** How long Stop waits for Claude to abort a turn before killing the process. */
389const CLAUDE_INTERRUPT_GRACE = "3 seconds";389const CLAUDE_INTERRUPT_GRACE = "3 seconds";
390390
391/**391/**
392 * Buffers a subagent snapshot's authoritative model under its392 * Buffers a value that a later task_started reads by tool_use_id (a racing
393 * parent_tool_use_id, for snapshots that beat their task_started to the393 * snapshot model, or a subagent's tool_use owner). task_started consumes the
394 * stream. task_started consumes the entry when it registers the task.394 * entry when it registers the task.
395 */395 */
396function rememberPendingTaskModel(396function rememberPendingTaskEntry(
397 pending: Map<string, string>,397 pending: Map<string, string>,
398 parentToolUseId: string,398 toolUseId: string,
399 model: string,399 value: string,
400): void {400): void {
401 pending.set(parentToolUseId, model);401 pending.set(toolUseId, value);
402 if (pending.size > PENDING_TASK_MODEL_CAP) {402 if (pending.size > PENDING_TASK_ENTRY_CAP) {
403 const oldest = pending.keys().next();403 const oldest = pending.keys().next();
404 if (!oldest.done) {404 if (!oldest.done) {
405 pending.delete(oldest.value);405 pending.delete(oldest.value);
...@@ -434,9 +434,16 @@ interface ClaudeSessionContext {...@@ -434,9 +434,16 @@ interface ClaudeSessionContext {
434 /**434 /**
435 * Authoritative subagent models from assistant snapshots that arrived before435 * Authoritative subagent models from assistant snapshots that arrived before
436 * their task_started registered the task, keyed by parent_tool_use_id.436 * their task_started registered the task, keyed by parent_tool_use_id.
437 * Written through `rememberPendingTaskModel`, consumed by task_started.437 * Written through `rememberPendingTaskEntry`, consumed by task_started.
438 */438 */
439 readonly pendingTaskModels: Map<string, string>;439 readonly pendingTaskModels: Map<string, string>;
440 /**
441 * tool_use_id → parent_tool_use_id for tool calls made inside a subagent.
442 * The SDK forwards those calls only as assistant snapshots, never as stream
443 * events, so task_started reads this to find the owner of a task launched
444 * from inside a subagent.
445 */
446 readonly subagentToolParents: Map<string, string>;
440 /**447 /**
441 * Last emitted workflow-member fingerprint per member slot. A coordinator448 * Last emitted workflow-member fingerprint per member slot. A coordinator
442 * task_progress repeats the FULL member array every tick; without a449 * task_progress repeats the FULL member array every tick; without a
...@@ -3370,18 +3377,45 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3370,18 +3377,45 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3370 const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId);3377 const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId);
3371 const snapshotModel = trimmedString(message.message.model);3378 const snapshotModel = trimmedString(message.message.model);
3372 const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined;3379 const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined;
3373 if (snapshotModel) {3380 const snapshotContent = Array.isArray(message.message.content) ? message.message.content : [];
3374 if (owningAgent) {3381 for (const block of snapshotContent) {
3382 if (block.type === "tool_use") {
3383 rememberPendingTaskEntry(context.subagentToolParents, block.id, assistantParentToolUseId);
3384 }
3385 }
3386 if (snapshotModel && owningAgent) {
3387 if (owningAgent.model !== snapshotModel) {
3375 owningAgent.model = snapshotModel;3388 owningAgent.model = snapshotModel;
3376 } else {3389 // Push the correction now. The next task row can be minutes away
3377 // The snapshot beat its task_started (or its tool_use_id was never3390 // when the subagent blocks on a long foreground tool.
3378 // recorded): hold the model until the task registers.3391 const stamp = yield* makeEventStamp();
3379 rememberPendingTaskModel(3392 yield* offerRuntimeEvent({
3380 context.pendingTaskModels,3393 type: "task.updated",
3381 assistantParentToolUseId,3394 eventId: stamp.eventId,
3382 snapshotModel,3395 provider: PROVIDER,
3383 );3396 createdAt: stamp.createdAt,
3397 threadId: context.session.threadId,
3398 ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
3399 payload: {
3400 taskId: RuntimeTaskId.make(owningAgent.taskId),
3401 ...taskLinkageFor(context.taskAgents, owningAgent.taskId),
3402 },
3403 providerRefs: nativeProviderRefs(context),
3404 raw: {
3405 source: "claude.sdk.message",
3406 method: "claude/assistant",
3407 payload: message,
3408 },
3409 });
3384 }3410 }
3411 } else if (snapshotModel) {
3412 // The snapshot beat its task_started (or its tool_use_id was never
3413 // recorded): hold the model until the task registers.
3414 rememberPendingTaskEntry(
3415 context.pendingTaskModels,
3416 assistantParentToolUseId,
3417 snapshotModel,
3418 );
3385 }3419 }
3386 context.lastAssistantUuid = message.uuid;3420 context.lastAssistantUuid = message.uuid;
3387 yield* updateResumeCursor(context);3421 yield* updateResumeCursor(context);
...@@ -3735,15 +3769,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3735,15 +3769,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3735 });3769 });
3736 return;3770 return;
3737 case "task_started": {3771 case "task_started": {
3738 // A task launched by a tool that itself ran inside a subagent (the3772 // A task launched by a tool that itself ran inside a subagent is
3739 // in-flight tool carries agentId from parent_tool_use_id) is3773 // agent-internal: a subagent's background shell, not parent work. A
3740 // agent-internal: a subagent's background shell, not parent work.3774 // streamed tool carries agentId from parent_tool_use_id; a tool seen
3741 const launchingTool = message.tool_use_id3775 // only in a subagent snapshot resolves through subagentToolParents.
3742 ? Array.from(context.inFlightTools.values()).find(3776 const toolUseId = message.tool_use_id;
3743 (tool) => tool.itemId === message.tool_use_id,3777 const launchingTool = toolUseId
3744 )3778 ? Array.from(context.inFlightTools.values()).find((tool) => tool.itemId === toolUseId)
3745 : undefined;3779 : undefined;
3746 const owningAgentId = launchingTool?.agentId;3780 const launchParentToolUseId = toolUseId
3781 ? context.subagentToolParents.get(toolUseId)
3782 : undefined;
3783 if (toolUseId) {
3784 context.subagentToolParents.delete(toolUseId);
3785 }
3786 const owningAgentId =
3787 launchingTool?.agentId ??
3788 agentIdForParentToolUse(context.taskAgents, launchParentToolUseId);
3747 if (3789 if (
3748 context.turnState &&3790 context.turnState &&
3749 classifyTaskAgentKind({3791 classifyTaskAgentKind({
...@@ -3754,13 +3796,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3754,13 +3796,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3754 context.turnState.hasSubagents = true;3796 context.turnState.hasSubagents = true;
3755 }3797 }
3756 // Model/effort: the Agent tool's input carries explicit overrides;3798 // Model/effort: the Agent tool's input carries explicit overrides;
3757 // absent ones inherit the session's selection (SDK behavior).3799 // absent ones inherit the owning subagent's model, else the session's
3758 // Subagent assistant snapshots refine model with the authoritative API3800 // selection (SDK behavior). Subagent assistant snapshots refine model
3759 // id: one that already arrived is buffered and outranks the seed here,3801 // with the authoritative API id: one that already arrived is buffered
3760 // later ones refine the record in place. AgentInput.effort may be a3802 // and outranks the seed here, later ones refine the record in place.
3761 // named level or an integer.3803 // AgentInput.effort may be a named level or an integer.
3762 const launchInput = launchingTool?.input;3804 const launchInput = launchingTool?.input;
3763 const toolUseId = message.tool_use_id;
3764 const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined;3805 const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined;
3765 if (toolUseId) {3806 if (toolUseId) {
3766 context.pendingTaskModels.delete(toolUseId);3807 context.pendingTaskModels.delete(toolUseId);
...@@ -3768,6 +3809,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3768,6 +3809,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3768 const model =3809 const model =
3769 bufferedModel ??3810 bufferedModel ??
3770 trimmedString(launchInput?.model) ??3811 trimmedString(launchInput?.model) ??
3812 (owningAgentId ? context.taskAgents.get(owningAgentId)?.model : undefined) ??
3771 trimmedString(context.session.model ?? undefined);3813 trimmedString(context.session.model ?? undefined);
3772 const rawLaunchEffort = launchInput?.effort;3814 const rawLaunchEffort = launchInput?.effort;
3773 const effort =3815 const effort =
...@@ -4471,6 +4513,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4471,6 +4513,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4471 const claudeTasks = new Map<string, ClaudeTaskState>();4513 const claudeTasks = new Map<string, ClaudeTaskState>();
4472 const taskAgents = new Map<string, ClaudeTaskAgentState>();4514 const taskAgents = new Map<string, ClaudeTaskAgentState>();
4473 const pendingTaskModels = new Map<string, string>();4515 const pendingTaskModels = new Map<string, string>();
4516 const subagentToolParents = new Map<string, string>();
4474 const workflowMemberFingerprints = new Map<string, string>();4517 const workflowMemberFingerprints = new Map<string, string>();
4475 const liveTaskIds = new Set<string>();4518 const liveTaskIds = new Set<string>();
44764519
...@@ -5078,6 +5121,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -5078,6 +5121,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
5078 claudeTasks,5121 claudeTasks,
5079 taskAgents,5122 taskAgents,
5080 pendingTaskModels,5123 pendingTaskModels,
5124 subagentToolParents,
5081 workflowMemberFingerprints,5125 workflowMemberFingerprints,
5082 liveTaskIds,5126 liveTaskIds,
5083 turnState: undefined,5127 turnState: undefined,