From f7dbed9faf53ff58327ec4df38d038590db902ef Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:02:51 -0700 Subject: [PATCH 1/7] fix(clients): native subagent threads show when they are working Provider-native subagent threads (Claude Agent tool, Codex/Cursor/Grok/ OpenCode native subagents) never get app runs; their work is a runless root turn whose status follows the subagent. Every working signal on web and mobile came from runs, so an open child thread looked idle: no "Working for" timer, no live tool row, no Thinking slot. Derive the working start from the child's active runless root turn in client-runtime and feed it into the existing working state on web and mobile. Runless timeline entries count as the live response only while that root turn is active. Stop, queue, and steer stay run-only. A replay-fixture invariant pins the server contract for every recorded native subagent: the child hangs off the subagent node, has only runless root turns, is running before its first item, and its root turn follows the subagent's activity, including a Claude resume re-opening it. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/mobile/src/lib/threadActivity.test.ts | 24 +++++ apps/mobile/src/lib/threadActivity.ts | 3 +- .../src/state/use-thread-composer-state.ts | 21 +++-- ...estratorReplayFixtures.integration.test.ts | 2 + .../testkit/fixtures/shared.ts | 61 +++++++++++++ apps/web/src/components/ChatView.tsx | 21 +++-- .../chat/MessagesTimeline.logic.test.ts | 90 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 20 +++-- .../src/components/chat/MessagesTimeline.tsx | 5 ++ .../src/state/threadExecution.test.ts | 45 ++++++++++ .../src/state/threadExecution.ts | 23 ++++- 11 files changed, 294 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e45641d54957..f8c11c2d9817 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -956,6 +956,30 @@ describe("buildThreadFeed", () => { }); }); + it("keeps a provider-native subagent's runless tool call live while it works", () => { + const startedAt = "2026-06-20T00:00:01.000Z"; + const { exitCode: _exitCode, ...completedCommand } = command(); + const runningCommand: OrchestrationV2TurnItem = { + ...completedCommand, + runId: null, + status: "running", + completedAt: null, + output: "", + }; + const feed = buildThreadFeed([ + projected({ ...userMessage(), runId: null }, 0), + projected(runningCommand, 1), + ]); + + const presented = deriveThreadFeedPresentation(feed, null, new Set(), new Set(), startedAt); + expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ + summary: "Running vp", + live: true, + shimmer: true, + }); + expect(presented.some((entry) => entry.type === "thinking")).toBe(false); + }); + it("waits for workspace preparation before showing provider activity", () => { const startedAt = "2026-04-01T00:00:01.000Z"; const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 5606f496cb9c..d2187dcef1df 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1037,9 +1037,10 @@ export function deriveThreadFeedPresentation( } const result: ThreadFeedEntry[] = []; for (const entry of sourceFeed) { + // A provider-native subagent works without a run, so its null-run tail + // is live while it works (activeRunId is null then too). const isActiveTailGroup = isWorking && - activeRunId !== null && entry.type === "activity-group" && activeTailGroup?.type === "activity-group" && activeTailGroup.id === entry.id && diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index c5115b576466..68525ae9ed9b 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 { + deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, threadRuntimeHasInterruptibleRun, @@ -397,11 +398,21 @@ export function useThreadComposerState() { if (!selectedThreadShell) { return null; } - return resolveThreadWorkingStartedAt({ - latestRun: selectedThreadActivityRun, - runtime: selectedThreadRuntime, - }); - }, [selectedThreadActivityRun, selectedThreadRuntime, selectedThreadShell]); + return ( + resolveThreadWorkingStartedAt({ + latestRun: selectedThreadActivityRun, + runtime: selectedThreadRuntime, + }) ?? + (selectedThreadProjection + ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) + : null) + ); + }, [ + selectedThreadActivityRun, + selectedThreadProjection, + selectedThreadRuntime, + selectedThreadShell, + ]); // 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 diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index ea5ee05ae33e..b74234d12209 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -17,6 +17,7 @@ import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "./fixtures/index.ts"; import { messageRestartInput } from "./fixtures/message_steering/input.ts"; import { + assertProviderNativeSubagentRootTurns, materializeFixtureInput, type OrchestratorFixtureInput, type ProviderOrchestratorReplayVariant, @@ -117,6 +118,7 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < input.driver.runContinuationWorker === true ? { runContinuationWorker: true } : {}, ).pipe(provideDeterministicTestRuntime); input.driver.assertOutput(result, transcript); + assertProviderNativeSubagentRootTurns(result); const expectedAbsentWorkspacePaths = input.driver.expectedAbsentWorkspacePaths; if (expectedAbsentWorkspacePaths !== undefined) { yield* Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index 96aee9e2d5e2..7fa9347d2de7 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -2,6 +2,7 @@ import { assert } from "@effect/vitest"; import { type ChatAttachment, CommandId, + isOrchestrationV2WorkActive, MessageId, ProjectId, ThreadId, @@ -1044,6 +1045,66 @@ export function assertNoExtraAppRunsForProviderChildren(input: { ); } +/** + * Provider-native subagent threads have no runs; clients show them working + * from the child's runless root turn. Pin that contract for every recorded + * native subagent: the child hangs off the subagent node, every root turn is + * runless, the root turn is live before the child's first item, and its + * activity mirrors the subagent's (including a resume re-opening it). + */ +export function assertProviderNativeSubagentRootTurns(result: OrchestratorV2ScenarioResult) { + const activity = (statuses: ReadonlyArray) => + statuses + .map((status) => (isOrchestrationV2WorkActive(status) ? "active" : status)) + .filter((status, index, all) => status !== all[index - 1]); + for (const projection of result.projections.values()) { + for (const subagent of projection.subagents) { + if (subagent.origin !== "provider_native" || subagent.childThreadId === null) continue; + const childThreadId = subagent.childThreadId; + const child = result.projections.get(childThreadId); + assert.isDefined(child, `missing child thread for subagent ${subagent.id}`); + assert.equal(child.thread.creationSource, "provider"); + assert.deepEqual(child.thread.forkedFrom, { type: "node", nodeId: subagent.id }); + assert.lengthOf(child.runs, 0); + const roots = child.nodes.filter((node) => node.kind === "root_turn"); + assert.isNotEmpty(roots, `child ${childThreadId} must have a root turn`); + for (const root of roots) assert.isNull(root.runId); + + const rootEvents = result.domainEvents.flatMap((event, index) => + event.type === "node.updated" && + event.payload.threadId === childThreadId && + event.payload.kind === "root_turn" + ? [{ index, status: event.payload.status }] + : [], + ); + const firstItemIndex = result.domainEvents.findIndex( + (event) => + event.type === "turn-item.updated" && + event.payload.threadId === childThreadId && + event.payload.type !== "user_message", + ); + assert.equal(rootEvents[0]?.status, "running"); + if (firstItemIndex !== -1) { + assert.isBelow( + rootEvents[0]?.index ?? Infinity, + firstItemIndex, + `child ${childThreadId} must be working before its first item`, + ); + } + const subagentStatuses = result.domainEvents.flatMap((event) => + event.type === "subagent.updated" && event.payload.id === subagent.id + ? [event.payload.status] + : [], + ); + assert.deepEqual( + activity(rootEvents.map((event) => event.status)), + activity(subagentStatuses), + `child ${childThreadId} root turn must follow subagent ${subagent.id}`, + ); + } + } +} + export function assertExecutionNodeKinds( projection: OrchestrationV2ThreadProjection, expectedKinds: ReadonlyArray, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 75e35d3cac1d..b0b4247a86d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -81,6 +81,7 @@ import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { useThreadActions } from "../hooks/useThreadActions"; import { + deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, @@ -2003,6 +2004,10 @@ export default function ChatView(props: ChatViewProps) { () => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)), [serverProjection], ); + const runlessWorkStartedAt = useMemo( + () => (serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection)), + [serverProjection], + ); const supportsProviderSwitchingViaHandoff = useMemo( () => threadSupportsProviderHandoff(serverProjection), [serverProjection], @@ -3414,7 +3419,12 @@ export default function ChatView(props: ChatViewProps) { compactRequestIsActive && !compactionSettled; const isWorking = - phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; + phase === "running" || + isSendBusy || + isConnecting || + isRevertingCheckpoint || + isCompacting || + runlessWorkStartedAt !== null; const activeContextWindow = useMemo( () => deriveLatestContextWindowSnapshot( @@ -3446,11 +3456,9 @@ export default function ChatView(props: ChatViewProps) { }), ]; }, [serverProjection]); - const activeWorkStartedAt = deriveActiveWorkStartedAt( - activeActivityRun, - activeRuntime, - localDispatchStartedAt, - ); + const activeWorkStartedAt = + deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ?? + runlessWorkStartedAt; // Server-side workspace preparation: unlike the local-dispatch flag this // survives reloads and shows on remote viewers of the same thread. const activeRunPreparing = activeActivityRun?.status === "preparing"; @@ -10445,6 +10453,7 @@ export default function ChatView(props: ChatViewProps) { } : {})} isWorking={!paintOnlyDisplayedTimeline && isWorking} + runlessWorkActive={runlessWorkStartedAt !== null} activeTurnInProgress={ !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled) } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 3f9bc37e9ba7..c9e0ac872742 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1904,6 +1904,96 @@ describe("deriveMessagesTimelineRows", () => { }); }); + it("shows a provider-native subagent's runless tools as live work while it works", () => { + const entries = (commandStatus: "inProgress" | "completed") => [ + { + id: "task-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "task" as never, + role: "user" as const, + text: "Audit the adapters", + runId: null, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "command-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "command", + createdAt: "2026-01-01T00:00:05Z", + runId: null, + label: "Running git", + command: "git diff --stat", + requestKind: "command" as const, + tone: "tool" as const, + toolLifecycleStatus: commandStatus, + }, + }, + ]; + const rows = (input: { commandStatus: "inProgress" | "completed"; working: boolean }) => + deriveMessagesTimelineRows({ + timelineEntries: entries(input.commandStatus), + latestRun: null, + isWorking: input.working, + runlessWorkActive: input.working, + activeTurnStartedAt: input.working ? "2026-01-01T00:00:00Z" : null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + const running = rows({ commandStatus: "inProgress", working: true }); + expect(running.map((row) => row.kind)).toEqual(["message", "working", "work-live"]); + expect(running.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "command" }, + active: true, + }); + + // Once the subagent settles, the same entries read as finished history. + const settled = rows({ commandStatus: "completed", working: false }); + expect(settled.map((row) => row.kind)).toEqual(["message", "work"]); + }); + + it("does not treat runless entries as live work on a thread with runs", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "runless-command-entry", + kind: "work", + createdAt: "2026-01-01T00:00:05Z", + entry: { + id: "runless-command", + createdAt: "2026-01-01T00:00:05Z", + runId: null, + label: "Running git", + command: "git status", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }, + }, + ], + latestRun: { + runId: "turn-1" as never, + status: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.some((row) => row.kind === "work-live")).toBe(false); + expect(rows.at(-1)?.kind).toBe("thinking"); + }); + it("renders a single completed tool call directly", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 6f0f73a11584..29d86b2fe9ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1039,6 +1039,11 @@ export function deriveMessagesTimelineRows(input: { expandedAttemptIds?: ReadonlySet; expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; + /** + * The live work has no app run (a provider-native subagent thread), so + * runless entries are the current response instead of settled history. + */ + runlessWorkActive?: boolean; activeTurnStartedAt?: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; @@ -1098,11 +1103,13 @@ export function deriveMessagesTimelineRows(input: { } } } + const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; + const runIdIsActiveResponse = (runId: RunId | null | undefined) => + runId == null ? runlessWorkActive : activeVisualResponseRunIds.has(runId); const workEntryIsInActiveRun = (entry: WorkLogEntry) => input.isWorking && - unsettledRunId !== null && entry.toolLifecycleStatus === "inProgress" && - entry.runId === unsettledRunId; + (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId); // A steer continues the current turn. Keep its elapsed-time header below // the initiating prompt (or automatic wake), rather than moving it down. @@ -1115,7 +1122,7 @@ export function deriveMessagesTimelineRows(input: { // and once everything settles it keeps the latest tool in past tense // instead of vanishing (#8984). const activeToolEntries: Array> = []; - if (input.isWorking && unsettledRunId !== null) { + if (input.isWorking && (unsettledRunId !== null || runlessWorkActive)) { let tailAttemptId: string | null | undefined; for (let index = timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) { const entry = timelineEntries[index]!; @@ -1125,8 +1132,7 @@ export function deriveMessagesTimelineRows(input: { entry.entry.sourceActivityKind === "runtime.error" || entry.entry.itemType === "system_notice" || entry.entry.itemType === "notification" || - entry.entry.runId == null || - !activeVisualResponseRunIds.has(entry.entry.runId) || + !runIdIsActiveResponse(entry.entry.runId) || entry.entry.sourceActivityKind === "context-compaction" || collapsedEntryIds.has(entry.id) || collapsedSupersededEntryIds.has(entry.id) || @@ -1469,9 +1475,7 @@ export function deriveMessagesTimelineRows(input: { const assistantResponseStillInProgress = timelineEntry.message.role === "assistant" && - timelineEntry.message.runId !== null && - timelineEntry.message.runId !== undefined && - activeVisualResponseRunIds.has(timelineEntry.message.runId); + runIdIsActiveResponse(timelineEntry.message.runId); const durationStart = durationStartByMessageId.get(timelineEntry.message.id) ?? timelineEntry.message.createdAt; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1ade3af63861..45420846d972 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -401,6 +401,8 @@ interface MessagesTimelineProps { sourceAnchor: AssistantCitationSourceAnchor, ) => boolean; isWorking: boolean; + /** The live work belongs to a runless root turn (a provider-native subagent). */ + runlessWorkActive?: boolean; activeTurnInProgress: boolean; activeTurnStartedAt?: string | null; worktreeSetup?: WorktreeSetupSnapshot | null; @@ -483,6 +485,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ citationHistoryLoading = false, onCiteAssistantText, isWorking, + runlessWorkActive = false, activeTurnInProgress, activeTurnStartedAt = null, worktreeSetup = null, @@ -741,6 +744,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedAttemptIds, expandedWorkGroupIds, isWorking, + runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, @@ -763,6 +767,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedAttemptIds, expandedWorkGroupIds, isWorking, + runlessWorkActive, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index c9d4bd9c0e26..186a799ccb1c 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -3,6 +3,7 @@ import { NodeId, MessageId, RunId, + type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -11,6 +12,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { deriveLatestThreadRun, + deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, threadRuntimeHasInterruptibleRun, @@ -167,6 +169,49 @@ describe("thread execution presentation", () => { }); }); +describe("deriveRunlessWorkStartedAt", () => { + const later = DateTime.makeUnsafe("2026-07-28T10:05:00.000Z"); + const rootTurn = ( + status: OrchestrationV2ExecutionNode["status"], + startedAt = now, + ): OrchestrationV2ExecutionNode => ({ + id: NodeId.make("child-root"), + threadId: v2Projection.thread.id, + runId: null, + parentNodeId: null, + rootNodeId: NodeId.make("child-root"), + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: null, + }); + + it("times a provider-native subagent from its runless root turn while it works", () => { + const projection = { ...v2Projection, 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(); + }); + + 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); + }, + ); + + it("ignores root turns that belong to a run", () => { + const owned = { ...rootTurn("running"), runId: RunId.make("run-1") }; + expect(deriveRunlessWorkStartedAt({ ...v2Projection, nodes: [owned] })).toBeNull(); + }); +}); + describe("threadRuntimeCanArchive", () => { const runtime = ( status: ThreadRuntimeSummary["status"], diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 9a7012cc57d5..3b99c597923f 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -2,7 +2,10 @@ import { latestRootProviderFailure, threadErrorSummary, } from "@t3tools/shared/orchestrationV2ThreadError"; -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { + isOrchestrationV2WorkActive, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as DateTime from "effect/DateTime"; @@ -67,6 +70,24 @@ export function deriveThreadActivityRun( return run === null ? null : summarizeThreadRun(projection, run); } +/** + * Provider-native subagent threads never get app runs: their work is a runless + * root turn whose status follows the subagent. Returns when that work started + * while it is still active, so clients can show the same working state (and + * timer) as a run. Stop, queue, and steer stay run-only. + */ +export function deriveRunlessWorkStartedAt( + projection: OrchestrationV2ThreadProjection, +): string | null { + const node = projection.nodes.findLast( + (candidate) => + candidate.kind === "root_turn" && + candidate.runId === null && + isOrchestrationV2WorkActive(candidate.status), + ); + return node?.startedAt == null ? null : DateTime.formatIso(node.startedAt); +} + export function deriveThreadRuntime( projection: OrchestrationV2ThreadProjection, ): ThreadRuntimeSummary | null { From 6a93fae7b26c530915d02fa12a08cb671f2be5aa Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:12:37 -0700 Subject: [PATCH 2/7] fix(mobile): only runless subagent work makes a runless tail live Dropping the active-run check let any working thread without an unsettled run (the optimistic-send window, or a queued latest run) mark a runless tail group live with shimmer. Pass an explicit runlessWorkActive flag, as the web timeline does, and live-mark a runless tail only for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/ThreadDetailScreen.tsx | 3 ++ .../src/features/threads/ThreadFeed.tsx | 3 ++ .../features/threads/ThreadRouteScreen.tsx | 1 + apps/mobile/src/lib/threadActivity.test.ts | 34 ++++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++-- .../src/state/use-thread-composer-state.ts | 21 ++++++------ 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 01bbf21e0265..31700c794aac 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -136,6 +136,8 @@ export interface ThreadDetailScreenProps { readonly selectedThreadFeed: ReadonlyArray; readonly activityRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; + /** The live work is a provider-native subagent's runless root turn. */ + readonly runlessWorkActive?: boolean; readonly isCompacting: boolean; /** * The server has not created this thread yet. "preparing" runs while the @@ -1031,6 +1033,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadTitle={props.selectedThread.title} latestRun={props.activityRun} activeWorkStartedAt={props.activeWorkStartedAt} + runlessWorkActive={props.runlessWorkActive ?? false} listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 59940e4c9fe2..a927e68811c1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -277,6 +277,7 @@ export interface ThreadFeedProps { readonly agentLabel: string; readonly latestRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; + readonly runlessWorkActive?: boolean; readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; @@ -2606,6 +2607,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { .map(([groupId]) => groupId), ), props.activeWorkStartedAt, + props.runlessWorkActive ?? false, ), props.feed, props.queuedMessages, @@ -2615,6 +2617,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedTurnIds, expandedWorkGroups, props.activeWorkStartedAt, + props.runlessWorkActive, props.feed, props.latestRun, ], diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f1b2eee2bc0b..4958cf9d05e9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1005,6 +1005,7 @@ function ThreadRouteContent( : composer.activeWorkStartedAt } isCompacting={composer.isCompacting} + runlessWorkActive={composer.runlessWorkActive} creationState={creationState} setupWorkingStartedAt={ composer.activeWorkStartedAt !== null && diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f8c11c2d9817..32034bdb1559 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -971,7 +971,14 @@ describe("buildThreadFeed", () => { projected(runningCommand, 1), ]); - const presented = deriveThreadFeedPresentation(feed, null, new Set(), new Set(), startedAt); + const presented = deriveThreadFeedPresentation( + feed, + null, + new Set(), + new Set(), + startedAt, + true, + ); expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({ summary: "Running vp", live: true, @@ -980,6 +987,31 @@ describe("buildThreadFeed", () => { expect(presented.some((entry) => entry.type === "thinking")).toBe(false); }); + it("keeps a runless tail settled while a normal thread waits for its sent run", () => { + // Right after a send the local clock runs before the server creates the + // run, and the latest run may still be queued: neither is runless work. + const startedAt = "2026-06-20T00:00:05.000Z"; + const feed = buildThreadFeed([ + projected({ ...userMessage(), runId: null }, 0), + projected({ ...command(), runId: null }, 1), + ]); + for (const latestRun of [ + null, + { runId, status: "queued" as const, startedAt: null, completedAt: null }, + ]) { + const presented = deriveThreadFeedPresentation( + feed, + latestRun, + new Set(), + new Set(), + startedAt, + ); + const toggle = presented.find((entry) => entry.type === "work-toggle"); + expect(toggle).toMatchObject({ live: false, shimmer: false }); + expect(presented.at(-1)?.type).toBe("thinking"); + } + }); + it("waits for workspace preparation before showing provider activity", () => { const startedAt = "2026-04-01T00:00:01.000Z"; const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null }; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d2187dcef1df..ff9b8731b9b5 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1019,6 +1019,8 @@ export function deriveThreadFeedPresentation( expandedRunIds: ReadonlySet, expandedWorkGroupIds: ReadonlySet = new Set(), activeWorkStartedAt: string | null = null, + /** The live work is a provider-native subagent's runless root turn. */ + runlessWorkActive = false, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( (entry) => @@ -1037,10 +1039,11 @@ export function deriveThreadFeedPresentation( } const result: ThreadFeedEntry[] = []; for (const entry of sourceFeed) { - // A provider-native subagent works without a run, so its null-run tail - // is live while it works (activeRunId is null then too). + // A provider-native subagent works without a run: its null-run tail is + // live only while that runless work is active. const isActiveTailGroup = isWorking && + (activeRunId !== null || runlessWorkActive) && entry.type === "activity-group" && activeTailGroup?.type === "activity-group" && activeTailGroup.id === entry.id && diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 68525ae9ed9b..623971c9531a 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -394,6 +394,13 @@ export function useThreadComposerState() { selectedThreadVisibleTurnItems, ]); + const runlessWorkStartedAt = useMemo( + () => + selectedThreadProjection + ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) + : null, + [selectedThreadProjection], + ); const activeWorkStartedAt = useMemo(() => { if (!selectedThreadShell) { return null; @@ -402,17 +409,10 @@ export function useThreadComposerState() { resolveThreadWorkingStartedAt({ latestRun: selectedThreadActivityRun, runtime: selectedThreadRuntime, - }) ?? - (selectedThreadProjection - ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) - : null) + }) ?? runlessWorkStartedAt ); - }, [ - selectedThreadActivityRun, - selectedThreadProjection, - selectedThreadRuntime, - selectedThreadShell, - ]); + }, [selectedThreadActivityRun, runlessWorkStartedAt, selectedThreadRuntime, selectedThreadShell]); + const runlessWorkActive = runlessWorkStartedAt !== null; // 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 @@ -1035,6 +1035,7 @@ export function useThreadComposerState() { selectedThreadQueuedMessages, dispatchingQueuedMessageId, activeWorkStartedAt, + runlessWorkActive, isCompacting, draftMessage, draftAttachments, From 280bd062fefcdcd238bee1132391e53060b5a4b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:44:06 -0700 Subject: [PATCH 3/7] fix(clients): native subagent threads show status instead of a composer A subagent that the provider spawns on its own (Claude's Agent tool, Codex, Cursor, Grok, and OpenCode native subagents) is driven by the provider. Its child thread still showed the full composer with model, effort, and access pickers. Sending from it started an unrelated provider session (Claude, Cursor), resumed the child's native thread and raced the parent (Codex, OpenCode), or switched the shared ACP runtime away from the parent (Grok). The server now refuses `message.dispatch` to a provider-native subagent thread with a typed `OrchestratorSubagentThreadReadOnlyError` before any event is written. Its message reaches both clients through the existing dispatch error path. T3 `delegate_task` children (`creationSource: "mcp"`) still take follow-ups. Web and mobile replace the composer on those threads with a compact bar: the model, "Working 12s" / "Completed in 34s", and "Open parent". The bar is derived from the child's runless root turn and adds no websocket payload. Web keeps the composer while the child has a pending approval or question, since the composer renders those. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/ProviderSubagentBar.tsx | 55 +++ .../features/threads/ThreadDetailScreen.tsx | 136 ++++--- .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/use-thread-composer-state.ts | 10 + ...OrchestratorMcpToolkit.integration.test.ts | 6 + .../src/orchestration-v2/Orchestrator.ts | 17 + .../orchestration-v2/ThreadMessageIntake.ts | 1 + .../ClaudeReplayFixtures.integration.test.ts | 82 +++- apps/web/src/components/ChatView.tsx | 361 ++++++++++-------- .../components/chat/ProviderSubagentBar.tsx | 55 +++ docs/user/cursor.md | 4 +- docs/user/thread-sidebar.md | 4 + .../src/state/threadExecution.test.ts | 72 ++++ .../src/state/threadExecution.ts | 64 ++++ packages/contracts/src/orchestrationV2.ts | 11 + 15 files changed, 660 insertions(+), 219 deletions(-) create mode 100644 apps/mobile/src/features/threads/ProviderSubagentBar.tsx create mode 100644 apps/web/src/components/chat/ProviderSubagentBar.tsx diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx new file mode 100644 index 000000000000..7aa26f9d0874 --- /dev/null +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -0,0 +1,55 @@ +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 { 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: { + readonly modelLabel: string; + readonly status: ProviderSubagentStatus; + readonly onOpenParent: (() => void) | null; +}) { + const live = 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); + + return ( + + + + {props.modelLabel} + + + {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..019d6775fd20 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -30,6 +30,8 @@ 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 type { ProviderSubagentStatus } from "@t3tools/client-runtime/state/thread-execution"; +import { formatModelSlugName } from "@t3tools/shared/model"; 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 +100,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 +141,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 @@ -1215,59 +1220,84 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > - <> - - + {props.providerSubagentStatus ? ( + + + 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..c93753c16b62 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; @@ -3997,6 +4008,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) => Effect.gen(function* () { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + if (isProviderNativeSubagentThread(projection.thread)) { + return yield* new OrchestratorSubagentThreadReadOnlyError({ + commandId: command.commandId, + threadId: command.threadId, + }); + } if (command.usageLimitContinuationOfRunId !== undefined) { const run = projection.runs.at(-1) ?? null; const failure = latestRootProviderFailure(run, projection.turnItems); 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/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..df8231ca3045 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -81,6 +81,7 @@ 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, deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveLatestThreadRun, @@ -105,6 +106,7 @@ import { import { applyClaudePromptEffortPrefix, createModelSelection, + formatModelSlugName, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { @@ -395,6 +397,7 @@ 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 { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; @@ -2008,6 +2011,10 @@ export default function ChatView(props: ChatViewProps) { () => (serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection)), [serverProjection], ); + const providerSubagentStatus = useMemo( + () => (serverProjection === null ? null : deriveProviderSubagentStatus(serverProjection)), + [serverProjection], + ); const supportsProviderSwitchingViaHandoff = useMemo( () => threadSupportsProviderHandoff(serverProjection), [serverProjection], @@ -4020,10 +4027,17 @@ 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, unless the subagent is asking for an approval or + // an answer, which the composer renders. + const showProviderSubagentBar = + providerSubagentStatus !== null && + pendingApprovals.length === 0 && + pendingUserInputs.length === 0; const mountComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, hostsRestingComposerControls: routeKind === "server", @@ -4031,12 +4045,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( () => ({ @@ -10616,166 +10631,186 @@ 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 && providerSubagentStatus ? ( + onOpenRelatedThread(parentThreadLink.threadId) + : 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/ProviderSubagentBar.tsx b/apps/web/src/components/chat/ProviderSubagentBar.tsx new file mode 100644 index 000000000000..f451cea6ac94 --- /dev/null +++ b/apps/web/src/components/chat/ProviderSubagentBar.tsx @@ -0,0 +1,55 @@ +import { + formatProviderSubagentStatus, + type ProviderSubagentStatus, +} from "@t3tools/client-runtime/state/thread-execution"; +import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; +import { ArrowUpLeftIcon } from "lucide-react"; +import { useLayoutEffect, useRef } from "react"; + +import { Button } from "../ui/button"; + +/** + * Stands in for 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: { + readonly modelLabel: string; + readonly status: ProviderSubagentStatus; + readonly onOpenParent: (() => void) | null; +}) { + const statusRef = useRef(null); + const { status } = props; + const live = isOrchestrationV2WorkActive(status.status); + + // 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.modelLabel} + + Runs on its own + {props.onOpenParent ? ( + + ) : null} +
+ ); +} diff --git a/docs/user/cursor.md b/docs/user/cursor.md index 0afeb7dd8108..582278846de0 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: it shows the task's status in place of a composer, and messages go to 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..d2f392016448 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -165,6 +165,10 @@ snooze limit stops by default. Providers without a reset time offer manual retry and the normal snooze choices. On web and desktop, use **Agents** to follow work delegated to subagents. +Subagents that a provider starts on its own, such as Claude's Agent tool, run +without you: their threads show the model and how long they have worked where +the composer would be, with **Open parent** to go back and message the parent. +Tasks started with `delegate_task` keep their composer and accept follow-ups. 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 diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 186a799ccb1c..2a6c0be69799 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -3,6 +3,7 @@ import { NodeId, MessageId, RunId, + ThreadId, type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, } from "@t3tools/contracts"; @@ -12,6 +13,8 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { deriveLatestThreadRun, + deriveProviderSubagentStatus, + formatProviderSubagentStatus, deriveRunlessWorkStartedAt, deriveThreadActivityRun, deriveThreadRuntime, @@ -212,6 +215,75 @@ describe("deriveRunlessWorkStartedAt", () => { }); }); +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"); + }); + + it("leaves T3 delegated tasks and ordinary threads alone", () => { + expect(deriveProviderSubagentStatus(child("mcp"))).toBeNull(); + expect(deriveProviderSubagentStatus({ ...v2Projection, nodes: [root] })).toBeNull(); + }); +}); + describe("threadRuntimeCanArchive", () => { const runtime = ( status: ThreadRuntimeSummary["status"], diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index 3b99c597923f..630821d0f527 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -4,9 +4,12 @@ import { } from "@t3tools/shared/orchestrationV2ThreadError"; import { isOrchestrationV2WorkActive, + isProviderNativeSubagentThread, + type OrchestrationV2ExecutionNode, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; import { @@ -88,6 +91,67 @@ export function deriveRunlessWorkStartedAt( return node?.startedAt == null ? null : DateTime.formatIso(node.startedAt); } +export interface ProviderSubagentStatus { + readonly status: OrchestrationV2ExecutionNode["status"]; + readonly startedAt: string | null; + readonly completedAt: string | null; +} + +/** + * Status of a provider-native subagent thread, which the provider runs and + * the user cannot message. Null for every other thread, including T3 + * delegated tasks, which keep their composer. + */ +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, + ); + 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), + }; +} + +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, + nowMs: number, +): string { + 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( projection: OrchestrationV2ThreadProjection, ): ThreadRuntimeSummary | null { 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", From b6d6fa17c0ce6f7f87e06f689f0c847452245470 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:26:50 -0700 Subject: [PATCH 4/7] fix(clients): subagent questions stay answerable on read-only threads Review fixes for native subagent threads: - The server guard sat inside dispatchMessage, which the async question answer path reuses, so a Codex subagent's message-mode question could never be answered from its thread. Only the message.dispatch command is refused now. - Web keeps the status bar while a subagent asks for an approval or an answer, and shows that composer below it without model, effort, access pickers or attachments, which belong to the provider. - The bar shows as soon as the thread is provider-native, before its root turn arrives ("Starting"). Runless working state is gated the same way. - Accessibility: the web live region announces status transitions only, not the ticking timer; on mobile only the text is one element, so "Open parent" stays reachable. - User docs say what to do instead of describing the layout. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/ProviderSubagentBar.tsx | 19 ++- .../features/threads/ThreadDetailScreen.tsx | 5 +- .../src/orchestration-v2/Orchestrator.ts | 25 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 158 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 21 ++- apps/web/src/components/chat/ChatComposer.tsx | 9 +- .../components/chat/ProviderSubagentBar.tsx | 24 ++- docs/user/cursor.md | 2 +- docs/user/thread-sidebar.md | 7 +- .../src/state/threadExecution.test.ts | 25 ++- .../src/state/threadExecution.ts | 18 +- 11 files changed, 259 insertions(+), 54 deletions(-) diff --git a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx index 7aa26f9d0874..165338fbf388 100644 --- a/apps/mobile/src/features/threads/ProviderSubagentBar.tsx +++ b/apps/mobile/src/features/threads/ProviderSubagentBar.tsx @@ -16,10 +16,11 @@ import { RequestActionButton } from "./RequestActionButton"; */ export function ProviderSubagentBar(props: { readonly modelLabel: string; - readonly status: ProviderSubagentStatus; + /** Null until the subagent's root turn arrives. */ + readonly status: ProviderSubagentStatus | null; readonly onOpenParent: (() => void) | null; }) { - const live = isOrchestrationV2WorkActive(props.status.status); + const live = props.status !== null && isOrchestrationV2WorkActive(props.status.status); const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { if (!live) return; @@ -29,13 +30,13 @@ export function ProviderSubagentBar(props: { const statusLabel = formatProviderSubagentStatus(props.status, nowMs); return ( - - + + {/* Only the text is one element, so "Open parent" stays reachable. */} + {props.modelLabel} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 019d6775fd20..e704899fe044 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -32,6 +32,7 @@ import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thre import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents"; import 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"; @@ -1220,7 +1221,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > - {props.providerSubagentStatus ? ( + {isProviderNativeSubagentThread(props.selectedThread.source) ? ( Effect.gen(function* () { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); - if (isProviderNativeSubagentThread(projection.thread)) { - return yield* new OrchestratorSubagentThreadReadOnlyError({ - commandId: command.commandId, - threadId: command.threadId, - }); - } if (command.usageLimitContinuationOfRunId !== undefined) { const run = projection.runs.at(-1) ?? null; const failure = latestRootProviderFailure(run, projection.turnItems); @@ -8784,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/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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index df8231ca3045..4efbc9940946 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, @@ -2015,6 +2016,8 @@ export default function ChatView(props: ChatViewProps) { () => (serverProjection === null ? null : deriveProviderSubagentStatus(serverProjection)), [serverProjection], ); + const isProviderSubagent = + serverThread !== null && isProviderNativeSubagentThread(serverThread.source); const supportsProviderSwitchingViaHandoff = useMemo( () => threadSupportsProviderHandoff(serverProjection), [serverProjection], @@ -4028,12 +4031,12 @@ export default function ChatView(props: ChatViewProps) { // 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, unless the subagent is asking for an approval or - // an answer, which the composer renders. - const showProviderSubagentBar = - providerSubagentStatus !== null && - pendingApprovals.length === 0 && - pendingUserInputs.length === 0; + // 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 mountComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, @@ -10631,7 +10634,7 @@ export default function ChatView(props: ChatViewProps) { aria-busy={isSavingQueuedEdit} >
- {showProviderSubagentBar && providerSubagentStatus ? ( + {showProviderSubagentBar ? ( - ) : ( + ) : null} + {showProviderSubagentBar && !providerSubagentNeedsResponse ? null : ( def.id); - const composerControls = showProviderUnavailable ? ( + const composerControls = hideThreadSettings ? null : showProviderUnavailable ? ( void) | null; }) { const statusRef = useRef(null); const { status } = props; - const live = isOrchestrationV2WorkActive(status.status); + const live = status !== null && isOrchestrationV2WorkActive(status.status); + // 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. @@ -37,12 +43,16 @@ export function ProviderSubagentBar(props: { }, [live, status]); return ( -
+
{props.modelLabel} - + + + {`${props.modelLabel} subagent: ${announcement}`} + Runs on its own {props.onOpenParent ? (