| 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 | 48 | import { Tool } from "effect/ai"; |
| 49 | 49 | import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; |
| 50 | 50 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 51 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 51 | 52 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 52 | 53 | |
| 53 | 54 | import { attachmentRelativePath } from "../../attachmentStore.ts"; |
| ... | ... | @@ -73,6 +74,29 @@ const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); |
| 73 | 74 | const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ |
| 74 | 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 | 100 | const CLAUDE_TEST_MODEL_SELECTION = { |
| 77 | 101 | instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), |
| 78 | 102 | model: "claude-sonnet-4-6", |
| ... | ... | @@ -2107,6 +2131,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2107 | 2131 | readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>; |
| 2108 | 2132 | readonly interrupt?: Effect.Effect<void>; |
| 2109 | 2133 | readonly environment?: NodeJS.ProcessEnv; |
| 2134 | readonly settings?: ClaudeSettings; | |
| 2110 | 2135 | // A CLI process opened after the first streams from its own queue, so the |
| 2111 | 2136 | // first one can exit (Queue.shutdown) and a later turn can start another. |
| 2112 | 2137 | readonly freshQueueOnReopen?: boolean; |
| ... | ... | @@ -2141,7 +2166,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2141 | 2166 | let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; |
| 2142 | 2167 | const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ |
| 2143 | 2168 | instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, |
| 2144 | settings: DEFAULT_CLAUDE_SETTINGS, | |
| 2169 | settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS, | |
| 2145 | 2170 | environment: options?.environment ?? {}, |
| 2146 | 2171 | attachmentsDir, |
| 2147 | 2172 | fileSystem, |
| ... | ... | @@ -8139,18 +8164,20 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8139 | 8164 | ); |
| 8140 | 8165 | yield* Queue.take(harness.terminalReceipts); |
| 8141 | 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 | 8169 | const initialModel = |
| 8143 | 8170 | source === "observed-before" |
| 8144 | ? observedModel | |
| 8171 | ? observedSlug | |
| 8145 | 8172 | : source === "inherit" |
| 8146 | 8173 | ? parentModel |
| 8147 | 8174 | : source === "unknown" |
| 8148 | 8175 | ? null |
| 8149 | : "haiku"; | |
| 8176 | : "claude-haiku-5-5"; | |
| 8150 | 8177 | assert.equal(subagents[0]?.subagent.model, initialModel); |
| 8151 | 8178 | assert.equal( |
| 8152 | 8179 | subagents.at(-1)?.subagent.model, |
| 8153 | source.startsWith("observed") ? observedModel : initialModel, | |
| 8180 | source.startsWith("observed") ? observedSlug : initialModel, | |
| 8154 | 8181 | ); |
| 8155 | 8182 | const child = harness.events.find((event) => event.type === "app_thread.created"); |
| 8156 | 8183 | assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); |
| ... | ... | @@ -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 | 8530 | it.effect("extracts text from direct content-block subagent results", () => |
| 8166 | 8531 | Effect.scoped( |
| 8167 | 8532 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+92-13| ... | ... | @@ -99,6 +99,10 @@ import { |
| 99 | 99 | import { |
| 100 | 100 | BUNDLED_CLAUDE_MODEL_CATALOG, |
| 101 | 101 | resolveClaudeCatalogContextWindowTokens, |
| 102 | type ClaudeModelCatalog, | |
| 103 | getClaudeCatalogModelCapabilities, | |
| 104 | resolveClaudeModelSlug, | |
| 105 | scopeClaudeModelCatalog, | |
| 102 | 106 | } from "../../provider/ClaudeModelCatalog.ts"; |
| 103 | 107 | import { |
| 104 | 108 | boundProviderEventForLogging, |
| ... | ... | @@ -2841,6 +2845,9 @@ interface ActiveClaudeSubagent { |
| 2841 | 2845 | // The tool call that started the current run: the Agent launch, then each |
| 2842 | 2846 | // SendMessage that resumes the subagent. A new one means a new prompt. |
| 2843 | 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 | 2851 | nextChildItemOrdinal: number; |
| 2845 | 2852 | resultItemOrdinal: number | null; |
| 2846 | 2853 | // The subagent's latest assistant message routed into its child thread, |
| ... | ... | @@ -2893,10 +2900,42 @@ interface ActiveClaudeToolCall { |
| 2893 | 2900 | // keyed by the tool_use_id that launches it. |
| 2894 | 2901 | interface PendingClaudeSubagentLaunch { |
| 2895 | 2902 | readonly model?: string; |
| 2903 | readonly effort?: string; | |
| 2896 | 2904 | // parent_tool_use_id of the subagent whose own Agent call launches this one. |
| 2897 | 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 | 2939 | const PENDING_CLAUDE_SUBAGENT_CAP = 64; |
| 2901 | 2940 | // Per-subagent bound on frames held while waiting for task_started. |
| 2902 | 2941 | const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256; |
| ... | ... | @@ -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 | |
| 2921 | * snapshots. A subagent's own Agent call arrives only in its snapshot, so the | |
| 2922 | * owner recorded here is all that links the subagent it starts back to it. | |
| 2959 | * Agent calls carry model and effort overrides even when the SDK omits child | |
| 2960 | * assistant snapshots. A subagent's own Agent call arrives only in its | |
| 2961 | * snapshot, so the owner recorded here is all that links the subagent it | |
| 2962 | * starts back to it. | |
| 2923 | 2963 | */ |
| 2924 | 2964 | function rememberClaudeSubagentLaunch( |
| 2965 | catalog: ClaudeModelCatalog, | |
| 2925 | 2966 | context: ActiveClaudeTurnContext, |
| 2926 | 2967 | pending: Map<string, PendingClaudeSubagentLaunch>, |
| 2927 | 2968 | toolUseId: string, |
| ... | ... | @@ -2934,17 +2975,23 @@ function rememberClaudeSubagentLaunch( |
| 2934 | 2975 | // task_started resolves it. |
| 2935 | 2976 | const model = |
| 2936 | 2977 | requested !== "inherit" |
| 2937 | ? requested | |
| 2978 | ? requested && canonicalClaudeSubagentModel(catalog, requested) | |
| 2938 | 2979 | : ownerToolUseId === null |
| 2939 | 2980 | ? context.input.modelSelection.model |
| 2940 | 2981 | : undefined; |
| 2982 | const effort = firstStringInputField(input, ["effort"]); | |
| 2941 | 2983 | // A model already known (a snapshot's, or an earlier sighting of this |
| 2942 | 2984 | // call) wins over the requested one. |
| 2943 | 2985 | const launch: PendingClaudeSubagentLaunch = { |
| 2944 | 2986 | ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }), |
| 2987 | ...(effort === undefined ? {} : { effort }), | |
| 2945 | 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 | 2995 | rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch); |
| 2949 | 2996 | } |
| 2950 | 2997 | } |
| ... | ... | @@ -3088,6 +3135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 3088 | 3135 | ) { |
| 3089 | 3136 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 3090 | 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 | 3143 | const continuationRequests = adapterOptions.continuationRequests ?? { |
| 3092 | 3144 | offer: () => Effect.void, |
| 3093 | 3145 | }; |
| ... | ... | @@ -4165,6 +4217,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4165 | 4217 | `task:${resume.taskId}:subagent`, |
| 4166 | 4218 | ), |
| 4167 | 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 | 4223 | nextChildItemOrdinal: 100, |
| 4169 | 4224 | resultItemOrdinal: null, |
| 4170 | 4225 | lastAssistantText: null, |
| ... | ... | @@ -4185,8 +4240,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4185 | 4240 | readonly prompt?: string; |
| 4186 | 4241 | readonly title?: string; |
| 4187 | 4242 | readonly model?: string; |
| 4188 | // The subagent whose own Agent call started this one; read only | |
| 4189 | // when this call registers the subagent. | |
| 4243 | // The Agent call's effort and the subagent whose own Agent call | |
| 4244 | // started this one; read only when this call registers the subagent. | |
| 4245 | readonly effort?: string; | |
| 4190 | 4246 | readonly owner?: ActiveClaudeSubagent; |
| 4191 | 4247 | readonly progress?: string; |
| 4192 | 4248 | readonly result?: string; |
| ... | ... | @@ -4285,6 +4341,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4285 | 4341 | existingSubagent.task, |
| 4286 | 4342 | ) |
| 4287 | 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 | 4351 | const task = { |
| 4289 | 4352 | ...(priorTask ?? { |
| 4290 | 4353 | id: nodeId, |
| ... | ... | @@ -4323,7 +4386,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4323 | 4386 | : {}), |
| 4324 | 4387 | ...(input.prompt === undefined ? {} : { prompt: input.prompt }), |
| 4325 | 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 | 4402 | ...(input.progress === undefined ? {} : { progress: input.progress }), |
| 4328 | 4403 | ...(input.result === undefined ? {} : { result: input.result }), |
| 4329 | 4404 | ...(isReopen ? { startedAt: now } : {}), |
| ... | ... | @@ -4341,6 +4416,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 4341 | 4416 | existingSubagent === undefined |
| 4342 | 4417 | ? (input.toolUseId ?? null) |
| 4343 | 4418 | : (resumeToolUseId ?? existingSubagent.runToolUseId), |
| 4419 | effort, | |
| 4344 | 4420 | nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100, |
| 4345 | 4421 | resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null, |
| 4346 | 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 | 4464 | parentNodeId: nodeId, |
| 4389 | 4465 | activeProviderThreadId: null, |
| 4390 | 4466 | providerInstanceId: input.context.input.modelSelection.instanceId, |
| 4391 | 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, | |
| 4467 | modelSelection: task.modelSelection ?? input.context.input.modelSelection, | |
| 4395 | 4468 | title: subagentThreadTitle({ |
| 4396 | 4469 | parentTitle: input.context.input.appThread.title, |
| 4397 | 4470 | prompt: task.prompt, |
| ... | ... | @@ -6194,7 +6267,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6194 | 6267 | const parentToolUseId = message.parent_tool_use_id; |
| 6195 | 6268 | const snapshotModel = |
| 6196 | 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 | 6274 | if (parentToolUseId !== null && model !== undefined) { |
| 6199 | 6275 | const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); |
| 6200 | 6276 | if (subagent === undefined) { |
| ... | ... | @@ -6301,6 +6377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6301 | 6377 | ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }), |
| 6302 | 6378 | ...(message.prompt === undefined ? {} : { prompt: message.prompt }), |
| 6303 | 6379 | ...(model === undefined ? {} : { model }), |
| 6380 | ...(launch?.effort === undefined ? {} : { effort: launch.effort }), | |
| 6304 | 6381 | ...(owner === undefined ? {} : { owner }), |
| 6305 | 6382 | title: message.description, |
| 6306 | 6383 | status: "running", |
| ... | ... | @@ -6402,6 +6479,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 6402 | 6479 | const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); |
| 6403 | 6480 | if (toolUse.name === "Agent") { |
| 6404 | 6481 | rememberClaudeSubagentLaunch( |
| 6482 | subagentModelCatalog, | |
| 6405 | 6483 | context, |
| 6406 | 6484 | pendingSubagentLaunchesByToolUseId, |
| 6407 | 6485 | toolUse.id, |
| ... | ... | @@ -7000,6 +7078,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 7000 | 7078 | const heldForEcho = context.heldRootFrames.length > 0; |
| 7001 | 7079 | if (toolName === "Agent") { |
| 7002 | 7080 | rememberClaudeSubagentLaunch( |
| 7081 | subagentModelCatalog, | |
| 7003 | 7082 | context, |
| 7004 | 7083 | pendingSubagentLaunchesByToolUseId, |
| 7005 | 7084 | nativeRequestId, |
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+31| ... | ... | @@ -1681,6 +1681,37 @@ layer("ProviderEventIngestorV2", (it) => { |
| 1681 | 1681 | instanceId: modelSelection.instanceId, |
| 1682 | 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 | 27 | import * as Schema from "effect/Schema"; |
| 28 | 28 | |
| 29 | 29 | import { AgentScope } from "@t3tools/shared/AgentScope"; |
| 30 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | |
| 30 | import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; | |
| 31 | 31 | import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; |
| 32 | 32 | import * as EventSink from "./EventSink.ts"; |
| 33 | 33 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| ... | ... | @@ -402,18 +402,32 @@ export const layer: Layer.Layer< |
| 402 | 402 | /** |
| 403 | 403 | * A native subagent's thread starts on the parent's model when the |
| 404 | 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 | |
| 406 | * move the thread to the reported one. Thread commands rewrite the whole | |
| 407 | * thread row under the thread's lock, so this read and write take it too. | |
| 405 | * with the subagent's first reply). Clients read the thread's model and | |
| 406 | * effort, so move the thread to the reported selection, or to the reported | |
| 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 | 411 | const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")( |
| 410 | 412 | function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) { |
| 411 | 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 | 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 | 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 | 431 | Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), |
| 418 | 432 | ); |
| 419 | 433 | // Nearly every update already matches; only a mismatch takes the lock. |
| ... | ... | @@ -427,10 +441,9 @@ export const layer: Layer.Layer< |
| 427 | 441 | const event = yield* makeDomainEvent(input, { |
| 428 | 442 | type: "thread.model-selection-updated", |
| 429 | 443 | threadId: thread.id, |
| 430 | // The parent's options belong to the parent's model. | |
| 431 | 444 | payload: { |
| 432 | 445 | ...thread, |
| 433 | modelSelection: { instanceId: thread.modelSelection.instanceId, model }, | |
| 446 | modelSelection: reportedSelection(thread), | |
| 434 | 447 | updatedAt: now, |
| 435 | 448 | }, |
| 436 | 449 | occurredAt: now, |
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts+7| ... | ... | @@ -384,6 +384,13 @@ describe("orchestrator replay recovery", () => { |
| 384 | 384 | assert.lengthOf(childThreads, 1); |
| 385 | 385 | const child = childThreads[0]; |
| 386 | 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 | 395 | const conversation = (child?.turnItems ?? []) |
| 389 | 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 | 22 | |
| 23 | 23 | const AGENT_A_TASK_ID = "a1a715b7d0bdfefea"; |
| 24 | 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 | 28 | function assistantTexts( |
| 28 | 29 | projection: OrchestrationV2ThreadProjection, |
apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts+8-3| ... | ... | @@ -12,9 +12,10 @@ import { |
| 12 | 12 | |
| 13 | 13 | const OUTER_TASK_ID = "a457ca677bc1ecd82"; |
| 14 | 14 | const NESTED_TASK_ID = "aac1d7a2af5371c7d"; |
| 15 | // The outer subagent's snapshots report this id; the nested one never streams | |
| 16 | // a snapshot of its own (the CLI's session storage shows it ran on the same). | |
| 17 | const SUBAGENT_MODEL = "claude-haiku-4-5-20251001"; | |
| 15 | // The outer subagent's snapshots report claude-haiku-4-5-20251001, recorded as | |
| 16 | // the catalog slug; the nested one never streams a snapshot of its own (the | |
| 17 | // CLI's session storage shows it ran on the same). | |
| 18 | const SUBAGENT_MODEL = "claude-haiku-4-5"; | |
| 18 | 19 | |
| 19 | 20 | export function assertClaudeNestedSubagentModelOutput( |
| 20 | 21 | result: OrchestratorV2ScenarioResult, |
| ... | ... | @@ -45,6 +46,10 @@ export function assertClaudeNestedSubagentModelOutput( |
| 45 | 46 | // only that snapshot can say who launched it and on which model. |
| 46 | 47 | assert.equal(nested.parentNodeId, outer.id, "a nested subagent hangs off its owner"); |
| 47 | 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 | 53 | const nestedNode = projection.nodes.find((node) => node.id === nested.id); |
| 49 | 54 | assert.isDefined(nestedNode); |
| 50 | 55 | assert.equal(nestedNode.parentNodeId, outer.id); |