| author | |
| committer | |
| log | 98beed1a226c42b85c52ff5ee9d5dbb67d776a77 |
| tree | 557c3447771d1bf5b9ba198161c3f7e52e0c50ff |
| parent | 94d1a8e93f0434d34fa894523a04fe6a6f6172f6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>7 files changed, 530 insertions(+), 29 deletions(-)
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+369-4| ... | @@ -48,6 +48,7 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -48,6 +48,7 @@ import * as TestClock from "effect/testing/TestClock"; |
| 48 | import { Tool } from "effect/ai"; | 48 | import { Tool } from "effect/ai"; |
| 49 | import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; | 49 | import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; |
| 50 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 50 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 51 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 51 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | 52 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 52 | 53 | ||
| 53 | import { attachmentRelativePath } from "../../attachmentStore.ts"; | 54 | import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| ... | @@ -73,6 +74,29 @@ const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); | ... | @@ -73,6 +74,29 @@ const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); |
| 73 | const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ | 74 | const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ |
| 74 | autoCompactWindow: "300000", | 75 | autoCompactWindow: "300000", |
| 75 | }); | 76 | }); |
| 77 | const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ | ||
| 78 | customModels: [ | ||
| 79 | // A bare custom slug that shadows the built-in "opus" alias. | ||
| 80 | "opus", | ||
| 81 | { | ||
| 82 | slug: "claude-custom-tuned", | ||
| 83 | name: "Tuned", | ||
| 84 | capabilities: { | ||
| 85 | optionDescriptors: [ | ||
| 86 | { | ||
| 87 | id: "effort", | ||
| 88 | label: "Reasoning", | ||
| 89 | type: "select", | ||
| 90 | options: [ | ||
| 91 | { id: "gentle", label: "Gentle", isDefault: true }, | ||
| 92 | { id: "brutal", label: "Brutal" }, | ||
| 93 | ], | ||
| 94 | }, | ||
| 95 | ], | ||
| 96 | }, | ||
| 97 | }, | ||
| 98 | ], | ||
| 99 | }); | ||
| 76 | const CLAUDE_TEST_MODEL_SELECTION = { | 100 | const CLAUDE_TEST_MODEL_SELECTION = { |
| 77 | instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), | 101 | instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), |
| 78 | model: "claude-sonnet-4-6", | 102 | model: "claude-sonnet-4-6", |
| ... | @@ -2107,6 +2131,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -2107,6 +2131,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2107 | readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>; | 2131 | readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>; |
| 2108 | readonly interrupt?: Effect.Effect<void>; | 2132 | readonly interrupt?: Effect.Effect<void>; |
| 2109 | readonly environment?: NodeJS.ProcessEnv; | 2133 | readonly environment?: NodeJS.ProcessEnv; |
| 2134 | readonly settings?: ClaudeSettings; | ||
| 2110 | // A CLI process opened after the first streams from its own queue, so the | 2135 | // A CLI process opened after the first streams from its own queue, so the |
| 2111 | // first one can exit (Queue.shutdown) and a later turn can start another. | 2136 | // first one can exit (Queue.shutdown) and a later turn can start another. |
| 2112 | readonly freshQueueOnReopen?: boolean; | 2137 | readonly freshQueueOnReopen?: boolean; |
| ... | @@ -2141,7 +2166,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -2141,7 +2166,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2141 | let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; | 2166 | let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; |
| 2142 | const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ | 2167 | const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ |
| 2143 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, | 2168 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, |
| 2144 | settings: DEFAULT_CLAUDE_SETTINGS, | 2169 | settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS, |
| 2145 | environment: options?.environment ?? {}, | 2170 | environment: options?.environment ?? {}, |
| 2146 | attachmentsDir, | 2171 | attachmentsDir, |
| 2147 | fileSystem, | 2172 | fileSystem, |
| ... | @@ -8139,18 +8164,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -8139,18 +8164,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8139 | ); | 8164 | ); |
| 8140 | yield* Queue.take(harness.terminalReceipts); | 8165 | yield* Queue.take(harness.terminalReceipts); |
| 8141 | const subagents = harness.events.filter((event) => event.type === "subagent.updated"); | 8166 | const subagents = harness.events.filter((event) => event.type === "subagent.updated"); |
| 8167 | // Aliases and dated ids are recorded as the catalog slug. | ||
| 8168 | const observedSlug = "claude-haiku-4-5"; | ||
| 8142 | const initialModel = | 8169 | const initialModel = |
| 8143 | source === "observed-before" | 8170 | source === "observed-before" |
| 8144 | ? observedModel | 8171 | ? observedSlug |
| 8145 | : source === "inherit" | 8172 | : source === "inherit" |
| 8146 | ? parentModel | 8173 | ? parentModel |
| 8147 | : source === "unknown" | 8174 | : source === "unknown" |
| 8148 | ? null | 8175 | ? null |
| 8149 | : "haiku"; | 8176 | : "claude-haiku-5-5"; |
| 8150 | assert.equal(subagents[0]?.subagent.model, initialModel); | 8177 | assert.equal(subagents[0]?.subagent.model, initialModel); |
| 8151 | assert.equal( | 8178 | assert.equal( |
| 8152 | subagents.at(-1)?.subagent.model, | 8179 | subagents.at(-1)?.subagent.model, |
| 8153 | source.startsWith("observed") ? observedModel : initialModel, | 8180 | source.startsWith("observed") ? observedSlug : initialModel, |
| 8154 | ); | 8181 | ); |
| 8155 | const child = harness.events.find((event) => event.type === "app_thread.created"); | 8182 | const child = harness.events.find((event) => event.type === "app_thread.created"); |
| 8156 | assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); | 8183 | assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); |
| ... | @@ -8162,6 +8189,344 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -8162,6 +8189,344 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8162 | ), | 8189 | ), |
| 8163 | ); | 8190 | ); |
| 8164 | 8191 | ||
| 8192 | it.effect.each([ | ||
| 8193 | { agentModel: "opus", slug: "claude-opus-5-5", requested: undefined, expected: "max" }, | ||
| 8194 | { agentModel: "opus", slug: "claude-opus-5-5", requested: "low", expected: "low" }, | ||
| 8195 | // claude-haiku-4-5 offers no effort, so none is sent or shown for it. | ||
| 8196 | { agentModel: "haiku-4.5", slug: "claude-haiku-4-5", requested: undefined, expected: null }, | ||
| 8197 | ])( | ||
| 8198 | "records the effort a $agentModel subagent runs at (Agent effort $requested)", | ||
| 8199 | ({ agentModel, slug, requested, expected }) => | ||
| 8200 | Effect.scoped( | ||
| 8201 | Effect.gen(function* () { | ||
| 8202 | const harness = yield* makeWakeHarness; | ||
| 8203 | const now = yield* DateTime.now; | ||
| 8204 | const toolUseId = "toolu-subagent-effort"; | ||
| 8205 | const parentSelection = { | ||
| 8206 | instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, | ||
| 8207 | model: "claude-opus-4-6", | ||
| 8208 | options: [ | ||
| 8209 | { id: "effort", value: "max" }, | ||
| 8210 | { id: "fastMode", value: false }, | ||
| 8211 | ], | ||
| 8212 | } satisfies ModelSelection; | ||
| 8213 | yield* harness.runtime.startTurn( | ||
| 8214 | makeClaudeTestTurnInput({ | ||
| 8215 | threadId: harness.threadId, | ||
| 8216 | providerThread: harness.providerThread, | ||
| 8217 | now, | ||
| 8218 | attemptId: RunAttemptId.make("attempt-subagent-effort"), | ||
| 8219 | text: "Spawn a subagent.", | ||
| 8220 | attachments: [], | ||
| 8221 | modelSelection: parentSelection, | ||
| 8222 | }), | ||
| 8223 | ); | ||
| 8224 | for (const frame of [ | ||
| 8225 | claudeSdkFrame({ | ||
| 8226 | type: "assistant", | ||
| 8227 | parent_tool_use_id: null, | ||
| 8228 | message: { | ||
| 8229 | model: parentSelection.model, | ||
| 8230 | id: "msg_subagent_effort_launch", | ||
| 8231 | type: "message", | ||
| 8232 | role: "assistant", | ||
| 8233 | content: [ | ||
| 8234 | { | ||
| 8235 | type: "tool_use", | ||
| 8236 | id: toolUseId, | ||
| 8237 | name: "Agent", | ||
| 8238 | input: { | ||
| 8239 | description: "Research", | ||
| 8240 | subagent_type: "general-purpose", | ||
| 8241 | model: agentModel, | ||
| 8242 | ...(requested === undefined ? {} : { effort: requested }), | ||
| 8243 | prompt: "Research the topic.", | ||
| 8244 | }, | ||
| 8245 | }, | ||
| 8246 | ], | ||
| 8247 | }, | ||
| 8248 | uuid: "00000000-0000-4000-8000-000000000211", | ||
| 8249 | session_id: WAKE_NATIVE_SESSION, | ||
| 8250 | }), | ||
| 8251 | claudeSdkFrame({ | ||
| 8252 | type: "system", | ||
| 8253 | subtype: "task_started", | ||
| 8254 | task_id: "task-subagent-effort", | ||
| 8255 | tool_use_id: toolUseId, | ||
| 8256 | description: "Research", | ||
| 8257 | task_type: "local_agent", | ||
| 8258 | uuid: "00000000-0000-4000-8000-000000000212", | ||
| 8259 | session_id: WAKE_NATIVE_SESSION, | ||
| 8260 | }), | ||
| 8261 | // The reply names the model the alias resolved to. | ||
| 8262 | claudeSdkFrame({ | ||
| 8263 | type: "assistant", | ||
| 8264 | parent_tool_use_id: toolUseId, | ||
| 8265 | message: { | ||
| 8266 | model: slug, | ||
| 8267 | id: "msg_subagent_effort_observed", | ||
| 8268 | type: "message", | ||
| 8269 | role: "assistant", | ||
| 8270 | content: [{ type: "text", text: "Researching." }], | ||
| 8271 | }, | ||
| 8272 | uuid: "00000000-0000-4000-8000-000000000213", | ||
| 8273 | session_id: WAKE_NATIVE_SESSION, | ||
| 8274 | }), | ||
| 8275 | makeResultFrame({ | ||
| 8276 | uuid: "00000000-0000-4000-8000-000000000214", | ||
| 8277 | result: "Spawned the subagent.", | ||
| 8278 | }), | ||
| 8279 | ]) { | ||
| 8280 | yield* Queue.offer(harness.sdkMessages, frame); | ||
| 8281 | } | ||
| 8282 | yield* Queue.take(harness.terminalReceipts); | ||
| 8283 | |||
| 8284 | // The alias is stored as its slug, so the reply is no model change, | ||
| 8285 | // and the parent's fast mode stays with the parent. | ||
| 8286 | const subagentSelection = { | ||
| 8287 | instanceId: parentSelection.instanceId, | ||
| 8288 | model: slug, | ||
| 8289 | ...(expected === null ? {} : { options: [{ id: "effort", value: expected }] }), | ||
| 8290 | }; | ||
| 8291 | const child = harness.events.find((event) => event.type === "app_thread.created"); | ||
| 8292 | assert.deepEqual(child?.appThread.modelSelection, subagentSelection); | ||
| 8293 | const subagents = harness.events.filter((event) => event.type === "subagent.updated"); | ||
| 8294 | assert.deepEqual( | ||
| 8295 | subagents.map((event) => event.subagent.modelSelection), | ||
| 8296 | subagents.map(() => subagentSelection), | ||
| 8297 | ); | ||
| 8298 | }).pipe( | ||
| 8299 | Effect.provide( | ||
| 8300 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | ||
| 8301 | ), | ||
| 8302 | ), | ||
| 8303 | ), | ||
| 8304 | ); | ||
| 8305 | |||
| 8306 | it.effect("resolves a subagent's model and effort against the instance's custom models", () => | ||
| 8307 | Effect.scoped( | ||
| 8308 | Effect.gen(function* () { | ||
| 8309 | const TUNED_TOOL_USE_ID = "toolu-effort-custom-tuned"; | ||
| 8310 | const SHADOW_TOOL_USE_ID = "toolu-effort-custom-shadow"; | ||
| 8311 | const harness = yield* makeWakeHarnessWithOptions({ | ||
| 8312 | settings: CUSTOM_MODEL_CLAUDE_SETTINGS, | ||
| 8313 | }); | ||
| 8314 | const now = yield* DateTime.now; | ||
| 8315 | yield* harness.runtime.startTurn( | ||
| 8316 | makeClaudeTestTurnInput({ | ||
| 8317 | threadId: harness.threadId, | ||
| 8318 | providerThread: harness.providerThread, | ||
| 8319 | now, | ||
| 8320 | attemptId: RunAttemptId.make("attempt-subagent-custom-effort"), | ||
| 8321 | text: "Spawn subagents on custom models.", | ||
| 8322 | attachments: [], | ||
| 8323 | }), | ||
| 8324 | ); | ||
| 8325 | for (const frame of [ | ||
| 8326 | claudeSdkFrame({ | ||
| 8327 | type: "assistant", | ||
| 8328 | parent_tool_use_id: null, | ||
| 8329 | message: { | ||
| 8330 | model: CLAUDE_TEST_MODEL_SELECTION.model, | ||
| 8331 | id: "msg_custom_effort_launch", | ||
| 8332 | type: "message", | ||
| 8333 | role: "assistant", | ||
| 8334 | content: [ | ||
| 8335 | { | ||
| 8336 | type: "tool_use", | ||
| 8337 | id: TUNED_TOOL_USE_ID, | ||
| 8338 | name: "Agent", | ||
| 8339 | input: { | ||
| 8340 | description: "Tuned", | ||
| 8341 | subagent_type: "general-purpose", | ||
| 8342 | model: "claude-custom-tuned", | ||
| 8343 | effort: "brutal", | ||
| 8344 | prompt: "Check one thing.", | ||
| 8345 | }, | ||
| 8346 | }, | ||
| 8347 | { | ||
| 8348 | type: "tool_use", | ||
| 8349 | id: SHADOW_TOOL_USE_ID, | ||
| 8350 | name: "Agent", | ||
| 8351 | input: { | ||
| 8352 | description: "Shadow", | ||
| 8353 | subagent_type: "general-purpose", | ||
| 8354 | model: "opus", | ||
| 8355 | prompt: "Check another thing.", | ||
| 8356 | }, | ||
| 8357 | }, | ||
| 8358 | ], | ||
| 8359 | }, | ||
| 8360 | uuid: "00000000-0000-4000-8000-000000000231", | ||
| 8361 | session_id: WAKE_NATIVE_SESSION, | ||
| 8362 | }), | ||
| 8363 | ...[TUNED_TOOL_USE_ID, SHADOW_TOOL_USE_ID].map((id, index) => | ||
| 8364 | claudeSdkFrame({ | ||
| 8365 | type: "system", | ||
| 8366 | subtype: "task_started", | ||
| 8367 | task_id: `task-${id}`, | ||
| 8368 | tool_use_id: id, | ||
| 8369 | description: id, | ||
| 8370 | task_type: "local_agent", | ||
| 8371 | uuid: `00000000-0000-4000-8000-00000000023${index + 2}`, | ||
| 8372 | session_id: WAKE_NATIVE_SESSION, | ||
| 8373 | }), | ||
| 8374 | ), | ||
| 8375 | makeResultFrame({ | ||
| 8376 | uuid: "00000000-0000-4000-8000-000000000234", | ||
| 8377 | result: "Spawned the subagents.", | ||
| 8378 | }), | ||
| 8379 | ]) { | ||
| 8380 | yield* Queue.offer(harness.sdkMessages, frame); | ||
| 8381 | } | ||
| 8382 | yield* Queue.take(harness.terminalReceipts); | ||
| 8383 | |||
| 8384 | const selections = Object.fromEntries( | ||
| 8385 | harness.events.flatMap((event) => | ||
| 8386 | event.type === "subagent.updated" | ||
| 8387 | ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent.modelSelection] as const] | ||
| 8388 | : [], | ||
| 8389 | ), | ||
| 8390 | ); | ||
| 8391 | const instanceId = CLAUDE_TEST_MODEL_SELECTION.instanceId; | ||
| 8392 | assert.deepEqual(selections, { | ||
| 8393 | // The custom model's own effort levels apply. | ||
| 8394 | [`task-${TUNED_TOOL_USE_ID}`]: { | ||
| 8395 | instanceId, | ||
| 8396 | model: "claude-custom-tuned", | ||
| 8397 | options: [{ id: "effort", value: "brutal" }], | ||
| 8398 | }, | ||
| 8399 | // The custom slug is not resolved to the built-in model it shadows. | ||
| 8400 | [`task-${SHADOW_TOOL_USE_ID}`]: { instanceId, model: "opus" }, | ||
| 8401 | }); | ||
| 8402 | }).pipe( | ||
| 8403 | Effect.provide( | ||
| 8404 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | ||
| 8405 | ), | ||
| 8406 | ), | ||
| 8407 | ), | ||
| 8408 | ); | ||
| 8409 | |||
| 8410 | it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => | ||
| 8411 | Effect.scoped( | ||
| 8412 | Effect.gen(function* () { | ||
| 8413 | const OUTER_TOOL_USE_ID = "toolu-effort-outer"; | ||
| 8414 | const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; | ||
| 8415 | const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; | ||
| 8416 | const harness = yield* makeWakeHarness; | ||
| 8417 | const now = yield* DateTime.now; | ||
| 8418 | const parentSelection = { | ||
| 8419 | instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, | ||
| 8420 | model: "claude-opus-4-6", | ||
| 8421 | options: [{ id: "effort", value: "high" }], | ||
| 8422 | } satisfies ModelSelection; | ||
| 8423 | yield* harness.runtime.startTurn( | ||
| 8424 | makeClaudeTestTurnInput({ | ||
| 8425 | threadId: harness.threadId, | ||
| 8426 | providerThread: harness.providerThread, | ||
| 8427 | now, | ||
| 8428 | attemptId: RunAttemptId.make("attempt-subagent-nested-effort"), | ||
| 8429 | text: "Spawn a subagent that spawns two more.", | ||
| 8430 | attachments: [], | ||
| 8431 | modelSelection: parentSelection, | ||
| 8432 | }), | ||
| 8433 | ); | ||
| 8434 | const agentCall = (id: string, effort?: string) => ({ | ||
| 8435 | type: "tool_use" as const, | ||
| 8436 | id, | ||
| 8437 | name: "Agent", | ||
| 8438 | input: { | ||
| 8439 | description: id, | ||
| 8440 | subagent_type: "general-purpose", | ||
| 8441 | ...(effort === undefined ? {} : { effort }), | ||
| 8442 | prompt: "Check one thing.", | ||
| 8443 | }, | ||
| 8444 | }); | ||
| 8445 | const taskStarted = (id: string, uuid: string) => | ||
| 8446 | claudeSdkFrame({ | ||
| 8447 | type: "system", | ||
| 8448 | subtype: "task_started", | ||
| 8449 | task_id: `task-${id}`, | ||
| 8450 | tool_use_id: id, | ||
| 8451 | description: id, | ||
| 8452 | task_type: "local_agent", | ||
| 8453 | uuid, | ||
| 8454 | session_id: WAKE_NATIVE_SESSION, | ||
| 8455 | }); | ||
| 8456 | for (const frame of [ | ||
| 8457 | claudeSdkFrame({ | ||
| 8458 | type: "assistant", | ||
| 8459 | parent_tool_use_id: null, | ||
| 8460 | message: { | ||
| 8461 | model: parentSelection.model, | ||
| 8462 | id: "msg_nested_effort_outer_launch", | ||
| 8463 | type: "message", | ||
| 8464 | role: "assistant", | ||
| 8465 | content: [agentCall(OUTER_TOOL_USE_ID, "low")], | ||
| 8466 | }, | ||
| 8467 | uuid: "00000000-0000-4000-8000-000000000221", | ||
| 8468 | session_id: WAKE_NATIVE_SESSION, | ||
| 8469 | }), | ||
| 8470 | taskStarted(OUTER_TOOL_USE_ID, "00000000-0000-4000-8000-000000000222"), | ||
| 8471 | // The outer subagent's own Agent calls arrive in its snapshot. | ||
| 8472 | claudeSdkFrame({ | ||
| 8473 | type: "assistant", | ||
| 8474 | parent_tool_use_id: OUTER_TOOL_USE_ID, | ||
| 8475 | message: { | ||
| 8476 | model: parentSelection.model, | ||
| 8477 | id: "msg_nested_effort_inner_launch", | ||
| 8478 | type: "message", | ||
| 8479 | role: "assistant", | ||
| 8480 | content: [ | ||
| 8481 | agentCall(INHERITING_TOOL_USE_ID), | ||
| 8482 | agentCall(OVERRIDING_TOOL_USE_ID, "max"), | ||
| 8483 | ], | ||
| 8484 | }, | ||
| 8485 | uuid: "00000000-0000-4000-8000-000000000223", | ||
| 8486 | session_id: WAKE_NATIVE_SESSION, | ||
| 8487 | }), | ||
| 8488 | taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), | ||
| 8489 | taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), | ||
| 8490 | makeResultFrame({ | ||
| 8491 | uuid: "00000000-0000-4000-8000-000000000226", | ||
| 8492 | result: "Spawned the subagents.", | ||
| 8493 | }), | ||
| 8494 | ]) { | ||
| 8495 | yield* Queue.offer(harness.sdkMessages, frame); | ||
| 8496 | } | ||
| 8497 | yield* Queue.take(harness.terminalReceipts); | ||
| 8498 | |||
| 8499 | const efforts = new Map( | ||
| 8500 | harness.events.flatMap((event) => | ||
| 8501 | event.type === "subagent.updated" | ||
| 8502 | ? [ | ||
| 8503 | [ | ||
| 8504 | event.subagent.nativeTaskRef?.nativeId, | ||
| 8505 | getModelSelectionStringOptionValue(event.subagent.modelSelection, "effort"), | ||
| 8506 | ] as const, | ||
| 8507 | ] | ||
| 8508 | : [], | ||
| 8509 | ), | ||
| 8510 | ); | ||
| 8511 | assert.deepEqual(Object.fromEntries(efforts), { | ||
| 8512 | [`task-${OUTER_TOOL_USE_ID}`]: "low", | ||
| 8513 | [`task-${INHERITING_TOOL_USE_ID}`]: "low", | ||
| 8514 | [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", | ||
| 8515 | }); | ||
| 8516 | const childEfforts = harness.events.flatMap((event) => | ||
| 8517 | event.type === "app_thread.created" && event.appThread.id !== harness.threadId | ||
| 8518 | ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] | ||
| 8519 | : [], | ||
| 8520 | ); | ||
| 8521 | assert.deepEqual(childEfforts, ["low", "low", "max"]); | ||
| 8522 | }).pipe( | ||
| 8523 | Effect.provide( | ||
| 8524 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | ||
| 8525 | ), | ||
| 8526 | ), | ||
| 8527 | ), | ||
| 8528 | ); | ||
| 8529 | |||
| 8165 | it.effect("extracts text from direct content-block subagent results", () => | 8530 | it.effect("extracts text from direct content-block subagent results", () => |
| 8166 | Effect.scoped( | 8531 | Effect.scoped( |
| 8167 | Effect.gen(function* () { | 8532 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+92-13| ... | @@ -99,6 +99,10 @@ import { | ... | @@ -99,6 +99,10 @@ import { |
| 99 | import { | 99 | import { |
| 100 | BUNDLED_CLAUDE_MODEL_CATALOG, | 100 | BUNDLED_CLAUDE_MODEL_CATALOG, |
| 101 | resolveClaudeCatalogContextWindowTokens, | 101 | resolveClaudeCatalogContextWindowTokens, |
| 102 | type ClaudeModelCatalog, | ||
| 103 | getClaudeCatalogModelCapabilities, | ||
| 104 | resolveClaudeModelSlug, | ||
| 105 | scopeClaudeModelCatalog, | ||
| 102 | } from "../../provider/ClaudeModelCatalog.ts"; | 106 | } from "../../provider/ClaudeModelCatalog.ts"; |
| 103 | import { | 107 | import { |
| 104 | boundProviderEventForLogging, | 108 | boundProviderEventForLogging, |
| ... | @@ -2841,6 +2845,9 @@ interface ActiveClaudeSubagent { | ... | @@ -2841,6 +2845,9 @@ interface ActiveClaudeSubagent { |
| 2841 | // The tool call that started the current run: the Agent launch, then each | 2845 | // The tool call that started the current run: the Agent launch, then each |
| 2842 | // SendMessage that resumes the subagent. A new one means a new prompt. | 2846 | // SendMessage that resumes the subagent. A new one means a new prompt. |
| 2843 | readonly runToolUseId: string | null; | 2847 | readonly runToolUseId: string | null; |
| 2848 | // The API effort the subagent runs at: its Agent call's, else its owner's, | ||
| 2849 | // else the session's. Undefined when none is sent and the model default applies. | ||
| 2850 | readonly effort: string | undefined; | ||
| 2844 | nextChildItemOrdinal: number; | 2851 | nextChildItemOrdinal: number; |
| 2845 | resultItemOrdinal: number | null; | 2852 | resultItemOrdinal: number | null; |
| 2846 | // The subagent's latest assistant message routed into its child thread, | 2853 | // The subagent's latest assistant message routed into its child thread, |
| ... | @@ -2893,10 +2900,42 @@ interface ActiveClaudeToolCall { | ... | @@ -2893,10 +2900,42 @@ interface ActiveClaudeToolCall { |
| 2893 | // keyed by the tool_use_id that launches it. | 2900 | // keyed by the tool_use_id that launches it. |
| 2894 | interface PendingClaudeSubagentLaunch { | 2901 | interface PendingClaudeSubagentLaunch { |
| 2895 | readonly model?: string; | 2902 | readonly model?: string; |
| 2903 | readonly effort?: string; | ||
| 2896 | // parent_tool_use_id of the subagent whose own Agent call launches this one. | 2904 | // parent_tool_use_id of the subagent whose own Agent call launches this one. |
| 2897 | readonly ownerToolUseId?: string; | 2905 | readonly ownerToolUseId?: string; |
| 2898 | } | 2906 | } |
| 2899 | 2907 | ||
| 2908 | /** | ||
| 2909 | * Agent calls name aliases ("opus") and replies name dated ids; both resolve | ||
| 2910 | * to the catalog slug. A subagent's thread then keeps one model name across | ||
| 2911 | * its launch, its replies and a resume after a restart, so a later report of | ||
| 2912 | * the same model never reads as a model change that drops its effort. The | ||
| 2913 | * catalog is the instance's, so a custom model keeps its own name. | ||
| 2914 | */ | ||
| 2915 | function canonicalClaudeSubagentModel(catalog: ClaudeModelCatalog, model: string): string { | ||
| 2916 | return resolveClaudeModelSlug(catalog, model); | ||
| 2917 | } | ||
| 2918 | |||
| 2919 | /** | ||
| 2920 | * The selection a subagent's thread shows. An effort its model does not offer | ||
| 2921 | * is not sent for it, so it is left out (a nested subagent still inherits it). | ||
| 2922 | */ | ||
| 2923 | function claudeSubagentModelSelection( | ||
| 2924 | catalog: ClaudeModelCatalog, | ||
| 2925 | instanceId: ModelSelection["instanceId"], | ||
| 2926 | model: string, | ||
| 2927 | effort: string | undefined, | ||
| 2928 | ): ModelSelection { | ||
| 2929 | const descriptor = getClaudeCatalogModelCapabilities(catalog, model).optionDescriptors?.find( | ||
| 2930 | (candidate) => candidate.id === "effort", | ||
| 2931 | ); | ||
| 2932 | const offered = | ||
| 2933 | effort !== undefined && | ||
| 2934 | descriptor?.type === "select" && | ||
| 2935 | descriptor.options.some((option) => option.id === effort); | ||
| 2936 | return { instanceId, model, ...(offered ? { options: [{ id: "effort", value: effort }] } : {}) }; | ||
| 2937 | } | ||
| 2938 | |||
| 2900 | const PENDING_CLAUDE_SUBAGENT_CAP = 64; | 2939 | const PENDING_CLAUDE_SUBAGENT_CAP = 64; |
| 2901 | // Per-subagent bound on frames held while waiting for task_started. | 2940 | // Per-subagent bound on frames held while waiting for task_started. |
| 2902 | const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; | 2941 | const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; |
| ... | @@ -2917,11 +2956,13 @@ function rememberPendingClaudeSubagentLaunch( | ... | @@ -2917,11 +2956,13 @@ function rememberPendingClaudeSubagentLaunch( |
| 2917 | } | 2956 | } |
| 2918 | 2957 | ||
| 2919 | /** | 2958 | /** |
| 2920 | * Agent calls carry model overrides even when the SDK omits child assistant | 2959 | * Agent calls carry model and effort overrides even when the SDK omits child |
| 2921 | * snapshots. A subagent's own Agent call arrives only in its snapshot, so the | 2960 | * assistant snapshots. A subagent's own Agent call arrives only in its |
| 2922 | * owner recorded here is all that links the subagent it starts back to it. | 2961 | * snapshot, so the owner recorded here is all that links the subagent it |
| 2962 | * starts back to it. | ||
| 2923 | */ | 2963 | */ |
| 2924 | function rememberClaudeSubagentLaunch( | 2964 | function rememberClaudeSubagentLaunch( |
| 2965 | catalog: ClaudeModelCatalog, | ||
| 2925 | context: ActiveClaudeTurnContext, | 2966 | context: ActiveClaudeTurnContext, |
| 2926 | pending: Map<string, PendingClaudeSubagentLaunch>, | 2967 | pending: Map<string, PendingClaudeSubagentLaunch>, |
| 2927 | toolUseId: string, | 2968 | toolUseId: string, |
| ... | @@ -2934,17 +2975,23 @@ function rememberClaudeSubagentLaunch( | ... | @@ -2934,17 +2975,23 @@ function rememberClaudeSubagentLaunch( |
| 2934 | // task_started resolves it. | 2975 | // task_started resolves it. |
| 2935 | const model = | 2976 | const model = |
| 2936 | requested !== "inherit" | 2977 | requested !== "inherit" |
| 2937 | ? requested | 2978 | ? requested && canonicalClaudeSubagentModel(catalog, requested) |
| 2938 | : ownerToolUseId === null | 2979 | : ownerToolUseId === null |
| 2939 | ? context.input.modelSelection.model | 2980 | ? context.input.modelSelection.model |
| 2940 | : undefined; | 2981 | : undefined; |
| 2982 | const effort = firstStringInputField(input, ["effort"]); | ||
| 2941 | // A model already known (a snapshot's, or an earlier sighting of this | 2983 | // A model already known (a snapshot's, or an earlier sighting of this |
| 2942 | // call) wins over the requested one. | 2984 | // call) wins over the requested one. |
| 2943 | const launch: PendingClaudeSubagentLaunch = { | 2985 | const launch: PendingClaudeSubagentLaunch = { |
| 2944 | ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }), | 2986 | ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }), |
| 2987 | ...(effort === undefined ? {} : { effort }), | ||
| 2945 | ...(ownerToolUseId === null ? {} : { ownerToolUseId }), | 2988 | ...(ownerToolUseId === null ? {} : { ownerToolUseId }), |
| 2946 | }; | 2989 | }; |
| 2947 | if (launch.model !== undefined || launch.ownerToolUseId !== undefined) { | 2990 | if ( |
| 2991 | launch.model !== undefined || | ||
| 2992 | launch.effort !== undefined || | ||
| 2993 | launch.ownerToolUseId !== undefined | ||
| 2994 | ) { | ||
| 2948 | rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch); | 2995 | rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch); |
| 2949 | } | 2996 | } |
| 2950 | } | 2997 | } |
| ... | @@ -3088,6 +3135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -3088,6 +3135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 3088 | ) { | 3135 | ) { |
| 3089 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; | 3136 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 3090 | const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; | 3137 | const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; |
| 3138 | // Subagent model names and effort levels, including the instance's custom models. | ||
| 3139 | const subagentModelCatalog = scopeClaudeModelCatalog( | ||
| 3140 | BUNDLED_CLAUDE_MODEL_CATALOG, | ||
| 3141 | adapterOptions.settings.customModels, | ||
| 3142 | ); | ||
| 3091 | const continuationRequests = adapterOptions.continuationRequests ?? { | 3143 | const continuationRequests = adapterOptions.continuationRequests ?? { |
| 3092 | offer: () => Effect.void, | 3144 | offer: () => Effect.void, |
| 3093 | }; | 3145 | }; |
| ... | @@ -4165,6 +4217,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -4165,6 +4217,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4165 | `task:${resume.taskId}:subagent`, | 4217 | `task:${resume.taskId}:subagent`, |
| 4166 | ), | 4218 | ), |
| 4167 | runToolUseId: launchToolUseId, | 4219 | runToolUseId: launchToolUseId, |
| 4220 | // The launch's effort went with the old process. Without a task | ||
| 4221 | // modelSelection the child thread keeps the one it was given. | ||
| 4222 | effort: undefined, | ||
| 4168 | nextChildItemOrdinal: 100, | 4223 | nextChildItemOrdinal: 100, |
| 4169 | resultItemOrdinal: null, | 4224 | resultItemOrdinal: null, |
| 4170 | lastAssistantText: null, | 4225 | lastAssistantText: null, |
| ... | @@ -4185,8 +4240,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -4185,8 +4240,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4185 | readonly prompt?: string; | 4240 | readonly prompt?: string; |
| 4186 | readonly title?: string; | 4241 | readonly title?: string; |
| 4187 | readonly model?: string; | 4242 | readonly model?: string; |
| 4188 | // The subagent whose own Agent call started this one; read only | 4243 | // The Agent call's effort and the subagent whose own Agent call |
| 4189 | // when this call registers the subagent. | 4244 | // started this one; read only when this call registers the subagent. |
| 4245 | readonly effort?: string; | ||
| 4190 | readonly owner?: ActiveClaudeSubagent; | 4246 | readonly owner?: ActiveClaudeSubagent; |
| 4191 | readonly progress?: string; | 4247 | readonly progress?: string; |
| 4192 | readonly result?: string; | 4248 | readonly result?: string; |
| ... | @@ -4285,6 +4341,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -4285,6 +4341,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4285 | existingSubagent.task, | 4341 | existingSubagent.task, |
| 4286 | ) | 4342 | ) |
| 4287 | : existingSubagent.task; | 4343 | : existingSubagent.task; |
| 4344 | const effort = | ||
| 4345 | existingSubagent === undefined | ||
| 4346 | ? (input.effort ?? | ||
| 4347 | input.owner?.effort ?? | ||
| 4348 | compileClaudeModelSelection(input.context.input.modelSelection).effort) | ||
| 4349 | : existingSubagent.effort; | ||
| 4350 | const model = input.model ?? priorTask?.model ?? null; | ||
| 4288 | const task = { | 4351 | const task = { |
| 4289 | ...(priorTask ?? { | 4352 | ...(priorTask ?? { |
| 4290 | id: nodeId, | 4353 | id: nodeId, |
| ... | @@ -4323,7 +4386,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -4323,7 +4386,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4323 | : {}), | 4386 | : {}), |
| 4324 | ...(input.prompt === undefined ? {} : { prompt: input.prompt }), | 4387 | ...(input.prompt === undefined ? {} : { prompt: input.prompt }), |
| 4325 | ...(input.title === undefined ? {} : { title: input.title }), | 4388 | ...(input.title === undefined ? {} : { title: input.title }), |
| 4326 | ...(input.model === undefined ? {} : { model: input.model }), | 4389 | model, |
| 4390 | // What the subagent runs with, unless it was recovered after a | ||
| 4391 | // restart. The parent's other options belong to the parent's session. | ||
| 4392 | ...(existingSubagent === undefined || existingSubagent.task.modelSelection !== undefined | ||
| 4393 | ? { | ||
| 4394 | modelSelection: claudeSubagentModelSelection( | ||
| 4395 | subagentModelCatalog, | ||
| 4396 | input.context.input.modelSelection.instanceId, | ||
| 4397 | model || input.context.input.modelSelection.model, | ||
| 4398 | effort, | ||
| 4399 | ), | ||
| 4400 | } | ||
| 4401 | : {}), | ||
| 4327 | ...(input.progress === undefined ? {} : { progress: input.progress }), | 4402 | ...(input.progress === undefined ? {} : { progress: input.progress }), |
| 4328 | ...(input.result === undefined ? {} : { result: input.result }), | 4403 | ...(input.result === undefined ? {} : { result: input.result }), |
| 4329 | ...(isReopen ? { startedAt: now } : {}), | 4404 | ...(isReopen ? { startedAt: now } : {}), |
| ... | @@ -4341,6 +4416,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -4341,6 +4416,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4341 | existingSubagent === undefined | 4416 | existingSubagent === undefined |
| 4342 | ? (input.toolUseId ?? null) | 4417 | ? (input.toolUseId ?? null) |
| 4343 | : (resumeToolUseId ?? existingSubagent.runToolUseId), | 4418 | : (resumeToolUseId ?? existingSubagent.runToolUseId), |
| 4419 | effort, | ||
| 4344 | nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, | 4420 | nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, |
| 4345 | resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, | 4421 | resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, |
| 4346 | // Every task_started begins a new run of the subagent (including a | 4422 | // Every task_started begins a new run of the subagent (including a |
| ... | @@ -4388,10 +4464,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -4388,10 +4464,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4388 | parentNodeId: nodeId, | 4464 | parentNodeId: nodeId, |
| 4389 | activeProviderThreadId: null, | 4465 | activeProviderThreadId: null, |
| 4390 | providerInstanceId: input.context.input.modelSelection.instanceId, | 4466 | providerInstanceId: input.context.input.modelSelection.instanceId, |
| 4391 | modelSelection: | 4467 | modelSelection: task.modelSelection ?? input.context.input.modelSelection, |
| 4392 | task.model && task.model !== input.context.input.modelSelection.model | ||
| 4393 | ? { instanceId: input.context.input.modelSelection.instanceId, model: task.model } | ||
| 4394 | : input.context.input.modelSelection, | ||
| 4395 | title: subagentThreadTitle({ | 4468 | title: subagentThreadTitle({ |
| 4396 | parentTitle: input.context.input.appThread.title, | 4469 | parentTitle: input.context.input.appThread.title, |
| 4397 | prompt: task.prompt, | 4470 | prompt: task.prompt, |
| ... | @@ -6194,7 +6267,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -6194,7 +6267,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6194 | const parentToolUseId = message.parent_tool_use_id; | 6267 | const parentToolUseId = message.parent_tool_use_id; |
| 6195 | const snapshotModel = | 6268 | const snapshotModel = |
| 6196 | typeof message.message.model === "string" ? message.message.model.trim() : ""; | 6269 | typeof message.message.model === "string" ? message.message.model.trim() : ""; |
| 6197 | const model = snapshotModel.length === 0 ? undefined : snapshotModel; | 6270 | const model = |
| 6271 | snapshotModel.length === 0 | ||
| 6272 | ? undefined | ||
| 6273 | : canonicalClaudeSubagentModel(subagentModelCatalog, snapshotModel); | ||
| 6198 | if (parentToolUseId !== null && model !== undefined) { | 6274 | if (parentToolUseId !== null && model !== undefined) { |
| 6199 | const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); | 6275 | const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); |
| 6200 | if (subagent === undefined) { | 6276 | if (subagent === undefined) { |
| ... | @@ -6301,6 +6377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -6301,6 +6377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6301 | ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), | 6377 | ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), |
| 6302 | ...(message.prompt === undefined ? {} : { prompt: message.prompt }), | 6378 | ...(message.prompt === undefined ? {} : { prompt: message.prompt }), |
| 6303 | ...(model === undefined ? {} : { model }), | 6379 | ...(model === undefined ? {} : { model }), |
| 6380 | ...(launch?.effort === undefined ? {} : { effort: launch.effort }), | ||
| 6304 | ...(owner === undefined ? {} : { owner }), | 6381 | ...(owner === undefined ? {} : { owner }), |
| 6305 | title: message.description, | 6382 | title: message.description, |
| 6306 | status: "running", | 6383 | status: "running", |
| ... | @@ -6402,6 +6479,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -6402,6 +6479,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6402 | const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); | 6479 | const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); |
| 6403 | if (toolUse.name === "Agent") { | 6480 | if (toolUse.name === "Agent") { |
| 6404 | rememberClaudeSubagentLaunch( | 6481 | rememberClaudeSubagentLaunch( |
| 6482 | subagentModelCatalog, | ||
| 6405 | context, | 6483 | context, |
| 6406 | pendingSubagentLaunchesByToolUseId, | 6484 | pendingSubagentLaunchesByToolUseId, |
| 6407 | toolUse.id, | 6485 | toolUse.id, |
| ... | @@ -7000,6 +7078,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( | ... | @@ -7000,6 +7078,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7000 | const heldForEcho = context.heldRootFrames.length > 0; | 7078 | const heldForEcho = context.heldRootFrames.length > 0; |
| 7001 | if (toolName === "Agent") { | 7079 | if (toolName === "Agent") { |
| 7002 | rememberClaudeSubagentLaunch( | 7080 | rememberClaudeSubagentLaunch( |
| 7081 | subagentModelCatalog, | ||
| 7003 | context, | 7082 | context, |
| 7004 | pendingSubagentLaunchesByToolUseId, | 7083 | pendingSubagentLaunchesByToolUseId, |
| 7005 | nativeRequestId, | 7084 | nativeRequestId, |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+31| ... | @@ -1681,6 +1681,37 @@ layer("ProviderEventIngestorV2", (it) => { | ... | @@ -1681,6 +1681,37 @@ layer("ProviderEventIngestorV2", (it) => { |
| 1681 | instanceId: modelSelection.instanceId, | 1681 | instanceId: modelSelection.instanceId, |
| 1682 | model: "gpt-6.1-sol", | 1682 | model: "gpt-6.1-sol", |
| 1683 | }); | 1683 | }); |
| 1684 | |||
| 1685 | // A selection the provider reports, effort included, replaces the thread's. | ||
| 1686 | const reportedSelection = { | ||
| 1687 | instanceId: modelSelection.instanceId, | ||
| 1688 | model: "gpt-6.1-sol", | ||
| 1689 | options: [{ id: "reasoningEffort", value: "low" }], | ||
| 1690 | }; | ||
| 1691 | const reported = yield* ingest({ | ||
| 1692 | ...subagentUpdated, | ||
| 1693 | subagent: { ...subagentUpdated.subagent, modelSelection: reportedSelection }, | ||
| 1694 | }); | ||
| 1695 | assert.deepEqual( | ||
| 1696 | reported.map((stored) => stored.event.type), | ||
| 1697 | ["subagent.updated", "thread.model-selection-updated"], | ||
| 1698 | ); | ||
| 1699 | assert.deepEqual( | ||
| 1700 | (yield* projectionStore.getThread(childThreadId)).modelSelection, | ||
| 1701 | reportedSelection, | ||
| 1702 | ); | ||
| 1703 | |||
| 1704 | // A subagent recovered after a restart reports its model but no | ||
| 1705 | // selection; the same model keeps the effort the thread holds. | ||
| 1706 | const recovered = yield* ingest(subagentUpdated); | ||
| 1707 | assert.deepEqual( | ||
| 1708 | recovered.map((stored) => stored.event.type), | ||
| 1709 | ["subagent.updated"], | ||
| 1710 | ); | ||
| 1711 | assert.deepEqual( | ||
| 1712 | (yield* projectionStore.getThread(childThreadId)).modelSelection, | ||
| 1713 | reportedSelection, | ||
| 1714 | ); | ||
| 1684 | }), | 1715 | }), |
| 1685 | ); | 1716 | ); |
| 1686 | }); | 1717 | }); |
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+21-8| ... | @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -27,7 +27,7 @@ import * as Layer from "effect/Layer"; |
| 27 | import * as Schema from "effect/Schema"; | 27 | import * as Schema from "effect/Schema"; |
| 28 | 28 | ||
| 29 | import { AgentScope } from "@t3tools/shared/AgentScope"; | 29 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 30 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 30 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; |
| 31 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; | 31 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; |
| 32 | import * as EventSink from "./EventSink.ts"; | 32 | import * as EventSink from "./EventSink.ts"; |
| 33 | import * as ProjectionStore from "./ProjectionStore.ts"; | 33 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| ... | @@ -402,18 +402,32 @@ export const layer: Layer.Layer< | ... | @@ -402,18 +402,32 @@ export const layer: Layer.Layer< |
| 402 | /** | 402 | /** |
| 403 | * A native subagent's thread starts on the parent's model when the | 403 | * A native subagent's thread starts on the parent's model when the |
| 404 | * provider names the real one later (a Claude agent file's model arrives | 404 | * provider names the real one later (a Claude agent file's model arrives |
| 405 | * with the subagent's first reply). Clients read the thread's model, so | 405 | * with the subagent's first reply). Clients read the thread's model and |
| 406 | * move the thread to the reported one. Thread commands rewrite the whole | 406 | * effort, so move the thread to the reported selection, or to the reported |
| 407 | * thread row under the thread's lock, so this read and write take it too. | 407 | * model alone when the provider reports no selection. Thread commands |
| 408 | * rewrite the whole thread row under the thread's lock, so this read and | ||
| 409 | * write take it too. | ||
| 408 | */ | 410 | */ |
| 409 | const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( | 411 | const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( |
| 410 | function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { | 412 | function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { |
| 411 | const { childThreadId, model } = subagent; | 413 | const { childThreadId, model } = subagent; |
| 412 | if (subagent.origin !== "provider_native" || childThreadId === null || model === null) { | 414 | if ( |
| 415 | subagent.origin !== "provider_native" || | ||
| 416 | childThreadId === null || | ||
| 417 | (model === null && subagent.modelSelection === undefined) | ||
| 418 | ) { | ||
| 413 | return []; | 419 | return []; |
| 414 | } | 420 | } |
| 421 | const reportedSelection = ({ modelSelection }: { modelSelection: ModelSelection }) => | ||
| 422 | subagent.modelSelection ?? | ||
| 423 | (model === null || model === modelSelection.model | ||
| 424 | ? modelSelection | ||
| 425 | : // The parent's options belong to the parent's model. | ||
| 426 | { instanceId: modelSelection.instanceId, model }); | ||
| 415 | const staleThread = projections.getThread(childThreadId).pipe( | 427 | const staleThread = projections.getThread(childThreadId).pipe( |
| 416 | Effect.map((thread) => (thread.modelSelection.model === model ? null : thread)), | 428 | Effect.map((thread) => |
| 429 | modelSelectionsEqual(thread.modelSelection, reportedSelection(thread)) ? null : thread, | ||
| 430 | ), | ||
| 417 | Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), | 431 | Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), |
| 418 | ); | 432 | ); |
| 419 | // Nearly every update already matches; only a mismatch takes the lock. | 433 | // Nearly every update already matches; only a mismatch takes the lock. |
| ... | @@ -427,10 +441,9 @@ export const layer: Layer.Layer< | ... | @@ -427,10 +441,9 @@ export const layer: Layer.Layer< |
| 427 | const event = yield* makeDomainEvent(input, { | 441 | const event = yield* makeDomainEvent(input, { |
| 428 | type: "thread.model-selection-updated", | 442 | type: "thread.model-selection-updated", |
| 429 | threadId: thread.id, | 443 | threadId: thread.id, |
| 430 | // The parent's options belong to the parent's model. | ||
| 431 | payload: { | 444 | payload: { |
| 432 | ...thread, | 445 | ...thread, |
| 433 | modelSelection: { instanceId: thread.modelSelection.instanceId, model }, | 446 | modelSelection: reportedSelection(thread), |
| 434 | updatedAt: now, | 447 | updatedAt: now, |
| 435 | }, | 448 | }, |
| 436 | occurredAt: now, | 449 | occurredAt: now, |
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts+7| ... | @@ -384,6 +384,13 @@ describe("orchestrator replay recovery", () => { | ... | @@ -384,6 +384,13 @@ describe("orchestrator replay recovery", () => { |
| 384 | assert.lengthOf(childThreads, 1); | 384 | assert.lengthOf(childThreads, 1); |
| 385 | const child = childThreads[0]; | 385 | const child = childThreads[0]; |
| 386 | assert.equal(child?.thread.id, subagent?.childThreadId); | 386 | assert.equal(child?.thread.id, subagent?.childThreadId); |
| 387 | // The recovered subagent's replies name the model its thread already | ||
| 388 | // holds, so the effort recorded at launch survives the restart. | ||
| 389 | assert.deepEqual(child?.thread.modelSelection, { | ||
| 390 | instanceId: CLAUDE_MODEL_SELECTION.instanceId, | ||
| 391 | model: "claude-sonnet-5", | ||
| 392 | options: [{ id: "effort", value: "high" }], | ||
| 393 | }); | ||
| 387 | 394 | ||
| 388 | const conversation = (child?.turnItems ?? []) | 395 | const conversation = (child?.turnItems ?? []) |
| 389 | .toSorted((left, right) => left.ordinal - right.ordinal) | 396 | .toSorted((left, right) => left.ordinal - right.ordinal) |
apps/server/src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/output.ts+2-1| ... | @@ -22,7 +22,8 @@ import { | ... | @@ -22,7 +22,8 @@ import { |
| 22 | 22 | ||
| 23 | const AGENT_A_TASK_ID = "a1a715b7d0bdfefea"; | 23 | const AGENT_A_TASK_ID = "a1a715b7d0bdfefea"; |
| 24 | const AGENT_B_TASK_ID = "af44d5c14aa3ce867"; | 24 | const AGENT_B_TASK_ID = "af44d5c14aa3ce867"; |
| 25 | const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5-20251001"; | 25 | // Its snapshots report claude-haiku-4-5-20251001, recorded as the catalog slug. |
| 26 | const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5"; | ||
| 26 | 27 | ||
| 27 | function assistantTexts( | 28 | function assistantTexts( |
| 28 | projection: OrchestrationV2ThreadProjection, | 29 | projection: OrchestrationV2ThreadProjection, |
apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts+8-3| ... | @@ -12,9 +12,10 @@ import { | ... | @@ -12,9 +12,10 @@ import { |
| 12 | 12 | ||
| 13 | const OUTER_TASK_ID = "a457ca677bc1ecd82"; | 13 | const OUTER_TASK_ID = "a457ca677bc1ecd82"; |
| 14 | const NESTED_TASK_ID = "aac1d7a2af5371c7d"; | 14 | const NESTED_TASK_ID = "aac1d7a2af5371c7d"; |
| 15 | // The outer subagent's snapshots report this id; the nested one never streams | 15 | // The outer subagent's snapshots report claude-haiku-4-5-20251001, recorded as |
| 16 | // a snapshot of its own (the CLI's session storage shows it ran on the same). | 16 | // the catalog slug; the nested one never streams a snapshot of its own (the |
| 17 | const SUBAGENT_MODEL = "claude-haiku-4-5-20251001"; | 17 | // CLI's session storage shows it ran on the same). |
| 18 | const SUBAGENT_MODEL = "claude-haiku-4-5"; | ||
| 18 | 19 | ||
| 19 | export function assertClaudeNestedSubagentModelOutput( | 20 | export function assertClaudeNestedSubagentModelOutput( |
| 20 | result: OrchestratorV2ScenarioResult, | 21 | result: OrchestratorV2ScenarioResult, |
| ... | @@ -45,6 +46,10 @@ export function assertClaudeNestedSubagentModelOutput( | ... | @@ -45,6 +46,10 @@ export function assertClaudeNestedSubagentModelOutput( |
| 45 | // only that snapshot can say who launched it and on which model. | 46 | // only that snapshot can say who launched it and on which model. |
| 46 | assert.equal(nested.parentNodeId, outer.id, "a nested subagent hangs off its owner"); | 47 | assert.equal(nested.parentNodeId, outer.id, "a nested subagent hangs off its owner"); |
| 47 | assert.equal(nested.model, SUBAGENT_MODEL, "a nested subagent runs on its owner's model"); | 48 | assert.equal(nested.model, SUBAGENT_MODEL, "a nested subagent runs on its owner's model"); |
| 49 | // The session's effort is not sent for claude-haiku-4-5, which offers none. | ||
| 50 | const subagentSelection = { instanceId: outer.providerInstanceId, model: SUBAGENT_MODEL }; | ||
| 51 | assert.deepEqual(outer.modelSelection, subagentSelection); | ||
| 52 | assert.deepEqual(nested.modelSelection, subagentSelection); | ||
| 48 | const nestedNode = projection.nodes.find((node) => node.id === nested.id); | 53 | const nestedNode = projection.nodes.find((node) => node.id === nested.id); |
| 49 | assert.isDefined(nestedNode); | 54 | assert.isDefined(nestedNode); |
| 50 | assert.equal(nestedNode.parentNodeId, outer.id); | 55 | assert.equal(nestedNode.parentNodeId, outer.id); |