Skip to content

fix(clients): native subagent threads show status instead of a composer - #13624

Merged
juliusmarminge merged 8 commits into
t3code/codex-turn-mappingfrom
v2/subagent-composer
Sep 25, 2026
Merged

juliusmarminge merged 8 commits into
t3code/codex-turn-mappingfrom
v2/subagent-composer

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

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:

  • Claude and Cursor started a new provider session with none of the subagent's context, because the child has activeProviderThreadId: null.
  • Codex and OpenCode resumed the child's native thread while the parent still owned it.
  • Grok switched the single ACP runtime to the child session, which puts the parent at risk.

What changed

  • Contracts: added isProviderNativeSubagentThread(thread), which checks lineage.relationshipToParent === "subagent" && creationSource === "provider". delegate_task children are creationSource: "mcp" and don't match.
  • Server: the message.dispatch command to such a thread fails with a typed OrchestratorSubagentThreadReadOnlyError before any event is written. The check sits on the command branch, not inside dispatchMessage, because the async question-answer path (runtime-request.respond with a message-mode question, which Codex subagents write to their own child thread) reuses dispatchMessage and must still land. The error message is "This subagent is run by its provider and cannot take messages. Message the parent thread instead." userFacingDispatchErrorMessage picks it up, so it lands in the web toast and the mobile outbox restore through the existing OrchestrationV2DispatchCommandError.detail path. ThreadMessageIntake treats it as not accepted, so any claimed attachments are released.
  • client-runtime: deriveProviderSubagentStatus(projection) gets status, start, and end times from the child's runless root turn (null until it arrives). formatProviderSubagentStatus produces 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 ids reasoningEffort/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.
  • Web: ChatView renders ProviderSubagentBar inside the existing ComposerSurface.Host ([provider icon] model · effort (muted) · status · "Runs on its own" · ghost "Open parent" button; the icon is the composer picker's ProviderInstanceIcon at size-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 is aria-hidden; a visually hidden role="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 new hideThreadSettings prop, which drops the model/effort/access pickers and attachments.
  • Mobile: ThreadDetailScreen renders the same bar in place of ThreadComposer whenever the thread is provider-native, with the composer's ProviderIcon (16px), the model name, the effort in text-foreground-secondary, and RequestActionButton for "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: docs/user/cursor.md said 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.md gets 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 recorded subagent Claude transcript through the full orchestrator. It then dispatches message.dispatch to the native child and asserts the OrchestratorSubagentThreadReadOnlyError tag 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_request item written to the child). A plain message.dispatch is refused with no message written, and runtime-request.respond still resolves the request, completes the item, and commits the answer message. With the guard back inside dispatchMessage, the answer is refused (checked).
  • OrchestratorMcpToolkit.integration.test.ts (extended): in the Codex delegated-task-status replay, the delegate_task child is a subagent thread but not provider-native, and it still accepts both a running and a queued t3_thread_send follow-up.
  • packages/client-runtime threadExecution.test.ts: 22 passed. Provider child gives status and timing, a delegate_task child 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.
  • Also ran: OrchestratorReplayFixtures.integration.test.ts (87 passed), web MessagesTimeline.logic/BranchToolbar.logic (208 passed), mobile threadActivity/threadAgentsPresentation (78 passed), server runtimeLayer/ClaudeReplayFixtures/UserFacingErrors/ThreadManagementService (65 passed).
  • tsc --noEmit on contracts, client-runtime, web, mobile, server: no errors. vp lint on touched files: no new findings. vp run knip:check: clean.
  • Not run: browser, desktop, or simulator checks (no dev servers per maintainer rule), so there are no before/after images yet. The bar needs a visual pass in a real client before landing. Repo-wide checks were not run either.

Model: Claude Opus 5.5 (Claude Code)

🤖 Generated with Claude Code


Devin Review

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>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Sep 25, 2026
Comment thread apps/mobile/src/features/threads/ProviderSubagentBar.tsx Outdated
Comment thread apps/web/src/components/chat/ProviderSubagentBar.tsx Outdated
Comment thread apps/web/src/components/ChatView.tsx Outdated
Comment thread apps/server/src/orchestration-v2/Orchestrator.ts Outdated
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 4.9 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.1 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.4 KiB — 29.3 KiB ✅
Codex Live turn messages — 1 — 8 ✅
Claude Total thread wire — 4.9 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 20.8 KiB — 29.3 KiB ✅
Claude Live turn messages — 2 — 8 ✅

Baseline: unavailable · PR result: 44f8e2f · Source CI: success

Scenario and decoded snapshot size

10 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.

  • Codex decoded thread snapshot: 106.1 KiB
  • Claude decoded thread snapshot: 106.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@macroscopeapp

macroscopeapp Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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:

  • 2 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

juliusmarminge and others added 3 commits September 25, 2026 03:12
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>
Comment thread apps/web/src/components/chat/ChatComposer.tsx
(cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
),
);
if (isProviderNativeSubagentThread(thread)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

juliusmarminge and others added 2 commits September 25, 2026 10:03
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>
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Sep 25, 2026
onEditorFocusChange={handleComposerFocusChange}
/>
</>
{isProviderNativeSubagentThread(props.selectedThread.source) ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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>
Base automatically changed from v2/subagent-working-state to t3code/codex-turn-mapping September 25, 2026 19:44
@juliusmarminge
juliusmarminge changed the base branch from v2/subagent-working-state to t3code/codex-turn-mapping September 25, 2026 19:44
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@juliusmarminge
juliusmarminge merged commit d3179b6 into t3code/codex-turn-mapping Sep 25, 2026
25 checks passed
@juliusmarminge
juliusmarminge deleted the v2/subagent-composer branch September 25, 2026 20:01
juliusmarminge added a commit that referenced this pull request Sep 26, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant