From eb08085f1d5f55219c151f8352f42a841ff787f9 Mon Sep 17 00:00:00 2001 From: Tobi <155588579+spiky02plateau@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:34:02 +0200 Subject: [PATCH] fix(server): keep subagent model and effort when a Claude agent resumes Reviving a finished subagent with SendMessage re-registers the same task under the SendMessage call, which names no model or effort, so the row fell back to the session's model and effort. Re-registration now keeps what is already known for the task. Subagent effort also defaulted to the session's effort, which is wrong whenever an agent definition sets its own. The SDK reports no per-agent effort, so effort is shown only when the launch input sets it. --- .../src/provider/Layers/ClaudeAdapter.test.ts | 88 +++++++++++++++++-- .../src/provider/Layers/ClaudeAdapter.ts | 33 +++---- 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7917b8360946..5344481385d1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3945,8 +3945,8 @@ describe("ClaudeAdapterLive", () => { attachments: [], }); - // No explicit model/effort on the launch input: the task inherits the - // session's selection. + // No explicit model/effort on the launch input: the model starts from the + // session's selection, and effort stays unknown. harness.query.emit({ type: "system", subtype: "task_started", @@ -3984,13 +3984,13 @@ describe("ClaudeAdapterLive", () => { assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { assert.equal(started.payload.model, SYNTHETIC_CLAUDE_CAPABLE_MODEL); - assert.equal(started.payload.effort, "max"); + assert.equal(started.payload.effort, undefined); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); if (progress?.type === "task.progress") { assert.equal(progress.payload.model, SYNTHETIC_SUBAGENT_MODEL); - assert.equal(progress.payload.effort, "max"); + assert.equal(progress.payload.effort, undefined); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -4063,7 +4063,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(started?.type, "task.started"); if (started?.type === "task.started") { assert.equal(started.payload.model, SYNTHETIC_SUBAGENT_MODEL); - assert.equal(started.payload.effort, "max"); + assert.equal(started.payload.effort, undefined); } const progress = taskEvents[1]; assert.equal(progress?.type, "task.progress"); @@ -4076,6 +4076,84 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("a subagent resumed by SendMessage keeps its model and effort", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const startedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.started"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "effort", value: "max" }], + ), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn an agent", + attachments: [], + }); + + const toolUse = (index: number, id: string, name: string, input: object) => + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session", + uuid: `stream-${id}`, + parent_tool_use_id: null, + event: { + type: "content_block_start", + index, + content_block: { type: "tool_use", id, name, input }, + }, + } as unknown as SDKMessage); + const taskStarted = (toolUseId: string) => + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-resumed", + description: "Agent R", + task_type: "local_agent", + tool_use_id: toolUseId, + uuid: `task-resumed-${toolUseId}`, + session_id: "sdk-session", + } as unknown as SDKMessage); + + toolUse(0, "toolu_agent_r", "Agent", { description: "Agent R", effort: "high" }); + taskStarted("toolu_agent_r"); + harness.query.emit({ + type: "assistant", + parent_tool_use_id: "toolu_agent_r", + message: { model: SYNTHETIC_SUBAGENT_MODEL, content: [] }, + uuid: "resumed-snapshot-uuid", + session_id: "sdk-session", + } as unknown as SDKMessage); + // Reviving a finished agent re-registers the same task under the + // SendMessage call, whose input names neither model nor effort. + toolUse(1, "toolu_send_r", "SendMessage", { to: "task-resumed", message: "continue" }); + taskStarted("toolu_send_r"); + + const resumed = Array.from(yield* Fiber.join(startedFiber))[1]; + assert.equal(resumed?.type, "task.started"); + if (resumed?.type === "task.started") { + assert.equal(resumed.payload.model, SYNTHETIC_SUBAGENT_MODEL); + assert.equal(resumed.payload.effort, "high"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d50b3cbb9248..0ee30f8c5dda 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -417,9 +417,6 @@ interface ClaudeSessionContext { readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; - /** Effective effort for the session's turns; subagents without an explicit - * effort override inherit this. */ - currentEffort: string | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -3720,28 +3717,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { context.turnState.hasSubagents = true; } - // Model/effort: the Agent tool's input carries explicit overrides; - // absent ones inherit the session's selection (SDK behavior). - // Subagent assistant snapshots refine model with the authoritative API - // id: one that already arrived is buffered and outranks the seed here, - // later ones refine the record in place. AgentInput.effort may be a - // named level or an integer. + // Model/effort: the Agent tool's input carries explicit overrides. + // A resumed agent re-registers under the same task_id, launched by a + // SendMessage call that carries neither, so it keeps what is already + // known. A fresh agent's model falls back to the session's, and + // subagent assistant snapshots refine it with the authoritative API id: + // one that already arrived is buffered and outranks the seed here, + // later ones refine the record in place. Effort has no such source (an + // agent definition may set its own), so it is only reported when + // explicit. AgentInput.effort may be a named level or an integer. const launchInput = launchingTool?.input; const toolUseId = message.tool_use_id; const bufferedModel = toolUseId ? context.pendingTaskModels.get(toolUseId) : undefined; if (toolUseId) { context.pendingTaskModels.delete(toolUseId); } + const known = context.taskAgents.get(message.task_id); const model = bufferedModel ?? trimmedString(launchInput?.model) ?? + known?.model ?? trimmedString(context.session.model ?? undefined); const rawLaunchEffort = launchInput?.effort; const effort = trimmedString(rawLaunchEffort) ?? (typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort) ? String(rawLaunchEffort) - : context.currentEffort); + : known?.effort); // Remember the agent identity so every later task.* payload for this // taskId is self-describing (identity must survive activity retention). context.taskAgents.set(message.task_id, { @@ -3752,7 +3754,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskType: message.task_type, workflowName: message.workflow_name, skipTranscript: message.skip_transcript === true, - runHandles: context.taskAgents.get(message.task_id)?.runHandles, + runHandles: known?.runHandles, owningAgentId, model, effort, @@ -5036,7 +5038,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, - currentEffort: effectiveEffort ?? undefined, resumeSessionId: sessionId, pendingApprovals, pendingUserInputs, @@ -5172,14 +5173,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; - const turnEffort = resolveClaudeCatalogEffort( - modelCatalog, - modelSelection.model, - getModelSelectionStringOptionValue(modelSelection, "effort"), - ); - context.currentEffort = - getEffectiveClaudeAgentEffort(modelCatalog, turnEffort ?? null, modelSelection.model) ?? - undefined; } // Apply interaction mode by switching the SDK's permission mode.