diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx index ffca391f260ab..9cd65135e1961 100644 --- a/apps/mobile/src/features/threads/SubagentRow.tsx +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -4,7 +4,10 @@ import { resolveProviderInstanceDisplayName, shouldShowInstanceBadge, } from "@t3tools/client-runtime/state/provider-instance-display"; -import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import { + resolveSubagentMetadata, + resolveSubagentModelTraits, +} from "@t3tools/client-runtime/state/subagent-display"; import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { View } from "react-native"; @@ -14,6 +17,8 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import { threadFamilies } from "../../state/threads"; import { subagentCardDetail } from "./subagent-card-presentation"; import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; @@ -23,6 +28,8 @@ type SubagentRowSubagent = Pick< | "threadId" | "childThreadId" | "model" + | "modelSelection" + | "origin" | "driver" | "providerInstanceId" | "title" @@ -125,9 +132,27 @@ function SubagentMetadata(props: { ); const showBadge = provider !== undefined && shouldShowInstanceBadge(provider, providers); const parent = useThreadShell(scopeThreadRef(environmentId, subagent.threadId))?.source; - const child = useThreadShell( - subagent.childThreadId === null ? null : scopeThreadRef(environmentId, subagent.childThreadId), - )?.source; + const childRef = + subagent.childThreadId !== null + ? scopeThreadRef(environmentId, subagent.childThreadId) + : subagent.remoteChild + ? scopeThreadRef(subagent.remoteChild.environmentId, subagent.remoteChild.threadId) + : null; + const childShell = useThreadShell(childRef)?.source; + const family = useEnvironmentQuery( + childRef !== null && + childShell === undefined && + subagent.origin === "app_owned" && + subagent.modelSelection === undefined + ? threadFamilies.familyAtom( + childRef.environmentId, + subagent.childThreadId !== null ? subagent.threadId : childRef.threadId, + ) + : null, + ); + const selectionChild = + childShell ?? family.data?.threads.find((row) => row.id === childRef?.threadId); + const child = subagent.childThreadId === null ? undefined : childShell; const parentProject = useProject( parent ? scopeProjectRef(environmentId, parent.projectId) : null, ); @@ -140,6 +165,18 @@ function SubagentMetadata(props: { parentProject, childProject, }); + const { effortLabel, speed } = resolveSubagentModelTraits({ + model: subagent.model, + providerInstanceId: subagent.providerInstanceId, + origin: subagent.origin, + modelSelection: subagent.modelSelection, + childThread: selectionChild, + provider, + }); + const traits = [ + effortLabel, + speed === "ultrafast" ? "Ultrafast" : speed === "fast" ? "Fast" : null, + ].filter((trait) => trait !== null); return ( {showBadge ? ( @@ -160,7 +197,7 @@ function SubagentMetadata(props: { )} {provider ? `${resolveProviderInstanceDisplayName(provider)} · ` : ""} - {modelLabel} + {[modelLabel, ...traits].join(" · ")} {workspace.map(({ label, value }) => ( // Preserve the label against flattening without making it a separate accessibility stop. diff --git a/apps/mobile/src/features/threads/thread-subagent-group.tsx b/apps/mobile/src/features/threads/thread-subagent-group.tsx index 4a42a83c9a371..bea71258bd989 100644 --- a/apps/mobile/src/features/threads/thread-subagent-group.tsx +++ b/apps/mobile/src/features/threads/thread-subagent-group.tsx @@ -128,6 +128,7 @@ export function ThreadSubagentGroup(props: { result: live?.result ?? item.result, progress: live?.progress ?? item.progress, model: live?.model ?? null, + ...(live?.modelSelection ? { modelSelection: live.modelSelection } : {}), }; }); const grouped = agents.length > 1; diff --git a/apps/mobile/src/features/threads/thread-workflow-opening.tsx b/apps/mobile/src/features/threads/thread-workflow-opening.tsx index b16a82b7190d9..8f2d0782359ac 100644 --- a/apps/mobile/src/features/threads/thread-workflow-opening.tsx +++ b/apps/mobile/src/features/threads/thread-workflow-opening.tsx @@ -63,6 +63,8 @@ function memberRowSubagent( threadId: coordinatorThreadId, childThreadId: member.childThreadId as ThreadId | null, model: member.model, + // The provider's own workflow tool launches its members. + origin: "provider_native" as const, driver: opening.driver!, providerInstanceId: opening.providerInstanceId!, title: member.title, diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 61515a053ac36..dc37b06d033e9 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -994,6 +994,63 @@ it.effect("keeps delegated child pull-request links independent of the parent", }).pipe(Effect.provide(layerTest)), ); +it.effect("records the selection a delegated task was launched with on its subagent", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const parentThreadId = ThreadId.make("thread:parent-selection"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-parent-selection"), + threadId: parentThreadId, + projectId: ProjectId.make("project:parent-selection"), + title: "Parent delegating at a chosen speed", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-parent-selection"), + threadId: parentThreadId, + messageId: MessageId.make("message:parent-selection"), + text: "Delegate a review", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const parentRun = (yield* projections.getThreadProjection(parentThreadId)).runs[0]!; + const delegatedSelection = { + ...modelSelection, + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: "priority" }, + ], + }; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate-parent-selection"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the changes", + modelSelection: delegatedSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + + const parent = yield* projections.getThreadProjection(parentThreadId); + assert.deepEqual(parent.subagents[0]?.modelSelection, delegatedSelection); + }).pipe(Effect.provide(layerTest)), +); + it.effect("wakes the parent once when its delegated task asks a question", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 35e9a78c4cd55..3460e4f2caff1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7593,6 +7593,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio prompt: command.task, title: command.title ?? null, model: command.modelSelection.model, + // A snapshot: the child thread's own selection can change on later turns. + modelSelection: command.modelSelection, ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }), status: "running", result: null, @@ -8007,6 +8009,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio prompt: command.task, title: command.title ?? null, model: command.modelSelection.model, + modelSelection: command.modelSelection, ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }), status: "running", result: null, diff --git a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts index 4bb8c103f6df7..aa98bf52dbdbc 100644 --- a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts +++ b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts @@ -215,6 +215,7 @@ it.layer(layerTest)("a delegated task in a linked environment", (it) => { const projection = yield* orchestrator.getThreadProjection(parent.threadId); assert.equal(projection.subagents.length, 1); const [task] = projection.subagents; + assert.deepEqual(task?.modelSelection, modelSelection); assert.equal(task?.childThreadId, null); assert.deepEqual(task?.remoteChild, remoteChild); assert.equal(task?.status, "running"); diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2fcaa596a5b6c..622701de63011 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -210,7 +210,7 @@ async function click(element: HTMLElement) { } function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: string }> } = {}) { - environmentQuery.data = { tasks: scheduledTasks }; + environmentQuery.data = { tasks: scheduledTasks, threads: [] }; } const TASK_ID = ScheduledTaskId.make("task-1"); diff --git a/apps/web/src/components/chat/SubagentTooltipContent.tsx b/apps/web/src/components/chat/SubagentTooltipContent.tsx index a965e89efd728..789c59634c8d4 100644 --- a/apps/web/src/components/chat/SubagentTooltipContent.tsx +++ b/apps/web/src/components/chat/SubagentTooltipContent.tsx @@ -9,11 +9,10 @@ import type { } from "@t3tools/contracts"; import { resolveSubagentMetadata, + resolveSubagentModelTraits, subagentDetailPreview, } from "@t3tools/client-runtime/state/subagent-display"; -import { REASONING_EFFORT_OPTION_IDS } from "@t3tools/client-runtime/state/thread-execution"; -import { getModelSelectionStringOptionValue, resolveSelectableModel } from "@t3tools/shared/model"; -import { getTraitsSpeedDisplay, TraitsSpeedIcon } from "./TraitsSpeed"; +import { TraitsSpeedIcon } from "./TraitsSpeed"; import type { ReactNode } from "react"; import { BotIcon, @@ -52,59 +51,8 @@ export function SubagentTooltipContent(props: { }) { const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props); const preview = subagentDetailPreview(props); - const model = props.model?.trim(); - const modelSlug = props.provider - ? resolveSelectableModel(props.provider.driver, model, props.provider.models) - : model; - const providerModel = props.provider?.models.find((candidate) => candidate.slug === modelSlug); - const childSelection = - props.modelSelection ?? - (props.origin === "app_owned" ? props.childThread?.modelSelection : undefined); - const childModel = props.provider - ? (resolveSelectableModel( - props.provider.driver, - childSelection?.model, - props.provider.models, - ) ?? childSelection?.model.trim()) - : childSelection?.model.trim(); + const { effortLabel: effort, speed } = resolveSubagentModelTraits(props); const provider = props.provider; - const matchingSelection = - childModel === (modelSlug ?? model) && childSelection?.instanceId === props.providerInstanceId - ? childSelection - : undefined; - const effort = REASONING_EFFORT_OPTION_IDS.map((id) => - getModelSelectionStringOptionValue(matchingSelection, id), - ).find(Boolean); - const speed = provider - ? providerModel?.capabilities?.optionDescriptors - ?.map((descriptor) => { - const saved = matchingSelection?.options?.find((option) => option.id === descriptor.id); - if ( - descriptor.id === "fastMode" && - descriptor.type === "boolean" && - typeof saved?.value === "boolean" - ) { - return getTraitsSpeedDisplay(provider.driver, { - ...descriptor, - currentValue: saved.value, - }); - } - if ( - provider.driver === "codex" && - descriptor.id === "serviceTier" && - descriptor.type === "select" && - typeof saved?.value === "string" && - descriptor.options.some((option) => option.id === saved.value) - ) { - return getTraitsSpeedDisplay(provider.driver, { - ...descriptor, - currentValue: saved.value, - }); - } - return null; - }) - .find(Boolean) - : undefined; const driver = props.provider?.driver ?? props.driver; const entries = deriveProviderInstanceEntries(props.providers ?? []); const entry = entries.find((candidate) => candidate.instanceId === props.provider?.instanceId); @@ -139,11 +87,11 @@ export function SubagentTooltipContent(props: { {showInstanceBadge ? `${modelLabel} · ${entry.displayName}` : modelLabel} - {effort || speed?.speedIcon ? ( + {effort || speed ? ( {effort ? " · " : null} - {speed?.speedIcon && provider ? ( - + {speed && provider ? ( + ) : null} {effort} diff --git a/apps/web/src/components/chat/TraitsSpeed.tsx b/apps/web/src/components/chat/TraitsSpeed.tsx index 6fdc71cc395cd..e91d9c36acb73 100644 --- a/apps/web/src/components/chat/TraitsSpeed.tsx +++ b/apps/web/src/components/chat/TraitsSpeed.tsx @@ -1,44 +1,9 @@ -import type { ProviderDriverKind, ProviderOptionDescriptor } from "@t3tools/contracts"; -import { getProviderOptionCurrentValue } from "@t3tools/shared/model"; +import type { ProviderDriverKind } from "@t3tools/contracts"; import { ZapIcon } from "lucide-react"; import { UltrafastIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { ComposerControlIcon, type ComposerControlSize } from "./ComposerControl"; -export function getTraitsSpeedDisplay( - provider: ProviderDriverKind, - descriptor: ProviderOptionDescriptor, -): { label: string; speedIcon: "fast" | "ultrafast" | null } | null { - if (descriptor.id === "fastMode" && descriptor.type === "boolean") { - return { - label: descriptor.currentValue === true ? "Fast" : "Normal", - speedIcon: descriptor.currentValue === true ? "fast" : null, - }; - } - if (provider !== "codex" || descriptor.id !== "serviceTier" || descriptor.type !== "select") { - return null; - } - const currentValue = getProviderOptionCurrentValue(descriptor); - const fastTier = descriptor.options.find(({ label }) => label === "Fast"); - const ultrafastTier = descriptor.options.find(({ label }) => label === "Ultrafast"); - if ( - ((fastTier || ultrafastTier) && currentValue === "default") || - (fastTier && currentValue === fastTier.id) || - (ultrafastTier && currentValue === ultrafastTier.id) - ) { - return { - label: descriptor.options.find(({ id }) => id === currentValue)?.label ?? "Normal", - speedIcon: - ultrafastTier && currentValue === ultrafastTier.id - ? "ultrafast" - : fastTier && currentValue === fastTier.id - ? "fast" - : null, - }; - } - return null; -} - export function TraitsSpeedIcon({ provider, speedIcon, diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index d9320d71488ce..ef57ddd511738 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -9,7 +9,8 @@ import { useNavigate } from "@tanstack/react-router"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { environmentThreadDetails } from "../../state/threads"; +import { environmentThreadDetails, threadFamilies } from "../../state/threads"; +import { useEnvironmentQuery } from "../../state/query"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { MiddleTruncate } from "../ui/middle-truncate"; import * as DateTime from "effect/DateTime"; @@ -19,7 +20,11 @@ import { type HandoffEndpoints, type HandoffTimelineRun, } from "@t3tools/client-runtime/handoff"; -import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { + formatSubagentDisplayTitle, + resolveSubagentMetadata, + resolveSubagentModelTraits, +} from "@t3tools/client-runtime/state/subagent-display"; import { ProviderDriverKind, type OrchestrationV2Notification, @@ -55,6 +60,7 @@ import { import { formatShortTimestamp } from "../../timestampFormat"; import { getTriggerDisplayModelName } from "./providerIconUtils"; import { ProviderInstanceIcon, providerTextColor } from "./ProviderInstanceIcon"; +import { TraitsSpeedIcon } from "./TraitsSpeed"; import { cn } from "~/lib/utils"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { subagentResumedSince } from "./V2LifecycleRow.logic"; @@ -441,6 +447,54 @@ function SubagentTimelineLink(props: { startedAt: isoOrNull(agent?.startedAt ?? props.startedAt), completedAt: isoOrNull(agent?.completedAt ?? props.completedAt), }; + // Older app-owned records keep their selection only on the child thread. + const fallbackChild = + props.origin === "app_owned" && agent?.modelSelection === undefined + ? threadId !== null + ? scopeThreadRef(props.parentRef.environmentId, threadId) + : remote + ? scopeThreadRef(remote.environmentId, remote.threadId) + : null + : null; + const childShell = useThreadShell(fallbackChild)?.source; + const family = useEnvironmentQuery( + fallbackChild !== null && childShell === undefined + ? threadFamilies.familyAtom( + fallbackChild.environmentId, + threadId !== null ? props.parentRef.threadId : fallbackChild.threadId, + ) + : null, + ); + const childModelSelection = ( + childShell ?? family.data?.threads.find((child) => child.id === fallbackChild?.threadId) + )?.modelSelection; + const model = agent?.model ?? null; + // Select only the derived labels so provider status pushes, which replace + // the provider objects, do not re-render every row. + const providersAtom = serverEnvironment.providersValueAtom( + remote?.environmentId ?? props.parentRef.environmentId, + ); + const findProvider = (providers: ReadonlyArray | null | undefined) => + providers?.find((provider) => provider.instanceId === props.providerInstanceId); + const modelTraits = (providers: ReadonlyArray | null | undefined) => + resolveSubagentModelTraits({ + model, + providerInstanceId: props.providerInstanceId, + origin: props.origin, + modelSelection: agent?.modelSelection, + childThread: childModelSelection ? { modelSelection: childModelSelection } : null, + provider: findProvider(providers), + }); + const modelLabel = useAtomValue(providersAtom, (providers) => + model === null + ? null + : resolveSubagentMetadata({ model, provider: findProvider(providers) }).modelLabel, + ); + const effortLabel = useAtomValue( + providersAtom, + (providers) => modelTraits(providers).effortLabel, + ); + const speed = useAtomValue(providersAtom, (providers) => modelTraits(providers).speed); const content = ( <> + {modelLabel === null ? null : ( + + {modelLabel} + {effortLabel !== null || speed !== null ? ( + + {effortLabel !== null ? " · " : null} + {speed !== null ? ( + + ) : null} + {effortLabel} + + ) : null} + + )} {detail !== null && (props.event !== undefined || status !== "completed") ? ( : null} model={agent?.model ?? null} - modelSelection={agent?.modelSelection} + modelSelection={agent?.modelSelection ?? childModelSelection} status={liveStatus} result={agent?.result ?? props.result} progress={agent?.progress ?? props.progress} @@ -545,7 +613,9 @@ function SubagentTimelineTooltip( const environmentId = props.parentRef.environmentId; // Read providers only in the open popup to avoid per-row subscriptions. const providers = - useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + useAtomValue( + serverEnvironment.providersValueAtom(props.remote?.environmentId ?? environmentId), + ) ?? EMPTY_SERVER_PROVIDERS; const parent = useThreadShell(props.parentRef)?.source; const child = useThreadShell( props.threadId ? scopeThreadRef(environmentId, props.threadId) : null, diff --git a/packages/client-runtime/src/state/subagentDisplay.test.ts b/packages/client-runtime/src/state/subagentDisplay.test.ts new file mode 100644 index 0000000000000..1cf549fedc70a --- /dev/null +++ b/packages/client-runtime/src/state/subagentDisplay.test.ts @@ -0,0 +1,239 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveSubagentModelTraits } from "./subagentDisplay.js"; + +describe("resolveSubagentModelTraits", () => { + const claudeInstance = ProviderInstanceId.make("claude"); + const codexInstance = ProviderInstanceId.make("codex"); + const effort = { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + }; + const claude = (extra: ReadonlyArray = []) => ({ + driver: ProviderDriverKind.make("claudeAgent"), + models: [ + { + slug: "claude-opus-4-7", + name: "Claude Opus 4.7", + aliases: ["claude-opus-4-7-20260101"], + isCustom: false, + capabilities: { + optionDescriptors: [ + effort, + { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + ...extra, + ], + }, + }, + ], + }); + const codex = (tiers: ReadonlyArray<{ id: string; label: string }>) => ({ + driver: ProviderDriverKind.make("codex"), + models: [ + { + slug: "gpt-6.1-sol", + name: "GPT-6.1-Sol", + isCustom: false, + capabilities: { + optionDescriptors: [ + { ...effort, id: "reasoningEffort" }, + { id: "serviceTier", label: "Service Tier", type: "select" as const, options: tiers }, + ], + }, + }, + ], + }); + const codexTiers = [ + { id: "default", label: "Standard" }, + { id: "priority", label: "Fast" }, + ]; + const selection = ( + instanceId: ProviderInstanceId, + model: string, + options: ReadonlyArray<{ id: string; value: string | boolean }>, + ) => ({ instanceId, model, options }); + + it("falls back to an app-owned child's selection but not a provider-native one's", () => { + const traits = (origin: "app_owned" | "provider_native") => + resolveSubagentModelTraits({ + model: "claude-opus-4-7-20260101", + providerInstanceId: claudeInstance, + origin, + childThread: { + modelSelection: selection(claudeInstance, "claude-opus-4-7", [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), + }, + provider: claude(), + }); + expect(traits("app_owned")).toEqual({ effortLabel: "Extra High", speed: "fast" }); + expect(traits("provider_native")).toEqual({ effortLabel: null, speed: null }); + expect( + resolveSubagentModelTraits({ + model: "claude-opus-4-7", + providerInstanceId: claudeInstance, + origin: "provider_native", + modelSelection: selection(claudeInstance, "claude-opus-4-7", [ + { id: "effort", value: "high" }, + ]), + }).effortLabel, + ).toBeNull(); + expect( + resolveSubagentModelTraits({ + model: "claude-opus-4-7", + providerInstanceId: claudeInstance, + origin: "provider_native", + modelSelection: selection(claudeInstance, "claude-opus-4-7", [ + { id: "effort", value: "high" }, + ]), + provider: claude(), + }).effortLabel, + ).toBeNull(); + }); + + it("prefers the subagent's own selection over its child thread's", () => { + expect( + resolveSubagentModelTraits({ + model: "claude-opus-4-7", + providerInstanceId: claudeInstance, + origin: "app_owned", + modelSelection: selection(claudeInstance, "claude-opus-4-7", [ + { id: "effort", value: "high" }, + { id: "fastMode", value: false }, + ]), + childThread: { + modelSelection: selection(claudeInstance, "claude-opus-4-7", [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), + }, + provider: claude(), + }), + ).toEqual({ effortLabel: "High", speed: null }); + }); + + it.each([ + ["cursor", "reasoning_effort"], + ["antigravity", "thinking_level"], + ] as const)("names %s reasoning effort", (driver, optionId) => { + const cursorInstance = ProviderInstanceId.make("cursor"); + expect( + resolveSubagentModelTraits({ + model: "gpt-6.1-sol", + providerInstanceId: cursorInstance, + origin: "provider_native", + modelSelection: selection(cursorInstance, "gpt-6.1-sol", [ + { id: optionId, value: "xhigh" }, + ]), + provider: { + driver: ProviderDriverKind.make(driver), + models: [ + { + slug: "gpt-6.1-sol", + name: "GPT-6.1-Sol", + isCustom: false, + capabilities: { optionDescriptors: [{ ...effort, id: optionId }] }, + }, + ], + }, + }), + ).toEqual({ effortLabel: "Extra High", speed: null }); + }); + + it("calls out a Codex fast tier and leaves the standard tier unmarked", () => { + const traits = (tier: string) => + resolveSubagentModelTraits({ + model: "gpt-6.1-sol", + providerInstanceId: codexInstance, + origin: "provider_native", + modelSelection: selection(codexInstance, "gpt-6.1-sol", [ + { id: "reasoningEffort", value: "high" }, + { id: "serviceTier", value: tier }, + ]), + provider: codex(codexTiers), + }); + expect(traits("priority")).toEqual({ effortLabel: "High", speed: "fast" }); + expect(traits("default")).toEqual({ effortLabel: "High", speed: null }); + }); + + it("omits speed when a tier shares its id with the standard choice", () => { + expect( + resolveSubagentModelTraits({ + model: "gpt-6.1-sol", + providerInstanceId: codexInstance, + origin: "provider_native", + modelSelection: selection(codexInstance, "gpt-6.1-sol", [ + { id: "serviceTier", value: "default" }, + ]), + provider: codex([ + { id: "default", label: "Standard" }, + { id: "default", label: "Fast" }, + ]), + }).speed, + ).toBeNull(); + }); + + it("claims no speed the provider does not run", () => { + // Claude ignores service tiers, and a tier the catalog does not list is unknown. + expect( + resolveSubagentModelTraits({ + model: "claude-opus-4-7", + providerInstanceId: claudeInstance, + origin: "provider_native", + modelSelection: selection(claudeInstance, "claude-opus-4-7", [ + { id: "serviceTier", value: "priority" }, + ]), + provider: claude([ + { id: "serviceTier", label: "Speed", type: "select", options: codexTiers }, + ]), + }).speed, + ).toBeNull(); + expect( + resolveSubagentModelTraits({ + model: "gpt-6.1-sol", + providerInstanceId: codexInstance, + origin: "provider_native", + modelSelection: selection(codexInstance, "gpt-6.1-sol", [ + { id: "serviceTier", value: "Fast" }, + ]), + provider: codex(codexTiers), + }).speed, + ).toBeNull(); + }); + + it("ignores a selection for another model or provider instance", () => { + const options = [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]; + expect( + resolveSubagentModelTraits({ + model: "claude-haiku-4-5", + providerInstanceId: claudeInstance, + origin: "provider_native", + modelSelection: selection(claudeInstance, "claude-opus-4-7", options), + provider: claude(), + }), + ).toEqual({ effortLabel: null, speed: null }); + expect( + resolveSubagentModelTraits({ + model: "claude-opus-4-7", + providerInstanceId: claudeInstance, + origin: "provider_native", + modelSelection: selection( + ProviderInstanceId.make("claude-work"), + "claude-opus-4-7", + options, + ), + provider: claude(), + }), + ).toEqual({ effortLabel: null, speed: null }); + }); +}); diff --git a/packages/client-runtime/src/state/subagentDisplay.ts b/packages/client-runtime/src/state/subagentDisplay.ts index 295e557fc8ff8..fbe413d80c938 100644 --- a/packages/client-runtime/src/state/subagentDisplay.ts +++ b/packages/client-runtime/src/state/subagentDisplay.ts @@ -1,12 +1,21 @@ import type { + ModelSelection, + OrchestrationV2Subagent, OrchestrationV2TurnItemStatus, OrchestrationV2ThreadShell, OrchestrationProjectShell, + ProviderInstanceId, ServerProvider, } from "@t3tools/contracts"; -import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; +import { + formatModelSlugName, + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { fileBasename } from "@t3tools/shared/path"; import { resolveModelShortName } from "../providerClients.ts"; +import { REASONING_EFFORT_OPTION_IDS } from "./threadExecution.ts"; import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; export function subagentGroupSummary( @@ -111,6 +120,74 @@ export function resolveSubagentMetadata(input: { return { modelLabel, workspace }; } +/** + * The reasoning effort and speed a subagent runs at, named as the composer + * names them. Only a selection for the model the subagent reported counts, + * since another model's options say nothing about this one. Normal speed is + * null: only a faster mode is worth calling out. + */ +export function resolveSubagentModelTraits(input: { + readonly model: string | null; + readonly providerInstanceId: ProviderInstanceId; + readonly origin: OrchestrationV2Subagent["origin"]; + readonly modelSelection?: ModelSelection | undefined; + /** + * Older app-owned records carry no selection; their child thread's stands + * in. A provider-native child's selection is inherited, not reported, so it + * says nothing about how the provider ran it. + */ + readonly childThread?: Pick | null | undefined; + readonly provider?: Pick | null | undefined; +}): { readonly effortLabel: string | null; readonly speed: "fast" | "ultrafast" | null } { + const { provider } = input; + const resolve = (model: string | undefined) => + (provider ? resolveSelectableModel(provider.driver, model, provider.models) : null) ?? + model?.trim(); + const selection = + input.modelSelection ?? + (input.origin === "app_owned" ? input.childThread?.modelSelection : undefined); + const modelSlug = resolve(input.model ?? undefined); + if ( + selection === undefined || + modelSlug === undefined || + selection.instanceId !== input.providerInstanceId || + resolve(selection.model) !== modelSlug + ) { + return { effortLabel: null, speed: null }; + } + const descriptors = + provider?.models.find((candidate) => candidate.slug === modelSlug)?.capabilities + ?.optionDescriptors ?? []; + // Claude native selections can contain inherited effort instead of the agent definition's value. + const effortLabel = + input.origin === "provider_native" && (provider == null || provider.driver === "claudeAgent") + ? null + : (REASONING_EFFORT_OPTION_IDS.map((id) => { + const value = getModelSelectionStringOptionValue(selection, id); + if (value === undefined) return undefined; + const descriptor = descriptors.find((candidate) => candidate.id === id); + return descriptor?.type === "select" + ? (descriptor.options.find((option) => option.id === value)?.label ?? value) + : value; + }).find(Boolean) ?? null); + const fastMode = + descriptors.some(({ id, type }) => id === "fastMode" && type === "boolean") && + getModelSelectionBooleanOptionValue(selection, "fastMode") === true; + // A duplicated tier id does not identify which speed was selected. + const tierDescriptor = descriptors.find(({ id }) => id === "serviceTier"); + const tier = + provider?.driver === "codex" + ? getModelSelectionStringOptionValue(selection, "serviceTier") + : undefined; + const tiers = + tierDescriptor?.type === "select" + ? tierDescriptor.options.filter((option) => option.id === tier) + : []; + const onTier = (label: string) => tiers.length === 1 && tiers[0]?.label === label; + const speed = onTier("Ultrafast") ? "ultrafast" : fastMode || onTier("Fast") ? "fast" : null; + return { effortLabel, speed }; +} + export function subagentDetailPreview(input: { readonly status: OrchestrationV2TurnItemStatus; readonly result?: string | null | undefined;