Repository navigation
fix(native-chat): a message the chat said was not sent is never sent later on its own - #24232
Conversation
…l its Retry A native-chat send the host refused (for example "Chats were saved by a newer Orca. Your message was not sent.") or that never reached the host showed "not sent" with a Retry button, but the hold that stopped it lived only in the outbox hook's memory. The message itself was saved in the outbox, so the next launch lifted the hold and sent it with no Retry; a copy the user retyped in the meantime was held behind it and went out as well. The hold is now read from the failure the message already saves: a queued entry that carries its last failure waits for the user's Retry, on this launch and every later one, and an entry saved by an earlier build in that shape is held too. The drain passes over a held message instead of stopping behind it, so what the user sends next goes out as they send it. Retry clears the saved failure. On a host from before accepted-send, a new agent owner observed while the chat is open still sends the refused message again, as before; a relaunch does not. A held message keeps its operation id, and a host refuses an id older than a day as expired for good, so its Retry could never go through; a new id could deliver a message an earlier attempt already delivered. Such a message now goes back to the composer with a notice to check the chat before sending it again, and leaves the outbox.
… release them for an older host's new owner - A message the host refuses as expired under an id it kept stays a saved row reading "Orca couldn't confirm what happened. Check the chat.", and its Retry sends it under a new id. It no longer moves into the message box, where an automatic resend after a relaunch could put text the user never asked for, held only in memory. - An owner change releases a refused message only on a host known to predate accepted sends, and only for the refusals such a host gives while it restarts the chat's agent. Those rows say Orca will send it again when the agent restarts, beside their Retry. A host whose capability check has not answered, or failed, no longer releases anything. - Every failed message ahead of the one the queue stopped on keeps its Retry, since that Retry sends it at once. - A journal row saying the host cannot tell whether a message landed, and the unconfirmed probe's resend, replace an earlier attempt's saved failure, so the message is probed rather than held. - The drain stages from the hook's own outbox, so a hold kept only in memory after a failed save survives the next send; the hold is written once more after that failed save.
…message-stays-held
…, and a send is staged from the latest outbox A message the chat showed as not sent no longer goes out on its own when an older host's chat gets a new agent owner. Resending it on the owner change sent it after messages typed later, still delivered a retyped copy twice, and its "Orca will send it again when the agent restarts" row promised a resend that often never came. It now waits for the user's Retry, as it does on every current host. A send still in flight when the owner changes is still sent again under its id; it was never shown as failed. The drain admitted and staged the next send from the render's outbox. An owner change requeues the send it interrupted in an effect earlier in the same commit, and staging from the render's list wrote the old list back, leaving that send stuck as sending. The drain now reads the latest list, which still carries a hold kept only in memory after a failed save. Tests pass the view's target as one stable object, as the view does: a new object each render re-ran the owner-change requeue, which hid the drain bug.
…d its saved cause only when seen A message shown as not sent stays in the outbox and draws below every newer turn. It was an ordinary user row there, so it counted as the newest user row: while a new send waited for its turn to open, that turn's "Working for" clock drew under the old message, and once the turn ended an empty "Worked for" divider was left under it. The projection now marks such a bubble (held for its Retry, or rejected) as unsent; turn membership gives it no turn and never makes it the live one, on hosts that state turn scopes and on those that do not; and the transcript draws it after the live activity, as it draws a message waiting behind /compact. Mobile has no outbox, so its rows never carry the mark and its grouping is unchanged. A held message read back from storage repeated the cause it was saved with, which may no longer hold: "Update Orca to keep using them" after the user updated Orca. The outbox hook now remembers, in memory only, which messages failed while the chat was open; only those word their cause. Any other held message reads "Your message was not sent." with its Retry, and a Retry the cause still stops brings the full words back. An expired id keeps its words, since that cause cannot clear.
…message-stays-held Brings in #24221, which moves the desktop outbox into a per-session store that every writer commits to. Conflicts in the outbox hook, dispatch, retry, ownership and the unconfirmed probe resolve onto that store, keeping this branch's behaviour: - the drain admits and stages from the store's latest list (no render copy); - a failed staging save commits the Retry hold, saved if storage takes it; - Retry rotates an expired kept id; the probe strips a saved failure; - failures seen while the chat is open are recorded before each commit; - the in-memory blocked-head marker #24221 still threaded is dropped.
… only rows are not sent A message shown as not sent draws after the windowed transcript. When it was the only row, the windowed list was empty, and following the bottom or "Jump to latest" asked the virtualizer for an end it computes from its own rows: the top. It now scrolls to the container's own bottom when no row is windowed. A rejected send the journal recorded keeps its place but opens no turn, so the rail lit no tick when it was the row being read. A user row in no turn now lights its own tick. Also pins that a refusal seen while the chat is open reaches the rendered notice in full, and reads only "not sent" after the chat is reopened until a Retry is refused again.
…al arrives The low-evidence lint rejects "shape" as a symbol name.
Review status: ready for review (not merged)The problem. In a native chat, a message the chat said "was not sent" (with a Retry button) was sent on its own the next time Orca opened, without Retry. If you had typed it again meanwhile, the agent got it twice. This was on main before this PR, and live QA on main reproduced all three ways it shows up, counted from the agent's side:
What changes for you.
Fixed during review (several rounds of independent reviewers, plus live QA):
Deferred (small, recorded, not fixed here):
Verified.
Not verified.
|
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Repository guideline files applied to this review (1)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 (55)
💤 Files with no reviewable changes (12)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe outbox now records retry holds on individual entries instead of tracking a blocked message ID. Admission can skip held entries and dispatch later eligible entries, while Retry clears saved failure state and handles expired operation IDs. Delivery notices reflect outbox admission and current-chat failures. Held messages render as unsent rows outside turn membership, with related updates to transcript ordering, rail selection, and scrolling. Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to The change holds failed messages for explicit Retry while allowing later eligible messages to proceed. No concrete merge-blocking issue remains; mergeable after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change strengthens control over when failed messages are sent. The reviewed paths preserve explicit Retry and existing send controls. No new permission bypass was established, but restart guarantees depend on successfully saving the failure state, and broader authorization coverage remains limited. 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 💡 1📝 Generate docstrings 💡
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
- Derive the hold from the saved failure. A
queuedoutbox entry carryinglastFailurenow waits for its own Retry (structuredAgentSessionEntryHeldForRetry, newstructured-agent-session-outbox-admission.ts), so the hold survives a relaunch instead of living in the hook's memory. - Admission and staging move off the in-memory marker.
blockedClientMessageId/blockedIdRefare removed from the send disposition, dispatch, drain, notices, queued cards, ownership and Stop withdrawal; the drain reads and stages from the shared outbox store. - Retry and expired ids. Retry strips
lastFailure/outlivedStop; a kept message whose id the host refused withagent_session_operation_expiredis held as an "outcome unknown" row whose Retry rotates the id. - Wording gate. The new in-memory
failedHereset decides whether a held row words its saved cause in full or says only "Your message was not sent.". - Projection, turns and rail.
unsentoutbox bubbles get no turn key, are skipped by live-turn selection, draw below the live activity when the journal holds no position for them, and light their own rail tick. - Scroll and owner change. Following the bottom falls back to the container when no row is windowed; the owner-change release of a blocked message is removed, leaving only in-flight
dispatchingsends to be requeued.
I traced the new state machine across the disposition, reconcile, retry, probe, drain and notice paths; an unsent bubble still renders its Retry through NativeChatWaitingTranscriptItems sharing rowContext; and no non-unsent row loses its turn key or rail tick. I found no path that resets a held queued+lastFailure entry into a no-failure queued state and auto-sends it, and no owner-change ordering that either re-sends a refused message or loses a genuinely in-flight one. The new tests (relaunch-hold, owner-change, retry-hold, delivery notices, message-list unsent, turn membership, transcript-window) assert exact values rather than loose shapes.
ℹ️ The hold now applies to every queued+lastFailure entry, not just refusals
Worth confirming this breadth is intended. Because structuredAgentSessionEntryHeldForRetry is state === 'queued' && lastFailure !== undefined, it also covers a transient transport failure (lastFailure: { kind: 'failed' }) and, on a host that does not advertise the accepted-send capability, a send whose transport died mid-flight. Those no longer auto-retry on the next drain or owner change — they need a manual Retry. The PR body states this is deliberate ("A send that failed before reaching the host is held the same way"), and each row keeps a visible Retry, so there is nothing to change; flagging only because it is a broader behavior shift than the "chats saved by a newer Orca" case in the title.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

