Repository navigation
fix(native-chat): each chat failure says why in plain words, on the thing that failed - #23608
Conversation
…its reason History, subscribe, snapshot and options reads reach a chat through one accessor, whose open had no catch: a journal that would not open reached every client as a runtime error carrying the storage's own text (a path, "file is not a database"). The accessor, and the options read's own open, now throw the classified journal refusal: journalCorrupt when SQLite reports damage, journalUnavailable otherwise. The storage text goes to the log only. The wire code stays runtime_error and the message becomes the bare code, as for every thrown refusal; the reason rides in the error's data.
…no hand-written reason The sweep passed an English sentence as the stop's reason. It lived only in memory and nothing read it: the delivery loop words the error row and the rejection from the hostStopped fact. Dropped, with the display-name lookup that built it.
…never its message A thrown agent-session refusal reaches the client as runtime_error with the bare code as its message and the typed refusal in error.data. Stop, answers, options and goals, a launch's held option pick, the option picker's failure toast, and the Retry line of a chat that could not start now word it from that refusal through the shared notice table. What each caller decides about the outcome is unchanged: only the words move. The Retry line of a failed start no longer prints the host's message or a thrown error's text; it keeps the refusal as a fact and says the cause and step its reason names, or only that the chat could not be started. The option toast keeps a local option surface's own sentence.
…nd damage stops the retry The structured chat's read failure showed the host's text on the status line, and the pane always said Orca keeps trying. The read transport now takes the refusal from the error's data (a stream payload or a thrown RPC error), the reducer keeps it beside the failure text, and the pane and the status line word it through the notice table, once: on the pane when the failure took it, else beside the transcript that stays. A damaged journal says "Unable to load this chat." and the read stops reconnecting for that run; reopening the chat reads again. An open that can clear names its cause without "Try again", since the pane retries on its own. A failure that names no reason keeps today's generic line. Finality comes from the refusal's reason, never its message, which is the bare code for both.
A message the host recorded and then rejected keeps the host's typed fact beside its reason, but the Retry words re-read the reason alone. Now the fact decides: a hand-over failure says Orca couldn't reach the agent, a kind whose reason may be a legacy marker gets its fact's own sentence (a full queue now says so instead of only "not sent"), and any other kind shows the sentence the host wrote for it, which carries the agent's name and any words the provider wrote for a person. A row with no fact reads as before.
…s own row The structured chat showed one Retry strip under the transcript for whichever single entry it picked, so a second failed message had no reason and no Retry of its own. The terminal-backed chat's existing per-row delivery marker now carries a notice and an optional Retry, and the structured pane derives one per message from the outbox on each render: every rejected message, and the one the queue stopped on (read through the drain's own rule, so a Retry never names a message waiting behind it). Each is worded from that message's stored failure. The single strip is deleted. Nothing new is stored, and the shared message projection is untouched.
… already acts Three wording rules for the desktop chat: - A chat that could not start shows Retry beside its reason, so the reason stops at its cause where the Retry is the step: a reason whose action is to retry, and a start failure's "send your message again". Any other step stays (quit the terminal agent, start a new chat). The start-failure sentences take a retryControl context for this; what the host writes is unchanged. - A history that couldn't open right now still reconnects, so the pane keeps "Orca keeps trying to load it" under its cause. Only a damaged history, which no retry reads past, drops it. - A read failure that names no reason while the transcript is shown is only the pane reconnecting: the status line says "Reconnecting to this chat…" in muted text, not an error. New key components.native-chat.state.reconnecting, hand-translated for es/fr/ja/ko/zh.
… is moving Each rejected message's row offered its own Retry even while the queue was stopped on another message. Any Retry clears the stopped queue, so pressing a rejected message's Retry also sent the message the queue was holding, which the user had not retried; behind a message whose delivery is unconfirmed, the retried one instead went back into the queue with no notice and waited there. While the queue is stopped, only the message it stopped on offers Retry, as the single Retry strip this replaced did. A rejected message keeps its words on its row and gets its Retry back once the queue moves.
…n every reconnect A reader reconnects every 750 ms while a journal open can clear, and each attempt logged the failure with its full stack. The read door now logs a session's failure once until that session opens, closes, or fails differently; every attempt is still refused with its reason.
…tice stops at the cause A rejected message offering Retry read "Claude stopped before it finished starting. Send your message to try again." beside that button. Its row now takes the rule the launch strip already follows: beside its own Retry the words leave out sending or trying again, worded from the stored fact with the chat's agent name. The stored fact keeps less than the host wrote from (a refusal, the provider's words), so a reason it cannot rebuild exactly is kept as written. A rejected message without a Retry, while the queue is held, keeps the step. What the host writes and the phone's notice are unchanged.
…e one the queue is held on Retry released the queue's refusal hold whichever message it was pressed on. While a queued message waited ahead of a held one, every rejected message offered Retry, and pressing it also sent the held message the person had not retried. Retrying an unconfirmed message ahead of a held one did the same. Retry now releases the hold only for its own message.
…sign in first Beside a Retry the notice dropped the whole next step, so a chat that could not start because the agent was not signed in read only the cause. Pressing Retry without signing in fails the same way again. The words now keep the sign-in step and leave out only the resend, which the Retry button is.
…ep the same way A message the queue stopped on worded its refusal with no agent name and with its retry step, beside its own Retry, while a rejected message next to it named the agent and left the step to the button. The launch strip and the history pane each had their own copy of the same rule. One wording context now goes through the one notice table for every surface: a Retry beside the words, or a pane that reconnects on its own, is the step for a reason whose action is to retry, and every other step stays. What the phone and the host write is unchanged.
… fact, never by comparing sentences A message the host recorded and then rejected kept only the rejection's kind on the message, so its notice was reworded from that smaller copy only when it rebuilt the host's sentence word for word. A different agent name, an older host's wording, or anything the copy dropped (why a start failed, the provider's own words) left the host's sentence in place, beside a Retry that repeated its resend step. The notice now reads the journal's own rejection for that message, found by id, with the pane's agent name and Retry, and shows the provider's words only when they were written for a person. The message's smaller copy words it only when that journal row is not loaded. Nothing new is stored.
… new id the first time Whether a Retry needed a new message id was remembered in memory for one message, or read from the journal row when it was loaded. After a restart, or for an older message whose row was not loaded, the first Retry resent under the old id, the host answered with the same settled rejection, and nothing visibly happened. The message now says so itself: one the host recorded and rejected always retries under a new id, including after a restart. A refusal that already gave the message a fresh id, and a message whose delivery is unconfirmed or in flight, keep their id as before.
…eps the provider's words When the journal row that rejected a message is not loaded, the message's own copy of the fact has no provider detail or start refusal. For the kinds worded from those, the row now shows the sentence the host wrote for the person instead of a thinner rebuilt one.
…aded journal row Nothing covered the pane handing the journal's rows to the per-message notices, so a pane that stopped passing them would quietly fall back to the message's smaller copy of the rejection and show the host's sentence, resend step and all. The new case renders the pane with a rejected message whose journal row is loaded and checks that it reads that row's refusal in the chat's own agent name.
|
Review status: ready for maintainer review (still a draft). Head Review loops. Six review passes and one separate architecture review ran. The last pass came back clean. What they fixed:
Live QA ran on a separate Mac against dev builds, before (
Readiness and compatibility. Readiness reviews at the start and end both passed, with no release blockers. The phone is unaffected: its shared calls produce the same words, and nothing on the wire changed except the one disclosed case below. Typecheck (node, web, mobile), lint and the tests pass. Known and disclosed (see Known weaknesses):
Overlaps. #23026 touches the same outbox hook (Retry and the hold). Whichever lands second merges the other. |
A read the host refused for a named reason put its sentence under the generic "Could not load conversation" title, so a damaged history read as two lines saying the same thing. The pane's own sentence now takes the title's place; a history that can come back keeps its line saying Orca keeps trying. A failure that names nothing keeps the generic title.
… was not sent
When an agent stopped before it finished starting, the chat showed the start's
red row ("Claude stopped before it finished starting. Send your message to try
again.") and then repeated that cause under every message the start rejected.
Each of those messages now reads "Your message was not sent." beside its Retry.
The match is made on typed facts, not on the words: the host writes the start's
row and the rejection of its queued messages from the same failure fact, and the
row is keyed by the start. The pane finds the loaded start-failure rows by that
key and shortens a message's notice only when its loaded journal submission was
rejected with the same fact. Any other rejection, or one whose submission or row
is not loaded, keeps its full notice. The row key moves to a shared module so the
host that writes it and the pane that reads it use one definition.
…less often A read the host kept refusing (its history store could not be opened right now) reopened every 750 ms for as long as the chat stayed open, about 40 opens every 30 seconds. Each reconnect now waits twice as long as the last, from 750 ms up to 30 seconds, and never gives up; the first read that delivers anything starts the wait over at 750 ms. A damaged history still stops reconnecting at once. Reset happens on a delivered read, not on connect: a local subscribe resolves before the host's open refuses, so resetting there would keep the 750 ms loop.
|
Status update: ready for maintainer review (still a draft). Head Follow-up changes from the owner's review of the live QA. Each change has a test that fails without it, and a focused review of these commits found no issues.
Live QA round 3, on a separate Mac with the chat tab active in every shot; screenshots are in the description:
Known weaknesses, updated in the description:
Typecheck (node, web) passed on these commits, and CI is green at |
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (4)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review. 📝 WalkthroughWalkthroughThe changes add structured refusal handling for journal reads and session launches. The renderer now carries refusal details into read-error state, reconnect decisions, and user-facing notices. Delivery failures now produce per-message notices with retry controls based on outbox state and journal failure facts. Tests cover refusal classification, retry behavior, reconnect timing, and notice wording. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to No blocking issue is established for the reviewed reconnect changes; this PR is mergeable after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The affected chat is experimental and off by default. The review found no demonstrated new access path or security finding, but the new failure and recovery behavior has enough moving parts to warrant review. Retained concerns Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
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. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — full 67-file diff at head f7892dc8 against base 8b410b4, with independent passes over the reconnect backoff, the "no host error.message reaches a surface" invariant, and the host-side open classification.
- Per-message delivery notices — the single
NativeChatDeliveryRetrystrip is gone; each failed user row renders its ownNativeChatDeliveryNoticefrom the newstructured-agent-session-delivery-notices.ts, derived per render and keyed byagentJournalSubmissionKey(which is exactly the projected message id). Retry availability reuses the drain's ownadmitStructuredAgentSessionOutboxEntryrule, and a Retry now clears the queue's hold only when it is that message's own hold. - Read-failure wording and finality —
structured-agent-session-read-failure-notice.tsdrives the one pane line;journalCorruptstops reconnecting,journalUnavailablekeeps retrying with backoff. I traced the subscribe path for local, remote-environment, and SSH targets and confirmed a refused open emits no subscribe frame, so thereset()inhandleEventcannot pin the backoff — the documented 750 ms → 30 s growth holds in production. - Host classification —
journal-open-failure.tsclassifies at the read door and logs once per session+failure (capped); storage text and paths stay in the log while the wire carries the bare code plusdata.refusal. - Typed facts over strings — start-failure rows are keyed by a shared identity, the start-failure facts hook holds identity across streaming, and refusals thrown by Stop/answer/option/goal/create are worded from typed data. I traced every renderer
.message/String(error)read in the covered paths and found no host refusal that reaches a user-visible string. - Restart-safe retry — rotation is decided from the message's own saved rejected state, so a rejected message retried after a restart goes out under a fresh id the first time.
The new tests assert exact copy and exact call arguments (not loose truthiness), and the harness change projects the real outbox so the notice keys and row ids are exercised through the same path production uses.
ℹ️ The open-failure wrap reports more than journal failures
journalOpenReadRefusal / createJournalOpenReadRefusals wrap the whole host.open, so an adapter.historyFilePath throw, an adoptOpened/publish failure, or a thrown defect is reported to the reader as agent_session_journal_unreadable and worded as a history open that can clear. This is not a regression — the write path classifies any openConversation throw the same way today, and the retry behavior is unchanged from before this PR — and I am not recommending a change here, only flagging it so the distinction is a deliberate one.
Technical details
# Non-journal `host.open` failures are worded as journal-open failures
## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts:120-122` — wraps all of `host.open`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts:92-94` — same for `openConversation`.
- `src/main/native-chat/agent-session-journal/journal-open-failure.ts:25-27` — `classifyJournalOpenFailure` falls back to `journalUnavailable`.
## What happens
`host.open` reaches `openStructuredAgentSessionConversation`, which also computes
`adapter.historyFilePath`, opens/imports the journal, and runs `adoptOpened` (publish). Any
throw from those, or from a plain bug, becomes `agent_session_journal_unreadable` with
`journalUnavailable` (or `journalCorrupt` only when the cause chain carries an
`ERR_SQLITE_ERROR` errcode 11/26). A non-journal fault is therefore presented as a transient
history problem that keeps retrying, rather than as the fault it is.
## Note
`AgentSessionRefusalError` raised for any other condition passes through unchanged
(`journal-open-failure.ts:35-37, 51-53`), so other refusals are not mislabelled. Pre-PR, the
same failures surfaced as raw text on the status line and retried anyway, so this changes the
words, not the behavior.DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
The read retry doubles its wait up to 30 s during an outage, and nothing reset it when the remote runtime reconnected, so the transcript could lag the reconnect by up to 30 s. The read now watches the runtime status store's contact-regained edges (hostContactEpoch for a same-runtime return, connectionGeneration for a new runtime session) and, when one lands, runs a waiting retry immediately with the wait reset to its base.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single commit added since the prior pullfrog review (f7892dc8 → 4c03975). It makes a remote chat's read retry immediately when its host comes back, instead of waiting out the grown backoff.
- Host-contact-regained subscription — new
runtime-host-contact-regained.tswatches the runtime status store and fires when an environment'shostContactEpochorconnectionGenerationmoves past its last value, covering both a same-runtime return and a new runtime session; a cleared-then-returning entry still counts as a reconnect. - Read transport hooks the edge —
structured-agent-session-read-transport.tssubscribes forenvironmenttargets only, and on a regain callsretryNow(): it resets the doubling wait to 750 ms and, if a retry is pending, runs it immediately. A local read is unchanged, and the watch is torn down indispose(). - Tests — the transport test drives an outage to the 30 s cap, asserts the immediate reconnect on the contact edge and a 750 ms wait after a post-reconnect failure, and that a local read never watches; the new store test covers the epoch/generation edges, the same-runtime case not moving
connectionGeneration, and environment isolation.
The backoff growth, journalCorrupt finality, and teardown from the earlier commits are untouched, and the new subscription is cleaned up alongside the reconnect scheduler.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
Conflicts with #23608 resolved to keep a crash-doubted or older not-delivered send drawn as sent, with no barrier or Retry, while keeping its per-message notices, the retryControl wording and the Retry id rotation read from the message. Its admission test that relied on a live unknown turning unconfirmed now pins that a live unknown holds nothing and offers no Retry.
…3614) into #23059 Conflicts, resolved to keep main's mechanism and #23059's turn facts: - Codex turn boundaries and translator: main's #23682 wins; the error-driven `fail` end and the oversized-notification settler are gone, and only `turn/completed` ends a turn, a command's turn included. - Codex adapter: the compaction tracker stays deleted; #23618's settlement of sends in an ended turn is kept, and `codexDispatchRejection` is imported from its new home. - Journal reducer: main's per-item creating fence (#23456) moves into the item and submission folds this branch split out of the reducer. - Stale-turn verdict: main's proof-of-death revision; every turn revision states the thread scope. - Failure words: main's retry-control rules (#23608) with this branch's any-command retry step; `commandRefused` drops its retry step beside a Retry, as hostFault does. - Delivery: both the commit wake and main's death-evidence resettle and dispose. - Fold row and transcript slots: both the turn-outcome flag and main's `agentId`. - pr.yml and pnpm-lock.yaml taken from main. Beyond the conflicts, the merged files needed: - use-structured-agent-session: both sides added the same `submissions` key; one kept. - structured-agent-session-projection: the root-item import main still used is unused here. - The desktop message list and the mobile chat view each grew past the line limit from both sides' additions: the turn-membership memo moves into its own hook, and the mobile list footer (live status, then waiting rows) into its own helper.

Follows #23116 (merged). This is the desktop half of the chat failure-copy work; the phone's half is a later PR.
Scope: which chat this affects
experimentalStructuredNativeChat, off by default). The terminal-backed chat's "Not delivered — check the terminal" marker now renders through the same per-row notice, with the same words and no Retry.ELI5
Some chat errors still showed Orca's internal text, and one error line stood in for several messages. Now a chat whose history can't be opened says so in plain words and stops retrying when it never can load. Each message that didn't go through says why on its own row, and its Retry sends only that message. Every refusal is worded from the typed reason the host sent, never from Orca's log text.
What Changed
Before
After
Why
error.message, and a message's rejection was shown from the host's sentence. Under fix(native-chat): the host writes chat failures for a person, with a typed fact beside them #23116 the host'smessageis log text, so any surface reading it can show internal text. This PR removes that at the source. The host classifies at the read door, the client carries the typed refusal or fact to each surface, and every surface words it through the one copy table (agent-session-refusal-notice.tswithagent-session-failure-words.ts).details.reason === 'journalCorrupt'stops reconnecting. A temporary failure, an older host that sends no reason, or any other refusal keeps the current retry.Differences from the common pattern
Linked Issue
No separate issue. Follow-up to #23116; reported from live QA of #22918.
Visual Proof
Live Electron QA ran on a separate Mac against dev builds with isolated profiles. BEFORE is this PR's merge base on
main(2f8f4f576dd); AFTER is this PR, at5e41e506a98for the first four pairs and the restart shot at4fd67d7cd59; later commits change only tests. A shim made Claude fail to start on demand; history damage was made by overwriting a chat'sjournal.db(not a database) or removing its permissions (chmod 000), restored afterwards.Two messages queued behind a start that fails. Before: one strip for both, neither message marked. After: the start-failure row states the cause once, and each message it rejected reads "Your message was not sent." with its own Retry.
Pressing one Retry. Before: the strip's Retry sent the first message and the second stayed stuck under the same strip. After: pressing the second message's Retry sent only the second; the first kept its own notice and Retry.
Damaged history. Before: red "file is not a database", "Orca keeps trying to load it.", 40 reconnects in 30 s. After: one line, "Unable to load this chat.", no raw text, 0 reconnects in 30 s.
History that can't be opened right now (
chmod 000). Before: red "unable to open database file". After: "Orca couldn't open this chat's history right now." as the one title, and it keeps retrying with backoff (two retries 24 s apart in a 60 s window, against 40 per 30 s onmain), with the host logging it once instead of about 80 times a minute.A rejected message retried after an app restart. After quitting and relaunching with a rejected message on screen, one press of its Retry sent it (one agent start, reply received).
The chat-create strip's wording could not be reproduced live on either build (a refused create needs a host race); it is covered by unit and pane tests.
Testing
Platforms: macOS, live (the QA above) and unit. The host change runs wherever the chat's history lives, so it applies unchanged to SSH and other remote hosts; SSH was not exercised live. Windows and Linux were not run; nothing here is platform-dependent.
Each new test was checked against a copy with the change reverted, and each failed for the expected reason:
journal-open-failure.test.ts,structured-agent-session-at-rest.test.ts: a read, subscribe or options call on a history that won't open is refused withagent_session_journal_unreadableandjournalCorruptorjournalUnavailable, the storage text and path never reach the reply, and a reconnecting reader logs once per failure.structured-agent-session-read-transport.test.ts,NativeChatStructuredSession.read-error.test.tsx,NativeChatEmptyState.test.tsx: damage stops reconnecting and reads "Unable to load this chat." as the one line, with no generic title; a temporary failure keeps going with retries backing off from 750 ms to 30 s (fake timers) and resetting after a successful read; a failure with no reason while messages are shown reads "Reconnecting to this chat…".NativeChatStructuredSession.start-failure-notice.test.tsx,use-structured-agent-session-start-failure-facts.test.ts: messages a failed start rejected read "Your message was not sent." under the start-failure row that states the cause; a different rejection keeps its full words; with no start row loaded the full words stay; the start rows' facts keep one identity while unchanged, so streaming doesn't re-render every row.structured-agent-session-delivery-notices.test.ts,NativeChatStructuredSessionDelivery.test.tsx,NativeChatMessageRow.test.tsx: each failed message gets its own notice; only the held message offers Retry while the queue is stopped; clicking one Retry sends only that message; a recorded rejection is worded from its journal row, whatever sentence the host wrote; the not-loaded fallback keeps the provider's words.use-structured-agent-session-outbox-admission.test.tsx,use-structured-agent-session-outbox-rejection-cause.test.tsx: a Retry never releases another held message, including behind a queued or unconfirmed message; a message rejected before a restart retries under a new id the first time.agent-session-refusal-notice.test.ts: across every code, reason and write, words beside a Retry equal the plain words minus only a retry step; "Sign in first." stays.use-structured-agent-session-write-refusal.test.tsx,NativeChatSessionOptionPickers.test.tsx,structured-agent-session-launch-options.test.ts,NativeChatStructuredSession.launch-lifecycle.test.tsx: Stop, answers, options, goal, the option-picker toast and the chat-create strip are worded from the error's typed refusal, and outcomes are unchanged.structured-agent-session-send-disposition.test.ts,agent-session-write-failure.test.ts,structured-agent-session-reducer.test.ts: the rejection words, the refusal read from both error shapes, and the reducer's typed read failure.mobile-structured-send-delivery.test.tsand four related mobile suites pass.Known weaknesses
agent_session_journal_unreadableinstead of raw storage text (which could include a file path). Other read refusals already reach older apps as bare codes.maintoo and is not changed here. Owner: feat(native-chat): one structured-chat journal database per host, owned by one process #23613.New strings for native review
components.native-chat.state.reconnecting("Reconnecting to this chat…"): es, fr, ja, ko, zh.Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)