Skip to content

fix(native-chat): a message the chat said was not sent is never sent later on its own - #24232

Merged
brennanb2025 merged 8 commits into
mainfrom
brennanb2025/refused-message-stays-held
Oct 1, 2026
Merged

brennanb2025 merged 8 commits into
mainfrom
brennanb2025/refused-message-stays-held

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 33 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1465 $\color{#cf222e}{\Huge{\mathbf{−}}}$​158 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1307
Prod 22 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​351 $\color{#cf222e}{\Huge{\mathbf{−}}}$​221 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​130

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.

  1. You send a message in a native chat whose history was saved by a newer Orca (or any time the host refuses the send, or the send never reaches it).
  2. Under the message you see "Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them." with a Retry button. Nothing says it will be sent later.
  3. You update Orca, or just quit and reopen it. The message is sent to the agent on its own, possibly long after you wrote it, to an agent whose context has moved on.
  4. If you typed the same message again before quitting, the copy waited silently behind the refused one (with no status of its own), and after the relaunch both went out: the agent received it twice.

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.

  • A message that shows "not sent" with a Retry stays unsent across quitting, reopening and updating Orca, and keeps its words and its Retry. Pressing Retry sends it (under the same operation id when the host recorded nothing, so it cannot be delivered twice).
  • A message you send after a refused one goes out right away instead of waiting silently behind it. In the "chats saved by a newer Orca" case that new message is refused too and gets its own Retry, so nothing is sent twice.
  • A message shown as not sent sits below newer turns, after their live activity, and takes no part in them. Before, when you sent a new message past it, the new turn's "Working for" clock showed under the old not-sent message, and once the turn ended an empty "Worked for 1s" divider was left under it. Now the clock and the "Worked for" divider stay with the new message's own turn.
  • After quitting and reopening Orca, a message shown as not sent says just "Your message was not sent." with its Retry, instead of repeating the cause it was saved with, which may no longer hold (for example "Update Orca to keep using them" after you updated Orca). If the cause still holds, pressing Retry is refused again and the full reason comes back. A message whose operation id expired keeps its "Orca couldn't confirm what happened" words, since that cannot clear.
  • A refused message sitting ahead of a message whose delivery is unconfirmed now keeps its Retry. Before, only the unconfirmed one had a Retry, although the refused one's Retry would send it at once.
  • A send that failed before reaching the host is held the same way. If Orca also could not save the message to storage just before sending it, the hold is saved as soon as storage takes a write; if storage keeps failing, the hold lasts only while the chat stays open (a banner says the message could not be saved).
  • A message Orca re-sends on its own after a relaunch (one it was in the middle of sending when Orca quit), or one you retry, more than a day after it was first sent: the host no longer accepts its operation id. It stays in the chat as a row saying "Orca couldn't confirm what happened. Check the chat." with a Retry, because an earlier attempt may already have delivered it. Pressing Retry sends it as a new message. Before this PR it went into the message box instead, with a notice that the next send erased; before that, on main, its Retry was refused the same way forever.
  • On every host, including one running an Orca from before the accepted-send capability, a message shown as not sent goes out only when you press its Retry, or when the host's own journal row for it says it may have it after all (then it is checked and resent under its id, as any message in doubt is). On such an older host a new agent owner (the agent restarting while the chat is open) used to send it again on its own, possibly after messages you typed later, and alongside a copy you retyped; it no longer does. A send that was still on its way when the agent restarted, and never shown as failed, is still sent again under its id.

Mechanism.

  • The hold is derived from the failure the message already saves (lastFailure): a queued outbox entry that carries its last failure waits for the user's Retry (structuredAgentSessionEntryHeldForRetry, new structured-agent-session-outbox-admission.ts). No new stored field.
  • The in-memory marker (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.
  • The drain passes over a held message the way it already passes over a rejected one. 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.
  • Any newer word on where a message stands replaces an earlier attempt's failure: Retry, a journal row showing the host has it (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.
  • The drain admits and stages the next send from the session's shared outbox store (the one copy the composer, a launch prompt and a message sent from outside the chat all write), never from the render's copy or straight from storage. So a hold only memory holds (after a failed save) survives the next send, and an owner change's requeue of the send it interrupted, written earlier in the same commit, is not undone. After a failed save the hold is written once more.
  • An operation id the host refused as agent_session_operation_expired on 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.
  • An owner change (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).
  • The message projection marks an outbox bubble held for its Retry or rejected as unsent. 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.
  • The outbox hook keeps, in memory only, the ids whose send failed or was refused while the chat was open (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.
  • With every drawn row a not-sent one (none in the journal yet), following the bottom and "Jump to latest" scroll to the container's own bottom, since the windowed list holds no row. A prompt in no turn (a rejected send the journal recorded) lights its own rail tick. A not-sent prompt drawn after the conversation has no rail tick (follow-up).

Why

  • The words on screen are the contract: "not sent" with a Retry means nothing happens until Retry. The hold has to live where the message lives, or a relaunch loses it.
  • Deriving the hold from the saved failure, rather than adding a new flag or state, also holds messages that current and recent builds already saved in that shape. That is exactly the reported case: the older build wrote the refused entry, and the updated build is the one that sent it. A new state would be dropped by older builds' parser, losing the text on a downgrade.
  • Letting a new send go past a held message removes the double send without asking the user to guess which copy will go out. A message waiting behind a held one showed no status at all, so it looked sent while it was not.
  • Why a row, not the message box, for an expired id: the automatic resend after a relaunch reaches it with no user action, and the message box is memory only, so text moved there on its own is lost on the next quit and its notice was overwritten by the next send. A row keeps the text saved, carries its own translated words, and its explicit Retry sends a new message; the user checks the chat first, as the row says.
  • Alternatives considered:
    • Persisting the old in-memory marker as a separate field: rejected, a second copy of a fact the entry already holds, and it would not hold entries already saved by earlier builds.
    • Keeping the held message as a barrier for everything after it: rejected, it keeps the retyped copy waiting silently and the double send comes back on the first Retry.
    • Dropping the refused message on relaunch: rejected, it would lose text the user wrote and was told to retry.

Differences from the common pattern

The two reference implementations differ from each other in places; each entry says which shape this follows.

  • Where the hold lives. One keeps queued messages only in memory (after a restart a failed one is gone, never resent); the other persists the failure on the message, and a recorded failure excludes it from automatic sends. This PR follows the persisted shape, with the same gate: the saved failure is the hold. Intended: Orca already saves its outbox across launches, so the in-memory shape would drop text the user was told to retry.
  • No automatic retry of a message shown as not sent. The references disagree: the persisted one retries a failed queued message up to 3 times on a timer (15 s, 60 s, 5 min) while showing its failure, then stops; the in-memory one never resends it. This follows the never-resend shape. Intended: the failures in scope do not clear on a timer (a journal written by a newer Orca, a stale checkpoint), and an automatic resend of a message the user was told was not sent is the observed bug here (the refactor(native-chat): keep agent-session records in the chat journal database #24006 QA saw it resent on relaunch; the double send is reproduced by 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).
  • A held message does not hold back later ones. The references disagree for messages already queued behind a failed one: the persisted one never blocks ("a queue, not a pipeline"); the in-memory one keeps already-queued ones behind the failed head but lets a new send past when everything queued is held. This follows the persisted one. Intended: in Orca every send goes through this outbox as a direct send, and a message waiting behind a held one showed no status, which is what produced the double send.
  • An expired message. None: matches the common pattern. A failed queued message stays a row until the user acts, and the explicit send always works because it goes out under a new id (as the in-memory reference's "Send now" does on every send).
  • A refused direct send stays as a Retry row on desktop. Both references, and Orca's own phone, return a failed direct send to the composer; desktop keeps it in the outbox with a Retry. Temporary (pre-existing, not changed here): follow-up: evaluate returning a refusal that proves nothing was recorded to the composer, as the phone does (no ticket yet).

Compatibility.

  • No host, RPC or wire change, so SSH and remote hosts and mixed client/host versions are unaffected on the wire.
  • The saved entry's shape is unchanged. A newer build reads an older build's refused entry and holds it (pinned by holds a refused message an earlier Orca saved, in the shape that build writes).
  • Downgrading: an older build that opens the chat sends a held message on its own (that build's own bug, which cannot be fixed from here). If the chat is not opened while the older build runs, the hold is intact after upgrading again.
  • Builds from before fix(native-chat): say a refused chat write in plain words, and keep it with the write it describes #22999 did not save the failure on the message, so a refused entry they left behind looks like an unsent one and is still sent once.
  • Mobile is not affected: the phone keeps no message text across launches (only operation ids for resends), and a refused message goes back to its composer.

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: main 3727100cc9d; this PR at 090ab9a5737 (first round), a3426e9e445 (after the review rounds) and 686d8a494ff (current head).

main 3727100cc9d this PR
Refused, then relaunched once the database is fixed, nothing pressed main: after relaunch the refused message was sent and answered with no Retry
Sent and answered on its own about 2 s after launch.
PR: after relaunch, still unsent, Your message was not sent. with Retry
a3426e9e445: still unsent 90 s later, with its Retry. Retry then sent it: agent got it once.
Same message typed again after the refusal, then relaunch main: after relaunch both copies sent and answered
Both copies sent: agent got it twice.
PR: after Retry on the newer copy it is sent once; the original still waits with its Retry
a3426e9e445: nothing sent on relaunch; Retry on the newer copy sent it once; the original still waits.
New message while a refused one waits main: after relaunch the refused message went out by itself, then the new one
The refused message went out by itself first.
PR: new message answered; the held message below with Your message was not sent. and Retry
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:

first round 090ab9a5737 a3426e9e445
first round: held message below the newer answer, followed by an empty Worked for divider
An empty "Worked for 1s" divider under the waiting message.
after the fix: nothing under the held message, no empty Worked for line
Nothing under the waiting message.

During the new message's turn (a3426e9e445), the clock stays with that turn:

one second in seven seconds in
one second into the new turn, Working for 0s sits directly under the new message
"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.
seven seconds in, the answer streams and the held message stays at the bottom with its Retry
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.

after relaunch after a Retry refused again
after relaunch, still refused, only Your message was not sent. with Retry Retry refused again brings back the full newer-Orca words

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

686d8a494ff: about 7 s into the reply after Retry, the view follows the streaming answer

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.ts words a held cause only for a failure seen while the chat was open; use-structured-agent-session-outbox-relaunch-hold.test.tsx shows "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 journal pending or unknown row 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 delivered became waits 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:web and tc:node pass; 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 on 686d8a494ff.

  • 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

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and 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

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…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.
…, 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.
@brennanb2025
brennanb2025 marked this pull request as ready for review October 1, 2026 02:04
@brennanb2025

Copy link
Copy Markdown
Contributor Author

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:

  • a refused message went out on relaunch with no Retry;
  • a retyped copy was delivered twice;
  • a refused message jumped ahead of a newer one.

What changes for you.

  • A message shown as "not sent" stays unsent until you press its Retry, on every host, across quitting, reopening and updating Orca.
  • Retry sends it exactly once.
  • A newer message goes out while the refused one waits.
  • After a relaunch the waiting message reads "Your message was not sent." with Retry, instead of a cause that may no longer hold. The full reason comes back if Retry is refused again.
  • Older hosts change too. On a host from before the accepted-send capability, an agent restart used to resend such a message by itself; it no longer does. A send that was mid-flight when the agent restarted is still resent.

Fixed during review (several rounds of independent reviewers, plus live QA):

  • Messages whose operation id expired after a day: these were moved into the message box with no Retry, and could be lost on quit. They now stay a row with "Orca couldn't confirm what happened. Check the chat." Its Retry sends a new message.
  • The agent-restart resend: it also fired on current remote hosts during a reconnect, and on older hosts it reordered messages and doubled retyped copies. It is removed.
  • An empty "Worked for 1s" divider appeared under a waiting message once a newer turn ran.
  • A held message ahead of an unconfirmed one had no Retry.
  • A save failure could undo an owner-change requeue, or lose a memory-only hold.
  • A message whose only rows were unsent scrolled to the top on "Jump to latest", and a recorded rejected message lost its rail tick.
  • Merged main's shared outbox store (fix(notes): send AI notes to open structured chat sessions #24221) and checked that notes sent into an open chat still work alongside held messages.
  • CI was red on every earlier head because of one lint error, so CI's unit suite hadn't run. It's fixed, and the full PR Checks run is green on 686d8a4.

Deferred (small, recorded, not fixed here):

  • A waiting message drawn after the conversation has no message-rail tick.
  • Waiting messages have no Discard button, only Retry.
  • A refused direct send stays a Retry row on desktop, while the phone returns it to the message box (pre-existing).
  • A rare case where an expired message needs two Retries.
  • If storage refuses two writes in a row, the hold lasts only while the chat stays open.
  • Two existing outbox error lines are still English-only (pre-existing).
  • Rarely, a message whose earlier delivery is unconfirmed can be overtaken by a later one. This is unproven to reorder anything in practice.

Verified.

  • CI green on 686d8a4 (static analysis, unit tests 1-5/5, relay, verify).
  • Typecheck for web, node and mobile.
  • 131 targeted test files, 1,274 tests, with each fix ablated so its tests go red without it.
  • Live QA on a remote Mac in four phases (screenshots in the description):
    • the three bug scenarios on main and on the PR;
    • the divider and clock;
    • the wording after relaunch;
    • scroll-follow after Retry versus a normal send.

Not verified.

  • No person tested it by hand; an agent ran the live QA in a hidden background window.
  • The older-host behaviour is covered by tests only; it was not run live.
  • SSH and remote hosts were not run live. No wire or host code changed.
  • The final commit after the scroll fix only renamed a test parameter; the QA builds before it covered the code.

@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium risk] Fixes message delivery state tracking in the chat outbox.

The PR appears safe to merge; no actionable regression was established.

Summary

The PR derives a native-chat message’s Retry hold from its saved outbox failure, preventing a refused message from being sent automatically after relaunch. It also lets later messages proceed, updates Retry and notice handling, and keeps unsent rows outside newer turns.

  • Adds tests for relaunches, owner changes, storage failures, delivery notices, and transcript placement.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Queued outbox entry] --> B{Saved failure?}
  B -- Yes --> C[Hold for user Retry]
  B -- No --> D[Admit for dispatch]
  C --> E[Later messages may proceed]
  C --> F{Retry or newer host journal evidence?}
  F -- Retry --> D
  F -- Host evidence --> G[Reconcile delivery state]
Loading

Reviews (1) · Last reviewed commit: "test(native-chat): name the relaunch tes..."

@coderabbitai

coderabbitai Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Repository guideline files applied to this review (1)
AGENTS.md — auto-discovered

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 46897b31-031c-452b-b773-80caad6bcaac

📥 Commits

Reviewing files that changed from the base of the PR and between 6729f1b and 686d8a4.

📒 Files selected for processing (55)
  • src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx
  • src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx
  • src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts
  • src/renderer/src/components/native-chat/native-chat-active-rail-item.ts
  • src/renderer/src/components/native-chat/native-chat-transcript-slots.ts
  • src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts
  • src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts
  • src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts
  • src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts
  • src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts
  • src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts
  • src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts
  • src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx
  • src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-failed-here.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts
  • src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session.ts
  • src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts
  • src/shared/agent-session-refusal-retry.test.ts
  • src/shared/native-chat-turn-membership.test.ts
  • src/shared/native-chat-turn-membership.ts
  • src/shared/native-chat-types.ts
  • src/shared/structured-agent-session-draft-hand-off.test.ts
  • src/shared/structured-agent-session-message-projection.ts
  • src/shared/structured-agent-session-outbox-admission.ts
  • src/shared/structured-agent-session-outbox-retry-hold.test.ts
  • src/shared/structured-agent-session-outbox-stop-withdrawal.ts
  • src/shared/structured-agent-session-outbox.queue-delivery.test.ts
  • src/shared/structured-agent-session-outbox.ts
  • src/shared/structured-agent-session-send-disposition.test.ts
  • src/shared/structured-agent-session-send-disposition.ts
💤 Files with no reviewable changes (12)
  • src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx
  • src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The 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 686d8

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 Review

Security architecture risk: 🔵 Low · up to 686d8

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
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The security-relevant outcome is dispatch of user-authored message content to the selected session and runtime, potentially causing agent activity. The inspected client flow does not establish the agent's maximum asset, tenant, credential, or environment authority.

Trust Boundaries and Controls

  • observed — Skipping a held message does not bypass the checked dispatch controls: admission must select an eligible entry, a runtime fence must be present, and the hook must have no in-flight send. Staging must be saved before the host send begins. Matching journal evidence, rather than an ordinary queue drain, can supersede a saved local failure.

Resilience and Maintainability Implications

  • observed — If staging cannot be saved, dispatch does not call the host and attempts to save a failure hold. If that write also fails, the open session retains the hold in memory, but persistent storage may retain the earlier eligible entry. Thus retry-only behavior across closing or relaunch depends on eventually saving the hold. This is a residual durability limitation, not an established PR-introduced attack path.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 78 functions across 43 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is detailed and covers the user impact, implementation, scope, testing, visual proof, compatibility, and checklist. The Linked Issue section does not contain the required actual issue …
Title check ✅ Passed The title clearly describes the primary behavior change: a message reported as not sent must not send later without user action.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

@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 found.

Reviewed changes

  • Derive the hold from the saved failure. A queued outbox entry carrying lastFailure now waits for its own Retry (structuredAgentSessionEntryHeldForRetry, new structured-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/blockedIdRef are 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 with agent_session_operation_expired is held as an "outcome unknown" row whose Retry rotates the id.
  • Wording gate. The new in-memory failedHere set decides whether a held row words its saved cause in full or says only "Your message was not sent.".
  • Projection, turns and rail. unsent outbox 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 dispatching sends 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.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@brennanb2025
brennanb2025 merged commit 07e9fdf into main Oct 1, 2026
60 checks passed
@pullfrog pullfrog Bot mentioned this pull request Oct 1, 2026
7 of 9 tasks
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