authorgravatar for 88523594+RakshithBhat03@users.noreply.github.comRakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> 2026-10-10 18:31:50+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 08:01:50-05:00
log98beed1a226c42b85c52ff5ee9d5dbb67d776a77
tree557c3447771d1bf5b9ba198161c3f7e52e0c50ff
parent94d1a8e93f0434d34fa894523a04fe6a6f6172f6
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude subagents show the reasoning effort they run at (#17496)

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";
4848import { Tool } from "effect/ai";
4949import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction";
5050import * as HostProcess from "@t3tools/shared/HostProcess";
51import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
5152import { SpawnExecutableResolution } from "@t3tools/shared/shell";
5253
5354import { attachmentRelativePath } from "../../attachmentStore.ts";
......@@ -73,6 +74,29 @@ const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({});
7374const AUTO_COMPACT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({
7475 autoCompactWindow: "300000",
7576});
77const 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});
76100const CLAUDE_TEST_MODEL_SELECTION = {
77101 instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER),
78102 model: "claude-sonnet-4-6",
......@@ -2107,6 +2131,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
21072131 readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>;
21082132 readonly interrupt?: Effect.Effect<void>;
21092133 readonly environment?: NodeJS.ProcessEnv;
2134 readonly settings?: ClaudeSettings;
21102135 // A CLI process opened after the first streams from its own queue, so the
21112136 // first one can exit (Queue.shutdown) and a later turn can start another.
21122137 readonly freshQueueOnReopen?: boolean;
......@@ -2141,7 +2166,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
21412166 let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
21422167 const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
21432168 instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
2144 settings: DEFAULT_CLAUDE_SETTINGS,
2169 settings: options?.settings ?? DEFAULT_CLAUDE_SETTINGS,
21452170 environment: options?.environment ?? {},
21462171 attachmentsDir,
21472172 fileSystem,
......@@ -8139,18 +8164,20 @@ describe("ClaudeAdapterV2 background wake turns", () => {
81398164 );
81408165 yield* Queue.take(harness.terminalReceipts);
81418166 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";
81428169 const initialModel =
81438170 source === "observed-before"
8144 ? observedModel
8171 ? observedSlug
81458172 : source === "inherit"
81468173 ? parentModel
81478174 : source === "unknown"
81488175 ? null
8149 : "haiku";
8176 : "claude-haiku-5-5";
81508177 assert.equal(subagents[0]?.subagent.model, initialModel);
81518178 assert.equal(
81528179 subagents.at(-1)?.subagent.model,
8153 source.startsWith("observed") ? observedModel : initialModel,
8180 source.startsWith("observed") ? observedSlug : initialModel,
81548181 );
81558182 const child = harness.events.find((event) => event.type === "app_thread.created");
81568183 assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel);
......@@ -8162,6 +8189,344 @@ describe("ClaudeAdapterV2 background wake turns", () => {
81628189 ),
81638190 );
81648191
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
81658530 it.effect("extracts text from direct content-block subagent results", () =>
81668531 Effect.scoped(
81678532 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+92-13
......@@ -99,6 +99,10 @@ import {
9999import {
100100 BUNDLED_CLAUDE_MODEL_CATALOG,
101101 resolveClaudeCatalogContextWindowTokens,
102 type ClaudeModelCatalog,
103 getClaudeCatalogModelCapabilities,
104 resolveClaudeModelSlug,
105 scopeClaudeModelCatalog,
102106} from "../../provider/ClaudeModelCatalog.ts";
103107import {
104108 boundProviderEventForLogging,
......@@ -2841,6 +2845,9 @@ interface ActiveClaudeSubagent {
28412845 // The tool call that started the current run: the Agent launch, then each
28422846 // SendMessage that resumes the subagent. A new one means a new prompt.
28432847 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;
28442851 nextChildItemOrdinal: number;
28452852 resultItemOrdinal: number | null;
28462853 // The subagent's latest assistant message routed into its child thread,
......@@ -2893,10 +2900,42 @@ interface ActiveClaudeToolCall {
28932900// keyed by the tool_use_id that launches it.
28942901interface PendingClaudeSubagentLaunch {
28952902 readonly model?: string;
2903 readonly effort?: string;
28962904 // parent_tool_use_id of the subagent whose own Agent call launches this one.
28972905 readonly ownerToolUseId?: string;
28982906}
28992907
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 */
2915function 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 */
2923function 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
29002939const PENDING_CLAUDE_SUBAGENT_CAP = 64;
29012940// Per-subagent bound on frames held while waiting for task_started.
29022941const PENDING_CLAUDE_SUBAGENT_FRAME_CAP = 256;
......@@ -2917,11 +2956,13 @@ function rememberPendingClaudeSubagentLaunch(
29172956}
29182957
29192958/**
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.
29232963 */
29242964function rememberClaudeSubagentLaunch(
2965 catalog: ClaudeModelCatalog,
29252966 context: ActiveClaudeTurnContext,
29262967 pending: Map<string, PendingClaudeSubagentLaunch>,
29272968 toolUseId: string,
......@@ -2934,17 +2975,23 @@ function rememberClaudeSubagentLaunch(
29342975 // task_started resolves it.
29352976 const model =
29362977 requested !== "inherit"
2937 ? requested
2978 ? requested && canonicalClaudeSubagentModel(catalog, requested)
29382979 : ownerToolUseId === null
29392980 ? context.input.modelSelection.model
29402981 : undefined;
2982 const effort = firstStringInputField(input, ["effort"]);
29412983 // A model already known (a snapshot's, or an earlier sighting of this
29422984 // call) wins over the requested one.
29432985 const launch: PendingClaudeSubagentLaunch = {
29442986 ...(model === undefined || pending.get(toolUseId)?.model !== undefined ? {} : { model }),
2987 ...(effort === undefined ? {} : { effort }),
29452988 ...(ownerToolUseId === null ? {} : { ownerToolUseId }),
29462989 };
2947 if (launch.model !== undefined || launch.ownerToolUseId !== undefined) {
2990 if (
2991 launch.model !== undefined ||
2992 launch.effort !== undefined ||
2993 launch.ownerToolUseId !== undefined
2994 ) {
29482995 rememberPendingClaudeSubagentLaunch(pending, toolUseId, launch);
29492996 }
29502997}
......@@ -3088,6 +3135,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
30883135) {
30893136 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
30903137 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 );
30913143 const continuationRequests = adapterOptions.continuationRequests ?? {
30923144 offer: () => Effect.void,
30933145 };
......@@ -4165,6 +4217,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
41654217 `task:${resume.taskId}:subagent`,
41664218 ),
41674219 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,
41684223 nextChildItemOrdinal: 100,
41694224 resultItemOrdinal: null,
41704225 lastAssistantText: null,
......@@ -4185,8 +4240,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
41854240 readonly prompt?: string;
41864241 readonly title?: string;
41874242 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;
41904246 readonly owner?: ActiveClaudeSubagent;
41914247 readonly progress?: string;
41924248 readonly result?: string;
......@@ -4285,6 +4341,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
42854341 existingSubagent.task,
42864342 )
42874343 : 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;
42884351 const task = {
42894352 ...(priorTask ?? {
42904353 id: nodeId,
......@@ -4323,7 +4386,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
43234386 : {}),
43244387 ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
43254388 ...(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 : {}),
43274402 ...(input.progress === undefined ? {} : { progress: input.progress }),
43284403 ...(input.result === undefined ? {} : { result: input.result }),
43294404 ...(isReopen ? { startedAt: now } : {}),
......@@ -4341,6 +4416,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
43414416 existingSubagent === undefined
43424417 ? (input.toolUseId ?? null)
43434418 : (resumeToolUseId ?? existingSubagent.runToolUseId),
4419 effort,
43444420 nextChildItemOrdinal: existingSubagent?.nextChildItemOrdinal ?? 100,
43454421 resultItemOrdinal: existingSubagent?.resultItemOrdinal ?? null,
43464422 // Every task_started begins a new run of the subagent (including a
......@@ -4388,10 +4464,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
43884464 parentNodeId: nodeId,
43894465 activeProviderThreadId: null,
43904466 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,
43954468 title: subagentThreadTitle({
43964469 parentTitle: input.context.input.appThread.title,
43974470 prompt: task.prompt,
......@@ -6194,7 +6267,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
61946267 const parentToolUseId = message.parent_tool_use_id;
61956268 const snapshotModel =
61966269 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);
61986274 if (parentToolUseId !== null && model !== undefined) {
61996275 const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId);
62006276 if (subagent === undefined) {
......@@ -6301,6 +6377,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
63016377 ...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
63026378 ...(message.prompt === undefined ? {} : { prompt: message.prompt }),
63036379 ...(model === undefined ? {} : { model }),
6380 ...(launch?.effort === undefined ? {} : { effort: launch.effort }),
63046381 ...(owner === undefined ? {} : { owner }),
63056382 title: message.description,
63066383 status: "running",
......@@ -6402,6 +6479,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
64026479 const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input);
64036480 if (toolUse.name === "Agent") {
64046481 rememberClaudeSubagentLaunch(
6482 subagentModelCatalog,
64056483 context,
64066484 pendingSubagentLaunchesByToolUseId,
64076485 toolUse.id,
......@@ -7000,6 +7078,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
70007078 const heldForEcho = context.heldRootFrames.length > 0;
70017079 if (toolName === "Agent") {
70027080 rememberClaudeSubagentLaunch(
7081 subagentModelCatalog,
70037082 context,
70047083 pendingSubagentLaunchesByToolUseId,
70057084 nativeRequestId,
apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts+31
......@@ -1681,6 +1681,37 @@ layer("ProviderEventIngestorV2", (it) => {
16811681 instanceId: modelSelection.instanceId,
16821682 model: "gpt-6.1-sol",
16831683 });
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 );
16841715 }),
16851716 );
16861717});
apps/server/src/orchestration-v2/ProviderEventIngestor.ts+21-8
......@@ -27,7 +27,7 @@ import * as Layer from "effect/Layer";
2727import * as Schema from "effect/Schema";
2828
2929import { AgentScope } from "@t3tools/shared/AgentScope";
30import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
30import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model";
3131import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
3232import * as EventSink from "./EventSink.ts";
3333import * as ProjectionStore from "./ProjectionStore.ts";
......@@ -402,18 +402,32 @@ export const layer: Layer.Layer<
402402 /**
403403 * A native subagent's thread starts on the parent's model when the
404404 * 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.
408410 */
409411 const syncSubagentThreadModel = Effect.fn("ProviderEventIngestor.syncSubagentThreadModel")(
410412 function* (input: ProviderEventIngestInput, subagent: OrchestrationV2Subagent) {
411413 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 ) {
413419 return [];
414420 }
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 });
415427 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 ),
417431 Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }),
418432 );
419433 // Nearly every update already matches; only a mismatch takes the lock.
......@@ -427,10 +441,9 @@ export const layer: Layer.Layer<
427441 const event = yield* makeDomainEvent(input, {
428442 type: "thread.model-selection-updated",
429443 threadId: thread.id,
430 // The parent's options belong to the parent's model.
431444 payload: {
432445 ...thread,
433 modelSelection: { instanceId: thread.modelSelection.instanceId, model },
446 modelSelection: reportedSelection(thread),
434447 updatedAt: now,
435448 },
436449 occurredAt: now,
apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts+7
......@@ -384,6 +384,13 @@ describe("orchestrator replay recovery", () => {
384384 assert.lengthOf(childThreads, 1);
385385 const child = childThreads[0];
386386 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 });
387394
388395 const conversation = (child?.turnItems ?? [])
389396 .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 {
2222
2323const AGENT_A_TASK_ID = "a1a715b7d0bdfefea";
2424const AGENT_B_TASK_ID = "af44d5c14aa3ce867";
25const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5-20251001";
25// Its snapshots report claude-haiku-4-5-20251001, recorded as the catalog slug.
26const AGENT_A_OBSERVED_MODEL = "claude-haiku-4-5";
2627
2728function assistantTexts(
2829 projection: OrchestrationV2ThreadProjection,
apps/server/src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/output.ts+8-3
......@@ -12,9 +12,10 @@ import {
1212
1313const OUTER_TASK_ID = "a457ca677bc1ecd82";
1414const 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).
17const 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).
18const SUBAGENT_MODEL = "claude-haiku-4-5";
1819
1920export function assertClaudeNestedSubagentModelOutput(
2021 result: OrchestratorV2ScenarioResult,
......@@ -45,6 +46,10 @@ export function assertClaudeNestedSubagentModelOutput(
4546 // only that snapshot can say who launched it and on which model.
4647 assert.equal(nested.parentNodeId, outer.id, "a nested subagent hangs off its owner");
4748 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);
4853 const nestedNode = projection.nodes.find((node) => node.id === nested.id);
4954 assert.isDefined(nestedNode);
5055 assert.equal(nestedNode.parentNodeId, outer.id);