diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx new file mode 100644 index 000000000000..813b64148901 --- /dev/null +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -0,0 +1,80 @@ +import { + formatProviderSubagentStatus, + type ProviderSubagentStatus, +} from "@t3tools/client-runtime/state/thread-execution"; +import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { RequestActionButton } from "./RequestActionButton"; + +/** + * Replaces the composer on a provider-native subagent thread. The provider + * runs that conversation, so there is nothing to send; the bar says which + * model is working, for how long, and leads back to the parent. + */ +export function ProviderSubagentBar(props: { + /** Driver and catalog icon of the provider running the subagent. */ + readonly provider: { readonly driver: string; readonly iconUrl?: string | undefined } | null; + readonly modelLabel: string; + /** Reasoning effort as the composer names it, when the subagent has one. */ + readonly effortLabel: string | null; + /** Null until the subagent's root turn arrives. */ + readonly status: ProviderSubagentStatus | null; + readonly onOpenParent: (() => void) | null; +}) { + const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + if (!live) return; + const id = setInterval(() => setNowMs(Date.now()), 1_000); + return () => clearInterval(id); + }, [live]); + const statusLabel = formatProviderSubagentStatus(props.status, nowMs); + const modelDescription = + props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; + + return ( + + {/* Only the text is one element, so "Open parent" stays reachable. */} + + + {props.provider ? ( + + ) : null} + + {props.modelLabel} + + {props.effortLabel === null ? null : ( + + {props.effortLabel} + + )} + + + {statusLabel} · Runs on its own + + + {props.onOpenParent ? ( + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31700c794aac..defcc19f15a6 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -30,6 +30,12 @@ import { } from "@t3tools/client-runtime/codex-artifact-templates"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; +import { + formatModelSelectionEffort, + type ProviderSubagentStatus, +} from "@t3tools/client-runtime/state/thread-execution"; +import { formatModelSlugName } from "@t3tools/shared/model"; +import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -98,6 +104,7 @@ import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { ProviderSubagentBar } from "./ProviderSubagentBar"; import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -138,6 +145,8 @@ export interface ThreadDetailScreenProps { readonly activeWorkStartedAt: string | null; /** The live work is a provider-native subagent's runless root turn. */ readonly runlessWorkActive?: boolean; + /** Set on a provider-native subagent thread, which shows status instead of a composer. */ + readonly providerSubagentStatus?: ProviderSubagentStatus | null; readonly isCompacting: boolean; /** * The server has not created this thread yet. "preparing" runs while the @@ -731,6 +740,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; + const providerSubagentProvider = props.serverConfig?.providers.find( + (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, + ); + const providerSubagentCatalogModel = providerSubagentProvider?.models.find( + (model) => model.slug === props.selectedThread.modelSelection.model, + ); const workspaceContentWidth = useWorkspaceContentWidth(); const composerWidthStyle = useAnimatedStyle(() => isSplitLayout && workspaceContentWidth !== null @@ -1215,59 +1230,92 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > - <> - - + {isProviderNativeSubagentThread(props.selectedThread.source) ? ( + + + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(props.selectedThread.lineage.parentThreadId), + }) + } + /> + + ) : ( + <> + + + )} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 4958cf9d05e9..72d868677ba8 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1006,6 +1006,7 @@ function ThreadRouteContent( } isCompacting={composer.isCompacting} runlessWorkActive={composer.runlessWorkActive} + providerSubagentStatus={composer.providerSubagentStatus} creationState={creationState} setupWorkingStartedAt={ composer.activeWorkStartedAt !== null && diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 623971c9531a..1f9eed3d675c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -2,6 +2,7 @@ import type { ComposerTextPaste } from "../native/T3ComposerEditor.types"; import { useAtomValue } from "@effect/atom-react"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import { + deriveProviderSubagentStatus, deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, @@ -414,6 +415,14 @@ export function useThreadComposerState() { }, [selectedThreadActivityRun, runlessWorkStartedAt, selectedThreadRuntime, selectedThreadShell]); const runlessWorkActive = runlessWorkStartedAt !== null; + const providerSubagentStatus = useMemo( + () => + selectedThreadProjection + ? deriveProviderSubagentStatus(selectedThreadProjection.projection) + : null, + [selectedThreadProjection], + ); + // The run can start, or be cancelled from another client, while its message // is open in the composer. Leave edit mode rather than saving into a run the // server will refuse, and keep whatever was typed if there is room for it. @@ -1036,6 +1045,7 @@ export function useThreadComposerState() { dispatchingQueuedMessageId, activeWorkStartedAt, runlessWorkActive, + providerSubagentStatus, isCompacting, draftMessage, draftAttachments, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index ed32fb6d89b7..3e67bba6e8b6 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, EventId, IsoDateTime, + isProviderNativeSubagentThread, MessageId, type ModelSelection, type OrchestrationV2ProviderCapabilities, @@ -3212,6 +3213,11 @@ describe("orchestrator MCP toolkit", () => { delegated.resultContextTransferId, ); + // Delegated children are subagent threads too, but T3 owns them, so + // they keep taking follow-ups (provider-native children do not). + const delegatedChild = yield* orchestrator.getThreadProjection(delegated.childThreadId); + expect(delegatedChild.thread.lineage.relationshipToParent).toBe("subagent"); + expect(isProviderNativeSubagentThread(delegatedChild.thread)).toBe(false); const followupStartSequence = yield* orchestrator.getThreadEventSequence( delegated.childThreadId, ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 05565a40e2eb..5736823eedbe 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -9,6 +9,7 @@ import { import { type ChatAttachment, CommandId, + isProviderNativeSubagentThread, MessageId, type ModelSelection, OrchestrationV2Command, @@ -155,6 +156,15 @@ export class OrchestratorProviderAdapterError extends Schema.TaggedError()( + "OrchestratorSubagentThreadReadOnlyError", + { commandId: CommandId, threadId: ThreadId }, +) { + override get message(): string { + return "This subagent is run by its provider and cannot take messages. Message the parent thread instead."; + } +} + export class OrchestratorCommandPreviouslyRejectedError extends Schema.TaggedError()( "OrchestratorCommandPreviouslyRejectedError", { @@ -203,6 +213,7 @@ export const OrchestratorV2Error = Schema.Union([ OrchestratorProviderAdapterError, OrchestratorCommandPreviouslyRejectedError, OrchestratorCommandIdConflictError, + OrchestratorSubagentThreadReadOnlyError, ]); export type OrchestratorV2Error = typeof OrchestratorV2Error.Type; @@ -8767,9 +8778,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "provider-session.detach": yield* dispatchProviderSessionDetach(command, events, effects); break; - case "message.dispatch": + case "message.dispatch": { + // The provider owns a native subagent's conversation, so a sent + // message has nowhere to go. Only sends are refused: answers to the + // subagent's own questions reuse dispatchMessage and must still land. + const thread = yield* projectionStore + .getThread(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (isProviderNativeSubagentThread(thread)) { + return yield* new OrchestratorSubagentThreadReadOnlyError({ + commandId: command.commandId, + threadId: command.threadId, + }); + } yield* dispatchMessage(command, events, effects); break; + } case "notification.delivery.accept": yield* dispatchNotificationAccepted(command, events); break; diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index 4832be85d7c1..b3c37e2fea83 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -22,6 +22,7 @@ function dispatchWasNotAccepted( case "OrchestratorProviderAdapterError": case "OrchestratorCommandPreviouslyRejectedError": case "OrchestratorCommandIdConflictError": + case "OrchestratorSubagentThreadReadOnlyError": return true; default: return false; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index df6572c90244..19f156d723f3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -863,6 +863,164 @@ it.layer(TestLayer)("OrchestrationV2LayerLive", (it) => { }), ); + it.effect("answers a native subagent's question while refusing messages to it", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const eventSink = yield* EventSinkV2; + const now = yield* DateTime.now; + const parentId = ThreadId.make("runtime-native-child-parent"); + const childId = ThreadId.make("runtime-native-child"); + const requestId = RuntimeRequestId.make("runtime-native-child-request"); + const nodeId = NodeId.make("runtime-native-child-question-node"); + const itemId = TurnItemId.make("runtime-native-child-question-item"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-native-child-parent-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId: ProjectId.make("runtime-native-child-project"), + title: "Native child parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const parent = yield* orchestrator.getThreadProjection(parentId); + // A Codex native subagent asks an async (message-mode) question on its + // own child thread, as CodexAdapterV2 writes it. + yield* eventSink.write({ + commandId: CommandId.make("runtime-native-child-seed"), + events: [ + { + id: EventId.make("runtime-native-child-thread-event"), + type: "thread.created", + threadId: childId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: { + ...parent.thread, + id: childId, + title: "Native child", + createdBy: "agent", + creationSource: "provider", + activeProviderThreadId: null, + lineage: { + parentThreadId: parentId, + relationshipToParent: "subagent", + rootThreadId: parentId, + }, + forkedFrom: { type: "node", nodeId: NodeId.make("runtime-native-child-subagent") }, + createdAt: now, + updatedAt: now, + }, + }, + { + id: EventId.make("runtime-native-child-question-node-event"), + type: "node.updated", + threadId: childId, + nodeId, + occurredAt: now, + payload: { + id: nodeId, + threadId: childId, + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }, + { + id: EventId.make("runtime-native-child-question-request-event"), + type: "runtime-request.updated", + threadId: childId, + nodeId, + occurredAt: now, + payload: { + id: requestId, + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + }, + { + id: EventId.make("runtime-native-child-question-item-event"), + type: "turn-item.updated", + threadId: childId, + nodeId, + occurredAt: now, + payload: { + id: itemId, + type: "user_input_request", + threadId: childId, + runId: null, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "waiting", + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + requestId, + responseMode: "message", + questions: [{ id: "scope", header: "Scope", question: "Which files?", options: [] }], + }, + }, + ], + }); + + const refused = yield* orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make("runtime-native-child-send"), + createdBy: "user", + creationSource: "web", + threadId: childId, + messageId: MessageId.make("runtime-native-child-send-message"), + text: "Also check the tests.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }) + .pipe(Effect.flip); + assert.equal(refused._tag, "OrchestratorSubagentThreadReadOnlyError"); + assert.deepEqual((yield* orchestrator.getThreadProjection(childId)).messages, []); + + yield* orchestrator.dispatch({ + type: "runtime-request.respond", + commandId: CommandId.make("runtime-native-child-answer"), + threadId: childId, + requestId, + answers: { scope: "Only the adapters" }, + }); + const answered = yield* orchestrator.getThreadProjection(childId); + assert.equal(answered.runtimeRequests[0]?.status, "resolved"); + assert.equal(answered.turnItems.find((item) => item.id === itemId)?.status, "completed"); + assert.deepEqual( + answered.messages.map((message) => message.text), + ["Which files?\nOnly the adapters"], + ); + }), + ); + it.effect("dismisses message-capable questions directly and while settling", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts index b5e51ba123fa..3753206c94df 100644 --- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts @@ -1,10 +1,26 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import type { ProviderReplayTranscript } from "@t3tools/contracts"; +import { + CommandId, + isProviderNativeSubagentThread, + MessageId, + ProviderDriverKind, + type ProviderReplayTranscript, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { classifyClaudeNativeTool } from "../Adapters/ClaudeAdapterV2.ts"; +import { ClaudeOrchestratorReplayHarness } from "../Adapters/ClaudeAdapterV2.testkit.ts"; +import { layer as idAllocatorLayer } from "../IdAllocator.ts"; +import { OrchestratorV2 } from "../Orchestrator.ts"; +import { userFacingDispatchErrorMessage } from "../UserFacingErrors.ts"; +import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; +import { subagentInput } from "./fixtures/subagent/input.ts"; +import { runOrchestratorV2Scenario } from "./OrchestratorScenario.ts"; +import { makeOrchestratorV2ProviderReplayLayer } from "./ProviderReplayHarness.ts"; +import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayTranscriptNdjson.ts"; +import { CLAUDE_MODEL_SELECTION, materializeFixtureInput } from "./fixtures/shared.ts"; import { THREAD_FORK_NATIVE_CONTINUE_FORK_MARKER, THREAD_FORK_NATIVE_CONTINUE_RECALL, @@ -116,6 +132,70 @@ function claudeToolUseNamesFromTranscript( } describe("Claude Agent SDK replay fixtures", () => { + it.effect("refuses messages to a native subagent thread without touching it", () => + Effect.gen(function* () { + const raw = yield* readClaudeTranscriptFixture("subagent"); + const transcript = yield* ClaudeOrchestratorReplayHarness.decodeTranscript( + materializeReplayTranscriptRuntimeInstructions(raw, { + driver: ProviderDriverKind.make("claudeAgent"), + model: CLAUDE_MODEL_SELECTION.model, + }), + ); + const materialized = yield* materializeFixtureInput({ + scenario: "subagent", + fixtureInput: subagentInput(), + driver: ProviderDriverKind.make("claudeAgent"), + modelSelection: CLAUDE_MODEL_SELECTION, + }).pipe(Effect.provide(idAllocatorLayer), provideDeterministicTestRuntime); + const scenario = { + name: "subagent/claudeAgent:read-only-child", + transcript, + commands: materialized.commands, + steps: materialized.steps, + projectionThreadIds: materialized.projectionThreadIds, + }; + yield* Effect.gen(function* () { + const result = yield* runOrchestratorV2Scenario(scenario); + const orchestrator = yield* OrchestratorV2; + const child = [...result.projections.values()].find((projection) => + isProviderNativeSubagentThread(projection.thread), + ); + assert.isDefined(child); + const before = yield* orchestrator.getThreadEventSequence(child.thread.id); + + const refused = yield* orchestrator + .dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:subagent:message-native-child"), + threadId: child.thread.id, + messageId: MessageId.make("message:subagent:message-native-child"), + text: "Also check the tests.", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }) + .pipe(Effect.flip); + assert.equal(refused._tag, "OrchestratorSubagentThreadReadOnlyError"); + // The wire error carries this text to the web toast and mobile outbox. + assert.equal( + userFacingDispatchErrorMessage(refused), + "This subagent is run by its provider and cannot take messages. Message the parent thread instead.", + ); + assert.equal(yield* orchestrator.getThreadEventSequence(child.thread.id), before); + const after = yield* orchestrator.getThreadProjection(child.thread.id); + assert.lengthOf(after.runs, 0); + assert.deepEqual(after.messages, child.messages); + }).pipe( + Effect.provide( + makeOrchestratorV2ProviderReplayLayer(scenario, ClaudeOrchestratorReplayHarness), + ), + provideDeterministicTestRuntime, + Effect.scoped, + ); + }), + ); + it.effect("classifies every Claude fixture tool use through the native tool table", () => Effect.gen(function* () { const unknownToolNames = new Set(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b0b4247a86d0..d346a8bf9706 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,6 +45,7 @@ import { type AssistantCitation, type ChatFileAttachment, DEFAULT_MODEL, + isProviderNativeSubagentThread, type ChatAttachment as ContractChatAttachment, type EnvironmentId, type MessageId, @@ -81,6 +82,8 @@ import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { useThreadActions } from "../hooks/useThreadActions"; import { + deriveProviderSubagentStatus, + formatModelSelectionEffort, deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveLatestThreadRun, @@ -105,6 +108,7 @@ import { import { applyClaudePromptEffortPrefix, createModelSelection, + formatModelSlugName, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { @@ -395,8 +399,14 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; +import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; +import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; -import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; +import { + overlayComposerIsResting, + resolveComposerTimelineInset, + resolveScrollToEndClearance, +} from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; @@ -553,6 +563,7 @@ import { } from "./chat/composerPromptHistory"; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; @@ -1862,6 +1873,8 @@ export default function ChatView(props: ChatViewProps) { const [composerTimelineInset, setComposerTimelineInset] = useState(0); const composerTimelineInsetRef = useRef(0); const composerRestingRef = useRef(false); + // False while a status bar stands in for the composer (a native subagent). + const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback( @@ -2008,6 +2021,12 @@ export default function ChatView(props: ChatViewProps) { () => (serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection)), [serverProjection], ); + const providerSubagentStatus = useMemo( + () => (serverProjection === null ? null : deriveProviderSubagentStatus(serverProjection)), + [serverProjection], + ); + const isProviderSubagent = + serverThread !== null && isProviderNativeSubagentThread(serverThread.source); const supportsProviderSwitchingViaHandoff = useMemo( () => threadSupportsProviderHandoff(serverProjection), [serverProjection], @@ -4020,10 +4039,29 @@ export default function ChatView(props: ChatViewProps) { // When context is enabled, keep a hidden, off-flow strip mounted so the composer // can measure whether its relocated controls fit. The visible chrome remains // content-driven: Git/environment context or controls that actually fit. + // A provider-native subagent cannot take messages: a status bar replaces the + // composer and its strips. While the subagent asks for an approval or an + // answer, the composer that renders those stays below the bar, without the + // thread settings that belong to the provider. + const showProviderSubagentBar = isProviderSubagent; + const providerSubagentNeedsResponse = + isProviderSubagent && (pendingApprovals.length > 0 || pendingUserInputs.length > 0); + const composerMounted = !showProviderSubagentBar || providerSubagentNeedsResponse; + const providerSubagentModels = selectedProviderEntry?.models ?? EMPTY_PROVIDER_MODELS; + const providerSubagentCatalogModel = providerSubagentModels.find( + (model) => model.slug === activeThread?.modelSelection.model, + ); + const providerSubagentModelLabel = providerSubagentCatalogModel + ? getTriggerDisplayModelName(providerSubagentCatalogModel) + : formatModelSlugName(activeThread?.modelSelection.model ?? ""); + const providerSubagentEffortLabel = + activeThread === undefined + ? null + : formatModelSelectionEffort(activeThread.modelSelection, providerSubagentModels); const mountComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, hostsRestingComposerControls: routeKind === "server", @@ -4031,12 +4069,13 @@ export default function ChatView(props: ChatViewProps) { const showComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); - const mountComposerModelStrip = routeKind === "server" && !mountComposerContextStrip; + const mountComposerModelStrip = + routeKind === "server" && !mountComposerContextStrip && !showProviderSubagentBar; const showComposerModelStrip = mountComposerModelStrip && restingComposerControlsVisible; const terminalShortcutLabelOptions = useMemo( () => ({ @@ -6399,13 +6438,17 @@ export default function ChatView(props: ChatViewProps) { (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; + const isResting = overlayComposerIsResting({ + composerMounted: composerMountedRef.current, + composerReportedResting: composerRestingRef.current, + }); const modelStrip = composerOverlayElement?.querySelector( '[data-composer-model-strip="true"]', ); // The model-only strip disappears on expansion; counting it again would // add 32px of timeline padding as soon as the empty composer collapses. const restingOnlyHeight = - modelStrip && composerRestingRef.current + modelStrip && isResting ? Math.max( 0, modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), @@ -6414,7 +6457,7 @@ export default function ChatView(props: ChatViewProps) { const nextInset = resolveComposerTimelineInset({ currentInset: composerTimelineInsetRef.current, overlayHeight: nextHeight, - isResting: composerRestingRef.current, + isResting, restingOnlyHeight, }); if (composerTimelineInsetRef.current !== nextInset) { @@ -6478,6 +6521,15 @@ export default function ChatView(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); + // Swapping the composer for the status bar (or back) changes what the + // overlay holds, so rebuild the reservation from the new content. + useLayoutEffect(() => { + if (composerMountedRef.current === composerMounted) return; + composerMountedRef.current = composerMounted; + if (!composerOverlayElement) return; + composerTimelineInsetRef.current = 0; + publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); + }, [composerMounted, composerOverlayElement, publishComposerOverlayHeight]); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); const activeThreadReferenceCopyTarget = useMemo( () => @@ -10616,166 +10668,190 @@ export default function ChatView(props: ChatViewProps) { aria-busy={isSavingQueuedEdit} >
- - ) : null - } - bannerItems={composerBannerItems} - // With attachments or contexts aboard the pick just inserts the - // text, so it sends as a prompt like the typed path would. - onUsageLimitsCommand={ - usageLimitsOffered && - usageLimitsKey !== null && - !composerHasNonPromptContent - ? openUsageLimits - : undefined - } - environmentUnavailable={activeEnvironmentUnavailableState} - activePendingApproval={activePendingApproval} - pendingApprovals={pendingApprovals} - pendingUserInputs={pendingUserInputs} - activePendingProgress={activePendingProgress} - activePendingResolvedAnswers={activePendingResolvedAnswers} - activePendingIsResponding={activePendingIsResponding} - activePendingDraftAnswers={activePendingDraftAnswers} - activePendingQuestionIndex={activePendingQuestionIndex} - respondingRequestIds={respondingRequestIds} - showPlanFollowUpPrompt={showPlanFollowUpPrompt} - activeProposedPlan={activeProposedPlan} - threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase} - runtimeMode={runtimeMode} - interactionMode={interactionMode} - lockedProvider={modelPickerLockedProvider} - providerStatuses={providerStatuses as ServerProvider[]} - providerCatalogKnown={serverConfig !== null} - activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} - activeThreadModelSelection={activeThread?.modelSelection} - activeContextWindow={activeContextWindow} - activeTasksProgress={activeComposerTasksProgress} - activeTaskSteps={activeComposerTaskSteps} - compactThreadUnavailable={compactThreadUnavailable} - compactDisabled={compactDisabled} - compactDisabledReason={compactDisabledReason} - resolvedTheme={resolvedTheme} - settings={settings} - keybindings={keybindings} - terminalOpen={Boolean(terminalUiState.terminalOpen)} - gitCwd={gitCwd} - pullRequestProjectId={ - supportsPullRequests ? (activeProject?.id ?? null) : null - } - pullRequestRepository={ - supportsPullRequests ? activeProjectRepository : null - } - restingControlsHost={restingComposerControlsHost} - restingControlsHaveLeadingContext={ - mountComposerContextStrip && - (isGitRepo || showComposerEnvironmentIndicator) - } - onRestingControlsVisibilityChange={setRestingComposerControlsVisible} - getTimelineScrollableNode={getTimelineScrollableNode} - isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} - timelineOverflows={timelineOverflows} - onComposerOverlayHeightChange={publishComposerOverlayHeight} - onRestingChange={onComposerRestingChange} - promptRef={promptRef} - composerImagesRef={composerImagesRef} - composerFilesRef={composerFilesRef} - composerTerminalContextsRef={composerTerminalContextsRef} - onPageScrollKeyDown={onComposerPageScrollKeyDown} - onPageScrollKeyUp={onComposerPageScrollKeyUp} - onPageScrollRelease={onComposerPageScrollRelease} - onCompactContext={onCompactContext} - onSend={onSend} - onInterrupt={onInterrupt} - onImplementPlanInNewThread={onImplementPlanInNewThread} - onRespondToApproval={onRespondToApproval} - onSelectActivePendingUserInputOption={ - onSelectActivePendingUserInputOption - } - onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} - onDismissActivePendingUserInput={onDismissUserInput} - onPreviousActivePendingUserInputQuestion={ - onPreviousActivePendingUserInputQuestion - } - onChangeActivePendingUserInputCustomAnswer={ - onChangeActivePendingUserInputCustomAnswer - } - onProviderModelSelect={onProviderModelSelect} - onOpenProviderSetup={openProviderSetup} - getModelDisabledReason={getModelDisabledReason} - toggleInteractionMode={toggleInteractionMode} - handleRuntimeModeChange={handleRuntimeModeChange} - handleInteractionModeChange={handleInteractionModeChange} - focusComposer={focusComposer} - scheduleComposerFocus={scheduleComposerFocus} - setThreadError={setThreadError} - onExpandImage={onExpandTimelineImage} - onFileOpen={openFileAttachment} - editingQueuedAttachments={composerEditingQueuedAttachments} - onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment} - /> + {showProviderSubagentBar ? ( + onOpenRelatedThread(parentThreadLink.threadId) + : null + } + /> + ) : null} + {!composerMounted ? null : ( + + ) : null + } + bannerItems={composerBannerItems} + // With attachments or contexts aboard the pick just inserts the + // text, so it sends as a prompt like the typed path would. + onUsageLimitsCommand={ + usageLimitsOffered && + usageLimitsKey !== null && + !composerHasNonPromptContent + ? openUsageLimits + : undefined + } + environmentUnavailable={activeEnvironmentUnavailableState} + activePendingApproval={activePendingApproval} + pendingApprovals={pendingApprovals} + pendingUserInputs={pendingUserInputs} + activePendingProgress={activePendingProgress} + activePendingResolvedAnswers={activePendingResolvedAnswers} + activePendingIsResponding={activePendingIsResponding} + activePendingDraftAnswers={activePendingDraftAnswers} + activePendingQuestionIndex={activePendingQuestionIndex} + respondingRequestIds={respondingRequestIds} + showPlanFollowUpPrompt={showPlanFollowUpPrompt} + activeProposedPlan={activeProposedPlan} + threadSyncPhase={ + activeEnvironmentUnavailable ? null : threadSyncPhase + } + runtimeMode={runtimeMode} + interactionMode={interactionMode} + lockedProvider={modelPickerLockedProvider} + providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} + activeProjectDefaultModelSelection={ + activeProjectDefaultModelSelection + } + activeThreadModelSelection={activeThread?.modelSelection} + activeContextWindow={activeContextWindow} + activeTasksProgress={activeComposerTasksProgress} + activeTaskSteps={activeComposerTaskSteps} + compactThreadUnavailable={compactThreadUnavailable} + compactDisabled={compactDisabled} + compactDisabledReason={compactDisabledReason} + resolvedTheme={resolvedTheme} + settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalUiState.terminalOpen)} + gitCwd={gitCwd} + pullRequestProjectId={ + supportsPullRequests ? (activeProject?.id ?? null) : null + } + pullRequestRepository={ + supportsPullRequests ? activeProjectRepository : null + } + restingControlsHost={restingComposerControlsHost} + restingControlsHaveLeadingContext={ + mountComposerContextStrip && + (isGitRepo || showComposerEnvironmentIndicator) + } + onRestingControlsVisibilityChange={setRestingComposerControlsVisible} + getTimelineScrollableNode={getTimelineScrollableNode} + isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} + timelineOverflows={timelineOverflows} + onComposerOverlayHeightChange={publishComposerOverlayHeight} + onRestingChange={onComposerRestingChange} + promptRef={promptRef} + composerImagesRef={composerImagesRef} + composerFilesRef={composerFilesRef} + composerTerminalContextsRef={composerTerminalContextsRef} + onPageScrollKeyDown={onComposerPageScrollKeyDown} + onPageScrollKeyUp={onComposerPageScrollKeyUp} + onPageScrollRelease={onComposerPageScrollRelease} + onCompactContext={onCompactContext} + onSend={onSend} + onInterrupt={onInterrupt} + onImplementPlanInNewThread={onImplementPlanInNewThread} + onRespondToApproval={onRespondToApproval} + onSelectActivePendingUserInputOption={ + onSelectActivePendingUserInputOption + } + onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} + onDismissActivePendingUserInput={onDismissUserInput} + onPreviousActivePendingUserInputQuestion={ + onPreviousActivePendingUserInputQuestion + } + onChangeActivePendingUserInputCustomAnswer={ + onChangeActivePendingUserInputCustomAnswer + } + onProviderModelSelect={onProviderModelSelect} + onOpenProviderSetup={openProviderSetup} + getModelDisabledReason={getModelDisabledReason} + toggleInteractionMode={toggleInteractionMode} + handleRuntimeModeChange={handleRuntimeModeChange} + handleInteractionModeChange={handleInteractionModeChange} + focusComposer={focusComposer} + scheduleComposerFocus={scheduleComposerFocus} + setThreadError={setThreadError} + onExpandImage={onExpandTimelineImage} + onFileOpen={openFileAttachment} + editingQueuedAttachments={composerEditingQueuedAttachments} + onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment} + /> + )}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index aac01d291c9c..ffb36fdfd08f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -188,6 +188,7 @@ import { import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { + composerModelPickerCanStayOpen, getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, shouldAnimateComposerRestingTransition, @@ -1511,6 +1512,11 @@ export interface ChatComposerProps { isLocalDraftThread: boolean; forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; + /** + * The thread's model, effort, and access belong to its provider (a native + * subagent answering a question): hide those pickers and attachments. + */ + hideThreadSettings?: boolean; // Session phase phase: SessionPhase; @@ -1685,6 +1691,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, projectSelectionRequired, + hideThreadSettings = false, phase, isConnecting, isSendBusy, @@ -2754,6 +2761,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const showCollapsedMobilePromptRow = isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const showComposerAttachAction = + !hideThreadSettings && fileStagingLimit !== null && (!activePendingProgress || (supportsQuestionAttachments && @@ -5058,7 +5066,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerControlsVisibleInStrip = composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible; const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible; - if (composerControlsHidden && isComposerModelPickerOpen) { + if ( + isComposerModelPickerOpen && + !composerModelPickerCanStayOpen({ + controlsHidden: composerControlsHidden, + threadSettingsHidden: hideThreadSettings, + }) + ) { setIsComposerModelPickerOpen(false); } useLayoutEffect(() => { @@ -5304,7 +5318,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hiddenRestingBlockIds = restingBlockDefs .slice(restingBlockDefs.length - restingHiddenBlockCount) .map((def) => def.id); - const composerControls = showProviderUnavailable ? ( + const composerControls = hideThreadSettings ? null : showProviderUnavailable ? ( void) | null; +}) { + const statusRef = useRef(null); + const { status } = props; + const live = status !== null && isOrchestrationV2WorkActive(status.status); + const modelDescription = + props.effortLabel === null ? props.modelLabel : `${props.modelLabel}, ${props.effortLabel}`; + // Announced once per transition; the ticking label below is not. + const announcement = formatProviderSubagentStatus( + status === null ? null : { ...status, startedAt: null }, + 0, + ); + + // The label is written from an effect, and live bars tick through DOM + // writes, so a running timer never re-renders the chat view. + useLayoutEffect(() => { + const update = () => { + if (statusRef.current) { + statusRef.current.textContent = formatProviderSubagentStatus(status, Date.now()); + } + }; + update(); + if (!live) return; + const id = setInterval(update, 1_000); + return () => clearInterval(id); + }, [live, status]); + + return ( +
+ + {props.provider ? ( + + ) : null} + {props.modelLabel} + {props.effortLabel === null ? null : ( + {props.effortLabel} + )} + + + + {`${modelDescription} subagent: ${announcement}`} + + Runs on its own + {props.onOpenParent ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 9a1f54b1e48f..f892da336ccc 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -6,6 +6,8 @@ import { COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX, COMPOSER_RESTING_EXPANSION_MIN_PX, getRestingComposerImagePreviewCounts, + composerModelPickerCanStayOpen, + overlayComposerIsResting, resolveComposerTimelineInset, resolveScrollToEndClearance, resolveRestingComposerControlsLayout, @@ -77,6 +79,38 @@ describe("shouldUseCompactComposerPrimaryActions", () => { }); }); +describe("composerModelPickerCanStayOpen", () => { + it("closes the picker while a native subagent hides the thread settings", () => { + expect( + composerModelPickerCanStayOpen({ controlsHidden: false, threadSettingsHidden: true }), + ).toBe(false); + expect( + composerModelPickerCanStayOpen({ controlsHidden: true, threadSettingsHidden: false }), + ).toBe(false); + expect( + composerModelPickerCanStayOpen({ controlsHidden: false, threadSettingsHidden: false }), + ).toBe(true); + }); +}); + +describe("overlayComposerIsResting", () => { + it("drops a resting reservation once a status bar replaces the composer", () => { + // The composer rested on a scroll, then the thread swapped it for the + // subagent bar. The bar's 56px overlay must not keep the resting estimate. + const isResting = overlayComposerIsResting({ + composerMounted: false, + composerReportedResting: true, + }); + expect(isResting).toBe(false); + expect(resolveComposerTimelineInset({ currentInset: 0, overlayHeight: 56, isResting })).toBe( + 56, + ); + expect(overlayComposerIsResting({ composerMounted: true, composerReportedResting: true })).toBe( + true, + ); + }); +}); + describe("resolveComposerTimelineInset", () => { it("follows the expanded overlay height", () => { expect( diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 55cf7076cfd8..2eb7a9550a38 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -94,6 +94,31 @@ export function resolveComposerTimelineInset(input: { : input.overlayHeight; } +/** + * Whether the overlay's composer is resting. Only a mounted composer can be: + * a status bar in its place (a native subagent thread) never is, even if the + * composer it replaced last reported resting. + */ +export function overlayComposerIsResting(input: { + composerMounted: boolean; + composerReportedResting: boolean; +}): boolean { + return input.composerMounted && input.composerReportedResting; +} + +/** + * The model picker lives in the composer controls. When they are hidden (a + * collapsed composer, or a native subagent whose settings belong to its + * provider) the picker cannot stay open, or a shortcut that opened it would + * pop it up later on another thread. + */ +export function composerModelPickerCanStayOpen(input: { + controlsHidden: boolean; + threadSettingsHidden: boolean; +}): boolean { + return !input.controlsHidden && !input.threadSettingsHidden; +} + export function shouldAnimateComposerRestingTransition(input: { hasCompletedInitialLayout: boolean; stateChanged: boolean; diff --git a/docs/user/cursor.md b/docs/user/cursor.md index 0afeb7dd8108..ad1a69ef956c 100644 --- a/docs/user/cursor.md +++ b/docs/user/cursor.md @@ -55,8 +55,8 @@ instructions must carry forward. Cursor task events include an `agentId`, but the local SDK does not register that identifier as a resumable agent: `Agent.resume()` returns `AgentNotFoundError`. The adapter therefore does not attach -a provider thread to native task projections or advertise subagent thread IDs. Sending a new message -from a projected child starts a new Cursor agent rather than pretending to resume the task runtime. +a provider thread to native task projections or advertise subagent thread IDs. A projected child is +read-only: send messages from the parent thread. Runtime modes map to the controls the local SDK exposes: full access disables its sandbox, while restricted modes and explicit non-full-access sandbox policies enable it. Explicit approval policy diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 163ad91b02b0..369e85493d73 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -166,6 +166,9 @@ retry and the normal snooze choices. On web and desktop, use **Agents** to follow work delegated to subagents. +Subagent threads started by the agent can't take messages; message the parent +thread instead. + Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it finishes; the call's own result shows its status. diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 186a799ccb1c..4e8f48cc30aa 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -2,7 +2,9 @@ import { TurnItemId, NodeId, MessageId, + ProviderInstanceId, RunId, + ThreadId, type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, } from "@t3tools/contracts"; @@ -12,6 +14,9 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { deriveLatestThreadRun, + deriveProviderSubagentStatus, + formatModelSelectionEffort, + formatProviderSubagentStatus, deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, @@ -192,8 +197,21 @@ describe("deriveRunlessWorkStartedAt", () => { completedAt: null, }); + const nativeChild = { + ...v2Projection, + thread: { + ...v2Projection.thread, + creationSource: "provider" as const, + lineage: { + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent" as const, + rootThreadId: ThreadId.make("parent"), + }, + }, + }; + it("times a provider-native subagent from its runless root turn while it works", () => { - const projection = { ...v2Projection, nodes: [rootTurn("running", later)] }; + const projection = { ...nativeChild, nodes: [rootTurn("running", later)] }; expect(deriveRunlessWorkStartedAt(projection)).toBe("2026-07-28T10:05:00.000Z"); // The subagent has no run, so it stays unstoppable and unqueueable. expect(deriveThreadRuntime(projection)).toBeNull(); @@ -202,13 +220,143 @@ describe("deriveRunlessWorkStartedAt", () => { it.each(["completed", "cancelled", "failed", "interrupted", "idle"] as const)( "is idle once the subagent is %s", (status) => { - expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [rootTurn(status)] })).toBe(null); + expect(deriveRunlessWorkStartedAt({ ...nativeChild, nodes: [rootTurn(status)] })).toBe(null); }, ); - it("ignores root turns that belong to a run", () => { + it("ignores root turns that belong to a run, and threads the provider does not run", () => { const owned = { ...rootTurn("running"), runId: RunId.make("run-1") }; - expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [owned] })).toBeNull(); + expect(deriveRunlessWorkStartedAt({ ...nativeChild, nodes: [owned] })).toBeNull(); + expect( + deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [rootTurn("running")] }), + ).toBeNull(); + }); +}); + +describe("deriveProviderSubagentStatus", () => { + const root = { + id: NodeId.make("child-root"), + threadId: v2Projection.thread.id, + runId: null, + parentNodeId: null, + rootNodeId: NodeId.make("child-root"), + kind: "root_turn" as const, + status: "completed" as const, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: now, + completedAt: now, + }; + const child = (creationSource: "provider" | "mcp") => ({ + ...v2Projection, + thread: { + ...v2Projection.thread, + creationSource, + lineage: { + parentThreadId: ThreadId.make("parent"), + relationshipToParent: "subagent" as const, + rootThreadId: ThreadId.make("parent"), + }, + }, + nodes: [root], + }); + + it("reports the provider's own subagent from its runless root turn", () => { + expect(deriveProviderSubagentStatus(child("provider"))).toEqual({ + status: "completed", + startedAt: "2026-07-28T10:00:00.000Z", + completedAt: "2026-07-28T10:00:00.000Z", + }); + }); + + it("says how long the subagent has worked, or took", () => { + const startedAt = "2026-07-28T10:00:00.000Z"; + const at = (iso: string) => Date.parse(iso); + expect( + formatProviderSubagentStatus( + { status: "running", startedAt, completedAt: null }, + at("2026-07-28T10:01:05.400Z"), + ), + ).toBe("Working 1m 5s"); + expect( + formatProviderSubagentStatus( + { status: "completed", startedAt, completedAt: "2026-07-28T10:00:34.000Z" }, + at("2026-07-28T11:00:00.000Z"), + ), + ).toBe("Completed in 34s"); + expect( + formatProviderSubagentStatus( + { status: "cancelled", startedAt, completedAt: "2026-07-28T10:00:34.000Z" }, + 0, + ), + ).toBe("Cancelled"); + expect(formatProviderSubagentStatus(null, 0)).toBe("Starting"); + }); + + it("leaves T3 delegated tasks and ordinary threads alone", () => { + expect(deriveProviderSubagentStatus(child("mcp"))).toBeNull(); + expect(deriveProviderSubagentStatus({ ...v2Projection, nodes: [root] })).toBeNull(); + }); +}); + +describe("formatModelSelectionEffort", () => { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const selection = (options?: ReadonlyArray<{ id: string; value: string }>) => ({ + instanceId, + model: "claude-sonnet-5", + ...(options === undefined ? {} : { options }), + }); + const catalog = (descriptor: { currentValue?: string }) => [ + { + slug: "claude-sonnet-5", + name: "Claude Sonnet 5", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select" as const, + options: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + ...descriptor, + }, + ], + }, + }, + ]; + + it("shows the model's default effort when the user never picked one", () => { + expect(formatModelSelectionEffort(selection(), catalog({}))).toBe("High"); + }); + + it("names a stored effort the way the catalog does", () => { + expect( + formatModelSelectionEffort(selection([{ id: "effort", value: "xhigh" }]), catalog({})), + ).toBe("Extra High"); + }); + + it("uses the descriptor's current value over the default", () => { + expect(formatModelSelectionEffort(selection(), catalog({ currentValue: "medium" }))).toBe( + "Medium", + ); + }); + + it("shows nothing for a model the catalog does not describe", () => { + expect(formatModelSelectionEffort(selection([{ id: "effort", value: "high" }]))).toBeNull(); + expect( + formatModelSelectionEffort( + { ...selection(), model: "claude-haiku-4-5" }, + catalog({ currentValue: "medium" }), + ), + ).toBeNull(); }); }); diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 3b99c597923f..3530df2d7d9b 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -4,9 +4,15 @@ import { } from "@t3tools/shared/orchestrationV2ThreadError"; import { isOrchestrationV2WorkActive, + isProviderNativeSubagentThread, + type ModelSelection, + type ServerProviderModel, + type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; import { @@ -79,13 +85,96 @@ export function deriveThreadActivityRun( export function deriveRunlessWorkStartedAt( projection: OrchestrationV2ThreadProjection, ): string | null { + const status = deriveProviderSubagentStatus(projection); + return status !== null && isOrchestrationV2WorkActive(status.status) ? status.startedAt : null; +} + +export interface ProviderSubagentStatus { + readonly status: OrchestrationV2ExecutionNode["status"]; + readonly startedAt: string | null; + readonly completedAt: string | null; +} + +/** + * Status of a provider-native subagent thread (see + * isProviderNativeSubagentThread), read from its runless root turn. Null + * until that root turn arrives, and for every other thread. + */ +export function deriveProviderSubagentStatus( + projection: OrchestrationV2ThreadProjection, +): ProviderSubagentStatus | null { + if (!isProviderNativeSubagentThread(projection.thread)) return null; const node = projection.nodes.findLast( - (candidate) => - candidate.kind === "root_turn" && - candidate.runId === null && - isOrchestrationV2WorkActive(candidate.status), + (candidate) => candidate.kind === "root_turn" && candidate.runId === null, ); - return node?.startedAt == null ? null : DateTime.formatIso(node.startedAt); + if (node === undefined) return null; + return { + status: node.status, + startedAt: node.startedAt === null ? null : DateTime.formatIso(node.startedAt), + completedAt: node.completedAt === null ? null : DateTime.formatIso(node.completedAt), + }; +} + +// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode). +const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const; + +/** + * The reasoning effort a thread's model runs at, resolved and named the way + * the composer's effort picker does: the stored choice when valid, else the + * descriptor's current value, else the model's default. Null when the + * provider catalog has no effort option for this model (a subagent on a + * model the catalog does not describe), rather than guessing. + */ +export function formatModelSelectionEffort( + selection: ModelSelection, + models: ReadonlyArray = [], +): string | null { + const caps = models.find((model) => model.slug === selection.model)?.capabilities; + if (!caps) return null; + const descriptors = getProviderOptionDescriptors({ caps, selections: selection.options }); + for (const id of REASONING_EFFORT_OPTION_IDS) { + const descriptor = descriptors.find((candidate) => candidate.id === id); + if (descriptor?.type !== "select") continue; + const label = getProviderOptionCurrentLabel(descriptor); + if (label) return label; + } + return null; +} + +const SUBAGENT_STATUS_LABELS: Record = { + idle: "Idle", + pending: "Working", + running: "Working", + waiting: "Waiting", + completed: "Completed", + interrupted: "Interrupted", + failed: "Failed", + cancelled: "Cancelled", + rolled_back: "Cancelled", +}; + +/** + * One line for the read-only subagent bar: "Working 12s", "Completed in 34s", + * or just the status when no duration is known. + */ +export function formatProviderSubagentStatus( + status: ProviderSubagentStatus | null, + nowMs: number, +): string { + if (status === null) return "Starting"; + const label = SUBAGENT_STATUS_LABELS[status.status]; + const live = isOrchestrationV2WorkActive(status.status); + if (!live && status.status !== "completed") return label; + const start = status.startedAt === null ? Number.NaN : Date.parse(status.startedAt); + const end = live + ? nowMs + : status.completedAt === null + ? Number.NaN + : Date.parse(status.completedAt); + if (!Number.isFinite(start) || !Number.isFinite(end)) return label; + // Whole seconds: a ticking label must not flicker through tenths. + const elapsed = formatDuration(Math.max(1_000, Math.floor((end - start) / 1_000) * 1_000)); + return live ? `${label} ${elapsed}` : `${label} in ${elapsed}`; } export function deriveThreadRuntime( diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 3ba1244729bd..8e1cbd51e5e4 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -418,6 +418,17 @@ export const OrchestrationV2AppThread = Schema.Struct({ }); export type OrchestrationV2AppThread = typeof OrchestrationV2AppThread.Type; +/** + * A subagent the provider spawned on its own (Claude's Agent tool, Codex or + * Cursor native subagents). The provider owns its conversation, so it cannot + * take messages; T3 delegate_task children (`creationSource: "mcp"`) can. + */ +export function isProviderNativeSubagentThread( + thread: Pick, +): boolean { + return thread.lineage.relationshipToParent === "subagent" && thread.creationSource === "provider"; +} + export const OrchestrationV2RunStatus = Schema.Literals([ "preparing", "queued",