Skip to content

fix(native-chat): reasoning rows with a real open/finished state, and readable Claude thinking - #19221

Merged
brennanb2025 merged 58 commits into
mainfrom
brennanb2025/nc-reasoning-row
Oct 6, 2026
Merged

brennanb2025 merged 58 commits into
mainfrom
brennanb2025/nc-reasoning-row

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 36 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2527 $\color{#cf222e}{\Huge{\mathbf{−}}}$​55 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​2472
Prod 67 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1891 $\color{#cf222e}{\Huge{\mathbf{−}}}$​423 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1468

ELI5

When Claude or Codex stops to think before acting, Orca's chat should say so while it is thinking, and only then. Today it gets this wrong. On the Claude version Orca launches now, Claude's thinking reaches Orca empty, so Orca never says "Thinking" at all. On slightly older Claude versions, "Thinking" shows up only after the thinking is over, and then stays on screen while Claude writes out a large file (up to 30 seconds in real recordings).

This PR is step 1 of 2. It makes Orca ask Claude for a readable summary of its thinking, and gives every reasoning row in the chat a real "still thinking / finished" state that the host records. While Claude or Codex is thinking, you can open the single "Thinking" line and read the thinking as it streams. When the block ends it becomes a collapsed "Thought for 12s" row in the transcript (still open if you opened it). Step 2 (the next PR, stacked on this one) moves the "Thinking" label on the activity line onto a live signal from the host.

What Changed

What you see (before → after):

Before After
Claude reasons, current CLI No reasoning row, no "Thinking" at all (thinking arrives empty) The activity line says "Thinking"; open it to read the summary as it streams (height-capped, scrolls). When the block ends, a "Thought for 25s" row takes its place, still open if you opened it; the next block starts closed
Claude reasons, then writes a large file "Thinking" appears after reasoning ends and sticks through the file write "Thinking" shows on the activity line only while the reasoning block is open (from its first summary text; step 2 makes it start at the block itself); the file write reads "Working…"
Reasoning row header Tiny grey inline text (older code path), no duration Collapsed row led by a brain icon in the same column as tool-row icons: "Thought for N s", "Thought" when no span was seen, "Reasoning" for rows from an older host
Mobile Reasoning drawn inline as dimmed text The same: tap the "Thinking" line to read the live text, and the finished block becomes the same collapsed row with the brain icon. On Android, a long press on the opened text (live or finished) offers Copy message / Select text, as on every other message
Stray row A "claude · message:stream_event:ping" status row in some chats Gone (keep-alive pings write nothing)

How it works:

  • A lifecycle on reasoning rows. Journal message rows gain two optional fields, state (running / completed) and completedAt, modelled like the existing tool-call state. The host writes them: a row opens when its first reasoning text arrives and is stamped with the time its block (Claude) or item (Codex) started. It closes when the block or item finishes, when the turn ends or is interrupted, when the provider exits, and in the crash and stale-session sweeps. Readers only treat a row as live when its own turn is still running, so a missed close can never show as thinking.
  • A brain icon on the row. The collapsed row starts with a brain icon in the same 16px slot tool rows use for their icons, and its header is laid out like a tool run's header, so its icon and text line up with the rows around it. The icon is a named entry in the shared native-chat icon list, like the other row icons. The opened text starts under the headline. The chevron follows the headline: hidden until you hover or focus the row with the keyboard, always shown on touch screens, and turned down while the row is open. Mobile leads its row with the same icon, spaced like its tool rows. This follows the common pattern, where the reasoning row carries a brain icon like every other activity row, on desktop and mobile.
  • No blank rows. A row is written only once there is text, so already-shipped mobile builds never draw an empty reasoning gap.
  • One live indicator you can open. The activity line is the only live "Thinking". Once the open block has text, the line becomes a disclosure for that text; the block's own row is hidden for exactly as long as the line shows it, and appears when the block closes. Both facts come from one shared value (src/shared/native-chat-live-line.ts), used by desktop and mobile, so a row can never be hidden without the line showing it, and "Thinking" is never said twice. The line and the finished row share one open/closed key, so a block you opened stays open when it lands. Rows never say "Thinking"; any other unfinished block (a subagent's, or one whose line gave way to a question card) draws in place as an openable "Reasoning" row. Screen readers hear the line's label change, not the streaming text.
  • The spinner's "Thinking" (still the old "newest row" rule until step 2) now counts a reasoning row only while it is running. That stops "Thinking" sticking through a file write.
  • Readable Claude thinking. Claude launches with --thinking-display summarized, which asks Claude Code for summaries of its thinking. Orca never passes --thinking, so a user who turned thinking off stays off. The flag exists from Claude Code 2.1.94. Because Orca runs the user's own claude, it checks the version first: it runs <claude> --version with the launch's own folder and environment (so version managers like asdf or mise pick the same binary), with credentials removed. It remembers the answer per binary and folder. A launch waits for that check at most 1.5 s (measured warm: 20–66 ms), once per binary per folder per Orca run. If a CLI still rejects the flag ("unknown option '--thinking-display'"), Orca remembers that and leaves the flag off from the next launch.
  • Codex. Reasoning rows close on item/completed and at turn or session end, timed at the moment the host received each event. Eviction, settlement and a completion with no text now share one rule for how an unfinished item ends. Orca does not request Codex summaries, so Codex rows only appear for users who turned summaries on in their own Codex config.

Cost and privacy of thinking summaries: summaries are produced on Anthropic's side from thinking Claude already does, so they don't add thinking. They add the summary text to the output. The text is saved in the chat journal on the machine running the agent and syncs to paired clients like any other row. Claude Code also keeps it in its own transcript, as the terminal CLI already does with summaries on.

Why

The root cause was that "Thinking" was guessed from history. Orca looked at the newest row in the transcript, and if it was reasoning, it said "Thinking". Journal message rows had no open/finished state (tool calls did), so a reasoning row that had finished hours ago, in effect, still looked like "now". The same kind of bug was fixed for the activity line's tool text in #24218.

The common pattern is to track a reasoning block's lifecycle (open while it streams, closed when it finishes, the turn ends or it is interrupted) and show "Thinking" only while one is open. This PR follows that for the rows. Differences from the common pattern, each labelled:

  • The lifecycle is stored on the durable row by the host, rather than recomputed by each reader from raw events. Intended: Orca's journal is the durable projection, and remote and mobile clients can't re-derive it from provider frames.
  • Whether a block was cut short comes from its turn, not a separate "interrupted" state on the row. Intended: one source of truth.
  • The summaries flag is version-checked, where the common pattern passes it unconditionally. Intended: Orca runs the user's installed binary, and an old one fails to start on an unknown flag.
  • The open block is hidden by its row id while the line shows it, rather than never being a transcript row. Intended: the host records the open block in the journal so other clients and reloads see it; the shared value hides exactly that row and nothing else.
  • The line keeps the short label "Thinking" rather than previewing the text; the text is one click away. Intended: one label for every provider, including Codex, whose reasoning is often blank.
  • A subagent's open reasoning draws in its own section as "Reasoning", which you can open. Intended: Orca draws subagents as sections inside the parent chat, so this keeps their thinking readable while it streams without a second "Thinking".
  • The live "Thinking" for a block with no text yet (Claude takes 2–4 s from block start to its first summary text; Codex reasoning is blank on Orca's settings) shows as "Working…" here until the first text arrives. Temporary: step 2 adds a typed "reasoning is open" signal on the host's live turn activity, driven by the same lifecycle.

Alternatives considered: writing a blank "open" row at block start (rejected: shipped mobile builds draw blank reasoning rows as empty dimmed gaps); asking Claude after start via a control request (rejected: its semantics are unverified and the first turn would race it); relaunching without the flag after a refusal (rejected: the refusal lands after the session is published and the user's first message is already rejected).

Known effect on apps that haven't updated: desktop and mobile builds from before this change still use the old "newest reasoning row" rule. With Claude summaries on, their "Thinking" appears 2–3 s late and stays while tool input streams. That is the same pattern they already show on older Claude versions. Already-shipped mobile builds also draw the summary inline as dimmed text. Both are cosmetic, and updating fixes them.

Linked Issue

Maintainer change; no issue.

Visual Proof

Reading reasoning while it streams (head 09546329717). A stand-in claude replayed a real Claude 2.1.280 recording at 1x into a hidden, isolated test build on a separate Mac; no real Claude or credentials.

Dark Light
While thinking: one "Thinking" line, no row Thinking line, no reasoning row Thinking line, light
Opened: the summary as it streams Opened Thinking line showing the streaming summary Opened Thinking line, light
Block ends: the row takes its place, still open; the line says Working… Thought for 24s row, still open, Working below Landed row, light
Turn over: collapsed rows in the tool-icon column Collapsed Thought rows aligned with Edited rows Collapsed rows, light

Keyboard focus on the live line shows the focus ring and its chevron: Focused Thinking line

An in-page check sampled every 250 ms over five replays (dark, light, keyboard): never a row next to the "Thinking" line for the same block, never two "Thinking" labels, and the live and landed text measure the same colour. The brain icon and the row text start at the same x as the tool rows' icon and label (measured earlier: 518.5 px and 539.5 px).

The reasoning lifecycle, before → after (earlier QA, before the icon and before the line could be opened):

Same real recording of Claude 2.1.280 thinking and then writing a large file, replayed at 1x into a hidden test build of each version on a separate Mac (dark theme unless noted).

While Claude is thinking (15 s into its thinking block)

Before (main) After
Before: Working… while Claude is thinking After: Thinking while Claude is thinking, no row drawn yet

While Claude writes the file (5 s into the Write input, thinking already over)

Before (main) After
Before: full reasoning inline and Thinking stuck during the write After: collapsed Thought for 24s row and Working…

The finished row, expanded (dark and light)

Dark Light
Expanded Thought for 24s row, dark Expanded Thought for 24s row, light

Measured over the whole turn (250 ms sampler): before, "Thinking" showed for 41.5 s and 12.5 s while Claude wrote the two files and never while it was thinking; after, never during the writes, and during thinking from its first summary text (2.3 s and 3.2 s after each block started; step 2 closes that gap).

Testing

  • Automated tests added/updated. Real Claude 2.1.280 and Codex 0.159 sessions were recorded and replayed through Orca's real translators. Replay spans match the recordings exactly (Claude 24.6 s / 6.3 s / 4.65 s / 12.95 s; Codex 5.6–8.5 s). Journal writes per thinking block are bounded by the same throttle streamed text uses (a 2.5 KB block: 14 writes). Every new close path, the version gate, the probe budget, the self-heal and the row-visibility rule has a test that fails when its fix is removed.
  • After merging current main (Oct 5): node, web and mobile typechecks green locally; the 503 test files covering what this PR touches pass (5,215 tests), as does the full mobile suite apart from 6 timing-sensitive tests that also pass when rerun on their own.
  • After the icon, its review fixes and a second merge of main (Oct 5, head e88916d45de): node, cli, web and mobile typechecks and the mobile tests-typecheck check green locally; the affected desktop, main-process and mobile test files pass (408 + 92 tests). New tests cover the icon going through the shared icon list, the chevron's reveal on hover, focus, touch and open, the mobile icon, the Android long-press sheet on opened reasoning, and the Claude exit flag passing through the connection wrapper; each fails with its fix removed.
  • After making the live line readable (Oct 6, head 09546329717, current main merged in): node, web, cli and mobile typechecks green; the affected desktop, shared and mobile test files pass; new tests cover the shared live-line value, the row hidden only while the line shows it, the open state carrying to the finished row, the slot that keeps a turn bar or diff rollup not drawing the hidden row, the single live region, and Android copy on the live text, each failing with its fix removed.
  • CI: fully green on 09546329717 (one test shard rerun after its runner was shut down).
  • Rendered checks (Oct 5 and 6): the same stand-in replay on the separate Mac: the icon before and after, and the readable live line in dark and light, opened and closed, landing, and keyboard focus (screenshots above).
  • Typecheck (node, web, cli) green locally. Mobile typecheck, tests-typecheck and tests green in CI.
  • Manual QA on a separate Mac (macOS): a stand-in claude replayed the real recording above into hidden test builds of main and of this PR, both in isolated profiles, with no real Claude or credentials involved. Checked the activity line over time, the collapsed and expanded rows, light and dark themes, and that the launch passed --thinking-display summarized and never --thinking.
  • Not verified: Windows probe timing and exit ordering (covered by the 1.5 s cap and the existing spawn path); mobile UI on a device, including the live line, the mobile icon and the Android long-press sheet (no test host with a reasoning chat could be paired without touching a live Orca; covered by mobile unit tests); Windows and Linux rendering, a touch screen, a narrow pane, and a real screen reader.

AI Disclosure

Author: @BrennanKB5

Review

Three rounds of independent review (host lifecycle, Codex and wire, launch and renderer, merge audit, design challenge, mobile) plus a release-readiness pass (SSH/remote, crash/growth, security, performance, correctness, backward compatibility, cross-platform, organization), all with no P0/P1 open. Every P0–P2 finding is fixed. A further round on Oct 5 reviewed the icon commit and the merges of main (merge resolutions, layout and accessibility, mobile parity, a design challenge, and readiness). It fixed: the mobile row lacked the icon; the chevron had become hover-only, hiding it from keyboard and touch users; the opened text started left of the headline; the icon bypassed the shared icon list; the first merge had dropped Android's long-press Copy / Select text sheet from reasoning rows; and the second merge would have made every Stop or quit of a Claude chat look like a crash, because the branch's connection wrapper dropped a new exit argument from main (the wrapper now forwards every argument). Deferred P3: the focus ring's left edge overlaps the icon, and the row repeats the tool-run header's styling instead of sharing it; tool rows have the same ring, and a shared header component is the follow-up for both. On Oct 6, reading reasoning while it streams was added to match the common pattern, followed by a full review round (desktop, mobile, a design challenge), a re-review of its fixes, a review of the main merges, and a readiness pass. It fixed: a row that also carried its turn's status bar or file-change summary still drew under the live line, so the text showed twice; the line's condition and the hidden row are now one shared value; the screen-reader region is now kept across label changes; Android could not copy the live text; the mobile line's touch target and indent; the Android copy sheet closing or reopening on its own; and the live text being brighter than the finished row's. Deferred (P3): keyboard focus returns to the page when a block you opened from the line finishes; an open Android copy sheet closes if the line itself goes away at turn end. Earlier deferred P3s: a Claude API retry inflates that block's duration by the retry wait; after a reconnect on older Codex, a measured duration reads "Thought"; with over 256 concurrent Codex items, an evicted row can stay running until the next sweep, though readers never show it live.

Agent skill upstream boundary

  • Not applicable.

Notes

Security: the version check runs the same binary the launch runs, with Claude credentials removed; there are no new dependencies or network calls. Cross-platform: the check goes through Orca's shared process runner (Windows .cmd shims handled). Remote SSH: structured Claude and Codex run on the execution host only; the new row fields are optional and older clients ignore them. Performance: see Testing.

Checklist

  • This PR is small and focused (step 1 of 2)
  • I explained what changed and why
  • Before/after screenshots attached
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered
  • Typecheck and targeted tests pass locally; CI covers the rest

@brennanb2025
brennanb2025 marked this pull request as ready for review September 7, 2026 06:51
@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Claude streaming now translates thinking blocks into bounded reasoning messages and manages their identities and lifecycle. Codex reasoning items now use message bodies with the reasoning role, while plans remain status bodies. Native chat renders reasoning messages in a collapsible row with streaming duration labels and markdown support. Tests cover translation, projection, streaming, reconciliation, truncation, and rendering behavior.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 9bcb7

For turns containing multiple reasoning entries, already-settled entries may incorrectly remain labeled “Thinking...” and display a duration for the full turn. This is a bounded presentation issue but should be corrected before relying on per-block timing.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 14 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ⚠️ Warning The description states that no issue URL was supplied, although the template requires an issue link in every pull request. Link the pull request to the relevant issue using a valid issue reference, such as Fixes #123.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The production changes, renderer updates, localization, projection updates, and tests all support rendering structured reasoning as collapsible transcript messages. No unrelated changes are identified…
Title check ✅ Passed The title clearly summarizes the main changes: reasoning rows now have a real open/finished state, and Claude thinking is readable. It is specific and concise enough to identify the change.
Description check ✅ Passed The description is detailed and covers the template’s required sections, including the user-facing change, rationale, linked-issue exception for a maintainer, visual proof, testing, review, compatibil…
Full details: Docstring Coverage

Explanation

Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 14 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch brennanb2025/nc-reasoning-row

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 9c2fb807-60b9-4aee-b0be-d4f2eeb0423e

📥 Commits

Reviewing files that changed from the base of the PR and between 2ccf35b and 533bb68.

📒 Files selected for processing (14)
  • src/main/claude/claude-streamed-block-identity.ts
  • src/main/claude/claude-streamed-thinking.ts
  • src/main/claude/claude-structured-journal-translation.test.ts
  • src/main/claude/claude-structured-journal-translation.ts
  • src/main/claude/claude-structured-reasoning.test.ts
  • src/main/codex/codex-structured-item-translation.test.ts
  • src/main/codex/codex-structured-item-translation.ts
  • src/main/codex/codex-structured-journal-translation-settlement.test.ts
  • src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
  • src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx
  • src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx
  • src/renderer/src/i18n/locales/en.json
  • src/shared/agent-session-journal-schemas.test.ts
  • src/shared/structured-agent-session-projection.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.

Comment on lines +285 to +290
body: text?.trim()
? {
kind: 'message',
role: 'reasoning',
blocks: [
{ type: 'text', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Trim the reasoning text before bounding it.

text?.trim() only checks whether the body is blank. The original text is still passed to boundInlineText. This preserves avoidable whitespace and allows a large blank prefix to consume the payload limit before the actual reasoning content.

Store the trimmed value and pass it to boundInlineText.

Proposed fix
 function reasoningItem(text: string | null): CodexJournalItem {
+  const trimmed = text?.trim() ?? ''
   return {
-    body: text?.trim()
+    body: trimmed
       ? {
           kind: 'message',
           role: 'reasoning',
           blocks: [
-            { type: 'text', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
+            { type: 'text', text: boundInlineText(trimmed, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }
           ]
         }
       : null,

@pullfrog pullfrog Bot left a comment

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.

ℹ️ One rough edge worth a look — minor suggestion inline.

Reviewed changes

  • Reasoning emission — Claude and Codex structured-journal translation now emit reasoning as message items with role: 'reasoning' (bounded text blocks) instead of kind: 'status'.
  • Claude streamed thinking — new createClaudeStreamedThinking reuses the block registry and text checkpoints for thinking deltas; createClaudeStreamedBlockRegistry is generalized with a blockType parameter.
  • Collapsible rendering — new NativeChatReasoningRow collapses reasoning behind a first-line summary, mounting CommentMarkdown only on expand; MessageRow early-returns it for role === 'reasoning'.
  • Tests — new coverage for both providers, journal admission, the shared projection, disclosure states, blank reasoning, and plan fallback.

The change is clean and well-scoped: the wire schema already accepts role: 'reasoning', the shared projection passes it through, and mobile already renders it, so desktop now aligns with mobile. Single-thinking-block reconciliation is correct.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

return false
}
const identity =
blocks.reconcile(envelope) ?? claudeThinkingIdentity(envelope.sessionId, envelope.uuid)

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.

finalize reconciles only the first streamed thinking block (blocks.reconcile shifts one identity from awaitingFinal), but claudeThinkingText joins every thinking part in the envelope into a single body. If one assistant message carries multiple thinking blocks (interleaved thinking), streaming mints one identity per block, so the second and later blocks' streamed rows are never reconciled or forgotten — they linger as duplicate partial reasoning beside the joined final row.

Technical details
# Multi-thinking-block reconciliation gap

## Affected sites
- src/main/claude/claude-streamed-thinking.ts:54 — `blocks.reconcile(envelope)` consumes only one identity, while `claudeThinkingText(envelope)` (claude-structured-item-translation.ts:159) joins all thinking parts with `\n`.

## Required outcome
- Either reconcile/forget one identity per thinking block in the envelope, or collapse a message's thinking into a single streaming identity so the final join has exactly one row to land on. The streaming and final paths must agree on how many reasoning rows one message produces.

## Open questions for the human (optional)
- Is interleaved thinking (multiple thinking blocks per message) a supported input today? If not, this is latent; if it can be enabled, it produces duplicate rows.

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No new issues in the latest commit — the prior minor finding is unchanged and still applies.

Reviewed changes

  • Main sync — b51f4f8 is a merge of origin/main into the branch. It brings in the structured-agent-session-adoption-replay test that the PR body flagged as failing on shard 6 (a test introduced on main and previously absent from this branch), so that CI integration failure is resolved by the merge rather than by a change to this PR's code.
  • No PR code delta — the branch's 14 changed files are byte-for-byte identical to the prior head 533bb68; the only diff offset is the en.json hunk shifting by 4 lines (4 unrelated upstream entries added to main).

The multi-thinking-block reconciliation gap flagged in the prior review (createClaudeStreamedThinking.finalize reconciles one identity while claudeThinkingText joins all thinking parts) is untouched by this merge and remains the single open item.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Manual UI validation — reasoning row (Electron, CDP, hidden renderer)

Driven in a real Orca dev build from this branch on an isolated dev profile (ORCA_BACKGROUND_LAUNCH=1, private renderer port, CDP screenshots of a hidden window — no focus steal, no OS input). The pane is a real native-chat surface reading a real Codex transcript through the production decoder, so every row below went through decodeCodexTranscriptLine → MessageRow → NativeChatReasoningRow.

Verdict: all five behaviors hold, in both themes.

1. Collapsed by default, with a meaningful one-line summary (dark)

collapsed dark
The reasoning row sits between the user prompt and the assistant prose as its own row — chevron + left rule + muted text — and is data-state="closed" on first render; the summary is the reasoning's whole first line, not a mid-sentence truncation.

2. Expanded reveals the full reasoning body (dark)

expanded dark
Clicking the trigger rotates the chevron and reveals the complete reasoning text with its markdown (paragraphs and the ordered list) intact, rendered italic/muted so it still reads as an aside rather than assistant prose.

3. Collapsing works

collapsed again dark
A second click returns the row to data-state="closed" and hides the body again — the transcript is back to the one-line summary.

4. No thrash or flicker while streaming

streaming stays open
With the first row left open, 14 further frames were appended to the live transcript. A MutationObserver pinned to that row's DOM node recorded 0 removals and 0 spontaneous data-state flips, and the same node stayed mounted and open throughout — the only state change in the whole run was the click I made myself. New reasoning arriving mid-stream (rows 2 and 3 here) correctly appears collapsed without disturbing the open one.

5. Empty / whitespace-only reasoning produces no row at all

empty vs normal
Turn 1 carries a normal reasoning payload and gets a row. Turn 2 carries a whitespace-only reasoning payload and renders nothing — no chevron, no rule, no empty strip. The reasoning row count stayed at 1 while the frame that followed the blank payload rendered, proving the payload was ingested and then deliberately dropped rather than never arriving.

Both themes

collapsed light
Light theme, collapsed: the reasoning rows stay muted with their left rule while assistant prose stays full-contrast body copy and the system line (Conversation interrupted) stays smaller and chrome-free — three visually distinct registers.

expanded light
Light theme, expanded: same disclosure behavior and the same muted/italic treatment for the revealed body.

Notes for the author

  • Minor, non-blocking: the summary line is repeated as the first line of the expanded body (visible in screenshots 2 and 7), so the reader sees the same sentence twice on expand. Dropping the first line from CollapsibleContent, or leaving the trigger as a static label, would read cleaner.
  • No regression seen in the surrounding transcript: user prompt cards, assistant prose, the system aside, and the composer all render as before.
  • A sidebar error card unrelated to this PR appeared once from my synthetic folder workspace (trimStart in sidebar.worktrees); the string does not appear anywhere in this diff. It is a rig artifact and was dismissed before the screenshots.

@brennanb2025

brennanb2025 commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Status: ready for review

Plan review

Reviewed before any code was written. APPROVE WITH AMENDMENTS, all applied. The load-bearing question was wire safety, and it was proven rather than assumed: MessageBody validates role as z.string().min(1) — an open string by deliberate design — so emitting role: 'reasoning' is a safe additive change and needs no new journal item kind. Amendments applied: reuse the existing prose pipeline rather than forking a second markdown path; pin the Codex plan journal body byte-for-byte with a test, since a parallel lane owns that branch; and emit no row for whitespace-only reasoning on both providers, not just one.

Code review loop

Same-model subagent review returned clean. readiness-checklist run at start and end — both PASS, including the mobile backward-compatibility subcheck. 122 focused tests pass.

Verification

  • Central typecheck: exit 0.
  • Merged with origin/main, two parents verified.

Manual UI validation

Real Orca dev build, hidden renderer over CDP (ORCA_BACKGROUND_LAUNCH=1, isolated profile, no window shown or focused). 7 screenshots attached above. All five behaviours hold in both themes:

  • renders as its own row, distinct from assistant prose and from the system aside
  • data-state=closed on first render, with the reasoning's first line as the summary
  • expands to the full markdown body and collapses again
  • no thrash while streaming — a MutationObserver pinned to the open row recorded 0 removals and 0 spontaneous state flips across 14 appended stream frames
  • whitespace-only payload renders no row at all

Process deviations, stated plainly

  • The review loop's $orchestration arm was not used. Nested worker depth is 1 in this install, so a dispatched worker cannot dispatch its own workers (nested_worker_depth_exceeded). The UI validation lane was run directly by the coordinator at depth 0. The substance of the loop — subagent review to clean, readiness bookends, independent UI validation with visual evidence — was carried out in full.
  • readiness-checklist's severity-counsel step was unavailable for the same depth reason, and is recorded as unavailable rather than reported as a verdict.
  • Validation ran on Claude, not the originally-planned agent, which ran out of quota mid-programme.

Known-red, not caused by this PR

structured-agent-session-adoption-replay.test.ts:188 fails on a clean detached origin/main checkout — reproduced independently by two lanes, byte-identical failure. It is a pre-existing break on main and was deliberately not touched.

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No new issues in the latest commit — the prior minor finding is unchanged and still applies.

Reviewed changes

  • Main sync (11b9b9b) — merged origin/main, pulling in #19218 (hover/focus message timestamps) and #19226 (subagent-group and tool-row identity), both of which touch NativeChatMessageRow.tsx.
  • Reasoning re-integration — the reasoning early-return to NativeChatReasoningRow survived the merge intact; reasoning rows render the collapse toggle but still omit the timestamp and agent controls.
  • Test strengthened — NativeChatMessageRow.test.tsx now asserts reasoning rows render exactly one button (the collapse toggle) and no time/copy/scroll controls, replacing the prior "zero buttons" chrome-free assertion.

The claude-streamed-thinking.ts multi-thinking-block reconciliation gap from the prior review is untouched and remains the single open item.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Fresh alignment screenshots — these supersede the earlier alignment shots above.

The expanded reasoning uses pl-5 (20px), exactly matching size-3.5 (14px) + gap-1.5 (6px). In both themes, the summary and first body paragraph measured at the same x coordinate (415px). The rail, italics, summary, and disclosure behavior are unchanged.

Captured through Playwright CDP in this branch’s existing hidden Electron instance, launched with ORCA_BACKGROUND_LAUNCH=1; no window was revealed or focused. A temporary Codex transcript was loaded through the production decoder into the real native-chat pane, temporarily mounted in the app’s content area with surrounding chrome. The temporary pane and transcript were removed afterward; no fixture code is included in the change.

Dark, collapsed: summary starts past the chevron.
Dark collapsed

Dark, expanded: full reasoning begins directly under the summary text.
Dark expanded

Light, collapsed: the same summary inset.
Light collapsed

Light, expanded: matching text edges with the existing italic treatment.
Light expanded

Validation: focused NativeChatReasoningRow and NativeChatMessageRow suites passed (12 tests); changed-file oxlint passed. Typecheck remains with the coordinator.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Fresh reasoning-row screenshots — these supersede all earlier reasoning-row screenshots on this PR.

The left rail and its padding are removed. The summary starts at the row’s left edge, the nonshrinking chevron sits at the right edge, and the expanded italic body uses pl-4 (16px) relative to the summary. Existing disclosure rotation, reduced-motion class, accessible label, and summary derivation are unchanged.

Captured the real native-chat pane through Playwright CDP in this worktree’s hidden Electron instance with ORCA_BACKGROUND_LAUNCH=1, without revealing or focusing a window. A temporary Codex transcript was loaded through the production decoder; the temporary pane and transcript were removed afterward.

Dark, collapsed — no rail; chevron points right at the end of the summary row.
Dark collapsed

Dark, expanded — chevron points down; italic reasoning is indented 16px from the summary.
Dark expanded

Light, collapsed — matching rail-free layout and trailing chevron.
Light collapsed

Light, expanded — matching body indentation and italic treatment.
Light expanded

Validation: both focused reasoning-row/message-row suites passed (12 tests), and changed-file oxlint passed. At a temporary 300px trigger width, the summary truncated with ellipsis and the chevron stayed 14px wide at the right edge. Typecheck remains with the coordinator; sending messages and SSH execution were outside this visual check.

@brennanb2025

brennanb2025 commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor Author

This supersedes all earlier reasoning-row screenshots on this PR, including the initial version of this comment. Final code: ab0675aed2.

The headline uses fixed, muted 13px copy: Thinking..., an interpolated locally observed duration, or Thought for a few seconds for replayed history. The first reasoning line appears only after expansion.

Duration follows this block’s own locally observed text revisions. The row starts on first rendering active and records its last text revision; settlement freezes that interval. Later answer/turn work is excluded, and already-settled history never receives a number. No journal timestamp, receipt timestamp, or turn-duration value is used.

Fresh full-window screenshots were captured through CDP from this worktree’s hidden Electron renderer with ORCA_BACKGROUND_LAUNCH=1, using controlled session data through the production message list and composer. The timed state shows Thought for 14s while the separate turn row says Worked for 17s, because three seconds of later turn work were excluded. Live agent sends and SSH transport were not exercised.

The expanded body stays italic with a 16px indent. The chevron remains 14px wide at the right edge and rotates to 90° when expanded, with no left rail. The existing reduced-motion guard is preserved; the CDP media-emulation probe still reported a 0.15s transition, so runtime reduced-motion behavior is not claimed as verified.

Focused reasoning/message-row suites: 15 tests passed, including a regression for reasoning finishing before the turn. Changed-file oxlint and React Doctor lint passed; no pnpm tc was run.

Replayed history, collapsed: no inferred number.

Dark Light
dark collapsed light collapsed

Replayed history, expanded: reasoning stays visually secondary.

Dark Light
dark expanded light expanded

Observed streaming: Thinking... uses the existing pulse treatment.

Dark Light
dark streaming light streaming

Settled: 14s of observed reasoning, excluding later turn work.

Dark Light
dark timed light timed

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: a6a4b243-87ad-44e3-bdd9-a977658f3a6f

📥 Commits

Reviewing files that changed from the base of the PR and between d09f3b8 and 9bcb747.

📒 Files selected for processing (4)
  • src/renderer/src/components/native-chat/NativeChatMessageRow.tsx
  • src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx
  • src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx
  • src/renderer/src/i18n/locales/en.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/i18n/locales/en.json

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.

<div ref={rowRef}>
<NativeChatReasoningRow
blockId={message.id}
isStreaming={activeTurnIsWorking}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Description: Inspect how activeTurnIsWorking is supplied to MessageRow.
rg -nP -C 6 '<MessageRow' --type=tsx --type=ts src/renderer/src
rg -nP -C 4 '\bactiveTurnIsWorking\b' src/renderer/src --type=ts --type=tsx

Repository: stablyai/orca

Length of output: 215


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
fd -t f 'NativeChatMessageRow|MessageRow|native-chat' src/renderer/src | head -80
printf '%s\n' '--- activeTurnIsWorking references ---'
rg -n -C 8 '\bactiveTurnIsWorking\b' src/renderer/src
printf '%s\n' '--- NativeChatMessageRow outline ---'
ast-grep outline src/renderer/src/components/native-chat/NativeChatMessageRow.tsx

Repository: stablyai/orca

Length of output: 50369


🤖 get_repo_knowledge executed:

get_repo_knowledge stablyai/orca /tmp/coderabbit-repo-knowledge/stablyai-orca-89dc44e4/learnings

Length of output: 15364


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- MessageList render path ---'
sed -n '180,260p' src/renderer/src/components/native-chat/NativeChatMessageList.tsx
printf '%s\n' '--- reasoning row implementation ---'
fd -t f 'NativeChatReasoningRow' src/renderer/src/components/native-chat
sed -n '1,220p' src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx
printf '%s\n' '--- message assembly and reasoning-message shape ---'
rg -n -C 5 'role:\s*['"'"'"'"'"]reasoning|reasoning.*message|type:\s*['"'"'"'"'"]reasoning' src/renderer/src/components/native-chat --glob '*.ts' --glob '*.tsx' | head -160

Repository: stablyai/orca

Length of output: 7730


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- reasoning message producers ---'
rg -n -C 4 "role: ['\"]reasoning|role:\s*['\"]reasoning|role:.*reasoning" src/renderer/src/components/native-chat --glob '*.ts' --glob '*.tsx' | head -180
printf '%s\n' '--- turn grouping and message list inputs ---'
rg -n -C 5 'messages\s*=|NativeChatMessage\[\]|turnKeys|group.*turn|reasoning' src/renderer/src/components/native-chat/NativeChatMessageList.tsx src/renderer/src/components/native-chat/native-chat-message-grouping.ts src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts | head -220

Repository: stablyai/orca

Length of output: 10437


Limit isStreaming to the active reasoning message.

NativeChatMessageList passes the same current-turn activeTurnIsWorking value to every MessageRow. If a turn contains multiple reasoning messages, every NativeChatReasoningRow shows Thinking... while the turn is active. Each row then measures elapsed time until the turn ends, not only for its own block. Pass true only to the latest reasoning message, or derive it from message-level lifecycle state.

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No critical issues — one minor, defer-able observation inline.

Reviewed changes

  • Timing labels replace the summary headline — NativeChatReasoningRow now shows Thinking... (pulsing) while active, Thought for {{duration}} on settlement, and Thought for a few seconds for replayed history, instead of a 120-character first-line excerpt.
  • Local per-block observation clock — the row tracks startedAt/elapsedMs keyed by blockId, freezing the observed duration when the row settles; MessageRow forwards message.id and activeTurnIsWorking.
  • Tests and i18n — five new deterministic Date.now-mocked timing tests plus three native-chat locale keys; the earlier summary-length/first-line tests were rewritten to match.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

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.

ℹ️ ab0675a cleanly resolves the turn-scoped duration concern — no new issues in this delta. Approval is still held by one pre-existing open finding.

Reviewed changes

  • Duration excludes later turn work — NativeChatReasoningRow now freezes lastUpdatedAt at the block's last text revision and computes elapsedMs = lastUpdatedAt - startedAt, so tool execution and final prose no longer inflate Thought for. The effect deps gain markdown.
  • Mocked-clock regression test — excludes later turn work after the block stops changing pins 12s (not the full turn span) under mocked Date.now.

Remaining open item (unchanged by this delta)

  • createClaudeStreamedThinking.finalize reconciles a single streamed identity while claudeThinkingText joins every thinking part, so an interleaved multi-thinking-block message would orphan the later streamed rows. Latent today — the app does not currently emit interleaved thinking — but still open.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No new issues in this merge — the prior multi-thinking-block finding is unchanged and still applies.

Reviewed changes

  • Main sync (0c80264) — merged origin/main, pulling upstream refactors into the Claude and Codex structured-journal translators (appendUnmodeledClaudeContent now owns unmodeled content; Codex plan items gain a presentation: 'plan-document' field).
  • Reasoning re-integration — the PR's reasoning emission survived the merge: Claude thinking still flows through streamedThinking.finalize, and appendUnmodeledClaudeContent skips thinking parts because isModeledClaudeContent returns true for them, so nothing is emitted twice.
  • Verification — 152 tests pass across the touched Claude/Codex/shared/renderer suites; the PR's own behavioral surface is unchanged from ab0675a.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Re-home the streamed-thinking rows onto main's split translator: the
streamed reasoning row now carries the open turn's scope and its
producer's subagent linkage, a thinking delta opens the turn like a text
delta does, and the finished-message reasoning row reconciles onto the
streamed row. Codex keeps main's typed reasoning message and omits blank
reasoning text.
A reasoning row now says whether its block is still streaming (`state`)
and, when the host saw it end, when (`completedAt`). The row's own
start is its first-write time, so "Thought for N s" is measured on the
execution host instead of by whichever window happened to be watching.

Rows open with their first non-empty text and close on every path that
ends them: the block's final frame, a new message in the same stream,
every Claude turn end through one hook on the open turn, Codex
item/completed, turn and session settlement, active-item eviction, and
both host sweeps for a dead generation. Closing writes are lifecycle
writes so backpressure cannot leave a row open. Rows without the field
(older hosts, older journals) never read as live.

The reasoning row's headline reads the row's lifecycle and its own
turn's liveness: Thinking while open in a running turn, then Thought
for N s, Thought when no span was seen, Reasoning when the host kept no
lifecycle.
Under Orca's launch the Claude CLI streams thinking blocks with empty
text, so no reasoning row ever had anything to show. Pass only
`--thinking-display summarized`: it fills thinking blocks with the
API's summaries without turning thinking on, so a user who disabled
thinking keeps it off.

Orca runs the user's own binary, and a CLI older than the flag exits on
it before the session starts. The launch probes the binary it is about
to run, overlapping the rest of launch resolution, and passes the flag
only when that probe has already answered with 2.1.94 or newer. A slow
or failed probe never delays a launch and is never remembered; a
successful one is kept per binary until the binary changes.
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Adds reasoning message display to chat UI.

The PR does not appear safe to merge while the Claude capability cache can reuse a result across launch environments that select different CLI versions.

Findings

  1. P1 CLI capability cache ignores environment ▶
  2. P2 Replay label misses fallback ▶

Summary

The PR renders structured Claude and Codex reasoning as collapsible transcript messages and carries reasoning lifecycle information through the journal to desktop and mobile. Since the previous review, it has consolidated the providers’ reasoning-body helpers and adjusted Codex retention ordering. No new actionable issue was established in those latest changes.

Reviews (5) · Last reviewed commit: "test(claude): read the launch PATH the w..."

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No new issues in the latest commits — the icon, Android long-press, and exit-argument changes are clean. One minor test-coverage suggestion.

Reviewed changes

  • Brain glyph on the reasoning row — brain joins the shared NativeChatToolIconName vocabulary and the row leads with NativeChatToolRunIcon iconName="brain", drawn through the same 16px slot a tool row uses; mobile leads with the same Brain at 15pt and indents the opened text to match.
  • Chevron reachable without hover — the row's chevron now hides only behind can-hover:opacity-0, so touch and keyboard users still see it; group-focus-visible/reasoning and group-data-[state=open]/reasoning reveal it. This matches the established hover-reveal ordering already in use (HostSectionHeaderMenu, TabBarQuickCommandItem).
  • Android long-press on opened reasoning — the actions sheet is restored on reasoning rows: Content moves to MobileNativeChatLongPressContent, the expanded body renders through it, and the reasoning branch renders MobileNativeChatMessageActionsSheet so a long press on the opened text offers Copy / Select text.
  • Claude exit arguments forwarded — openClaudeConnectionOf's onExit wrapper now spreads every argument to the session handler rather than forwarding a fixed pair, so an argument the connection adds later still reaches the session.

ℹ️ Nitpicks

  • The new hover-reveal control in NativeChatReasoningRow.tsx is not in HOVER_REVEAL_FILES in hover-reveal-touch-action-visibility.test.ts, the ratchet that keeps a hover-only hide off touch devices. Adding the path (one line) would lock in the can-hover: gate this row now depends on.

The pre-existing createClaudeStreamedThinking.finalize multi-thinking-block reconciliation gap (thread PRRT_kwDORpCz1s6f0K_V) is unchanged and still latent; it is not re-raised here.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…ning-row

Conflicts in claude-structured-launch-resolution(.test).ts: take main's opaque provider handle
(head.nativeId) and keep the branch's early cwd/env resolution for the thinking-display probe.
The branch's reasoning-sweep test builds its identity with codexProviderHandle, main's new shape.

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No new issues in this delta — the two origin/main merges resolve cleanly. Approval is still held only by the pre-existing open multi-thinking-block finding on claude-streamed-thinking.ts, which this delta did not touch.

Reviewed changes

  • Main sync (0362442292) — merged origin/main, adopting main's opaque provider-handle API in claude-structured-launch-resolution.ts (head.nativeId, agentSessionProviderHandleRoot, claudeProviderHandleLeafUuid) while keeping the branch's early cwd/env resolution for the thinking-display probe. git diff 086b06c1d6..HEAD on that file is exactly the branch's probe additions on top of main, so the conflict resolution lost nothing. The reasoning-sweep test now builds its identity with codexProviderHandle.
  • Main sync (616255036b) — merged origin/main, bringing the relay director deploy driver and a codexProviderHandle test fix; no PR code delta.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…ning-row

Conflict in agent-session-journal-schemas.ts: both sides extracted a schema. Use main's
AgentSessionFailureFactSchema and the branch's AgentJournalThreadGoalStateSchema (each identical to
the inline schema it replaces), keep main's answered-turn import and the branch's message state.

@pullfrog pullfrog Bot left a comment

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.

ℹ️ No new issues in this merge — the conflict resolution is behavior-preserving. Approval remains held only by the pre-existing open multi-thinking-block finding on claude-streamed-thinking.ts, which this merge did not touch.

Reviewed changes

  • Main sync (c6253cc431) — merged the current origin/main (9756dae). The only authored conflict resolution is in src/shared/agent-session-journal-schemas.ts, where both sides had extracted an inline schema: the merge takes main's AgentSessionFailureFactSchema and AgentJournalAnsweredTurnSchema while keeping the branch's AgentJournalThreadGoalStateSchema and the message body's state/completedAt. Each import replaces an identical inline definition, so the wire behavior is unchanged.
  • Both sides preserved — codex-structured-session-state.ts carries the branch's CODEX_RECEIPT_TIMED_METHODS alongside main's answeredInTurn dispatch-settlement field, and the locales keep the branch's reasoning/thought/thoughtForDuration keys alongside main's new translations.
  • Verification — agent-session-journal-schemas.test.ts (36 tests) and pnpm run verify:localization-catalogs pass; the merge adds no net behavioral change.

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…"Thinking" line

While the agent's open reasoning block has text, the turn's live activity line is its
disclosure: collapsed by default, expandable to the live text (capped and scrollable), and
the block's row draws nothing meanwhile. When the block ends, its row appears in place,
open if the reader opened it live, because the line and the row read one disclosure key.
Which block the line discloses is derived from the line's own render condition, so a row
is never hidden while nothing on screen shows it; any other open block (a subagent's, or
one a prompt pushed off the line) draws as "Reasoning". Desktop and mobile alike; no host
or wire change.
…er draws its message

The transcript row drew the message of every message slot, while the slot builder pushes a slot
for a row it declined to draw whenever that row also carries its turn's bar or diff rollup. So the
open reasoning block the live line discloses still drew as a "Reasoning" row when it was a
provider-opened turn's first row or the last row of a turn that changed files, and one click
opened both. The builder's decision now travels on the slot (`drawsMessage`) and the row draws
only the bar and rollup when it is false; the row-level `folded` guard it made redundant is gone.
… one live region

Desktop and mobile now render the live activity line from one pure function,
`nativeChatLiveLine`: whether it draws, what it says, and the open reasoning block it
discloses with the text it has so far. The open block's row is hidden from that same value,
so desktop no longer restates the line's render condition beside it, and the lines no longer
re-derive the text.

The line keeps one element, and so one live region, through every state; only its trigger
and body come and go, so a screen reader hears "Thinking" and the label after it. On mobile
the live text gets the finished row's Android long press (copy or select through the message
actions sheet), the line's touch target is the row's 44 pt, and its label and body sit in
the finished row's column so nothing moves when the row takes over.
…pened for

On Android the sheet opened by a long press on the live reasoning was a flag gated on a live
block: it vanished when the block ended, mid Select text, and the stale flag reopened it
unprompted on the next block. The sheet now holds the message it was opened for.
…5/nc-reasoning-row

Conflicts:
- journal schemas: main's shared turn-lifecycle fields kept; the thread goal stays in its own module.
- journal terminal settlement and the dead-generation sweeps: main's turn-aware call ending
  (`terminalAgentJournalBody`/`runningCallEnd`) plus the branch's ending of an open reasoning row.
- Codex settlement: main's turn end for running calls is now an option of the branch's
  `interruptedCodexItemBody`, beside the end time that closes reasoning; the turn passes both.
- runtime: main's agent registrations; the Claude registration now carries the thinking-display
  support the branch's runtime deps hold (it was dropped, so no Claude launch asked for readable
  thinking), with a runtime test.
- renderer: main's row typography threads through the branch's slots hook; the live line takes main's
  text-size class; the reasoning row and body follow main's restyle (chat text size, faint tone,
  upright prose through NativeChatMarkdown); the collapsed reasoning estimate stays beside main's
  typography-scaled line height.
…ning-row

# Conflicts:
#	src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx
#	src/shared/native-chat-types.ts
#	src/shared/structured-agent-session-projection.ts
…nded

Rendered QA on a pre-merge build showed the live line's open reasoning in full foreground and the
landed row's in muted text, so it dimmed as the block landed: the body set no colour of its own and
inherited one from wherever it was mounted. Since the main merge (017ad74) the body carries the
chat's faint tone itself; this pins that, and that the line and the row draw the same body.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review and QA, Oct 6: reading reasoning while it streams

This replaces the Oct 5 comment, which described the earlier design.

The problem. Before this change, a reasoning block's row was hidden while it streamed, and the activity line just said "Thinking". So you couldn't read what Claude or Codex was thinking until the block ended. Main today draws reasoning inline as it streams (when it has text), and the common pattern lets you open the live indicator and read it. This PR had taken that away.

What changes for you.

  • While thinking: the single "Thinking" line on the activity line can be opened (click, keyboard, or tap on mobile) to read the summary as it streams, height-capped with its own scroll.
  • When the block ends: a "Thought for N s" row with the brain icon takes its place, still open if you opened it, and the line goes back to "Working…". The next block starts closed.
  • Labels: "Thinking" is never shown twice, and a row is never hidden unless the line is showing it. Any other unfinished block, a subagent's or one whose line gave way to a question card, draws in place as an openable "Reasoning" row.
  • Mobile: the same, including Android long-press Copy / Select text on the live text.
  • Blank blocks: still never drawn. A block with no text yet still reads "Working…" until its first text arrives; the stacked step-2 PR's live signal covers that gap.

How. One shared value (src/shared/native-chat-live-line.ts), used by desktop and mobile, decides what the line shows and which row it hides, so the two can't disagree. The line and the finished row share one open/closed key. No host or wire change, so older apps are unaffected.

Fixed during review (a full round on the design, a re-review of its fixes, a review of the main merges, and readiness):

  • A row that also carried its turn's status bar or file-change summary still drew under the live line, so the text showed twice.
  • The line's condition was restated by hand on desktop; it is now the shared value.
  • The screen-reader region was rebuilt on every label change.
  • Android couldn't copy the live text.
  • The mobile line's touch target and indent.
  • The Android copy sheet vanished when the block ended and reopened on its own on the next one.
  • The live text was brighter than the finished row's, so it dimmed on landing.

Earlier merges of main also needed a line-limit split in a Codex file.

Deferred (P3):

  • Keyboard focus returns to the page when a block you opened from the line finishes.
  • An open Android copy sheet closes if the line itself goes away at turn end.
  • Smaller items are in the review notes.

For the stacked step-2 PR (#24545):

  • It should only swap where "Thinking" comes from, to its host signal.
  • It should drop its own row-hiding code.
  • It should drop its per-subagent "Thinking" labels: no common pattern shows a second live indicator.
  • It should rewrite its "no row while a block is open" note.

Verified:

  • Rendered check: a hidden, isolated test build on a separate Mac replayed a real Claude 2.1.280 recording at 1x through a stand-in claude (no real Claude or credentials), in dark, light and keyboard focus, over five replays.
  • In-page sampler: never found a row next to the line for the same block or two "Thinking" labels, and the live and landed text measured the same colour.
  • Checks: typechecks and the affected tests pass locally, and CI is fully green on 09546329717.
While thinking Opened, streaming Landed, still open Turn over
Thinking line Streaming summary Thought for 24s, open Collapsed rows
Thinking line, light Streaming summary, light Landed, light Collapsed, light

Not verified:

  • Mobile on a device: no test host with a reasoning chat could be paired without touching a live Orca; mobile is covered by unit tests.
  • Other platforms and assistive tech: Windows and Linux rendering, a touch screen, a real screen reader.
  • Codex reasoning rows: not checked rendered.

@brennanb2025
brennanb2025 merged commit 7faa9f7 into main Oct 6, 2026
53 of 55 checks passed
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
… that owns it

Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which
this branch split it out of.
brennanb2025 added a commit that referenced this pull request Oct 7, 2026
…otocol (#25225)

* Leave a stopped turn's running tools to the agent's own end

When another writer settles the open turn (a person's Stop), the assembler
now only stops that turn's text and cancels its pending prompts. Running tool
calls stay the agent's: a progress update or completion it reports after the
Stop lands as reported, and whatever is still running settles at the agent's
turn end for that turn, the next turn's open, or the session's end.

An agent's end for an earlier turn while a newer one is open no longer clears
the open turn's activity line or ends its anonymous reply. An unnamed end right
after a Stop ends the stopped turn instead of being dropped. The test rig's
restart no longer writes the dead assembler's window text, matching dispose.

* Pin that a stopped turn's running tools hold budget until the agent's end

* Type the stopped turn's tool progress update as a tool body

* List every event the assembler hands to the decision step

The type-aware lint requires an exhaustive switch with no default case.
Also retitle a Stop test to say what it asserts.

* End a running call as its turn's journal row ends

A call still running when its turn ends takes the state of that turn's
row: a row another writer settled first (a person's Stop) stands, so its
calls read interrupted whatever the provider's later end reports. The
no-ending path that settled calls from the Stop row is gone, since a Stop
now leaves running calls to the provider. Adds the two Spanish strings.

* Say why a Grok turn failed, and keep task rows in Grok's own words

A failed Grok turn ended with no reason on screen: the translator dropped
every copy of Grok's message. The failed turn now gets one status row in
Orca's existing "provider did not accept this message" words with Grok's
reason, read from whichever copy arrives first (the given-up retry, the
turn's end, the prompt's completion notice, or the prompt's error answer);
later copies only fill a reason the row still lacks.

A running background command no longer reads "Background task <id>
started": a task's summary is mapped only once it has settled. A monitor
stays a monitor when the agent reads its output: a frame that names no
kind keeps the known one, and a "[monitor" command is a monitor.

A prompt's turn is marked started, so a late frame for an ended prompt
neither reopens it nor becomes the active turn. A tool's turn is held in
one place at a time.

* Read a monitor from Grok's exact output prefix

* Word a failed Grok turn in Grok's own text, not as a refused message

A turn that started and then failed was told "The provider did not accept
this message", Orca's sentence for a message refused before its turn. The
row now reads as a Codex turn-ending error does: an error status row with the
provider's own words. With no words, the dialect names the failure ("Grok
ended this turn with an error." / "Grok usage limit reached."), else the
agent's display name does.

* Settle a stopped turn's running call as its turn row ended after a restart too

The restart sweep ended every running call by the death evidence alone, so after
a person's Stop with no proof the child died the call read failed under a turn
that read interrupted. The sweep and the live dead-generation settlement now ask
the same rule the assembler does: a call in a turn already settled ends as that
row ended; only a turn still running leaves its calls to the evidence.

* Keep the dead-generation settlement under the line cap

* Register the ACP schema verify step in the PR preflight phase test

* refactor(agent-session): one required agent registry; declarations admit what they claim

A StructuredAgentRegistry, built once from the {definition, adapter}
registrations, is now a required host dependency and the router routes with
it. The adapter interface loses its optional router-only capabilities?() and
definition?(); every reader (options at rest, thread goal, rewind, the
/compact handover) asks the registry. A live session still narrows rewind
through the adapter, and the host combines declared and narrowed in one
helper. The registry refuses a registration that declares compact, a thread
goal or rewind without the adapter method behind it.

/compact is admitted by the declared capability, so an agent that declares
compact:false gets the commandRefused fact instead of a thrown error.

Also: the cut-turn notice names the agent from the catalog, create-support
builds the account home through agentSessionAccountHome, and the turn
status text older clients read names the session's agent instead of
defaulting to Codex (Claude/Codex text unchanged).

* Read ACP permissions, session events and prompt errors through the protocol client's own types

The translator now reads a permission request with the client's lenient reader, a session update
with its session-event reader, and takes only the agent's own error answer as a failed prompt's
reason, so an Orca-side error never reads as the provider's words. Tests cover protocol values
newer than this build.

* refactor(agent-session): the router applies the declared rewind itself

The router holds the registry, so its rewindSupport answers the owner's
declared rewind narrowed by the adapter, in one place no reader can bypass.
The host-side combining helper is gone; readers ask the adapter they hold.

* test(agent-session): register the agents the merged-in tests now need

The adoption replay test builds its host over this build's registered agents
instead of an adapter definition method, and the stored-form test passes the
stored agents the record guard now requires.

* chore(agent-session): the registry is the one lookup; drop the test-only empty capability record

* fix(agent-session): keep "dismiss all" restart offers dismissed for the desktop

The desktop renderer calls the restart methods as a paired runtime client
without the registered-agents capability, so it was handed a Claude/Codex
audience and its "dismiss all" took the scoped path: no dismissal fence and
no clearing of unwritten teardown witnesses, so a late teardown write could
bring a dismissed offer back.

The audience is now derived against this host's registry: a client that can
show every agent the host registered gets none, and dismisses exactly as
before (one fence for every offer). A client that truly cannot show some
agent still gets a final dismissal for what it sees: each cleared session is
fenced on its own (bounded, superseded by a dismiss-all fence) and only the
witnesses of agents it sees are dropped.

One predicate now answers which agents a client renders, for both tab
projection and restart offers; the Claude capability rule is folded in.

* fix(agent-session): a changed agent definition never hides that agent's chats

A record was readable only if every handle used the transport its agent's
definition declares today, and its account variable was declared by some
registered agent. A later build that drives the same agent over another
protocol, or under a renamed variable, would have set every existing chat of
that agent aside: no tab, no history, no error.

Readability now asks only that the record agree with itself: its agent is
registered, every handle shares one namespace owned by that agent, and the
account variable is a well-formed name. Whether this build can drive it (the
chain's transport is the one the agent speaks, and the account variable is
that agent's own, since it becomes the child's environment) is decided in
performAttach, the one admission every start of every agent passes through,
and refused there as hostUnsupported. A record pinning another agent's
variable is refused the same way rather than passing because some agent
declares it.

* refactor(agent-session): each agent's registration says where it runs and which account it pins

createSupport, create and the model catalog's account read still chose a
location rule and an account resolver by name (Claude, Codex, else none),
so a new agent would have needed a third branch beside the registration
list that already decides storage, routing and publication.

Each runtime registration now carries supportsLocation(location) and
resolveAccountHomePath({ launchEnv, location, purpose, workspacePath }),
and Claude's and Codex's rules move into their entries unchanged: a read
still syncs no home and starts no bridge, a Codex launch still trusts the
workspace first, and WSL locations resolve as before. The list exists
before the host is built, so none of these reads installs the host, and an
agent the list does not hold is answered no without opening the journal.

* test(agent-session): drop a duplicate registry key; keep fences out of the capsule state

A spread input already carries the registry, so the explicit key was
overwritten (TS2783). The session-fence helper now returns only the fence,
not the capsule state it was given.

* fix(agent-session): a scoped dismiss-all persists no per-session fence

The per-session dismissal fence added a forever-persisted capsule field
with an arbitrary cap that served no caller: only the local desktop calls
the restart methods, and on a host whose agents it all shows it gets no
audience and takes the unscoped, fenced dismissal. A scoped dismiss-all
(a caller that cannot show some registered agent) keeps that agent's
offers, clears only its audience's unwritten witnesses, and writes no
fence; a late write from this process is serialized behind it. The field
never left this unreleased branch.

* fix(agent-session): refuse an attach whose agent is not the session's own

The start check judges the record's agent while the router starts the adapter of params.agent, and the attach wire let the two differ. Admission now refuses agent !== provider as requestMalformed, so the agent checked is the agent started. Every host-built attach already sets them equal.

* fix(agent-session): offer to start a chat only when the start would accept it

The restart offer, a failure's retryable flag and the pre-send check asked only whether the adapter runs the chat's location, while the start also refuses a record this build cannot drive; such a chat was offered Resume and its Retry failed forever. One host predicate, hostCanStartRecord (location support and agentDrivesSession), now answers all four; adapterSupportsRecord stays the reader gate, and an undrivable chat's offer is kept, not retired.

* fix(agent-session): the model-catalog probe uses a record's account only if this build drives it

A record-scoped catalog read started the agent's lister under the record's account path whatever variable the record pinned. It now uses that path only when agentDrivesSession holds, and otherwise resolves the account as for a read with no record.

* refactor(agent-session): the record store admits agent ids; comments say where transport is checked

The store only ever asked whether an agent is registered, so its admission list is now the registered ids; the definition is the one home of an agent's transport and account variable. Comments that said the record store checks transport, and one that cited a nonexistent function, now point at agentDrivesSession and the attach admission. The isPersistedAgentSessionRecord(value, agents) call shape and the test fixture the cross-version probe imports are kept.

* docs(agent-session): the record store admits the registered agents' ids

* refactor(native-chat): Grok's registration declares where it runs; ACP no longer borrows Codex's location rule

The rule a self-supervised agent child runs under (this machine, no WSL, Windows only with process
start-time proof) is its own module that Codex and the ACP adapter both use. Grok's registration
takes the full account-home resolver signature, and D3's tests build hosts with the agent registry.

* fix(native-chat): Grok follows the ACP runtime's request contract and the managed process's close

A request the agent or a Stop cancels is answered with the agent's own cancelled reply by the code that
owns it (the runtime no longer answers a silent handler), so a Stop needs no separate decline pass. A
permission answer still being saved when the agent stopped waiting is reported unconfirmed, since the
protocol already answered it cancelled. Cancelling the agent's own turn is the plain cancel. Request
rows are matched under their generation-scoped ids. A refusal's reason comes from the dialect's wording
path. The child drops its own stderr tail and close policy for the managed process's, and a close
whose process tree was not proven gone is reported as the adapter contract asks.

* fix(native-chat): a Grok chat Orca already holds resumes without writing what Grok replays

A chat with a saved Grok session reattaches with session/resume where the agent offers it, else
session/load. Either way the call runs inside the translator's load window, so what Grok sends while
it reattaches (its saved exchange, a task the dead process left running, ended by the restart) opens
no turn and writes no row; only context usage reads on. A reply an Orca or Grok crash cut short is no
longer completed from Grok's saved history: it reads like a Claude or Codex chat's, with the existing
notice. The attach window also closes after a failed attach, and a created session that session/resume
reports missing is replaced like one session/load reports missing.

The replay reconciliation is removed: the lane no longer reads the journal, and D3's replayed-input
grammar test and completed-turn check in the assembler go with it.

* refactor(native-chat): a failed Grok reattach needs no window close of its own; its lane is replaced

* test(native-chat): D3's merged tests use the shipped declarations and the launch options main requires

* fix(native-chat): typecheck fallout of the base merges; any agent's empty chat is reusable

Main's idle-empty-chat lookup and launch join now take any registered agent, as the rest of the
launch path does. The refusal check moved into the prompt turns and the prompt-block conversion beside
the turns that send it, keeping both files in their line limit.

* fix(native-chat): a Grok Stop ends the process once Grok settles its turn; the next send resumes

Grok's session/cancel ends only the running turn: work it already moved to the background keeps
running and can begin a turn of its own after the person pressed Stop. Stop is now a session
boundary, as it is for Claude: the cancel answers open requests and lets Grok end the turn, the host
waits a bounded grace for that, then ends the process; the next send relaunches and resumes.
The adapter's own bounded close of a turn Grok began is gone. Its named-turn check stays: the host
ends the session unless the provider declines a Stop naming a turn that has since ended.

* test(native-chat): a Grok Stop ends the process only after Grok answered the cancel

* fix(native-chat): Steer on a Grok card cancels the running prompt, then sends it

A send that reached Grok while a prompt ran was held in the adapter until that turn ended: Steer
on a queued card took the card out of the host's editable queue and meant 'send after this turn'.
It now cancels the running prompt (session/cancel; the session stays) and sends as the next prompt
once Grok answers the cancel, as the common pattern does; a steer behind another cancels it in
turn, so the last one runs. The adapter holds a send only while that cancel lands, so its general
held-send queue and its holdsDispatch report are gone (every send it holds has its turn open in
the journal). An older client's mid-turn send takes the same path. capabilities.steering is
unchanged and still unread.

* refactor(native-chat): a close or Stop cancels a start through the acquire's own abort signal

The host owns the acquire it runs, so it now owns its cancellation: each attach's acquire gets an
AbortSignal, aborted from outside the session's queue by a close and by a Stop admitted now (the
same admission rule as before). The optional abandonStart adapter hook, the router's fan-out to
every adapter and the ACP adapter's session-keyed start map are gone; the ACP adapter keeps an
unkeyed set of starts only so quit can prove their children gone, and keeps a failed start's
unproven child until its exit is proven.
The hook also let a later close ask that child again. The host now does that from state it holds:
a close of a chat with no live child whose record still names an owner process with no death
evidence asks the adapter to release it. The answer is not recorded as proof (the lease probe
does that), so an owner pid an earlier Orca left is never killed or marked gone. Claude and Codex
ignore the signal and hold no such child; their release is a no-op (tested).

* fix(native-chat): a Grok crash that closes stdout before its exit still ends with Grok's last words

On macOS and Linux the agent's stdout ends before its exit is observed, with or without the
supervisor's EOF forwarding, so the connection's loss closed the journal first and its error text
became the session's ended reason, dropping Grok's stderr. The reason is now read at the proven
exit: the agent's last words when it left any, else why the connection closed. The failure already
carried them. Comments that assumed the exit comes first, that early frames past the cap refuse the
start, and that dispatch re-checks image support are corrected.

* fix(native-chat): nothing Grok sends while a held chat reattaches is written, marked as replay or not

The reattach window relied on the dialect's replay verdict, and Grok's frames read as live unless
they carry isReplay, so an unmarked chat frame during session/resume opened a turn that never
ended. D3 now marks every frame inside the window as replay before the translator reads it, so the
translator keeps only context usage whatever the agent marked; options and commands are still
adopted. The translator's load semantics are unchanged.

* test(native-chat): a Stop after a resume finds no turn an unmarked old reply opened

* test(native-chat): a resumed Grok chat keeps its last context reading; the resume refreshes only the window

* test(native-chat): a Grok background task a Stop ended reads as stopped reporting

* refactor(native-chat): quit's stop of each start answers through one promise kind

* fix(native-chat): quit aborts every start the host has in flight before draining attaches

A Grok that never answered its handshake held quit until the start's own 60 s bound, past the
20 s quit deadline. The host's teardown now aborts each in-flight acquire (and any the drain
still begins), so the adapter's own quit controller and its map of starts are gone: a start
has one canceller, the host's signal.

* fix(native-chat): a Grok start's abort stops reaching its child once the start has returned

The listener stayed on the host's signal until the attach finished committing, so a Close in that
window killed the now-live child behind the host's back and it read as Grok crashing. The start
now detaches it when it ends; a later Close goes through the session's own stop.

* fix(native-chat): a close or Stop during any attach phase stops the start before it launches

The attach began its abort controller only after reconciling leases, resolving recovery and
probing the previous owner, so a close or admitted Stop in those phases reached nothing and Grok
launched anyway. The controller now begins first, and the acquisition checks it before asking the
adapter to start.

* test(native-chat): a close during the attach's owner probe asks no adapter to start

Also renames the close test after the hook it no longer exercises.

* test(native-chat): a close's re-ask closes a Claude or Codex child a failed cleanup left

The re-ask is not a no-op for them: when the adapter still holds the child its cleanup could not
prove gone, the close stops it again as a requested close, and Claude persists the handle of the
conversation it ran so the next send resumes it. Corrects the tests' and comment's wording; the
close awaits the re-ask, bounded by each adapter's kill ladder.

* fix(native-chat): Steer during a turn Grok began itself cancels it and sends once it ends

A send while Grok ran a turn of its own (a background task waking it) went straight to Grok, which
queued it behind that turn where Orca could no longer withdraw it, while Stop treated the same turn
as the running reply. The send now waits as a steer, the turn is cancelled once, and the message
goes when the turn ends; a Stop withdraws it and an exit rejects it as never sent.

* test(native-chat): a steer whose cancel Grok never answers ends Grok and is rejected as never sent

Pins the bounded steer cancel kept from the runtime: past the bound the connection closes, the
running reply reads unverifiable, Grok's end reads as its exit, and the waiting steer is rejected
as never sent.

* fix(native-chat): a Grok crash stays a crash when a stop lands before its exit is proven

After the connection broke and the close could not prove Grok's exit, any later stop Orca asked
for (the next start, a Stop, a Close) marked the child as closed by Orca, so the crash read as a
requested close and Grok's last words were dropped; a send meanwhile was recorded unconfirmed.
The connection loss now decides the cause, and a send on that session is rejected as never sent.

* test(native-chat): fixtures this PR's registered Grok and desktop capability made stale

CI's unit shards failed on tests outside the PR's own lists. Each encodes something this PR changes
on purpose: Grok is now a registered agent (the seam test's unregistered agent is now Cursor); the
desktop now advertises registered agents (the restart-offer tests' older client drops that
capability explicitly); the attach context carries the start's abort controllers (the forget-status
double gains them); and the ACP real-host test rig sends to the host directly (listed beside the
other real-host rig in the send ratchet).

* fix(native-chat): a start quit stops is not the queued message's start failure

With quit now aborting a start it would have waited for, the delivery step recorded the aborted
start as the message's failure ("couldn't restart"). After quit has stopped delivery, the step
leaves the message to quit, which settles it as a close does ("The chat closed before this message
was sent."). The test that pinned quit waiting for that start and stopping its child now pins that
nothing is launched behind quit.

* fix(native-chat): a message sent after a Stop or close aborted a start gets its own start

A start the host aborts (an admitted Stop, a close, or quit) returned its refusal to the delivery
loop, which then rejected whatever was queued at that moment with "couldn't restart", including a
message the user sent after the Stop. The attach now reports that the host aborted it, and the loop
re-derives from the journal instead: what the Stop or close withdrew is already settled, a message
accepted since gets a start of its own, and quit's next step stops the loop. This replaces the
quit-only carve-out with the same rule for every abort and every agent.

* test(native-chat): the message sent after an aborted start is answered, so no settlement outlives the test

* fix(native-chat): a Grok model pick Grok never answers no longer holds Stop or Close

The pick runs on the session's queue. It now registers in the host's out-of-queue
abort registry beside a start, so a close, an admitted Stop or quit abandons it, and
the ACP adapter bounds it at 30 s like Claude and Codex. A late answer is still adopted.

* fix(agent-launch): a phone's launch opens a terminal for an agent whose chat it cannot show

agent.launch now reads the caller's capabilities by the rule tabs and restart offers
use (clientRendersStructuredAgent). A phone without registered-agents.v1 gets Grok as
a terminal again, as on main; the host's own callers and desktop clients are unchanged.

* fix(acp): strip every agent hook variable from the ACP child, from the shared list

ACP_CHILD_ENV_TO_DELETE was a second copy of the hook runtime keys that missed
ORCA_AGENT_HOOK_TRANSPORT; it now spreads AGENT_HOOK_RUNTIME_ENV_KEYS beside the pane
identity keys.

* refactor(native-chat): the mutation context carries the provider-wait registry itself

Keeps the host file within its line limit; one field instead of two closures over it.

* fix(agent-launch): agent.launch.v2 still vouches for Claude and Codex chats

The caller rule from the previous commit also turned Claude and Codex into terminals
for a client advertising only agent.launch.v2, whose contract says it opens a chat
(mobile retry-authority tests). Only an agent beyond those two now needs the client to
read it (clientRendersStructuredAgent); the test fixtures go back to what they were.

* refactor(native-chat): drop saved-history adoption from the timeline assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.

* refactor(acp): drop session/load history adoption from the translator

The common pattern discards the history an agent replays during session/load,
keeping only what it says about the context window. Remove the adoption path
(acp-history-adoption.ts, the adopt option, and the historical background-task
liveness rewrite it fed) so load replay is always dropped except usage.

* refactor(native-chat): a pending input is only Orca's send now

Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.

* test(acp): keep the task-result status table on live frames

Review follow-up to the adoption removal: the result-status mapping was only
tested through adopted history, so run the same table on live frames, and
cover an unmarked task notice during a load being dropped.

* test(acp): a frame helper for a shell command Grok is running

* fix(acp): a Grok crash settles through the host's provider-exit batch, scoped to the turn it ended

A Grok crash ended the journal unverifiable before the adapter reported the exit, so the host's
provider-exit settlement found no running turn and wrote nothing: the adapter's failure (with
Grok's last words) never reached the journal, and a later stale-session pass wrote a bare,
thread-scoped cut-short row, so the partial reply was not folded as Claude's and Codex's are.

At a proven exit the ACP lane now ends its running turn interrupted at the exit instant, as the
host's exit contract expects of a child's own translator (Codex's does the same). When Grok's
stdout closed first (every POSIX crash), the turn is unverifiable only until the exit is proven:
the host's provider-exit settlement now takes the exit as proof naming the child's fence and
revises what that child left unverifiable in the same batch, with the turn-scoped row and the
adapter's failure. Claude and Codex write no unverifiable turn of a live child except a command
whose hand-off is in doubt; that turn is now revised at the exit instead of at the next open.

* test(acp): a crash seen first leaves the host no Grok turn to revise

* refactor(native-chat): what a gone generation left unfinished gets its own module

The settlement file passed 300 lines with the exit-proof revision. The unfinished-work reads
(capture, interrupted-by-the-exit, in-progress) are their own concept and move out unchanged,
apart from the exit proof they now take.

* refactor(native-chat): a watched exit revises what its child left unverifiable without reading Stop marks

An exit's own instant is the turn's end, so the revision needs only each row's fence: the
settlement's journal type gains itemFence alone, and the host test fakes say so.

* test(native-chat): drop the duplicate itemFence on the fake that already had one

* test(claude, codex): an exit whose stdout ended first still reports as it always did

The provider supervisor now ends Orca's stdout when the agent's ends, so on every crash EOF
arrives before the exit is seen. Claude's and Codex's connections report nothing at EOF and
report the exit, with its usual reason, once it is seen.

* fix(acp): reopen a chat with session/load, as the common pattern does

An agent that offers both now reloads its session instead of resuming it; the
reattach window still discards what it replays except context usage.

* fix(acp): drop the 60 s handshake bound; an abort fails the start's waits at once

Neither common design bounds an ACP handshake: Close, Stop and quit end a start
that never answers. The abort now also closes the connection, as a kill there
does, so the start settles even before the child's exit is proven. The
host-stopped start refusal only this bound produced goes with it; the idle
sweep keeps its words.

* fix(acp): a Stop naming an ended turn follows Claude's rule

It still stops nothing while another turn is live, but in the gap before a
follow-up's turn opens, which no client can name, it now stops what is in
flight and the session ends, as a Claude Stop does.

* fix(native-chat): a close no longer re-asks a failed start's unproven child

Neither common design retries that stop at Close, and Orca's Claude contract
re-asks only at the next start and at quit. The ACP adapter keeps the child
until its exit is proven and asks it again there, as Claude does.

* fix(acp): a message sent during a turn the agent began itself goes at once

Both common designs send it straight to the agent with no cancel; only Orca's
own running prompt is steered (cancelled, then re-prompted).

* test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer

Uses an audience production sends (one that cannot show every agent), per review.

* fix(acp): launch Grok as `grok agent stdio`, without the update and leader flags

The common pattern passes neither --no-auto-update, --no-leader nor
GROK_DISABLE_AUTOUPDATER; full access still adds --always-approve.

* fix(acp): an agent that ends its stdout, or answers unreadably, is not a lost connection

As in the common pattern, only a broken stdin (or Orca's own close) ends the
agent; one that closed its output but can still be written to stays until a
Stop, a close or its exit. The provider supervisor goes back to its base
content, so Claude and Codex no longer get the forwarded stdout end either.

* fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts

On main every close of the chat writes its own Stop and settle. Here a later close joins the
first and writes no row, and the first's settle closed when its kill failed, so a turn that opened
in between and was cut by the next close read as failed. A person's close joining a person's close
whose Stop opened a settle now reopens that settle until its attempt is done.

Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and
the next reads as the person's cancellation (each fails without its half of the fix).

* test(native-chat): Grok opens as a chat only behind the structured-chat setting

agent.launch and orchestration worker-start read the same setting as the
renderer route; pin both states for Grok on each. The setting's description no
longer names only Codex and Claude, in every catalog.

* docs(acp): generic ACP comments say what holds for every agent, not Grok

Stop ends the session for every ACP agent, as in the common pattern; the
adoption hook comment goes (adoption is not planned); a failed start's child is
retried at the next start or quit.

* test(claude, codex): type the EOF-before-exit test's streams; the supervisor no longer forwards EOF

The Claude test wrote to the child's stdout and stderr through their Readable
type, which the node typecheck rejects; it now holds its own PassThrough
streams. The comments no longer credit the reverted supervisor change.

* feat(acp): a steer's cancel asks once and never ends the agent

The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle,
then close the connection, which ends the agent. A steer used it too, so a slow agent lost its
process just because the person added a message. requestSteerCancel() now sends session/cancel
once per prompt, cancels the agent's open requests and answers later permissions cancelled, and
never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel()
stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel
paths move into acp-prompt-cancel.ts over one cancel channel.

* chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge

* fix(acp): a repeated steer shares the cancel in flight; say what the caller owns

Per review: a second steer before the first write lands returns that write instead of resolving
early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that
fails instead must not take the steer until the caller rebuilds the session; the Stop's says a
prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives
the runtime a handler that would allow: the open permission's signal aborts and the late one never
reaches it.

* fix(native-chat): drop the stopDelivery the A3 merge doubled

* fix(acp): a steer's cancel asks Grok once and never ends it

A steer now uses D1's notify-only cancel. Two messages sent during a reply Grok began itself
cut that reply, as the common pattern does, and then both run; before, the queued first
message could not answer the bounded cancel and Orca ended Grok although Grok answered.
A Stop keeps the bounded cancel and its 4 s grace.

* fix(acp): a permission Grok asks with no prompt of Orca's running is declined

During a turn Grok began itself nobody asked it to act, so the request is answered
cancelled at once instead of opening a card that waits, as the common pattern does.

* fix(acp): a Grok that dies while starting is reported with its own last words

A dying process's stdout ends before its exit is seen, so the start failed as a closed
connection and Grok's stderr was lost. A start whose connection closed now waits, bounded by
the Stop grace (or a Close/Stop), for the exit before it is told.

* test: a Stop after a steer sends its own cancel; drop the import the A3 merge doubled

* test(native-chat): main's Stop-note test builds its turn context with the agent registry

* test(claude): say why the close test's fake child cast is safe

* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.

* test(native-chat): build the Stop-opened-turn test's identity with the opaque handle

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 and this branch both added the import at different lines; the merge kept both.

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 made the same fix as this branch at a different line; the merge kept both imports.

* feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore

A chat whose saved conversation the agent cannot reopen can now continue in a
fresh one: the handle chain records the new conversation as a creation that
replaces the lost one (which, why, and when), keeping every earlier link.

Rows keep a shape older builds read: the stored chain starts at the latest
replacement and carries the earlier links inside it.

* test(native-chat): build this stack's journal identities with main's opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.

* docs(acp): every reattach drops the agent's replay, not only for a chat the journal holds

* refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row

Older builds only read Claude and Codex records, so the nested stored form
protected rows no replacement can reach while adding a cap mismatch after a
downgrade. Store the chain as held, refuse a replacement in a Claude or Codex
chain until one has a stored shape older builds read, and refuse a supersession
key on a replacement that names no creation in the chain.

* refactor(native-chat): read hosts' structured agents from the app-shell services

Main grew the startup hydration hook to its line limit; the host agents sync is an app-lifetime subscription like the structured session tabs sync beside it, so it moves there.

* Use current provider handles in transition tests

* Use current provider handles in timeline fixtures

* test(native-chat): prove replacement rows survive downgrade and re-upgrade

* Require the ACP directory in the runtime import check

* test(ratchet): require src/main/acp now that this PR lands it

* feat(acp): a saved session the agent cannot reopen continues in a new one, with one warning row

When session/load (or session/resume) of a saved ACP session fails, the chat starts a new session and records it as a creation that replaces the lost one (#25747's 'replaces' link), and writes one warning row that the agent no longer remembers the earlier messages. A created session the agent reports missing is still superseded silently; a signed-out agent or a start that is over (Close, Stop, a lost agent) still fails the start.

* chore(acp): rewrap the acquire header comment

* test(acp): a start closed while the agent reopens fails without opening or announcing a new session

* Let ACP connections own their supervised agent process

* Preserve ACP cleanup evidence and isolate exit observers

* Expose ACP cleanup observations and type the permission fixture

* refactor(native-chat): the registered-agents capability lives in its own module

Main's growth put protocol-version.ts one counted line over its 300-line limit once the capability
was added; like main's other per-feature capabilities, it now has its own module, and importers read
it from there.

* refactor(acp): one connection owns the Grok process and its protocol

D3 now opens each ACP agent through createAcpAgentConnection (ACP-ALIGN #25810): one object spawns the
process on the execution host, owns its stdio and protocol, and reports its proven exit. It is built and
tracked before the handshake, so a start's abort (Close, Stop, quit) still reaches it, and a failed start
keeps that same connection for the next close to retry rather than spawning another process.

Deleted: the spawnAcpStructuredChild wrapper and its test, the raw-stream runtime assembly, the caller's
exit -> runtime.close wiring, the stdout-EOF heuristic (the connection no longer treats stdout EOF as
exit), and the 10 s steer/Stop cancel bound with requestSteerCancel. Reader control maps to
pauseReading/resumeReading; a close is connection.close after the host's existing 4 s Stop grace.

The adapter owns what the protocol no longer does: one session/cancel per running prompt however many
steers arrive (cleared with that send's settlement, retried after a failed write), and a Stop or steer
answers every open agent request the person has not already answered with the agent's own cancelled
reply. An answer already being saved when the Stop lands is sent.

Tests: blocked cancel write never holds Stop's grace, two quick steers send one cancel, a failed cancel
write is retried, a real process exiting while a child holds its stdout ends the session, and the
existing start-abort, retention, crash, connection-loss and reload-failure suites on the new rig.

* fix(acp): Grok signs in on its own machine with its API key or cached sign-in

When Grok reports that it needs authentication, Orca now names a sign-in method on the machine Grok runs
on, read from the same environment Grok was launched with: xai.api_key when XAI_API_KEY is set there and
Grok offers that method, else cached_token when Grok offers it, else none and the chat keeps the existing
not-signed-in refusal. The rule lives in Grok's launch spec; the adapter applies any agent's rule for new
and reopened sessions through the protocol client's caller-named method (authenticate, then retry once).
No new sign-in UI; interactive methods are never chosen.

* fix(acp): the adapter decides which of Grok's requests reach the person

The turn owner now admits every agent request, permission or question, from its own turn state: a
request reaches the person only while Orca's prompt runs and no steer or Stop is cutting it short (a
question may also come from a turn Grok began itself, until a Stop). Anything else gets the agent's
own cancelled reply and opens no card, so a question arriving after Stop or during a steer never
appears. A steer, like a Stop, withdraws the requests already open; an answer already being saved is
still sent. The protocol client's abort-on-cancel path is no longer used: after the connection
change its request signal aborts only when the connection closes.

* fix(acp): a plan Grok proposes shows as a plan, with no approval card

When Grok leaves plan mode it asks the client to approve its plan (x.ai/exit_plan_mode). Orca showed a
blocking 'Approve plan / Request changes' card for it; the common pattern has no such gate. Now the
plan goes into the chat's existing Plan row (the plan-document status row Codex and ACP plan updates
already use) and the request is answered at once with 'abandoned' plus feedback telling Grok to stop and
wait for the person's feedback or a request to implement it in a later turn, so nothing is approved on
the person's behalf. Dialects gain settleRequest for requests answered without asking anyone.

* fix(orchestration): worker-start opens a Grok worker in a terminal, as before

With the structured chat setting on, worker-start decided 'structured' for Grok and then the structured
worker factory (Claude and Codex only) refused it, so the start failed; main opened a terminal Grok
worker. Worker-start now decides with no registered agents beyond Claude and Codex, so Grok gets a
terminal worker as before. agent.launch and the app's own launches still open Grok as a structured
chat. Temporary until structured workers take registered agents.

* fix(acp): a prompt answer Orca can't read ends the turn instead of hanging it

A session/prompt rejection that was not the agent's own error answer (an answer that fails Orca's
schema, or one too large to read) left the turn running: the next message became a steer with nothing
to cancel and was never sent or settled, and Stop waited its full grace. As in the common pattern, any
prompt failure now ends the turn as failed (a failed-turn row without words, since none are the
agent's) and settles the send, so the next message goes. Only a closed connection keeps the send
running, for the connection-loss path to settle.

* fix(acp): send Grok's prompt-identity extension only to agents that echo it

session/prompt carried _meta {promptId, requestId} for every ACP agent, though only Grok's dialect
echoes it (injectedPromptIdentity). Now only an agent whose dialect declares it gets the extension;
other ACP agents get a plain prompt.

* refactor(native-chat): the registered-agents capability lives in protocol-version again, as on main

This reverts 0ef6d21. That commit moved the capability to its own module only because main's
protocol-version.ts was then one counted line over its limit; main now defines it there itself within
the limit, and main's new restart test imports it from there. Main's test also reads the desktop's
capability list as an older client; on this branch the desktop advertises registered agents, so its
older client is that list without this one capability.

* test(acp): read the sign-in method with a schema, not a type assertion

* refactor(native-chat): composer transport and Stop control in their own modules

Main's rewind change (#19338) brought NativeChatStructuredSession.tsx and use-structured-agent-session.ts
to their line limits, leaving no room for this branch's image-acceptance and unpublished-Stop lines.
The composer's transport (sends, commands, options, image acceptance) moves to
use-native-chat-structured-composer-transport.ts, and whether Stop shows and what it does moves to
structured-agent-session-stop-control.ts. Behavior is unchanged; the runtime cast on the composer's
'local' | 'remote' is now a typed return.

* test(native-chat): read registered agents by id, as main's structuredAgentsReadBy now takes

Main's A3 squash changed structuredAgentsReadBy to take agent ids; this branch's test still passed
{ agent } objects (CI typecheck TS2322).

* fix(acp): a first reopen warns when Grok forgets a chat that exchanged turns

A Grok session the chat created was treated as one Grok never saved, so
when Grok reported it missing on the chat's first reopen, Orca swapped in
a fresh session silently, even after completed exchanges: the person saw
the old messages while Grok had forgotten them.

The launch now counts a created session as never saved only when the
chat's journal, read at the failed reopen, holds no turn of that Grok
session; anything else, an unreadable journal included, takes the normal
path: the fresh session is recorded as replacing the old one and the one
warning row is written.

* fix(acp): a start writes the warning row an earlier attach failure dropped

The row saying Grok forgot the chat was written only into the attach's
deferred sink, while the fresh session's link was saved earlier. An attach
failure, quit or crash in between dropped the row forever.

Every start now derives the owed rows: each conversation the chain says was
lost to a failed restore gets its row unless the chat's journal already holds
it. The row now names the lost conversation rather than the fresh session, so
a row an unused replacement wrote still counts after Grok supersedes it.

* fix(acp): a question during a turn the agent began itself gets its cancelled reply

A question or other card-opening request the agent sends while no prompt of
Orca's runs (a turn it began itself, as when a background task wakes it) now
gets the agent's own cancelled reply and opens no card, the same rule
permissions already follow there. Nobody is waiting on that turn. A plan the
agent shares in it is still shown.

* test(native-chat): import the unfinished-work capture from the module that owns it

Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which
this branch split it out of.

* refactor(native-chat): keep the session host within its line budget after main's Stop work

Main's #25949 left the host at exactly its 300-line budget, and this branch's acquire-abort wiring
adds one line. The reveal module now comes in as a namespace import, as the host already does for
its other helper modules, and the earlier reorder of two type imports is undone.

* test(codex): move the stdout-before-exit test into its own file

Main's connection test file is at its 800-line test budget, and this branch's exit-order test
pushed it over (CI lint, max-lines).

* fix(acp): cancel a running turn before a close, dispose or quit ends the agent

Closing a tab, disposing a session or quitting while an ACP agent's turn ran
killed the process without asking the agent to cancel first. A requested
close now does what a Stop does: withdraw the agent's open requests and held
steers, send session/cancel once, and wait for the turn to end, bounded by
the Stop's grace (4 s), before closing the process. A close that follows a
Stop sends no second cancel and waits only what is left of that Stop's grace.
An idle close, a lost connection and a sink-failure force close are
unchanged. The cancel-and-wait moves to acp-structured-stop.ts, shared by
Stop and close.

* test(codex): pass resolveLaunchArgs in the stopped-send-order test so typecheck passes

Main's typecheck fails here too: #25721 made resolveLaunchArgs required and
this test (#25051) predates it. Same line, same place as the open main fix
(#25977), so merging main after it lands is a no-op.

* test(native-chat): run the close-aborts-start test on the Node runtime, as its SQLite journal fixture requires

* refactor(native-chat): the Stop control reads the outbox itself, so the chat hook stays within its line limit

* Keep the session host under the line cap after main's two new delegates

Pass the lifetime's conversation opener to reveal directly; it is already passed unbound to the mutation context.
brennanb2025 added a commit that referenced this pull request Oct 7, 2026
* refactor(native-chat): Grok's registration declares where it runs; ACP no longer borrows Codex's location rule

The rule a self-supervised agent child runs under (this machine, no WSL, Windows only with process
start-time proof) is its own module that Codex and the ACP adapter both use. Grok's registration
takes the full account-home resolver signature, and D3's tests build hosts with the agent registry.

* fix(native-chat): Grok follows the ACP runtime's request contract and the managed process's close

A request the agent or a Stop cancels is answered with the agent's own cancelled reply by the code that
owns it (the runtime no longer answers a silent handler), so a Stop needs no separate decline pass. A
permission answer still being saved when the agent stopped waiting is reported unconfirmed, since the
protocol already answered it cancelled. Cancelling the agent's own turn is the plain cancel. Request
rows are matched under their generation-scoped ids. A refusal's reason comes from the dialect's wording
path. The child drops its own stderr tail and close policy for the managed process's, and a close
whose process tree was not proven gone is reported as the adapter contract asks.

* fix(native-chat): a Grok chat Orca already holds resumes without writing what Grok replays

A chat with a saved Grok session reattaches with session/resume where the agent offers it, else
session/load. Either way the call runs inside the translator's load window, so what Grok sends while
it reattaches (its saved exchange, a task the dead process left running, ended by the restart) opens
no turn and writes no row; only context usage reads on. A reply an Orca or Grok crash cut short is no
longer completed from Grok's saved history: it reads like a Claude or Codex chat's, with the existing
notice. The attach window also closes after a failed attach, and a created session that session/resume
reports missing is replaced like one session/load reports missing.

The replay reconciliation is removed: the lane no longer reads the journal, and D3's replayed-input
grammar test and completed-turn check in the assembler go with it.

* refactor(native-chat): a failed Grok reattach needs no window close of its own; its lane is replaced

* test(native-chat): D3's merged tests use the shipped declarations and the launch options main requires

* fix(native-chat): typecheck fallout of the base merges; any agent's empty chat is reusable

Main's idle-empty-chat lookup and launch join now take any registered agent, as the rest of the
launch path does. The refusal check moved into the prompt turns and the prompt-block conversion beside
the turns that send it, keeping both files in their line limit.

* fix(native-chat): a Grok Stop ends the process once Grok settles its turn; the next send resumes

Grok's session/cancel ends only the running turn: work it already moved to the background keeps
running and can begin a turn of its own after the person pressed Stop. Stop is now a session
boundary, as it is for Claude: the cancel answers open requests and lets Grok end the turn, the host
waits a bounded grace for that, then ends the process; the next send relaunches and resumes.
The adapter's own bounded close of a turn Grok began is gone. Its named-turn check stays: the host
ends the session unless the provider declines a Stop naming a turn that has since ended.

* test(native-chat): a Grok Stop ends the process only after Grok answered the cancel

* fix(native-chat): Steer on a Grok card cancels the running prompt, then sends it

A send that reached Grok while a prompt ran was held in the adapter until that turn ended: Steer
on a queued card took the card out of the host's editable queue and meant 'send after this turn'.
It now cancels the running prompt (session/cancel; the session stays) and sends as the next prompt
once Grok answers the cancel, as the common pattern does; a steer behind another cancels it in
turn, so the last one runs. The adapter holds a send only while that cancel lands, so its general
held-send queue and its holdsDispatch report are gone (every send it holds has its turn open in
the journal). An older client's mid-turn send takes the same path. capabilities.steering is
unchanged and still unread.

* refactor(native-chat): a close or Stop cancels a start through the acquire's own abort signal

The host owns the acquire it runs, so it now owns its cancellation: each attach's acquire gets an
AbortSignal, aborted from outside the session's queue by a close and by a Stop admitted now (the
same admission rule as before). The optional abandonStart adapter hook, the router's fan-out to
every adapter and the ACP adapter's session-keyed start map are gone; the ACP adapter keeps an
unkeyed set of starts only so quit can prove their children gone, and keeps a failed start's
unproven child until its exit is proven.
The hook also let a later close ask that child again. The host now does that from state it holds:
a close of a chat with no live child whose record still names an owner process with no death
evidence asks the adapter to release it. The answer is not recorded as proof (the lease probe
does that), so an owner pid an earlier Orca left is never killed or marked gone. Claude and Codex
ignore the signal and hold no such child; their release is a no-op (tested).

* fix(native-chat): a Grok crash that closes stdout before its exit still ends with Grok's last words

On macOS and Linux the agent's stdout ends before its exit is observed, with or without the
supervisor's EOF forwarding, so the connection's loss closed the journal first and its error text
became the session's ended reason, dropping Grok's stderr. The reason is now read at the proven
exit: the agent's last words when it left any, else why the connection closed. The failure already
carried them. Comments that assumed the exit comes first, that early frames past the cap refuse the
start, and that dispatch re-checks image support are corrected.

* fix(native-chat): nothing Grok sends while a held chat reattaches is written, marked as replay or not

The reattach window relied on the dialect's replay verdict, and Grok's frames read as live unless
they carry isReplay, so an unmarked chat frame during session/resume opened a turn that never
ended. D3 now marks every frame inside the window as replay before the translator reads it, so the
translator keeps only context usage whatever the agent marked; options and commands are still
adopted. The translator's load semantics are unchanged.

* test(native-chat): a Stop after a resume finds no turn an unmarked old reply opened

* test(native-chat): a resumed Grok chat keeps its last context reading; the resume refreshes only the window

* test(native-chat): a Grok background task a Stop ended reads as stopped reporting

* refactor(native-chat): quit's stop of each start answers through one promise kind

* fix(native-chat): quit aborts every start the host has in flight before draining attaches

A Grok that never answered its handshake held quit until the start's own 60 s bound, past the
20 s quit deadline. The host's teardown now aborts each in-flight acquire (and any the drain
still begins), so the adapter's own quit controller and its map of starts are gone: a start
has one canceller, the host's signal.

* fix(native-chat): a Grok start's abort stops reaching its child once the start has returned

The listener stayed on the host's signal until the attach finished committing, so a Close in that
window killed the now-live child behind the host's back and it read as Grok crashing. The start
now detaches it when it ends; a later Close goes through the session's own stop.

* fix(native-chat): a close or Stop during any attach phase stops the start before it launches

The attach began its abort controller only after reconciling leases, resolving recovery and
probing the previous owner, so a close or admitted Stop in those phases reached nothing and Grok
launched anyway. The controller now begins first, and the acquisition checks it before asking the
adapter to start.

* test(native-chat): a close during the attach's owner probe asks no adapter to start

Also renames the close test after the hook it no longer exercises.

* test(native-chat): a close's re-ask closes a Claude or Codex child a failed cleanup left

The re-ask is not a no-op for them: when the adapter still holds the child its cleanup could not
prove gone, the close stops it again as a requested close, and Claude persists the handle of the
conversation it ran so the next send resumes it. Corrects the tests' and comment's wording; the
close awaits the re-ask, bounded by each adapter's kill ladder.

* fix(native-chat): Steer during a turn Grok began itself cancels it and sends once it ends

A send while Grok ran a turn of its own (a background task waking it) went straight to Grok, which
queued it behind that turn where Orca could no longer withdraw it, while Stop treated the same turn
as the running reply. The send now waits as a steer, the turn is cancelled once, and the message
goes when the turn ends; a Stop withdraws it and an exit rejects it as never sent.

* test(native-chat): a steer whose cancel Grok never answers ends Grok and is rejected as never sent

Pins the bounded steer cancel kept from the runtime: past the bound the connection closes, the
running reply reads unverifiable, Grok's end reads as its exit, and the waiting steer is rejected
as never sent.

* fix(native-chat): a Grok crash stays a crash when a stop lands before its exit is proven

After the connection broke and the close could not prove Grok's exit, any later stop Orca asked
for (the next start, a Stop, a Close) marked the child as closed by Orca, so the crash read as a
requested close and Grok's last words were dropped; a send meanwhile was recorded unconfirmed.
The connection loss now decides the cause, and a send on that session is rejected as never sent.

* test(native-chat): fixtures this PR's registered Grok and desktop capability made stale

CI's unit shards failed on tests outside the PR's own lists. Each encodes something this PR changes
on purpose: Grok is now a registered agent (the seam test's unregistered agent is now Cursor); the
desktop now advertises registered agents (the restart-offer tests' older client drops that
capability explicitly); the attach context carries the start's abort controllers (the forget-status
double gains them); and the ACP real-host test rig sends to the host directly (listed beside the
other real-host rig in the send ratchet).

* fix(native-chat): a start quit stops is not the queued message's start failure

With quit now aborting a start it would have waited for, the delivery step recorded the aborted
start as the message's failure ("couldn't restart"). After quit has stopped delivery, the step
leaves the message to quit, which settles it as a close does ("The chat closed before this message
was sent."). The test that pinned quit waiting for that start and stopping its child now pins that
nothing is launched behind quit.

* fix(native-chat): a message sent after a Stop or close aborted a start gets its own start

A start the host aborts (an admitted Stop, a close, or quit) returned its refusal to the delivery
loop, which then rejected whatever was queued at that moment with "couldn't restart", including a
message the user sent after the Stop. The attach now reports that the host aborted it, and the loop
re-derives from the journal instead: what the Stop or close withdrew is already settled, a message
accepted since gets a start of its own, and quit's next step stops the loop. This replaces the
quit-only carve-out with the same rule for every abort and every agent.

* test(native-chat): the message sent after an aborted start is answered, so no settlement outlives the test

* fix(native-chat): a Grok model pick Grok never answers no longer holds Stop or Close

The pick runs on the session's queue. It now registers in the host's out-of-queue
abort registry beside a start, so a close, an admitted Stop or quit abandons it, and
the ACP adapter bounds it at 30 s like Claude and Codex. A late answer is still adopted.

* fix(agent-launch): a phone's launch opens a terminal for an agent whose chat it cannot show

agent.launch now reads the caller's capabilities by the rule tabs and restart offers
use (clientRendersStructuredAgent). A phone without registered-agents.v1 gets Grok as
a terminal again, as on main; the host's own callers and desktop clients are unchanged.

* fix(acp): strip every agent hook variable from the ACP child, from the shared list

ACP_CHILD_ENV_TO_DELETE was a second copy of the hook runtime keys that missed
ORCA_AGENT_HOOK_TRANSPORT; it now spreads AGENT_HOOK_RUNTIME_ENV_KEYS beside the pane
identity keys.

* refactor(native-chat): the mutation context carries the provider-wait registry itself

Keeps the host file within its line limit; one field instead of two closures over it.

* fix(agent-launch): agent.launch.v2 still vouches for Claude and Codex chats

The caller rule from the previous commit also turned Claude and Codex into terminals
for a client advertising only agent.launch.v2, whose contract says it opens a chat
(mobile retry-authority tests). Only an agent beyond those two now needs the client to
read it (clientRendersStructuredAgent); the test fixtures go back to what they were.

* refactor(native-chat): drop saved-history adoption from the timeline assembler

The common pattern discards the history a provider replays while loading a
session, so the assembler has no use for an input.history event.

* refactor(acp): drop session/load history adoption from the translator

The common pattern discards the history an agent replays during session/load,
keeping only what it says about the context window. Remove the adoption path
(acp-history-adoption.ts, the adopt option, and the historical background-task
liveness rewrite it fed) so load replay is always dropped except usage.

* refactor(native-chat): a pending input is only Orca's send now

Review follow-up to the adoption removal: drop the comment naming the
provider's saved message, and make requestedAt required since every pending
input comes from input.accepted.

* test(acp): keep the task-result status table on live frames

Review follow-up to the adoption removal: the result-status mapping was only
tested through adopted history, so run the same table on live frames, and
cover an unmarked task notice during a load being dropped.

* test(acp): a frame helper for a shell command Grok is running

* fix(acp): a Grok crash settles through the host's provider-exit batch, scoped to the turn it ended

A Grok crash ended the journal unverifiable before the adapter reported the exit, so the host's
provider-exit settlement found no running turn and wrote nothing: the adapter's failure (with
Grok's last words) never reached the journal, and a later stale-session pass wrote a bare,
thread-scoped cut-short row, so the partial reply was not folded as Claude's and Codex's are.

At a proven exit the ACP lane now ends its running turn interrupted at the exit instant, as the
host's exit contract expects of a child's own translator (Codex's does the same). When Grok's
stdout closed first (every POSIX crash), the turn is unverifiable only until the exit is proven:
the host's provider-exit settlement now takes the exit as proof naming the child's fence and
revises what that child left unverifiable in the same batch, with the turn-scoped row and the
adapter's failure. Claude and Codex write no unverifiable turn of a live child except a command
whose hand-off is in doubt; that turn is now revised at the exit instead of at the next open.

* test(acp): a crash seen first leaves the host no Grok turn to revise

* refactor(native-chat): what a gone generation left unfinished gets its own module

The settlement file passed 300 lines with the exit-proof revision. The unfinished-work reads
(capture, interrupted-by-the-exit, in-progress) are their own concept and move out unchanged,
apart from the exit proof they now take.

* refactor(native-chat): a watched exit revises what its child left unverifiable without reading Stop marks

An exit's own instant is the turn's end, so the revision needs only each row's fence: the
settlement's journal type gains itemFence alone, and the host test fakes say so.

* test(native-chat): drop the duplicate itemFence on the fake that already had one

* test(claude, codex): an exit whose stdout ended first still reports as it always did

The provider supervisor now ends Orca's stdout when the agent's ends, so on every crash EOF
arrives before the exit is seen. Claude's and Codex's connections report nothing at EOF and
report the exit, with its usual reason, once it is seen.

* fix(acp): reopen a chat with session/load, as the common pattern does

An agent that offers both now reloads its session instead of resuming it; the
reattach window still discards what it replays except context usage.

* fix(acp): drop the 60 s handshake bound; an abort fails the start's waits at once

Neither common design bounds an ACP handshake: Close, Stop and quit end a start
that never answers. The abort now also closes the connection, as a kill there
does, so the start settles even before the child's exit is proven. The
host-stopped start refusal only this bound produced goes with it; the idle
sweep keeps its words.

* fix(acp): a Stop naming an ended turn follows Claude's rule

It still stops nothing while another turn is live, but in the gap before a
follow-up's turn opens, which no client can name, it now stops what is in
flight and the session ends, as a Claude Stop does.

* fix(native-chat): a close no longer re-asks a failed start's unproven child

Neither common design retries that stop at Close, and Orca's Claude contract
re-asks only at the next start and at quit. The ACP adapter keeps the child
until its exit is proven and asks it again there, as Claude does.

* fix(acp): a message sent during a turn the agent began itself goes at once

Both common designs send it straight to the agent with no cancel; only Orca's
own running prompt is steered (cancelled, then re-prompted).

* test(native-chat): dismiss-all through a remote client's audience keeps a newer Orca's offer

Uses an audience production sends (one that cannot show every agent), per review.

* fix(acp): launch Grok as `grok agent stdio`, without the update and leader flags

The common pattern passes neither --no-auto-update, --no-leader nor
GROK_DISABLE_AUTOUPDATER; full access still adds --always-approve.

* fix(acp): an agent that ends its stdout, or answers unreadably, is not a lost connection

As in the common pattern, only a broken stdin (or Orca's own close) ends the
agent; one that closed its output but can still be written to stays until a
Stop, a close or its exit. The provider supervisor goes back to its base
content, so Claude and Codex no longer get the forwarded stdout end either.

* fix(native-chat): a person's close joining a failed one still binds the turn its child end cuts

On main every close of the chat writes its own Stop and settle. Here a later close joins the
first and writes no row, and the first's settle closed when its kill failed, so a turn that opened
in between and was cut by the next close read as failed. A person's close joining a person's close
whose Stop opened a settle now reopens that settle until its attempt is done.

Tests: a close whose kill failed still closes its settle; a turn opened between a failed close and
the next reads as the person's cancellation (each fails without its half of the fix).

* test(native-chat): Grok opens as a chat only behind the structured-chat setting

agent.launch and orchestration worker-start read the same setting as the
renderer route; pin both states for Grok on each. The setting's description no
longer names only Codex and Claude, in every catalog.

* docs(acp): generic ACP comments say what holds for every agent, not Grok

Stop ends the session for every ACP agent, as in the common pattern; the
adoption hook comment goes (adoption is not planned); a failed start's child is
retried at the next start or quit.

* test(claude, codex): type the EOF-before-exit test's streams; the supervisor no longer forwards EOF

The Claude test wrote to the child's stdout and stderr through their Readable
type, which the node typecheck rejects; it now holds its own PassThrough
streams. The comments no longer credit the reverted supervisor change.

* feat(acp): a steer's cancel asks once and never ends the agent

The runtime had one cancel: send session/cancel, wait at most 10 s for Orca's prompt to settle,
then close the connection, which ends the agent. A steer used it too, so a slow agent lost its
process just because the person added a message. requestSteerCancel() now sends session/cancel
once per prompt, cancels the agent's open requests and answers later permissions cancelled, and
never bounds or closes: the prompt's own reply ends it and the steer's prompt follows. cancel()
stays the Stop: bounded, then close. A Stop after a steer still bounds and closes. Both cancel
paths move into acp-prompt-cancel.ts over one cancel channel.

* chore(native-chat): keep the record store and recovery capsule under max-lines after the main merge

* fix(acp): a repeated steer shares the cancel in flight; say what the caller owns

Per review: a second steer before the first write lands returns that write instead of resolving
early. The steer's JSDoc says the wait for the prompt's reply is unbounded and that a prompt that
fails instead must not take the steer until the caller rebuilds the session; the Stop's says a
prompt that settles in time leaves the agent for the Stop's owner to end. The steer test now gives
the runtime a handler that would allow: the open permission's signal aborts and the late one never
reaches it.

* fix(native-chat): drop the stopDelivery the A3 merge doubled

* fix(acp): a steer's cancel asks Grok once and never ends it

A steer now uses D1's notify-only cancel. Two messages sent during a reply Grok began itself
cut that reply, as the common pattern does, and then both run; before, the queued first
message could not answer the bounded cancel and Orca ended Grok although Grok answered.
A Stop keeps the bounded cancel and its 4 s grace.

* fix(acp): a permission Grok asks with no prompt of Orca's running is declined

During a turn Grok began itself nobody asked it to act, so the request is answered
cancelled at once instead of opening a card that waits, as the common pattern does.

* fix(acp): a Grok that dies while starting is reported with its own last words

A dying process's stdout ends before its exit is seen, so the start failed as a closed
connection and Grok's stderr was lost. A start whose connection closed now waits, bounded by
the Stop grace (or a Close/Stop), for the exit before it is told.

* test: a Stop after a steer sends its own cancel; drop the import the A3 merge doubled

* test(native-chat): main's Stop-note test builds its turn context with the agent registry

* test(claude): say why the close test's fake child cast is safe

* test(native-chat): build the Stop-opened-turn test's identity and turn context the current way

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle; the turn context also needs this branch's agent registry.

* test(native-chat): build the Stop-opened-turn test's identity with the opaque handle

The test (#25056) landed before the opaque provider handle (#24991), so main still built
the old {kind, threadId} handle.

* test(ratchet): require src/main/provider-process now that it has landed

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 and this branch both added the import at different lines; the merge kept both.

* test(native-chat): keep main's opaque-handle import in the Stop-opened-turn test

Main's #25706 made the same fix as this branch at a different line; the merge kept both imports.

* feat(native-chat): record a fresh provider conversation that replaced one the agent could not restore

A chat whose saved conversation the agent cannot reopen can now continue in a
fresh one: the handle chain records the new conversation as a creation that
replaces the lost one (which, why, and when), keeping every earlier link.

Rows keep a shape older builds read: the stored chain starts at the latest
replacement and carries the earlier links inside it.

* test(native-chat): build this stack's journal identities with main's opaque provider handle

Main's #24991 replaced the {kind, ...} handle with {transport, agent, nativeId}; three test
files from this stack still wrote the old shape. Same lines the downstream ACP branch uses.

* docs(acp): every reattach drops the agent's replay, not only for a chat the journal holds

* refactor(native-chat): store a replaced conversation flat; refuse it where older builds read the row

Older builds only read Claude and Codex records, so the nested stored form
protected rows no replacement can reach while adding a cap mismatch after a
downgrade. Store the chain as held, refuse a replacement in a Claude or Codex
chain until one has a stored shape older builds read, and refuse a supersession
key on a replacement that names no creation in the chain.

* refactor(native-chat): read hosts' structured agents from the app-shell services

Main grew the startup hydration hook to its line limit; the host agents sync is an app-lifetime subscription like the structured session tabs sync beside it, so it moves there.

* Use current provider handles in transition tests

* Use current provider handles in timeline fixtures

* test(native-chat): prove replacement rows survive downgrade and re-upgrade

* Require the ACP directory in the runtime import check

* test(ratchet): require src/main/acp now that this PR lands it

* feat(acp): a saved session the agent cannot reopen continues in a new one, with one warning row

When session/load (or session/resume) of a saved ACP session fails, the chat starts a new session and records it as a creation that replaces the lost one (#25747's 'replaces' link), and writes one warning row that the agent no longer remembers the earlier messages. A created session the agent reports missing is still superseded silently; a signed-out agent or a start that is over (Close, Stop, a lost agent) still fails the start.

* chore(acp): rewrap the acquire header comment

* test(acp): a start closed while the agent reopens fails without opening or announcing a new session

* Let ACP connections own their supervised agent process

* Preserve ACP cleanup evidence and isolate exit observers

* Expose ACP cleanup observations and type the permission fixture

* refactor(native-chat): the registered-agents capability lives in its own module

Main's growth put protocol-version.ts one counted line over its 300-line limit once the capability
was added; like main's other per-feature capabilities, it now has its own module, and importers read
it from there.

* refactor(acp): one connection owns the Grok process and its protocol

D3 now opens each ACP agent through createAcpAgentConnection (ACP-ALIGN #25810): one object spawns the
process on the execution host, owns its stdio and protocol, and reports its proven exit. It is built and
tracked before the handshake, so a start's abort (Close, Stop, quit) still reaches it, and a failed start
keeps that same connection for the next close to retry rather than spawning another process.

Deleted: the spawnAcpStructuredChild wrapper and its test, the raw-stream runtime assembly, the caller's
exit -> runtime.close wiring, the stdout-EOF heuristic (the connection no longer treats stdout EOF as
exit), and the 10 s steer/Stop cancel bound with requestSteerCancel. Reader control maps to
pauseReading/resumeReading; a close is connection.close after the host's existing 4 s Stop grace.

The adapter owns what the protocol no longer does: one session/cancel per running prompt however many
steers arrive (cleared with that send's settlement, retried after a failed write), and a Stop or steer
answers every open agent request the person has not already answered with the agent's own cancelled
reply. An answer already being saved when the Stop lands is sent.

Tests: blocked cancel write never holds Stop's grace, two quick steers send one cancel, a failed cancel
write is retried, a real process exiting while a child holds its stdout ends the session, and the
existing start-abort, retention, crash, connection-loss and reload-failure suites on the new rig.

* Add chat-owned OpenCode server transport foundation

* Combine server fixture type imports for CI lint

* fix(acp): Grok signs in on its own machine with its API key or cached sign-in

When Grok reports that it needs authentication, Orca now names a sign-in method on the machine Grok runs
on, read from the same environment Grok was launched with: xai.api_key when XAI_API_KEY is set there and
Grok offers that method, else cached_token when Grok offers it, else none and the chat keeps the existing
not-signed-in refusal. The rule lives in Grok's launch spec; the adapter applies any agent's rule for new
and reopened sessions through the protocol client's caller-named method (authenticate, then retry once).
No new sign-in UI; interactive methods are never chosen.

* Bound request serialization and correct transport fixture types

* fix(acp): the adapter decides which of Grok's requests reach the person

The turn owner now admits every agent request, permission or question, from its own turn state: a
request reaches the person only while Orca's prompt runs and no steer or Stop is cutting it short (a
question may also come from a turn Grok began itself, until a Stop). Anything else gets the agent's
own cancelled reply and opens no card, so a question arriving after Stop or during a steer never
appears. A steer, like a Stop, withdraws the requests already open; an answer already being saved is
still sent. The protocol client's abort-on-cancel path is no longer used: after the connection
change its request signal aborts only when the connection closes.

* fix(acp): a plan Grok proposes shows as a plan, with no approval card

When Grok leaves plan mode it asks the client to approve its plan (x.ai/exit_plan_mode). Orca showed a
blocking 'Approve plan / Request changes' card for it; the common pattern has no such gate. Now the
plan goes into the chat's existing Plan row (the plan-document status row Codex and ACP plan updates
already use) and the request is answered at once with 'abandoned' plus feedback telling Grok to stop and
wait for the person's feedback or a request to implement it in a later turn, so nothing is approved on
the person's behalf. Dialects gain settleRequest for requests answered without asking anyone.

* fix(orchestration): worker-start opens a Grok worker in a terminal, as before

With the structured chat setting on, worker-start decided 'structured' for Grok and then the structured
worker factory (Claude and Codex only) refused it, so the start failed; main opened a terminal Grok
worker. Worker-start now decides with no registered agents beyond Claude and Codex, so Grok gets a
terminal worker as before. agent.launch and the app's own launches still open Grok as a structured
chat. Temporary until structured workers take registered agents.

* Bound OpenCode event consumers and isolate chat caller identity

* fix(acp): a prompt answer Orca can't read ends the turn instead of hanging it

A session/prompt rejection that was not the agent's own error answer (an answer that fails Orca's
schema, or one too large to read) left the turn running: the next message became a steer with nothing
to cancel and was never sent or settled, and Stop waited its full grace. As in the common pattern, any
prompt failure now ends the turn as failed (a failed-turn row without words, since none are the
agent's) and settles the send, so the next message goes. Only a closed connection keeps the send
running, for the connection-loss path to settle.

* fix(acp): send Grok's prompt-identity extension only to agents that echo it

session/prompt carried _meta {promptId, requestId} for every ACP agent, though only Grok's dialect
echoes it (injectedPromptIdentity). Now only an agent whose dialect declares it gets the extension;
other ACP agents get a plain prompt.

* refactor(native-chat): the registered-agents capability lives in protocol-version again, as on main

This reverts 0ef6d21. That commit moved the capability to its own module only because main's
protocol-version.ts was then one counted line over its limit; main now defines it there itself within
the limit, and main's new restart test imports it from there. Main's test also reads the desktop's
capability list as an older client; on this branch the desktop advertises registered agents, so its
older client is that list without this one capability.

* test(acp): read the sign-in method with a schema, not a type assertion

* refactor(native-chat): composer transport and Stop control in their own modules

Main's rewind change (#19338) brought NativeChatStructuredSession.tsx and use-structured-agent-session.ts
to their line limits, leaving no room for this branch's image-acceptance and unpublished-Stop lines.
The composer's transport (sends, commands, options, image acceptance) moves to
use-native-chat-structured-composer-transport.ts, and whether Stop shows and what it does moves to
structured-agent-session-stop-control.ts. Behavior is unchanged; the runtime cast on the composer's
'local' | 'remote' is now a typed return.

* test(native-chat): read registered agents by id, as main's structuredAgentsReadBy now takes

Main's A3 squash changed structuredAgentsReadBy to take agent ids; this branch's test still passed
{ agent } objects (CI typecheck TS2322).

* fix(acp): a first reopen warns when Grok forgets a chat that exchanged turns

A Grok session the chat created was treated as one Grok never saved, so
when Grok reported it missing on the chat's first reopen, Orca swapped in
a fresh session silently, even after completed exchanges: the person saw
the old messages while Grok had forgotten them.

The launch now counts a created session as never saved only when the
chat's journal, read at the failed reopen, holds no turn of that Grok
session; anything else, an unreadable journal included, takes the normal
path: the fresh session is recorded as replacing the old one and the one
warning row is written.

* Add setting-gated OpenCode server native chat integration

* Use checked native event ordinals in timeline identities

* Validate nested account tags and preserve bounded restore failures

* Keep native history restoration within its session module

* Check native protocol records and support registered-agent drafts

* fix(acp): a start writes the warning row an earlier attach failure dropped

The row saying Grok forgot the chat was written only into the attach's
deferred sink, while the fresh session's link was saved earlier. An attach
failure, quit or crash in between dropped the row forever.

Every start now derives the owed rows: each conversation the chain says was
lost to a failed restore gets its row unless the chat's journal already holds
it. The row now names the lost conversation rather than the fresh session, so
a row an unused replacement wrote still counts after Grok supersedes it.

* fix(acp): a question during a turn the agent began itself gets its cancelled reply

A question or other card-opening request the agent sends while no prompt of
Orca's runs (a turn it began itself, as when a background task wakes it) now
gets the agent's own cancelled reply and opens no card, the same rule
permissions already follow there. Nobody is waiting on that turn. A plan the
agent shares in it is still shown.

* Update launch and status tests for registered structured agents

* test(native-chat): import the unfinished-work capture from the module that owns it

Main's reasoning sweep test (#19221) imported it from the dead-generation settlement, which
this branch split it out of.

* Run OpenCode structured chat over ACP

OpenCode joins the ACP lane as two launch rows (opencode and opencode2 run
'acp'), with a dialect that reads command output and exit codes from
OpenCode's metadata and labels its project-wide 'always' grant honestly.
The launch forces OPENCODE_CLIENT=acp and turns OpenCode's question tool
off, strips Orca's terminal status overlay from the child, and pins the
chat's OpenCode account as a tagged locator (managed profile, or data and
state directories). Image prompts go as ACP image blocks when the agent
advertises them, and restart recovery reads OpenCode's own store for
presence only.

* OpenCode 2 over ACP only for its default account

OpenCode 2's ACP command runs inside the user's own background service,
which the chat's environment never reaches, so a managed profile cannot be
honoured: while one is selected a new OpenCode 2 chat opens in the terminal,
and a record pinned to one never launches.

* Test OpenCode over ACP against its recorded traffic

Seven scrubbed recordings of opencode acp (1.18.31 and 2.0.14) replay through
the adapter: an approved shell command with its exit code and the
project-wide always label, deny on both majors, the 1.x subagent approval
that never arrives (Stop still ends the turn), the 2.x subagent approval
answered on the chat's session, the stray 1.x file write, and a provider
error shown in OpenCode's own words. The replay now plays recorded error
answers too.

* Name the normalized tool update for what it is

* OpenCode over ACP: 1.x only, honest grants, sound recovery

Review round 1:
- OpenCode 2 stays on its terminal-backed chat: its acp command runs in the
  user's own background service, which a chat's environment and account pin
  don't reach, and a declined local launch has no terminal fallback yet.
- The model-pick launch change is out of this PR (it could leave a worktree
  behind when the host later chose the terminal).
- 'Always allow' reads 'Allow for this chat': OpenCode 1.x keeps it in the
  chat's own process.
- A file write OpenCode repeats to a client with no file system leaves no
  row.
- Images are bounded by the encoded prompt line, not just raw bytes.
- Restart recovery: each accepted send claims its own stored copy first, and
  queued cards never trigger the read.
- A send whose child ended during the image read settles as not sent.
- An account Orca cannot pin (inline auth, relative directories) is refused
  as unsupported; default XDG directories the user never set stay unset; the
  retired shared plugin directory and data-account variables never reach the
  child; newer locator fields are tolerated.

* Round 2: recovery reads the chat's own database, honest grant label

- Restart recovery resolves OpenCode's database under the chat's own home,
  not Orca's, now that default data directories stay unset.
- 'Always allow' reads 'Allow until OpenCode restarts': Stop, quitting and an
  idle chat's stopped agent all end it.
- Only a message with images is refused for passing one protocol line, so a
  long text message never reads as an image problem.

* refactor(native-chat): keep the session host within its line budget after main's Stop work

Main's #25949 left the host at exactly its 300-line budget, and this branch's acquire-abort wiring
adds one line. The reveal module now comes in as a namespace import, as the host already does for
its other helper modules, and the earlier reorder of two type imports is undone.

* test(codex): move the stdout-before-exit test into its own file

Main's connection test file is at its 800-line test budget, and this branch's exit-order test
pushed it over (CI lint, max-lines).

* Keep the record's path bound, which the launch directory now uses

* Narrow the refusal in the prompt content test instead of casting it

* Type the replayed error answer as the protocol's error

* Read the replayed message through the replay's own schema

* Match recovered OpenCode messages through main's send fingerprint

Main gave every stored send one fingerprint helper (it drops the sender field); restart recovery now hashes stored messages through it instead of rebuilding the hash by hand.

* Keep an `opencode` that is 2.x on the terminal chat

Homebrew's `opencode` is 2.x now, so the binary, not only the agent name, decides whether OpenCode's acp runs in-process. An ACP launch spec can name the --version releases it runs on; OpenCode takes stable 1.x from 1.18.31. createSupport asks the binary a launch would spawn, with the environment a launch starts from, so a desktop launch of 2.x opens the terminal chat; every launch asks again before spawning, so a binary swapped after create never starts. The --version probe is shared and bounded, and a version it cannot read counts as unsupported.

* Type the mocked login-shell environment instead of asserting it

* Run the OpenCode the Command setting names, and say why a version check refused

The structured OpenCode and Grok chats resolved a bare `opencode`/`grok` from PATH while detection and the terminal chat honoured the per-agent Command setting, so a user who pointed Orca at a specific binary got another one (and, with the version gate, another version) in the structured chat. resolveStructuredAgentCommand now takes any agent id and the stock binary name; the ACP launch and createSupport's version check both resolve through it, so they ask about and spawn the same file. An unrunnable Command is admitted at create and refused at launch with agentCommandNotRunnable, as Claude and Codex do, instead of silently opening a terminal.

The --version probe now logs why it refused (exit code, timeout, oversized or versionless output, unsupported release), with the binary's own first words, so a refusal on a real host can be diagnosed. A real-probe test drives createSupport and the launch against stand-in binaries: per-agent PATH, Command setting, 2.x first on PATH, and an unrunnable Command.

* Say why a new OpenCode tab opened a terminal, and ask the host again for its agents

Live QA run 5 opened the terminal-backed OpenCode chat with the private 1.18.31 set as the Command and on the per-agent PATH, and Orca's main log had no version-check line. The host never decided: a local launch routes to the structured chat only once the renderer has learned the host's registered agents, and that list was read once at startup, so a read that failed (or was skipped while the local runtime's capabilities were unknown) kept OpenCode on the terminal for the whole session. A launch that finds the list unlearned now asks the host again, so the next launch opens the chat.

Every point that sends a launch to a terminal now says so, naming the check and never a value: the renderer's route decision ([agent-launch-route], in the renderer console), createSupport's refusing check ([structured-create-support]), a version check that passed as well as one that refused ([agent-cli-version]), an OpenCode account that cannot be pinned ([opencode-account]), and a host launch the default asked to be a chat ([agent-launch]). createSupport's composition moves out of the unchecked runtime class into structured-agent-launch-support.ts.

Tests reproduce run 5: the host's create check with HOME and every XDG directory in the rig, the Command and per-agent PATH naming a stand-in 1.18.31, and the account pinned to the rig's XDG directories; and the renderer route with those settings, before and after the host's list is learned (ablation: without the re-ask the route stays on the terminal chat).

* Type the mocked login-shell environment in the OpenCode probe tests instead of asserting it

* Pass the OpenCode Command and environment through the route test's store helper, which types its settings

* Open OpenCode's chat on the first new tab, and learn the host's agents when they become available

The renderer routes an OpenCode tab to the structured chat only once it has learned the host's registered agents, and that list was read once at startup with nothing to read it again, so a read that came too early or failed sent every OpenCode tab to the terminal chat.

- agentSession.agents answers from the registrations the host is built from, as createSupport already does, instead of installing the host first: the answer is fixed for this build, so it no longer waits on, or fails with, opening the chat journal.
- The local list is read again when the local runtime's capabilities land, so a startup read made while they were unknown is redone on the event that makes it possible.
- A new tab whose chat route waits only on that list asks the host for it and opens nothing until it answers or 3 seconds pass (the local admission bound), then decides: the first launch opens the chat. A host that does not publish its agents is never waited on.

* Type the new-tab route request as a launch request, which carries the delivery callback

* Declare that agentSession.agents answers from the registrations in the cross-version manifest

Older builds read the list from the installed host, newer ones from the registrations it is built from; the manifest now asks only that Codex is listed, which holds for both.

* Wait for this computer's runtime too before routing a new tab during a slow startup

Field QA: soon after a slow startup, a new tab opened the terminal chat because the local runtime had not reported its capabilities yet, which routes straight to the terminal; the bounded wait covered only an unlearned agent list. A new tab whose chat route waits only on either answer now asks for it and opens nothing until it comes or 3 seconds pass, then decides; the [agent-launch-route] line still names the blocker if it falls back. Tests that never meant to exercise startup now start with the runtime's answer known.

* Wait for the probe that lands the runtime capabilities, not one that failed at startup

When the startup probe fails (a rig whose terminal daemon failed to start), asking again answers null at once, so the new-tab wait ended early and the tab opened the terminal chat. The wait now subscribes to the event that delivers the capabilities, probes once, and stops at the 3 s cap; it applies to both routes, so OpenCode (whose blocker is the unlearned agent list) and Claude (unknown capabilities) both wait for it. Tests run the real capability module with a failed startup probe: Claude and OpenCode open their chat when a later probe lands within the wait, and the terminal chat after the cap otherwise.

* Answer an OpenCode account Orca cannot pin at create support, not as a failed chat

Live QA with inline OpenCode credentials (OPENCODE_AUTH_CONTENT) passed the version check at create support and was refused only at create, so the window showed a failed "OpenCode Chat" with Retry where a declined launch opens the terminal chat. Create support now resolves the account a new chat would pin through the same read-only resolver the model catalog and create use, and answers no, logging the account-pin check, when the host refuses to pin it; any other failure is left for create to state. Create's own refusal stays as the backstop.

* fix(opencode): start the chat with the user's OpenCode environment as-is

An OpenCode chat with no managed profile no longer records data/state
folders or a database choice, no longer refuses inline credentials or
relative paths, and launches with the user's environment unchanged.
Drops the create-support account-pin probe that only those refusals used.

* test(cross-version): build each method's params in their own file, under the line limit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant