Skip to content

fix(native-chat): a message stopped before the agent got it comes back as a paused card - #26827

Closed
brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/stop-keeps-unsent-as-cards
Closed

brennanb2025 wants to merge 4 commits into
mainfrom
brennanb2025/stop-keeps-unsent-as-cards

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​896 $\color{#cf222e}{\Huge{\mathbf{−}}}$​125 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​771
Prod 13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​132 $\color{#cf222e}{\Huge{\mathbf{−}}}$​131 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1

ELI5

In native chat, if you send a message and press Stop before the agent has picked it up, the agent never gets it. Until now the chat still showed that message in the conversation, marked "Stopped manually", so the screen said you had sent something the agent never saw. Now the message comes back as a queued card above the composer, paused, and you choose what happens to it: send it, edit it, or delete it.

What Changed

The problem. Send "test" and press Stop while the agent is still starting. The "test" bubble stays in the conversation with a grey "Stopped manually" row. Then ask "what was the last message I sent?" and the agent answers as if this were your first message, because "test" never reached it. The screen and the agent disagree.

The cause is that undelivered text was drawn as conversation. Orca already handles the same never-delivered message differently in two other cases. If Orca restarts, or the chat closes, before the message reaches the agent, the message is kept as a queued card at the head of the queue. Stop was the odd one out: it rejected the message and left the bubble.

Before / after (hidden dev app with stand-in agents; BEFORE is main at cbad641, AFTER is this branch):

Before (main) After (this PR)
Send "test", press Stop while Claude is starting main-s1-1-after-stop.png branch-s1-1-after-stop.png
Then ask "what was the last message I sent?" main-s1-2-after-question.png branch-s1-2-after-question.png
Codex, Stop while it starts main-s2-1-after-stop.png branch-s2-1-after-stop.png
  • Before: the "test" bubble shows "Stopped manually". The stand-in agent's input log shows only the question. "test" was never sent, yet it sits in the conversation.

  • After: no bubble. "test" is a card under the existing "Queue paused because you interrupted" header, with Resume and the card's usual Steer / Delete / More actions. Your next typed message goes first. Once the agent accepts it, the pause lifts and the card is sent next, exactly once. The agent's input log shows the question, then "test".

  • A new chat's first message works the same way. Start a chat with a message and press Stop while its agent is still starting: the message comes back as a card under the pause, not as a "Stopped manually" bubble, and nothing is put back in the composer.

  • Resume sends the card once:

    branch-s2-2-after-resume.png

  • Delete drops it, and it is never sent:

    branch-s3-2-after-delete.png

  • Reload: the card and its pause survive:

    branch-s4-2-after-reload.png

Unchanged: a message the agent may already have received keeps today's bubble and "Stopped manually" row exactly. That covers a message handed to Claude before its interrupt, a Codex send withdrawn before its turn opened, and a steer already written. Image sends, /compact and other commands, orchestration mail, dispatch preambles, restart continuations and a card's own hand-off are rejected exactly as before. Nothing new appears on screen: the card, the header, Resume and the card actions already exist on desktop and phone.

Mechanism.

  1. holdUnsentSends (journal-unsent-send-hold.ts), the routine that restart and close already use, gains a cancelled cause. That cause selects every send the host still holds and has provably not written to the agent. The existing rule decides what is kept: a person's text-only, non-command message becomes a card at the head of the queue. The card is written in the same database transaction as the send's rejection, and the rejection names the card (keptAsQueuedMessageId). If inserting the card fails, the send falls back to the plain rejection, so the Stop still completes.
  2. The chat's Stop (structured-agent-session-chat-stop.ts) uses that hold in place of its old reject-everything call. The interrupt never waits on it. The Stop event, which the queue's pause is derived from, is issued in the same tick as the hold. So clients never see a frame where the card is waiting, unpaused and next to send (that was review finding P2-1). A side effect: if the withdrawal throws after it landed, the card it returned now waits under the Stop's pause until Resume. On main it would resend on its own.
  3. A child that dies during its start after a Stop keeps your unrun message the same way (structured-agent-session-child-exit.ts).
  4. The transcript projection and turn grouping skip a send kept as a card, so the card is the only place its text appears.
  5. Dead code removed: unrunRejection, rejectPendingSubmissions and rejectQueuedSubmissions (their tests now go through holdUnsentSends or resolveDispatch).

No new persisted field, no wire change, no new capability, no agent-adapter change, and no new copy or notice.

Why

The fix models the concept rather than patching the symptom. Text the agent never received is not conversation, so it is drawn where every other unsent message already lives: the queue. Stop now goes through the same path restart and close already use, so all three cases that leave a message unsent behave the same way. The alternative was to keep the bubble and carry the stopped text to the agent inside the next message. That builds a second delivery mechanism, it sends the agent text the person may no longer want, and its framing has to be hidden from the person's own bubble. This change instead reuses the card, the pause and Resume that already exist, and leaves the choice with the person.

Differences from the common pattern:

  • A stopped first message becomes a card — intended. The common pattern only queues messages sent while the agent works. Here Stop pulls back a message the agent never received, and putting it in the queue is the only surface that does not claim it was sent.
  • No history is sent to the agent — intended. The agent hears only what you send or resume. Nothing is replayed behind your back.
  • The pause lifts on your next accepted turn, or on Resume — intended. This is unchanged from main and is the rule every other paused card already follows. Keeping main's rule means one pause rule for every card. The card stays visible, with Delete and Edit, until it sends.
  • The message moves to the queue at Stop, not when it is sent — intended. In the common pattern, a message that can't be delivered yet becomes a queued message the moment it is sent. Orca's typed messages are not queued that way today (the queue for typed messages is off), so the host holds them as sends. Stop is where a held send is either delivered or not, so that is where it becomes a card. This uses the same path Orca already uses when the app restarts or the chat closes.
  • Image sends and commands stopped before start are rejected as before — temporary. Cards are text-only. Image cards remove this.
  • An older client draws a Stop-kept message twice, as a bubble with "Stopped manually" and as a card — temporary. Nothing is sent twice, and the old client's send path already treats the kept message as queued. Native chat has no real users yet, and updating the client removes it.
  • A Stop of a card's own Send-now hand-off returns that card where it stood, while restart and close move it to the head — intended. The person cancelled that send, so the card goes back unchanged, as on main.

Known edge (rare storage failure): if the Stop's own record fails to save, the kept card is not paused, so it sends like any other card, once. A test pins this. The alternative was writing the Stop event in the same transaction as the first card. That needs a new row-writer path and a rework of how all four Stop branches record their event, which is too large for this change.

Linked Issue

None (maintainer change, no tracking issue).

Visual Proof

See the before/after table above. Hidden Orca dev app (ORCA_BACKGROUND_LAUNCH=1, fresh profile, mock keychain), driven through Playwright CDP, with stand-in Claude and Codex agents that log every write. Each screenshot was read and checked against the stand-in input log.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Hidden QA (macOS), on main and on this branch:

  • Claude, Stop during start, then a question. Main: one bubble with "Stopped manually", and the agent got only the question. Branch: a card under the pause; the agent got the question, then "test" exactly once.
  • Codex, Stop during start, then Resume. Main: a bubble, nothing to resume. Branch: a card; Resume sent it once, 64 ms after the click.
  • Delete a kept card: "delete me" was never sent.
  • Reload: the card and its pause survived, and the message was never sent.

Automated:

  • New host tests (real host, store and SQLite journal) cover:
    • two held sends kept as cards in order, with zero provider writes and no bubbles;
    • Resume, and the next typed message draining the cards once;
    • Retry, restart, /clear and rewind;
    • a failed card insert;
    • mail, /compact and images rejected as before;
    • the child-exit path;
    • a frame probe proving no published frame shows the kept card without the stopped pause;
    • the failed-Stop-record pin.
  • Turn grouping: a test that fails when the kept-send exclusion is removed.
  • Desktop/phone projection parity.
  • A new chat whose first message travels with its create request (Save a new chat's first message during create #26332): its create tests now expect a Stop before the hand-over to keep that message as a card (no write, the stopped pause, no bubble), sent exactly once on Resume or after the next message. A renderer test pins that the composer stays empty, so the card is the only place the text appears.
  • After merging current main: every test file under src/main/native-chat/ (370 files) passes, and so do the shared, renderer native-chat, launch and RPC agent-session tests (552 files) and the mobile queue/projection/parity tests (87 files).
  • Ablations: restoring main's Stop path fails the new kept-card tests. Restoring the old idle-branch ordering fails the frame test. Removing the turn-grouping exclusion fails its test.
  • tc:node, tc:web, tc:cli and the mobile typecheck pass. orca-ci-checks passes 22/22.

Not verified: real agent CLIs, SSH and WSL hosts (this is host-side bookkeeping with no wire change), Linux and Windows, the phone app on a device, and an actual older client against this host.

AI Disclosure

Review

An independent review found one P2 and four P3s. Every one is fixed or labelled above:

  • P2-1, fixed: a one-frame unpaused card on an idle agent.
  • P3-1, pinned and documented: a failed Stop record.
  • P3-2, labelled temporary: older clients draw the message twice.
  • P3-3, fixed: the untested turn-grouping exclusion now has a test.
  • P3-4, fixed: dead helpers removed and the capability comment corrected.

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Author: @BrennanKB5

  • Security: no new input surface.
  • Cross-platform: host-side journal logic only.
  • SSH/remote: the host that runs the agent does this bookkeeping, and nothing new crosses the wire.
  • Mobile: it shares the projection, and parity is tested.
  • Backwards compatibility: see the older-client difference above.
  • Performance: the hold issues one row per held send, as before.

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)

…eued card

A person's Stop pressed before the agent had a message rejected it and left
the bubble in the conversation with "Stopped manually", even though the agent
never received it, so the screen and the agent disagreed about what was sent.

A restart and a chat close already settle such a host-held send by keeping a
person's text as a card at the head of the queue. Stop now does the same:
holdUnsentSends gains the `cancelled` cause and the Stop's withdrawal uses it,
issuing every row at once so the fold shows nothing queued when it returns.
The kept card waits under the existing Stop pause; Resume or the person's next
accepted turn releases it. Images, commands, mail and a card's own hand-off
are rejected as before. A child that dies in its start after a Stop keeps its
unrun person's send the same way, so unrunRejection and the dead
rejectPendingSubmissions go. The projection no longer draws a kept send as a
stopped bubble; the card is its only surface.
On a ready, idle agent the Stop awaited its withdrawal before issuing its
event, so clients got one frame with the kept card waiting, unpaused and next
to send. The event is now issued in the hold's tick, so one frame carries both.

Also: a turn-membership test for the kept-send exclusion, a test pinning that
a failed Stop record leaves the kept card unpaused, the dead
rejectQueuedSubmissions helpers removed (their tests ported to holdUnsentSends
or resolveDispatch), and the queued-messages capability comment corrected.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review summary

The problem. In native chat, if you send a message and press Stop before the agent picked it up, the message stayed in the conversation as a bubble marked "Stopped manually", even though the agent never received it. Ask "what was the last message I sent?" and the agent answers as if that message never existed, so the screen and the agent disagree. When Orca restarts, or the chat closes, before a message reaches the agent, Orca already keeps it as a queued card. Stop was the only case that rejected the message and left a bubble.

What changes for you. A message stopped before the agent got it now comes back as a queued card above the composer, under the existing "Queue paused because you interrupted" header. Your next typed message goes first, then the card is sent exactly once. You can also Resume, edit or delete the card. A message the agent may already have received keeps today's bubble and "Stopped manually" row. Image sends and commands are rejected as before.

Review findings (one independent review of the first commit):

  • P2-1, fixed. On a ready, idle agent, clients briefly received a frame showing the card unpaused and marked as next to send. The Stop event is now issued in the same tick as the card, and a frame-by-frame test proves no frame shows the card without its pause. Side effect: if the Stop's withdrawal throws after it landed, the card it returned now waits under the pause until Resume. On main it would resend on its own.
  • P3-1, deferred (pinned and documented). If the Stop's own record fails to save, which is a rare storage failure, the kept card is not paused and sends once like any card. Writing the Stop event in the card's transaction would need a new multi-row write path and a rework of all four Stop branches. A test pins the current behaviour.
  • P3-2, deferred (labelled temporary). An older client draws a Stop-kept message twice: as a bubble and as a card. Nothing is sent twice. Native chat has no real users yet, and updating the client removes it.
  • P3-3, fixed. The turn-grouping rule that skips a kept send now has a test, and that test fails without the rule.
  • P3-4, fixed. The dead rejectQueuedSubmissions helpers are removed, with their tests ported, and the queued-messages capability comment is corrected.

Verified:

  • Hidden QA on macOS, main versus this branch, with stand-in Claude and Codex agents that log every write. I read every screenshot myself and checked every agent write against the log:
    • Stop during start, then a question: on the branch the agent got the question, then the stopped message once.
    • Codex Resume sends the card once.
    • A deleted card is never sent.
    • The card and its pause survive a reload.
  • All 294 test files under src/main/native-chat/ and the 527 shared, renderer and RPC agent-session test files pass, plus ablations for each new test.
  • tc:web and tc:cli pass. tc:node's only error is main's own at this base, fixed on main by test(native-chat): fix main typecheck after the batched journal commit #26781.
  • orca-ci-checks and PR CI are green.
  • The real Claude and Codex config files had the same hashes before and after QA.

Not verified: real agent CLIs; SSH, WSL, Linux and Windows hosts; the phone on a device; and a real older client against this host.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 9, 2026 10:05
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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: 423d0ad6-5074-4c23-89d0-6c57f490ac8f

📥 Commits

Reviewing files that changed from the base of the PR and between 62761db and 29c0d57.


📒 Files selected for processing (6)
  • src/main/native-chat/agent-session-wire/structured-agent-session-create-test-fixture.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-create.test.ts
  • src/main/runtime/rpc/methods/structured-agent-session-create-message.test.ts
  • src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts
  • src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts
  • src/shared/protocol-version.ts

🚧 Files skipped from review as they are similar to previous changes (1)
  • src/shared/protocol-version.ts

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



📝 Walkthrough

Walkthrough

Stop handling now settles unsent sends as rejected submissions that can retain queued-message cards. Eligible cards remain waiting under the stopped pause and are not dispatched by Stop. Stop orchestration uses the hold operation, and the journal removes its direct pending and queued rejection methods. Message projection excludes kept-card sends from stopped-before-start rows and turn anchors. Added tests cover journal settlement, Stop lifecycle cases, projection, and mobile snapshot parity.


Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 29c0d

This change keeps a text message stopped before the agent received it as a paused queued card. The supplied incremental changes only add test coverage and update a documentation comment, and no concrete merge-blocking issue was identified.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 48.65% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 32 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check Passed The title clearly and concisely describes the main behavior change: a message stopped before agent handoff returns as a paused queued card.
Description check Passed The description is complete and well aligned with the template. It explains the user impact, mechanism, rationale, testing, visual proof, known limitations, compatibility considerations, and checklist…


  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR


🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

…r is kept as a card

Main now carries a new chat's first message in its create request. A Stop
before the agent is handed that message keeps it as a card at the head of the
queue under the Stop's pause, like any person's held send: no provider write,
no bubble, sent once on Resume or after the next message. The create flow puts
nothing back in the composer for it, so the card is the one surface.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

CI on the final head 29c0d57901d (main synced after #26332). Four checks are red, and none of them comes from this PR:

  • cross-version wire compatibility: 7 tests in qoder-history-search-downgrade.unit.test.ts and session-search-agent-set-downgrade.unit.test.ts fail. The same 7 also fail on a clean copy of main at d358e5973cf (run locally, same assertions). These tests cover session-search agent negotiation, which main's Kiro session-history change (feat(kiro): report Kiro agent status and list Kiro sessions in history #26695) touched. This PR changes no session-search code.
  • test / tests node 24 4/5: NativeChatToolLine.test.tsx › "colors the full PowerShell command only when its shell is known" fails under bun. Another unrelated PR's run (nwparker/reproducible-codex-proxy-10-09) fails the same test, and it passes locally on this head. This PR does not touch that component.
  • package: Docker Hub returned "429 Too Many Requests" on both attempts. This is infrastructure, not code.
  • verify: the aggregate gate, red only because of the three above.

All other checks pass. Locally: every test file under src/main/native-chat/ (370), 552 shared, renderer, launch and RPC files, and 87 mobile files pass; tc:node, tc:web and tc:cli pass; orca-ci-checks passes 22/22.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Superseded by #27623. Instead of turning a message stopped before the agent got it into a paused card, #27623 keeps the bubble and its "Stopped manually" row and delivers the message to the agent with your next send, framed as context rather than a new request.

@brennanb2025
brennanb2025 deleted the brennanb2025/stop-keeps-unsent-as-cards branch October 20, 2026 17:04
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