ELI5
When a native chat could not send your message, it said "Your message was not sent" and showed a Retry button. But the next time you opened Orca, it sent that message anyway, without you pressing Retry. If you had typed the message again in the meantime, the agent got it twice. Now a message that says "not sent" stays unsent until you press its Retry, even after you quit and reopen Orca. The only other way it leaves that state is the host itself reporting that it may have the message after all.
What Changed
The bug, as the user sees it. Found by the live QA of #24006; it is on main and #24006 did not cause it.
Why it happened. The message is saved in the chat's outbox (browser storage) as "queued", together with the refusal that stopped it. What held it back was a separate marker kept only in the outbox hook's memory. A relaunch starts with that marker empty, finds a queued message, and sends it. The outbox's own contract says that on a current host only a Retry or a new send goes out.
Scope. Desktop native chat (structured agent sessions) only: the per-chat outbox in the desktop app. Not affected: terminal (TUI) agents, the phone app, the host side, and the wire between client and host.
After this change.
Mechanism.
lastFailure): aqueuedoutbox entry that carries its last failure waits for the user's Retry (structuredAgentSessionEntryHeldForRetry, newstructured-agent-session-outbox-admission.ts). No new stored field.blockedIdRef/blockedClientMessageId) is removed from the outbox hook, the send disposition, the drain, the delivery notices, the queued-card filter and the Stop withdrawal; they all read the saved entry instead, so none of them can disagree after a relaunch.rejectedone. Only a message whose delivery is unconfirmed (or one a Stop outlived) still stops the queue, because that one may yet land. A row gets its Retry when that Retry would send at once: every failed message at or before the one the queue stopped on.pending), and a journal row saying the host cannot tell (unknown, which makes it unconfirmed so the unconfirmed probe resends it). The probe's own resend also clears it.agent_session_operation_expiredon a message it kept (queued, because an earlier attempt may have landed) is held with that refusal; its row reads the existing translated sentence for an outcome Orca cannot confirm, and its Retry rotates the id. A first attempt refused that way was already given a fresh id and still is.useStructuredAgentSessionOutboxOwnerChange) resends only a send that was in flight; on main it also released the message the queue was blocked on. Nothing releases a held message now but its Retry, or the host's own journal row for it (above).rejectedasunsent. Turn membership (nativeChatTurnMembership, shared with mobile, which never has such rows) gives it no turn key and skips it when choosing the newest user row as the live turn, on both the scoped and the journal-order path. The transcript draws it after the live activity line, as it already draws a message waiting behind/compact; one the journal recorded before rejecting it keeps its journal place.failedHere, added when a send answer saves a new failure, cleared on Retry). A held message outside it reads the existing translated "Your message was not sent."; nothing new is stored and no new sentence is added.Why
Differences from the common pattern
The two reference implementations differ from each other in places; each entry says which shape this follows.
sends a message typed again after a refusal once, not a second time after a relaunch, which fails on main; and this PR's live QA saw main deliver the retyped copy twice; see Visual Proof).Compatibility.
holds a refused message an earlier Orca saved, in the shape that build writes).Linked Issue
Found by the live QA of #24006 (pre-existing on main), which saw a message shown as "not sent" delivered on the next launch.
Visual Proof
Live QA by an agent on a remote Mac (M4Air): real Orca builds running in a hidden background window, each with its own profile and home directory, with structured Claude chats. To make Orca refuse, the chat database was marked as written by a newer Orca (
user_version = 5, with the app quit), then set back to 4. "Agent got it" counts come from the agent's side: user turns in the Claude transcript, cross-checked against the submission rows in Orca's chat journal. Builds: main3727100cc9d; this PR at090ab9a5737(first round),a3426e9e445(after the review rounds) and686d8a494ff(current head).3727100cc9dSent and answered on its own about 2 s after launch.
a3426e9e445: still unsent 90 s later, with its Retry. Retry then sent it: agent got it once.Both copies sent: agent got it twice.
a3426e9e445: nothing sent on relaunch; Retry on the newer copy sent it once; the original still waits.The refused message went out by itself first.
a3426e9e445: the new message is answered; the refused one waits with its Retry (sent once on Retry).The turn divider under a waiting message, before and after the fix in this PR:
090ab9a5737a3426e9e445An empty "Worked for 1s" divider under the waiting message.
Nothing under the waiting message.
During the new message's turn (
a3426e9e445), the clock stays with that turn:"Working for 0s" sits under the new message; the waiting one is below with its Retry. A page recorder saw this in all 19 samples, 0 to 17 s.
The answer streams; the waiting message stays last with its Retry.
The words after a relaunch (
a3426e9e445): only "Your message was not sent." while the cause may have cleared, and the full reason again once a Retry is refused for it.Following the reply after Retry, current head
686d8a494ff: about 7 s into the reply the view follows the streaming answer, and "Jump to latest" never appeared (0 of 401 samples, with frames running at about 60 per second).Testing
use-structured-agent-session-outbox-relaunch-hold.test.tsx: relaunch after a returned and a thrown "newer Orca" refusal sends nothing and keeps the words and Retry, and Retry sends under the refused id; a message retyped after a refusal is not sent twice after a relaunch; a send that never reached the host stays held across a relaunch; an entry in the exact shape an earlier build saves is held; on an older host a refused message is sent neither on a relaunch onto a moved fence nor on a fence change while open, and its Retry sends it once; a failed save holds the message without holding back the next one, keeps holding it when the next message goes out, and holds it across a relaunch once storage takes the hold; an expired kept id stays a row with its words and its Retry sends a new id, including when the automatic resend after a relaunch meets it (nothing goes into the message box); a message in doubt that carries an earlier failure is probed and sent, not held.New
use-structured-agent-session-outbox-owner-change.test.tsx: when the chat's agent owner moves, a refused message is not sent on a current remote host whose capability check failed or has not answered, nor on a host known to be older (for each of seven refusal codes, and for a send that never reached the host), and its Retry then sends it once; a send in flight when the owner moves, with another queued behind it, goes out again first on the new owner, both on an older host and before the capability check answers, while a refused message beside it stays held.structured-agent-session-delivery-notices.test.ts: a refused or rejected message ahead of an unconfirmed or Stop-outlived one keeps its Retry, one behind it does not; the full words of a kept expired id ("Orca couldn't confirm what happened. Check the chat.", never "not sent").New
NativeChatMessageList.unsent-message.test.tsx: with a held or a rejected message below it, a new send in flight, then running, then done draws its "Working" bar and then its "Worked for" bar with its own prompt, the live activity line above the not-sent message, and no bar under the not-sent message, on a host that states turn scopes and on one that does not.native-chat-turn-membership.test.ts: such a row has no turn key and is never the live turn.D11 wording:
structured-agent-session-delivery-notices.test.tswords a held cause only for a failure seen while the chat was open;use-structured-agent-session-outbox-relaunch-hold.test.tsxshows "Your message was not sent." after a relaunch, and the full "newer Orca" words again after a Retry the host refuses again.NativeChatStructuredSessionDelivery.test.tsx: through the rendered chat, a refusal seen while it is open shows its full words; after the chat is reopened it reads "Your message was not sent."; a Retry the host refuses again brings the full words back.use-native-chat-transcript-window.options.test.tsx: with no windowed row, following the bottom goes to the container's bottom.native-chat-active-rail-item.test.ts: a prompt in no turn lights its own tick.structured-agent-session-outbox-retry-hold.test.ts: which entries are held, the drain passing over them but still stopping on an unconfirmed one, and a journalpendingorunknownrow replacing the failure.use-structured-agent-session-outbox-rejection-cause.test.tsx:leaves no error behind once a send refused before an agent restart is deliveredbecamewaits for its Retry across an agent restart, and leaves no error once that Retry lands: on an older host the fence move no longer sends the refused message; the user's Retry sends it once and it settles with no error.Each fix was ablated and its tests turned red.
131 targeted test files (1274 tests) pass on the pushed head: the 62 files this change touches or affects; the owner-change test; the notice-copy, refusal-notice, accepted-send-capability and i18n tests; the native-chat transcript, message-list, turn, rail, window and scroll tests; and every test fix(notes): send AI notes to open structured chat sessions #24221 added or changed (outside sends, the shared outbox store, stopping a send in doubt). The 7 mobile transcript and turn-status test files (69 tests) pass.
tc:webandtc:nodepass; oxlint, the changed-code quality gate, React Doctor and the localization checks pass. CI's full checks, including the unit suite (test 1–5/5), are green on686d8a494ff.Live QA on a remote Mac by an agent; see Visual Proof.
I manually tested these changes locally
Automated tests added/updated, or explained why not below
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
A message kept for its Retry has no Discard control, only Retry (pre-existing for rejected messages; this PR makes it apply to every refused one). Follow-up.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)