fix(clients): native subagent threads show status instead of a composer - #13624
Conversation
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) <noreply@anthropic.com>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This is a substantial cross-platform behavior change that replaces the composer and alters server dispatch for provider-native subagents. Unresolved high-severity risks remain around queued sends bypassing read-only enforcement and mobile queued-message edits being discarded. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
a6ba6c4 to
b6d6fa1
Compare
| (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), | ||
| ), | ||
| ); | ||
| if (isProviderNativeSubagentThread(thread)) { |
There was a problem hiding this comment.
🟠 High orchestration-v2/Orchestrator.ts:8792
Pre-existing queued sends still execute on provider-native subagent threads, bypassing the new read-only protection. handleTerminalRun unconditionally calls startNextQueuedRun when an active run finishes, and queue.resume can do the same, so queued message.dispatch runs created before this guard reaches dispatchOnce are forwarded to the provider. Enforce the read-only check when promoting queued runs (and resume), or cancel those queued sends during migration.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Orchestrator.ts around line 8792:
Pre-existing queued sends still execute on provider-native subagent threads, bypassing the new read-only protection. `handleTerminalRun` unconditionally calls `startNextQueuedRun` when an active run finishes, and `queue.resume` can do the same, so queued `message.dispatch` runs created before this guard reaches `dispatchOnce` are forwarded to the provider. Enforce the read-only check when promoting queued runs (and resume), or cancel those queued sends during migration.
Two leftovers from swapping the composer for the native subagent bar: - The timeline kept the resting composer's 94px reservation, because the resting flag was only reported by a mounted composer. Only a mounted composer counts as resting now, and the reservation is rebuilt when the composer and the bar swap. - The model picker shortcut could open the (hidden) picker on a native subagent thread, and it popped up later on another thread. The picker now closes whenever the thread settings are hidden. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The status bar on a native subagent thread now leads with the provider's icon and follows the model name with its reasoning effort in muted text, named the way the composer names it. Effort comes from the thread's own model selection (native children inherit the parent's options), so a subagent on a different model with no reported effort shows none. The effort is also part of the accessible label on web and mobile. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| onEditorFocusChange={handleComposerFocusChange} | ||
| /> | ||
| </> | ||
| {isProviderNativeSubagentThread(props.selectedThread.source) ? ( |
There was a problem hiding this comment.
🟠 High threads/ThreadDetailScreen.tsx:1233
Provider-native threads still expose ThreadFeed's pending-message edit action, but this branch renders only ProviderSubagentBar, so an edited outbox message has no composer in which to modify or send it. Pressing the visible ComposerQueuedEditBanner cancel action then clears the queued-edit draft and permanently discards the message. Disable pending-message editing and its queued-edit banner for provider-native threads, or render a composer that can complete the edit.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadDetailScreen.tsx around line 1233:
Provider-native threads still expose `ThreadFeed`'s pending-message edit action, but this branch renders only `ProviderSubagentBar`, so an edited outbox message has no composer in which to modify or send it. Pressing the visible `ComposerQueuedEditBanner` cancel action then clears the queued-edit draft and permanently discards the message. Disable pending-message editing and its queued-edit banner for provider-native threads, or render a composer that can complete the edit.
A thread whose user never changed effort stores no effort option, so the
bar showed none while the composer showed the model's default ("High").
Resolve effort with the composer's own descriptor logic: the stored
choice when valid, else the descriptor's current value, else the model's
default option. A model the provider catalog does not describe still
shows nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every provider now asks a native subagent's approvals and questions on the top-level parent thread, so the web composer that #13624 kept below the subagent bar for a pending request can never mount, and hideThreadSettings has no caller left. The bar now always replaces the composer. On mobile, a message already in the outbox for a native subagent thread could still be edited, and Cancel on the edit banner discarded it because there is no composer to finish the edit in. The pending-message edit action is off for those threads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Stacked on #13614 (base
v2/subagent-working-state).A native subagent's child thread (one the provider spawned on its own, for example Claude's Agent tool) showed the full composer with model/effort/access pickers, but you can't actually message that subagent. Sending anyway did something surprising on every provider:
activeProviderThreadId: null.What changed
isProviderNativeSubagentThread(thread), which checkslineage.relationshipToParent === "subagent" && creationSource === "provider".delegate_taskchildren arecreationSource: "mcp"and don't match.message.dispatchcommand to such a thread fails with a typedOrchestratorSubagentThreadReadOnlyErrorbefore any event is written. The check sits on the command branch, not insidedispatchMessage, because the async question-answer path (runtime-request.respondwith a message-mode question, which Codex subagents write to their own child thread) reusesdispatchMessageand must still land. The error message is "This subagent is run by its provider and cannot take messages. Message the parent thread instead."userFacingDispatchErrorMessagepicks it up, so it lands in the web toast and the mobile outbox restore through the existingOrchestrationV2DispatchCommandError.detailpath.ThreadMessageIntaketreats it as not accepted, so any claimed attachments are released.deriveProviderSubagentStatus(projection)gets status, start, and end times from the child's runless root turn (null until it arrives).formatProviderSubagentStatusproduces the text: "Starting", "Working 1m 5s", "Completed in 34s", "Cancelled".formatModelSelectionEffort(selection, models)reads the effort from the thread's own model selection (native children inherit the parent's options; a subagent on a different model reports none, so nothing shows) over the idsreasoningEffort/effort/reasoning/variant, and names it with the provider catalog's option label ("Extra High"), else capitalizes the raw value. Web and mobile include the effort in the accessible label.deriveRunlessWorkStartedAt(from fix(clients): native subagent threads show when they are working #13614) is now gated on the same predicate. Both read data the child projection already has, so there's no extra websocket payload.ChatViewrendersProviderSubagentBarinside the existingComposerSurface.Host([provider icon] model · effort (muted) · status · "Runs on its own" · ghost "Open parent" button; the icon is the composer picker'sProviderInstanceIconatsize-4, the model name is the picker's display name) whenever the thread is provider-native, and hides the context/model strips. The live timer updates the DOM directly, with no re-render per tick, and isaria-hidden; a visually hiddenrole="status"region announces only transitions ("Working", "Completed"). While the child has a pending approval or question, the composer that renders those appears below the bar with a newhideThreadSettingsprop, which drops the model/effort/access pickers and attachments.ThreadDetailScreenrenders the same bar in place ofThreadComposerwhenever the thread is provider-native, with the composer'sProviderIcon(16px), the model name, the effort intext-foreground-secondary, andRequestActionButtonfor "Open parent". Only the text block is one accessible element, so VoiceOver can reach "Open parent". Pending approvals and questions already render as cards above that slot.docs/user/cursor.mdsaid sending from a projected Cursor child starts a new agent; it now says the child is read-only and to send messages from the parent thread.docs/user/thread-sidebar.mdgets one sentence: subagent threads started by the agent can't take messages; message the parent thread instead.Surfaces: the composer is the only send entry point on web (command palette and keybindings route through it) and on mobile (
ThreadComposer; the outbox drain would hit the server guard). Stop, queue, and steer were already unavailable because the child has no run (#13614).Verification
ClaudeReplayFixtures.integration.test.ts(new): replays the recordedsubagentClaude transcript through the full orchestrator. It then dispatchesmessage.dispatchto the native child and asserts theOrchestratorSubagentThreadReadOnlyErrortag and its user-facing text, an unchanged child event sequence, no runs, and unchanged messages. Without the guard, the same dispatch commits a new run on the child (I checked by removing it).runtimeLayer.test.ts(new): a provider-native child thread with a Codex-style message-mode question (node, runtime request,user_input_requestitem written to the child). A plainmessage.dispatchis refused with no message written, andruntime-request.respondstill resolves the request, completes the item, and commits the answer message. With the guard back insidedispatchMessage, the answer is refused (checked).OrchestratorMcpToolkit.integration.test.ts(extended): in the Codexdelegated-task-statusreplay, thedelegate_taskchild is asubagentthread but not provider-native, and it still accepts both a running and a queuedt3_thread_sendfollow-up.packages/client-runtimethreadExecution.test.ts: 22 passed. Provider child gives status and timing, adelegate_taskchild and a normal thread give null, runless working state ignores non-provider threads, the formatter output is checked for starting, working, completed, and cancelled, and the effort formatter is checked for a catalog label, an uncatalogued value, and no effort (absent or an unrelated option id). 25 passed.OrchestratorReplayFixtures.integration.test.ts(87 passed), webMessagesTimeline.logic/BranchToolbar.logic(208 passed), mobilethreadActivity/threadAgentsPresentation(78 passed), serverruntimeLayer/ClaudeReplayFixtures/UserFacingErrors/ThreadManagementService(65 passed).tsc --noEmiton contracts, client-runtime, web, mobile, server: no errors.vp linton touched files: no new findings.vp run knip:check: clean.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code