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.