diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4f89f58b331529bb9d61f1281002af65568b7453..10f6a8c7627edb5996b93def2ebfa94164d5ab88 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -48,6 +48,7 @@ import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { attachmentRelativePath } from "../../attachmentStore.ts"; @@ -73,6 +74,29 @@ const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ autoCompactWindow: "300000", }); +const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ + customModels: [ + // A bare custom slug that shadows the built-in "opus" alias. + "opus", + { + slug: "claude-custom-tuned", + name: "Tuned", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "gentle", label: "Gentle", isDefault: true }, + { id: "brutal", label: "Brutal" }, + ], + }, + ], + }, + }, + ], +}); const CLAUDE_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), model: "claude-sonnet-4-6", @@ -2107,6 +2131,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + readonly settings?: ClaudeSettings; // A CLI process opened after the first streams from its own queue, so the // first one can exit (Queue.shutdown) and a later turn can start another. readonly freshQueueOnReopen?: boolean; @@ -2141,7 +2166,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CLAUDE_SETTINGS, + settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS, environment: options?.environment ?? {}, attachmentsDir, fileSystem, @@ -8139,18 +8164,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.take(harness.terminalReceipts); const subagents = harness.events.filter((event) => event.type === "subagent.updated"); + // Aliases and dated ids are recorded as the catalog slug. + const observedSlug = "claude-haiku-4-5"; const initialModel = source === "observed-before" - ? observedModel + ? observedSlug : source === "inherit" ? parentModel : source === "unknown" ? null - : "haiku"; + : "claude-haiku-5-5"; assert.equal(subagents[0]?.subagent.model, initialModel); assert.equal( subagents.at(-1)?.subagent.model, - source.startsWith("observed") ? observedModel : initialModel, + source.startsWith("observed") ? observedSlug : initialModel, ); const child = harness.events.find((event) => event.type === "app_thread.created"); assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); @@ -8162,6 +8189,344 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" }, + { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" }, + // claude-haiku-4-5 offers no effort, so none is sent or shown for it. + { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null }, + ])( + "records the effort a $agentModel subagent runs at (Agent effort $requested)", + ({ agentModel, slug, requested, expected }) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const toolUseId = "toolu-subagent-effort"; + const parentSelection = { + instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, + model: "claude-opus-4-6", + options: [ + { id: "effort", value: "max" }, + { id: "fastMode", value: false }, + ], + } satisfies ModelSelection; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-effort"), + text: "Spawn a subagent.", + attachments: [], + modelSelection: parentSelection, + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: parentSelection.model, + id: "msg_subagent_effort_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: toolUseId, + name: "Agent", + input: { + description: "Research", + subagent_type: "general-purpose", + model: agentModel, + ...(requested === undefined ? {} : { effort: requested }), + prompt: "Research the topic.", + }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000211", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-subagent-effort", + tool_use_id: toolUseId, + description: "Research", + task_type: "local_agent", + uuid: "00000000-0000-4000-8000-000000000212", + session_id: WAKE_NATIVE_SESSION, + }), + // The reply names the model the alias resolved to. + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: toolUseId, + message: { + model: slug, + id: "msg_subagent_effort_observed", + type: "message", + role: "assistant", + content: [{ type: "text", text: "Researching." }], + }, + uuid: "00000000-0000-4000-8000-000000000213", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000214", + result: "Spawned the subagent.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + // The alias is stored as its slug, so the reply is no model change, + // and the parent's fast mode stays with the parent. + const subagentSelection = { + instanceId: parentSelection.instanceId, + model: slug, + ...(expected === null ? {} : { options: [{ id: "effort", value: expected }] }), + }; + const child = harness.events.find((event) => event.type === "app_thread.created"); + assert.deepEqual(child?.appThread.modelSelection, subagentSelection); + const subagents = harness.events.filter((event) => event.type === "subagent.updated"); + assert.deepEqual( + subagents.map((event) => event.subagent.modelSelection), + subagents.map(() => subagentSelection), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("resolves a subagent's model and effort against the instance's custom models", () => + Effect.scoped( + Effect.gen(function* () { + const TUNED_TOOL_USE_ID = "toolu-effort-custom-tuned"; + const SHADOW_TOOL_USE_ID = "toolu-effort-custom-shadow"; + const harness = yield* makeWakeHarnessWithOptions({ + settings: CUSTOM_MODEL_CLAUDE_SETTINGS, + }); + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-custom-effort"), + text: "Spawn subagents on custom models.", + attachments: [], + }), + ); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: CLAUDE_TEST_MODEL_SELECTION.model, + id: "msg_custom_effort_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: TUNED_TOOL_USE_ID, + name: "Agent", + input: { + description: "Tuned", + subagent_type: "general-purpose", + model: "claude-custom-tuned", + effort: "brutal", + prompt: "Check one thing.", + }, + }, + { + type: "tool_use", + id: SHADOW_TOOL_USE_ID, + name: "Agent", + input: { + description: "Shadow", + subagent_type: "general-purpose", + model: "opus", + prompt: "Check another thing.", + }, + }, + ], + }, + uuid: "00000000-0000-4000-8000-000000000231", + session_id: WAKE_NATIVE_SESSION, + }), + ...[TUNED_TOOL_USE_ID, SHADOW_TOOL_USE_ID].map((id, index) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: `task-${id}`, + tool_use_id: id, + description: id, + task_type: "local_agent", + uuid: `00000000-0000-4000-8000-00000000023${index + 2}`, + session_id: WAKE_NATIVE_SESSION, + }), + ), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000234", + result: "Spawned the subagents.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + const selections = Object.fromEntries( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent.modelSelection] as const] + : [], + ), + ); + const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId; + assert.deepEqual(selections, { + // The custom model's own effort levels apply. + [`task-${TUNED_TOOL_USE_ID}`]: { + instanceId, + model: "claude-custom-tuned", + options: [{ id: "effort", value: "brutal" }], + }, + // The custom slug is not resolved to the built-in model it shadows. + [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" }, + }); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TOOL_USE_ID = "toolu-effort-outer"; + const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; + const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const parentSelection = { + instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, + model: "claude-opus-4-6", + options: [{ id: "effort", value: "high" }], + } satisfies ModelSelection; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-subagent-nested-effort"), + text: "Spawn a subagent that spawns two more.", + attachments: [], + modelSelection: parentSelection, + }), + ); + const agentCall = (id: string, effort?: string) => ({ + type: "tool_use" as const, + id, + name: "Agent", + input: { + description: id, + subagent_type: "general-purpose", + ...(effort === undefined ? {} : { effort }), + prompt: "Check one thing.", + }, + }); + const taskStarted = (id: string, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: `task-${id}`, + tool_use_id: id, + description: id, + task_type: "local_agent", + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: null, + message: { + model: parentSelection.model, + id: "msg_nested_effort_outer_launch", + type: "message", + role: "assistant", + content: [agentCall(OUTER_TOOL_USE_ID, "low")], + }, + uuid: "00000000-0000-4000-8000-000000000221", + session_id: WAKE_NATIVE_SESSION, + }), + taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"), + // The outer subagent's own Agent calls arrive in its snapshot. + claudeSdkFrame({ + type: "assistant", + parent_tool_use_id: OUTER_TOOL_USE_ID, + message: { + model: parentSelection.model, + id: "msg_nested_effort_inner_launch", + type: "message", + role: "assistant", + content: [ + agentCall(INHERITING_TOOL_USE_ID), + agentCall(OVERRIDING_TOOL_USE_ID, "max"), + ], + }, + uuid: "00000000-0000-4000-8000-000000000223", + session_id: WAKE_NATIVE_SESSION, + }), + taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000226", + result: "Spawned the subagents.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* Queue.take(harness.terminalReceipts); + + const efforts = new Map( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [ + [ + event.subagent.nativeTaskRef?.nativeId, + getModelSelectionStringOptionValue(event.subagent.modelSelection, "effort"), + ] as const, + ] + : [], + ), + ); + assert.deepEqual(Object.fromEntries(efforts), { + [`task-${OUTER_TOOL_USE_ID}`]: "low", + [`task-${INHERITING_TOOL_USE_ID}`]: "low", + [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", + }); + const childEfforts = harness.events.flatMap((event) => + event.type === "app_thread.created" && event.appThread.id !== harness.threadId + ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] + : [], + ); + assert.deepEqual(childEfforts, ["low", "low", "max"]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("extracts text from direct content-block subagent results", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 79e46c25dc8c9fa3edc0f622c188f0fd6ca39044..caf63f2811b5940854e7eae0c82d2b1207f3e696 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -99,6 +99,10 @@ import { import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindowTokens, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, } from "../../provider/ClaudeModelCatalog.ts"; import { boundProviderEventForLogging, @@ -2841,6 +2845,9 @@ interface ActiveClaudeSubagent { // The tool call that started the current run: the Agent launch, then each // SendMessage that resumes the subagent. A new one means a new prompt. readonly runToolUseId: string | null; + // The API effort the subagent runs at: its Agent call's, else its owner's, + // else the session's. Undefined when none is sent and the model default applies. + readonly effort: string | undefined; nextChildItemOrdinal: number; resultItemOrdinal: number | null; // The subagent's latest assistant message routed into its child thread, @@ -2893,10 +2900,42 @@ interface ActiveClaudeToolCall { // keyed by the tool_use_id that launches it. interface PendingClaudeSubagentLaunch { readonly model?: string; + readonly effort?: string; // parent_tool_use_id of the subagent whose own Agent call launches this one. readonly ownerToolUseId?: string; } +/** + * Agent calls name aliases ("opus") and replies name dated ids; both resolve + * to the catalog slug. A subagent's thread then keeps one model name across + * its launch, its replies and a resume after a restart, so a later report of + * the same model never reads as a model change that drops its effort. The + * catalog is the instance's, so a custom model keeps its own name. + */ +function canonicalClaudeSubagentModel(catalog: ClaudeModelCatalog, model: string): string { + return resolveClaudeModelSlug(catalog, model); +} + +/** + * The selection a subagent's thread shows. An effort its model does not offer + * is not sent for it, so it is left out (a nested subagent still inherits it). + */ +function claudeSubagentModelSelection( + catalog: ClaudeModelCatalog, + instanceId: ModelSelection["instanceId"], + model: string, + effort: string | undefined, +): ModelSelection { + const descriptor = getClaudeCatalogModelCapabilities(catalog, model).optionDescriptors?.find( + (candidate) => candidate.id === "effort", + ); + const offered = + effort !== undefined && + descriptor?.type === "select" && + descriptor.options.some((option) => option.id === effort); + return { instanceId, model, ...(offered ? { options: [{ id: "effort", value: effort }] } : {}) }; +} + const PENDING_CLAUDE_SUBAGENT_CAP = 64; // Per-subagent bound on frames held while waiting for task_started. const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; @@ -2917,11 +2956,13 @@ function rememberPendingClaudeSubagentLaunch( } /** - * Agent calls carry model overrides even when the SDK omits child assistant - * snapshots. A subagent's own Agent call arrives only in its snapshot, so the - * owner recorded here is all that links the subagent it starts back to it. + * Agent calls carry model and effort overrides even when the SDK omits child + * assistant snapshots. A subagent's own Agent call arrives only in its + * snapshot, so the owner recorded here is all that links the subagent it + * starts back to it. */ function rememberClaudeSubagentLaunch( + catalog: ClaudeModelCatalog, context: ActiveClaudeTurnContext, pending: Map, toolUseId: string, @@ -2934,17 +2975,23 @@ function rememberClaudeSubagentLaunch( // task_started resolves it. const model = requested !== "inherit" - ? requested + ? requested && canonicalClaudeSubagentModel(catalog, requested) : ownerToolUseId === null ? context.input.modelSelection.model : undefined; + const effort = firstStringInputField(input, ["effort"]); // A model already known (a snapshot's, or an earlier sighting of this // call) wins over the requested one. const launch: PendingClaudeSubagentLaunch = { ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }), + ...(effort === undefined ? {} : { effort }), ...(ownerToolUseId === null ? {} : { ownerToolUseId }), }; - if (launch.model !== undefined || launch.ownerToolUseId !== undefined) { + if ( + launch.model !== undefined || + launch.effort !== undefined || + launch.ownerToolUseId !== undefined + ) { rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch); } } @@ -3088,6 +3135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; + // Subagent model names and effort levels, including the instance's custom models. + const subagentModelCatalog = scopeClaudeModelCatalog( + BUNDLED_CLAUDE_MODEL_CATALOG, + adapterOptions.settings.customModels, + ); const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; @@ -4165,6 +4217,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( `task:${resume.taskId}:subagent`, ), runToolUseId: launchToolUseId, + // The launch's effort went with the old process. Without a task + // modelSelection the child thread keeps the one it was given. + effort: undefined, nextChildItemOrdinal: 100, resultItemOrdinal: null, lastAssistantText: null, @@ -4185,8 +4240,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly prompt?: string; readonly title?: string; readonly model?: string; - // The subagent whose own Agent call started this one; read only - // when this call registers the subagent. + // The Agent call's effort and the subagent whose own Agent call + // started this one; read only when this call registers the subagent. + readonly effort?: string; readonly owner?: ActiveClaudeSubagent; readonly progress?: string; readonly result?: string; @@ -4285,6 +4341,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent.task, ) : existingSubagent.task; + const effort = + existingSubagent === undefined + ? (input.effort ?? + input.owner?.effort ?? + compileClaudeModelSelection(input.context.input.modelSelection).effort) + : existingSubagent.effort; + const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { id: nodeId, @@ -4323,7 +4386,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : {}), ...(input.prompt === undefined ? {} : { prompt: input.prompt }), ...(input.title === undefined ? {} : { title: input.title }), - ...(input.model === undefined ? {} : { model: input.model }), + model, + // What the subagent runs with, unless it was recovered after a + // restart. The parent's other options belong to the parent's session. + ...(existingSubagent === undefined || existingSubagent.task.modelSelection !== undefined + ? { + modelSelection: claudeSubagentModelSelection( + subagentModelCatalog, + input.context.input.modelSelection.instanceId, + model || input.context.input.modelSelection.model, + effort, + ), + } + : {}), ...(input.progress === undefined ? {} : { progress: input.progress }), ...(input.result === undefined ? {} : { result: input.result }), ...(isReopen ? { startedAt: now } : {}), @@ -4341,6 +4416,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( existingSubagent === undefined ? (input.toolUseId ?? null) : (resumeToolUseId ?? existingSubagent.runToolUseId), + effort, nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, // Every task_started begins a new run of the subagent (including a @@ -4388,10 +4464,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( parentNodeId: nodeId, activeProviderThreadId: null, providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: - task.model && task.model !== input.context.input.modelSelection.model - ? { instanceId: input.context.input.modelSelection.instanceId, model: task.model } - : input.context.input.modelSelection, + modelSelection: task.modelSelection ?? input.context.input.modelSelection, title: subagentThreadTitle({ parentTitle: input.context.input.appThread.title, prompt: task.prompt, @@ -6194,7 +6267,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const parentToolUseId = message.parent_tool_use_id; const snapshotModel = typeof message.message.model === "string" ? message.message.model.trim() : ""; - const model = snapshotModel.length === 0 ? undefined : snapshotModel; + const model = + snapshotModel.length === 0 + ? undefined + : canonicalClaudeSubagentModel(subagentModelCatalog, snapshotModel); if (parentToolUseId !== null && model !== undefined) { const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); if (subagent === undefined) { @@ -6301,6 +6377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), ...(message.prompt === undefined ? {} : { prompt: message.prompt }), ...(model === undefined ? {} : { model }), + ...(launch?.effort === undefined ? {} : { effort: launch.effort }), ...(owner === undefined ? {} : { owner }), title: message.description, status: "running", @@ -6402,6 +6479,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); if (toolUse.name === "Agent") { rememberClaudeSubagentLaunch( + subagentModelCatalog, context, pendingSubagentLaunchesByToolUseId, toolUse.id, @@ -7000,6 +7078,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const heldForEcho = context.heldRootFrames.length > 0; if (toolName === "Agent") { rememberClaudeSubagentLaunch( + subagentModelCatalog, context, pendingSubagentLaunchesByToolUseId, nativeRequestId, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index d1b978e3862fafd05ec0454e9a454bf8ce5e7a8c..a6b7cc23d087bd8161f5434e225e5f5e7aa620ce 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1681,6 +1681,37 @@ layer("ProviderEventIngestorV2", (it) => { instanceId: modelSelection.instanceId, model: "gpt-6.1-sol", }); + + // A selection the provider reports, effort included, replaces the thread's. + const reportedSelection = { + instanceId: modelSelection.instanceId, + model: "gpt-6.1-sol", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const reported = yield* ingest({ + ...subagentUpdated, + subagent: { ...subagentUpdated.subagent, modelSelection: reportedSelection }, + }); + assert.deepEqual( + reported.map((stored) => stored.event.type), + ["subagent.updated", "thread.model-selection-updated"], + ); + assert.deepEqual( + (yield* projectionStore.getThread(childThreadId)).modelSelection, + reportedSelection, + ); + + // A subagent recovered after a restart reports its model but no + // selection; the same model keeps the effort the thread holds. + const recovered = yield* ingest(subagentUpdated); + assert.deepEqual( + recovered.map((stored) => stored.event.type), + ["subagent.updated"], + ); + assert.deepEqual( + (yield* projectionStore.getThread(childThreadId)).modelSelection, + reportedSelection, + ); }), ); }); diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 0b4d66394e1ced7bd59adad97b41a09852c44491..308b8b210cebecdf683c6950dc971628c564a1c9 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { AgentScope } from "@t3tools/shared/AgentScope"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -402,18 +402,32 @@ export const layer: Layer.Layer< /** * A native subagent's thread starts on the parent's model when the * provider names the real one later (a Claude agent file's model arrives - * with the subagent's first reply). Clients read the thread's model, so - * move the thread to the reported one. Thread commands rewrite the whole - * thread row under the thread's lock, so this read and write take it too. + * with the subagent's first reply). Clients read the thread's model and + * effort, so move the thread to the reported selection, or to the reported + * model alone when the provider reports no selection. Thread commands + * rewrite the whole thread row under the thread's lock, so this read and + * write take it too. */ const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { const { childThreadId, model } = subagent; - if (subagent.origin !== "provider_native" || childThreadId === null || model === null) { + if ( + subagent.origin !== "provider_native" || + childThreadId === null || + (model === null && subagent.modelSelection === undefined) + ) { return []; } + const reportedSelection = ({ modelSelection }: { modelSelection: ModelSelection }) => + subagent.modelSelection ?? + (model === null || model === modelSelection.model + ? modelSelection + : // The parent's options belong to the parent's model. + { instanceId: modelSelection.instanceId, model }); const staleThread = projections.getThread(childThreadId).pipe( - Effect.map((thread) => (thread.modelSelection.model === model ? null : thread)), + Effect.map((thread) => + modelSelectionsEqual(thread.modelSelection, reportedSelection(thread)) ? null : thread, + ), Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), ); // Nearly every update already matches; only a mismatch takes the lock. @@ -427,10 +441,9 @@ export const layer: Layer.Layer< const event = yield* makeDomainEvent(input, { type: "thread.model-selection-updated", threadId: thread.id, - // The parent's options belong to the parent's model. payload: { ...thread, - modelSelection: { instanceId: thread.modelSelection.instanceId, model }, + modelSelection: reportedSelection(thread), updatedAt: now, }, occurredAt: now, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index b3c979715b850fb634488e12b89057739a4cdb84..6c1a6757058e088dbfcf0e4d6ae8499106b4d8bf 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -384,6 +384,13 @@ describe("orchestrator replay recovery", () => { assert.lengthOf(childThreads, 1); const child = childThreads[0]; assert.equal(child?.thread.id, subagent?.childThreadId); + // The recovered subagent's replies name the model its thread already + // holds, so the effort recorded at launch survives the restart. + assert.deepEqual(child?.thread.modelSelection, { + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + model: "claude-sonnet-5", + options: [{ id: "effort", value: "high" }], + }); const conversation = (child?.turnItems ?? []) .toSorted((left, right) => left.ordinal - right.ordinal) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts index 4c91460bed69084e8f36157eaebd1664df79d154..fe993a3437f4cdb1a0da5a5da8a6bc5045e6f8df 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts @@ -22,7 +22,8 @@ import { const AGENT_A_TASK_ID = "a1a715b7d0bdfefea"; const AGENT_B_TASK_ID = "af44d5c14aa3ce867"; -const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5-20251001"; +// Its snapshots report claude-haiku-4-5-20251001, recorded as the catalog slug. +const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5"; function assistantTexts( projection: OrchestrationV2ThreadProjection, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts index bce49262e204e08d2826bc9974ccd8e10732abf6..2826ce9bbf2d771d3e1139510b5dc39a1de655c1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts @@ -12,9 +12,10 @@ import { const OUTER_TASK_ID = "a457ca677bc1ecd82"; const NESTED_TASK_ID = "aac1d7a2af5371c7d"; -// The outer subagent's snapshots report this id; the nested one never streams -// a snapshot of its own (the CLI's session storage shows it ran on the same). -const SUBAGENT_MODEL = "claude-haiku-4-5-20251001"; +// The outer subagent's snapshots report claude-haiku-4-5-20251001, recorded as +// the catalog slug; the nested one never streams a snapshot of its own (the +// CLI's session storage shows it ran on the same). +const SUBAGENT_MODEL = "claude-haiku-4-5"; export function assertClaudeNestedSubagentModelOutput( result: OrchestratorV2ScenarioResult, @@ -45,6 +46,10 @@ export function assertClaudeNestedSubagentModelOutput( // only that snapshot can say who launched it and on which model. assert.equal(nested.parentNodeId, outer.id, "a nested subagent hangs off its owner"); assert.equal(nested.model, SUBAGENT_MODEL, "a nested subagent runs on its owner's model"); + // The session's effort is not sent for claude-haiku-4-5, which offers none. + const subagentSelection = { instanceId: outer.providerInstanceId, model: SUBAGENT_MODEL }; + assert.deepEqual(outer.modelSelection, subagentSelection); + assert.deepEqual(nested.modelSelection, subagentSelection); const nestedNode = projection.nodes.find((node) => node.id === nested.id); assert.isDefined(nestedNode); assert.equal(nestedNode.parentNodeId, outer.id);