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", () => {
39913991
39923992 const taskEventsFiber = yield* adapter.streamEvents.pipe(
39933993 Stream.filter((event) => event.type.startsWith("task.")),
3994 Stream.take(2),
3994 Stream.take(3),
39953995 Stream.runCollect,
39963996 Effect.forkChild,
39973997 );
......@@ -4025,24 +4025,27 @@ describe("ClaudeAdapterLive", () => {
40254025 session_id: "sdk-session",
40264026 } as unknown as SDKMessage);
40274027 // 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.
40294031 harness.query.emit({
40304032 type: "assistant",
40314033 parent_tool_use_id: "toolu_agent_m",
40324034 message: {
40334035 model: SYNTHETIC_SUBAGENT_MODEL,
4034 content: [],
4036 content: [{ type: "tool_use", id: "toolu_nested", name: "Skill", input: {} }],
40354037 },
40364038 uuid: "subagent-snapshot-uuid",
40374039 session_id: "sdk-session",
40384040 } as unknown as SDKMessage);
40394041 harness.query.emit({
40404042 type: "system",
4041 subtype: "task_progress",
4042 task_id: "task-model",
4043 description: "Agent M",
4044 usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 },
4045 uuid: "task-model-progress-uuid",
4043 subtype: "task_started",
4044 task_id: "task-nested",
4045 description: "Nested",
4046 task_type: "local_agent",
4047 tool_use_id: "toolu_nested",
4048 uuid: "task-nested-uuid",
40464049 session_id: "sdk-session",
40474050 } as unknown as SDKMessage);
40484051
......@@ -4053,11 +4056,18 @@ describe("ClaudeAdapterLive", () => {
40534056 assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL);
40544057 assert.equal(started.payload.effort, "max");
40554058 }
4056 const progress = taskEvents[1];
4057 assert.equal(progress?.type, "task.progress");
4058 if (progress?.type === "task.progress") {
4059 assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL);
4060 assert.equal(progress.payload.effort, "max");
4059 const refined = taskEvents[1];
4060 assert.equal(refined?.type, "task.updated");
4061 if (refined?.type === "task.updated") {
4062 assert.equal(refined.payload.taskId, "task-model");
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);
40614071 }
40624072 }).pipe(
40634073 Effect.provideService(Random.Random, makeDeterministicRandomService()),
apps/server/src/provider/Layers/ClaudeAdapter.ts+80-36
......@@ -380,26 +380,26 @@ interface ClaudeTaskAgentState {
380380}
381381
382382/**
383 * How many racing snapshot models to buffer per session. A snapshot whose
384 * task_started never arrives would otherwise pin its entry for the session's
383 * How many entries each task_started lookup map buffers per session. An entry
384 * whose task_started never arrives would otherwise pin it for the session's
385385 * lifetime; oldest entries evict first.
386386 */
387const PENDING_TASK_MODEL_CAP = 64;
387const PENDING_TASK_ENTRY_CAP = 256;
388388/** How long Stop waits for Claude to abort a turn before killing the process. */
389389const CLAUDE_INTERRUPT_GRACE = "3 seconds";
390390
391391/**
392 * Buffers a subagent snapshot's authoritative model under its
393 * parent_tool_use_id, for snapshots that beat their task_started to the
394 * stream. task_started consumes the entry when it registers the task.
392 * Buffers a value that a later task_started reads by tool_use_id (a racing
393 * snapshot model, or a subagent's tool_use owner). task_started consumes the
394 * entry when it registers the task.
395395 */
396function rememberPendingTaskModel(
396function rememberPendingTaskEntry(
397397 pending: Map<string, string>,
398 parentToolUseId: string,
399 model: string,
398 toolUseId: string,
399 value: string,
400400): void {
401 pending.set(parentToolUseId, model);
402 if (pending.size > PENDING_TASK_MODEL_CAP) {
401 pending.set(toolUseId, value);
402 if (pending.size > PENDING_TASK_ENTRY_CAP) {
403403 const oldest = pending.keys().next();
404404 if (!oldest.done) {
405405 pending.delete(oldest.value);
......@@ -434,9 +434,16 @@ interface ClaudeSessionContext {
434434 /**
435435 * Authoritative subagent models from assistant snapshots that arrived before
436436 * 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.
438438 */
439439 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>;
440447 /**
441448 * Last emitted workflow-member fingerprint per member slot. A coordinator
442449 * task_progress repeats the FULL member array every tick; without a
......@@ -3370,18 +3377,45 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
33703377 const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId);
33713378 const snapshotModel = trimmedString(message.message.model);
33723379 const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined;
3373 if (snapshotModel) {
3374 if (owningAgent) {
3380 const snapshotContent = Array.isArray(message.message.content) ? message.message.content : [];
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) {
33753388 owningAgent.model = snapshotModel;
3376 } else {
3377 // The snapshot beat its task_started (or its tool_use_id was never
3378 // recorded): hold the model until the task registers.
3379 rememberPendingTaskModel(
3380 context.pendingTaskModels,
3381 assistantParentToolUseId,
3382 snapshotModel,
3383 );
3389 // Push the correction now. The next task row can be minutes away
3390 // when the subagent blocks on a long foreground tool.
3391 const stamp = yield* makeEventStamp();
3392 yield* offerRuntimeEvent({
3393 type: "task.updated",
3394 eventId: stamp.eventId,
3395 provider: PROVIDER,
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 });
33843410 }
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 );
33853419 }
33863420 context.lastAssistantUuid = message.uuid;
33873421 yield* updateResumeCursor(context);
......@@ -3735,15 +3769,23 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37353769 });
37363770 return;
37373771 case "task_started": {
3738 // A task launched by a tool that itself ran inside a subagent (the
3739 // in-flight tool carries agentId from parent_tool_use_id) is
3740 // agent-internal: a subagent's background shell, not parent work.
3741 const launchingTool = message.tool_use_id
3742 ? Array.from(context.inFlightTools.values()).find(
3743 (tool) => tool.itemId === message.tool_use_id,
3744 )
3772 // A task launched by a tool that itself ran inside a subagent is
3773 // agent-internal: a subagent's background shell, not parent work. A
3774 // streamed tool carries agentId from parent_tool_use_id; a tool seen
3775 // only in a subagent snapshot resolves through subagentToolParents.
3776 const toolUseId = message.tool_use_id;
3777 const launchingTool = toolUseId
3778 ? Array.from(context.inFlightTools.values()).find((tool) => tool.itemId === toolUseId)
37453779 : 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);
37473789 if (
37483790 context.turnState &&
37493791 classifyTaskAgentKind({
......@@ -3754,13 +3796,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37543796 context.turnState.hasSubagents = true;
37553797 }
37563798 // Model/effort: the Agent tool's input carries explicit overrides;
3757 // absent ones inherit the session's selection (SDK behavior).
3758 // Subagent assistant snapshots refine model with the authoritative API
3759 // id: one that already arrived is buffered and outranks the seed here,
3760 // later ones refine the record in place. AgentInput.effort may be a
3761 // named level or an integer.
3799 // absent ones inherit the owning subagent's model, else the session's
3800 // selection (SDK behavior). Subagent assistant snapshots refine model
3801 // with the authoritative API id: one that already arrived is buffered
3802 // and outranks the seed here, later ones refine the record in place.
3803 // AgentInput.effort may be a named level or an integer.
37623804 const launchInput = launchingTool?.input;
3763 const toolUseId = message.tool_use_id;
37643805 const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined;
37653806 if (toolUseId) {
37663807 context.pendingTaskModels.delete(toolUseId);
......@@ -3768,6 +3809,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37683809 const model =
37693810 bufferedModel ??
37703811 trimmedString(launchInput?.model) ??
3812 (owningAgentId ? context.taskAgents.get(owningAgentId)?.model : undefined) ??
37713813 trimmedString(context.session.model ?? undefined);
37723814 const rawLaunchEffort = launchInput?.effort;
37733815 const effort =
......@@ -4471,6 +4513,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
44714513 const claudeTasks = new Map<string, ClaudeTaskState>();
44724514 const taskAgents = new Map<string, ClaudeTaskAgentState>();
44734515 const pendingTaskModels = new Map<string, string>();
4516 const subagentToolParents = new Map<string, string>();
44744517 const workflowMemberFingerprints = new Map<string, string>();
44754518 const liveTaskIds = new Set<string>();
44764519
......@@ -5078,6 +5121,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
50785121 claudeTasks,
50795122 taskAgents,
50805123 pendingTaskModels,
5124 subagentToolParents,
50815125 workflowMemberFingerprints,
50825126 liveTaskIds,
50835127 turnState: undefined,