Skip to content

fix(native-chat): every chat action press is its own action (re-land #23916 onto main) - #24298

Closed
brennanb2025 wants to merge 1 commit into
mainfrom
brennanb2025/land-23916-on-main
Closed

brennanb2025 wants to merge 1 commit into
mainfrom
brennanb2025/land-23916-on-main

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Re-land of #23916. #23916 was approved and merged, but GitHub merged it into its old base branch (brennanb2025/clear-starts-on-first-send, #23935's branch) instead of main, so its changes never reached main. This PR carries exactly the same change: the squash commit 6468680, cherry-picked onto main with an identical patch (git patch-id matches). It applied with no conflicts. tc:node and tc:web pass on current main. Below is #23916's description, unchanged.

Files Added Deleted Net
Test 28 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1313 $\color{#cf222e}{\Huge{\mathbf{−}}}$​237 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1076
Prod 23 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​342 $\color{#cf222e}{\Huge{\mathbf{−}}}$​239 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​103

ELI5

When a chat action failed to get an answer (for example the connection dropped), Orca kept that action's id and quietly reused it the next time you pressed the same thing. The host then treated the new press as a repeat of the old one and did nothing. So picking an option again could leave the chat on the other one, a second Stop could do nothing, and setting a goal again could leave it cleared. Now every press is its own action. A harmless repeat, such as a second Stop of a turn you already stopped, or a second tap on a queued message's Send, is answered from what the chat already records, so it changes nothing and says nothing false.

The plan this belongs to

Goal: one operation id per user action for every structured chat write. Each press is new, only an explicit retry of that one action may reuse an id, and the host answers a harmless repeat from what the chat already records. It ships as:

What Changed

The problem. The desktop app kept operation ids in memory keyed by chat, action and exact payload, and kept one after a dropped connection, an "unknown" answer or a refusal. The next identical press, however much later, reused it, and the host replayed the old outcome instead of running. The phone's Stop and its /compact and /clear did the same. Seen as:

  • pick option A (connection drops), pick B, pick A again: the chat stayed on B;
  • a Stop naming a turn or a background task, pressed again after a failure: nothing happened;
  • set a goal (answer lost), clear it, set it again: it stayed cleared;
  • a /compact retyped later, even after newer messages, replayed the old "completed" and did not compact, and on the phone that lasted up to 24 hours.

Scope. Structured Claude and Codex chats, on desktop, paired desktop and phone, local and SSH hosts. That includes the Send, Delete and Resume buttons on messages queued while the agent works (#23731, #23736). Sends on the desktop already used a fresh id per message and are unchanged. Phone sends are step 2.

Before / After

You do Before After
Pick A (lost), B, then A again stays on B switches to A
Set a goal (lost), clear, set it again stays cleared set
Stop again after a failed Stop (desktop named Stop, background task, phone) nothing happens stops
Type /clear again after its answer was lost worked only until the app reloaded; after that "This conversation has been cleared" the chat moves to the current conversation, nothing new runs, also after a reload
Stop a turn twice by its id (a phone double-tap, or Stop on two devices), at once or a moment apart a second row, "The provider had already finished this turn.", which is false one "Cancellation requested." row
Stop a turn that already ended by itself "The provider had already finished this turn." no row; nothing was stopped
Press /compact again while the first waits or runs answered from the kept id refused: "Wait for the current turn to finish before using this command."; it runs once
Press /compact again after the first ended answered from the kept id, even after newer messages runs again
Press /clear again while the first is still running answered from the kept id refused: "Wait for the conversation operation to finish."; one new conversation
Tap a queued message's Send, Delete or Resume twice answered from the id shared by the two taps each tap has its own id; the second is answered from the card's state: already sent, already gone, or nothing left to resume

Mechanism

  • Renderer use-structured-agent-session-mutate.ts: every call mints its own id. The per-payload id map, the kept /compact and /clear id, and the shared "does this write name its target" check (structured-agent-session-operation-identity.ts) are deleted.
  • Mobile: Stop, the composer command and the queued-message buttons mint a fresh id per press (use-mobile-structured-agent-mutation.ts, use-mobile-structured-send-with-outcome.ts); the id-retention helpers are deleted.
  • Host, where a repeat is harmless, answers from what the chat records:
    • the same answer to a question already answered that way returns the stored answer (structured-agent-session-turns-prompt.ts);
    • Cancel of a question already cancelled answers ok (structured-agent-session-turns-cancel.ts);
    • a /clear from the same client after its /clear committed, with no tab left on the old conversation, returns that clear's result, replacement included, before anything starts (structured-conversation-command.ts). Only a successful clear counts; another client, or a cleared conversation reopened from history, still reads "cleared".
    • a Stop that stopped nothing writes no row (structured-agent-session-turns-cancel.ts). "The provider had already finished this turn." is gone. The one "Cancellation requested." note is keyed by the turn it stopped, found by the turn's id whether it still runs or has ended (structured-agent-session-turn-stop-notes.ts), so another Stop of that turn rewrites that row instead of adding one. A Stop that didn't do what was asked still says so: the refusal row while the turn keeps running, and "Cancellation was not confirmed." when the adapter failed.
    • a second /compact or /clear while the first waits or runs is refused by the existing gates, with the existing lines; one pressed after it ended runs again. A retry of the same operation id is answered from what it already wrote (the ledger replay, unchanged).
    • a second Send, Delete or Resume of a queued message: an already-sent card answers with the submission it made, an already-deleted one with "already withdrawn", and a Resume with nothing paused with "resumed nothing" (structured-agent-session-queued-mutations.ts, unchanged; now pinned by tests).
  • A new host capability, agent-session.repeated-stop.v1, says the host writes no row for a Stop that stopped nothing, so a repeated Stop is quiet. The desktop and phone check it, and against a host without it, each still joins a second Stop of a turn to one already on its way, as before this PR, so an older host never writes the false row.
  • The three capabilities that gate a Stop (conversation-stop, prompt-cancel and the new repeated-stop) moved together into src/shared/agent-session-stop-capabilities.ts. protocol-version.ts re-exports them, so no importer changes. Main had filled that file to its line limit, and this keeps one Stop's wire rules in one place instead of trimming unrelated lines.
  • Nothing new is saved. The only change on the wire is that one capability string.

Why

A press is a user action, and an action's identity cannot be inferred from its payload: the same pick an hour later is a new choice. Retries of one action stay possible where a stored record of that action exists (a desktop message's outbox entry; #23524's host-side /clear retry), so the client never has to keep an id it can't re-derive. Repeats that are harmless are answered from state the host already holds, not from a remembered id.

Alternatives considered:

Differences from the common pattern

  • A common approach mints a random command id per call and replays only an identical id, and records that id on every event it produces. This PR matches the per-call id and the exact-id replay. It does not record the id on every chat row, because nothing here needs to answer "which press made this row", and it would add a stored field to every row.
  • Matches: a Stop is recorded at the time it takes effect and only when it takes effect. One common approach writes its interrupted row in the same transaction that moves the thread to stopping, and a Stop that applies to nothing (already stopping, or idle) writes nothing. The other upserts the turn, keyed by turn id, to interrupted. Here the one note is written on the provider's answer to the interrupt, keyed by turn, and a Stop that stopped nothing writes nothing.
  • Matches: a failed Stop still says so. A common approach appends an "interrupt failed" activity when the interrupt fails or no session is bound. Here the refusal row and "Cancellation was not confirmed." stay.
  • Matches: /compact and /clear: each press has its own id, only a replay of the same id is answered from its record, a press while one runs or a turn is active is refused by state, and a later press runs again. That is how both common approaches handle them.
  • Intended: a common approach also joins concurrent Stops per thread in memory. Here two Stops at once reach the host one after the other on the session's lane, and the second either stops nothing, writing nothing, or rewrites the same turn-keyed row, so no join is needed on a current host.
  • Temporary: the desktop and the phone still join a second Stop of a turn to one on its way when the host lacks agent-session.repeated-stop.v1. It exists only so a new app talking to an older host doesn't write the false row. The follow-up deletes both client joins once no supported host lacks that capability.

Linked Issue

No issue. Found while fixing Stop and send delivery in #23026 and #23553.

Visual Proof

A Stop of a turn, sent twice under separate ids, and a Stop of a turn that already ended. A double Stop under separate ids is what a phone double-tap sends, or Stop pressed on two devices. Live on a second Mac with the real Claude CLI 2.1.285, in a production-renderer build driven over CDP. Two arms, each proven from its built bundle:

  • before: this PR's base, fix(native-chat): /clear starts nothing; the new chat's first message starts its agent #23935 at d4e7d53c7bd, where "The provider had already finished this turn." appears twice in the main bundle;
  • after: this PR at 8867990f21d, where it appears 0 times and the capability string 4 times.
    Each Stop goes through the host's own agentSession.cancel from the page, naming the running turn. The rows below were also read back from each arm's journal. After this PR, the note's key is the turn id, and it is written once per stopped turn.
Case Before After
Two Stops at once "Cancellation requested." then "The provider had already finished this turn." one "Cancellation requested."
The second Stop 1.5 s after the first one answered (the slow-link case) the same two rows one "Cancellation requested."
A Stop naming a turn that had already ended by itself "The provider had already finished this turn." no row
The desktop's own Stop button double-clicked (it names no turn) one row one row

Before, two Stops at once: the false line under the stopped reply:

Before: two Stops of the same turn at once leave "The provider had already finished this turn." under the stopped reply

Before, the second Stop 1.5 s after the first had answered:

Before: a second Stop 1.5 s after the first leaves "The provider had already finished this turn."

After, both at once and 1.5 s apart: no false line. The two runs stopped at the same point and rendered identically, so one image shows both:

After: two Stops of the same turn, at once or 1.5 s apart, leave no extra line

Before, a Stop naming a turn that had already finished:

Before: a Stop of a turn that had already finished writes "The provider had already finished this turn."

After, the same Stop writes nothing:

After: a Stop of a turn that had already finished writes no row

Phone QA: BLOCKED. The emulator QA skill this needs can only be started by a person, not by this session, and no other way of driving the phone is used in its place. So these phone checks were not run live: overlapping taps on a queued message's Send, Stop tapped twice against this PR's host, and the phone's Stop against a host without agent-session.repeated-stop.v1. The 68 mobile tests above cover them, including the phone's Stop join against a host without the capability, and both presses going out against one with it.

Earlier heads: nothing regressed. Live QA with the real Claude CLI on a second Mac, in a production-renderer build, driven over CDP. Three arms: this PR before its last rebase (09fa9e908eb), its base (f26e8039693), and the current head (89564af9862) for the /clear cases, each proven from its built bundle. Everything the app can produce behaves the same on every arm, so these runs show nothing regressed. The cases this PR fixes all need a lost reply, which the app cannot produce on demand; the tests below cover them.

Scenario Result, every arm
Stop pressed twice the reply stops, one Stop row, no error (at 110 ms the button is already gone, so the second tap misses)
Model Haiku → Sonnet → Haiku ends on Haiku, and the next reply ran on Haiku
/clear pressed twice ~27 ms apart one new conversation, no error, also on a conversation with a finished turn
/clear typed in the old conversation reopened from history "This conversation has been cleared. The command didn't run. Open the current conversation to continue.", as described above
Question card answered, pressed again resolves once, no error
"yes" sent twice both sent and answered

A Stop pressed twice: the reply stops and no error shows:

Stop pressed twice: the reply stops at 52 with one Stop row and no error

An option picked A, then B, then A again ends on A:

Model picked Haiku, Sonnet, then Haiku: the chat ends on Haiku and the next reply ran on it

/clear pressed twice on a conversation with a turn lands on one new, empty conversation (current head):

/clear pressed twice: one new empty conversation, no error

/clear on a cleared conversation reopened from history still says it was cleared (current head):

/clear on a reopened cleared conversation: "This conversation has been cleared"

The same text sent twice is sent twice, and a question card resolves once:

Both "yes" sends answered and the question resolved once

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • New use-structured-agent-session-mutate.test.tsx (a fake host ledger) and structured-agent-session-repeat-press.test.ts (the real host): an option picked again, a goal set again, a named and a background Stop pressed again, the joined Stop reporting once, and the same answer again.

  • structured-conversation-command.test.ts and structured-chat-tab-table.test.ts: a /clear double press and a retype after a lost answer land on the current conversation with nothing started; another client and a reopened conversation still read "cleared"; a failed clear runs fresh.

  • structured-agent-session-host.test.ts and -prompt-cancel.test.ts: a second prompt Cancel, sent while the first is in flight or after it, answers quietly and asks the agent once.

  • Mobile mobile-structured-composer-command.test.ts and mobile-structured-operation-id-retirement.test.ts: fresh ids per press. Against a host without the capability, Stop joins the one on its way. Against a host with it, both presses go out.

  • New structured-agent-session-repeated-stop.test.ts (the real host and journal): a second Stop after the first settled writes nothing; two Stops at once leave one row (keyed by turn; keying by operation id again fails it); a Stop of a turn that ended by itself, or two Stops that both found nothing, write nothing (bringing back "already finished" fails 7 tests); a relaunched host writes nothing more.

  • New structured-agent-session-queued-repeat-press.test.ts (the real host): Send, Delete and Resume of a queued message pressed twice under separate ids: sent once, deleted once, resumed once, and the second press answered, not refused. Removing the host's "already sent" answer fails the Send test. Mobile use-mobile-structured-agent-session-queued.test.tsx: overlapping card presses carry their own ids; it fails with main's id sharing.

  • structured-conversation-compaction.test.ts and structured-conversation-command.test.ts: a /compact pressed again under a new id while one runs is refused and runs once, and one pressed after it ended runs again; a second /clear pressed while the first runs is refused and makes one new conversation. Joining the same client's /clear instead fails the /clear test.

  • Desktop use-structured-agent-session-mutate.test.tsx: the Stop join is used only against a host without the capability; with it, every press goes out under its own id.

  • Each new test fails on the base; removing each rule fails its test.

  • tc:node, tc:web, the mobile typecheck and its test ratchet, oxlint, check:code-quality:changed, check:react-doctor:changed, the reliability gates and the anti-slop audit pass on the current head. 21 related host and desktop test files (227 tests) and 6 mobile files (68 tests) pass.

AI Disclosure

Review

Four review loops and an architecture challenge.

  • Loop 1 found that fresh ids made a quick double Stop or double Cancel write a misleading line; fixed by answering those repeats from what the chat already records, and by joining a Stop still in flight.
  • Loops 1 and 2 both found holes in keeping a /compact or /clear id on the client for a retry (it could replay an old "done", and it was lost on reload). Because the same mechanism failed twice, an architecture challenge replaced it: clients keep no retry id at all, and a /clear retry is found by the host (fix(native-chat): an unfinished /clear or refused Codex rewind no longer locks the chat #23524 and fix(native-chat): /clear starts nothing; the new chat's first message starts its agent #23935).
  • Loop 3 found only an accidental lockfile change and a doubled error toast, both fixed.
  • Final loop after the rebases found no code issues; description wording was corrected.
  • After merging main (the queued-message buttons) and adding a host-side Stop join, one more loop found:
    • two P1s, both test-only: five renderer test mocks lacked the new capability export, and one test from main still expected /clear to reuse its id. Both fixed.
    • a P2 in the design: the in-memory join covered only the time the host spent on the first Stop. It was replaced by the journal read described above, and the capability's meaning changed with it.
    • a /compact pressed again after a lost reply could compact twice once the first had finished, and a second /compact or /clear while the first was running showed a refusal line where main was quiet. Both are now answered from the command they repeat, as described above.
  • Checked against both reference projects first-hand, after that loop: the in-memory join and the journal lookup were replaced by the shapes above. A Stop records only what it did, keyed by turn; /compact and /clear repeats are refused while one runs and run again after, with only same-id replays answered. The repeat-answer code for /compact and the same-client /clear join were removed.
    • a P3 not changed: the desktop reads the capability from the runtime's status cache before a Stop naming a turn, which can cost one status round trip against a remote host. The desktop names a turn only for background-task Stops and for hosts without the conversation Stop, so this is rare.

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

Merge order: #23524, then #23935, then #23916, then #23917.

Mixed versions (clients and hosts update separately):

Follow-ups:

  • Delete the desktop and phone Stop joins once no supported host lacks agent-session.repeated-stop.v1 (see Differences, temporary).

Known, not changed here:

  • After a /clear whose answer was lost, a pane still showing the old conversation catches up only on its next tab sync; that sync is not covered by a test.

Same on macOS, Linux, Windows and SSH hosts.

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)

Author: @BrennanKB5

* fix(native-chat): give every chat write press its own operation id

A client kept an operation id per method and payload across presses, so a later identical press replayed an earlier action instead of running: an option picked again stayed on the other one, a goal set again stayed cleared, and a second Stop or phone Stop did nothing. Every press now mints its own id, on desktop and phone, and no client keeps a retry id.

The host answers a harmless repeat from what the chat records: the same prompt answer again returns the resolution it holds, and a Cancel of a prompt already cancelled answers ok. A second Stop of the same turn while the first is on its way joins it.

* fix(native-chat): answer a /clear pressed again after it committed with that clear

Every press now carries its own operation id, so a /clear that reaches the host after this caller's /clear already committed (a double press, or a retype after a lost answer) no longer replays the first id. It started the cleared conversation's agent and then refused it with "This conversation has been cleared."

The host now answers such a /clear from the committed record: the same caller, on a conversation whose tab moved to its replacement, gets that clear's result, replacement included, before admission starts anything. No second clear runs. Another window, or a cleared conversation reopened from history, still reads "cleared".

* test(native-chat): scope the prompt-cancel tests' journal rows to the thread

* test(native-chat): give the repeated prompt Cancel its own host test file

* chore(reliability): drop the deleted mobile id-retention test from the gates that ran it

* test(native-chat): a repeated draft press under its own id is answered by the draft's state

* fix(native-chat): the host joins a second Stop of a turn it is still stopping

A client joins it itself only against a host older than this, which runs both
and writes a false 'already finished' row for the second.

* fix(mobile): keep the Stop's fence narrowed, and type the held card answer

* test(native-chat): open the repeat-press host on the shared journal database

* fix(native-chat): a second Stop of a turn an earlier Stop answered for adds no row

Read from the earlier Stop's note on that turn in the journal, so it holds
however late the second Stop lands and from whichever client, and across a
host restart. Replaces the in-memory join. The capability now says the host
answers a repeated Stop quietly; clients still join a Stop on its way only
for a host without it.

* fix(native-chat): a /compact or /clear pressed again is answered from the one it repeats

A /compact from the same caller joins its last /compact while that one waits
or runs, and is answered by it once it compacted with nothing sent since,
read from the journal, so a retry after a lost reply never compacts twice.
The same caller's second /clear waits for the first and is answered by the
committed clear. Another caller or another command is still refused.

* Revert "fix(native-chat): a /compact or /clear pressed again is answered from the one it repeats"

This reverts commit 25541b7.

* fix(native-chat): a Stop writes its one note only when it stopped something, keyed by the turn

A Stop that cancelled nothing writes no row, and the note is keyed by the turn
it stopped, so another Stop of that turn rewrites it. The earlier-note lookup
goes. A /compact or /clear pressed again while one runs is refused as before,
and one pressed after it ended runs.

* chore: the repeated-Stop capability's comments say what it now means

* test(native-chat): the command-start test's client mock knows the repeated-Stop capability

* fix(native-chat): a Stop keeps the fence it was pressed at while the client checks the host

The desktop's capability check before a named Stop could let the runtime move
underneath, so the Stop went out against the new one; it now carries the fence
read at the press.

* test(native-chat): a Stop keeps its pressed fence against either kind of host
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Refactors chat action deduplication logic for repeated user presses.

The PR should not merge until a refused named Stop remains visible to the person whose turn is still running.

Findings

  1. P1 Named Stop refusals go silent ▶
  2. P2 Failed Stop overwrites success ▶

Summary

The PR gives each structured chat action press a fresh operation ID and teaches the host to answer harmless repeats from recorded chat state.

  • Updates desktop and mobile mutation paths, including queued-message actions and Stop capability negotiation.
  • Changes host handling for repeated prompt answers, cancellation notes, and committed /clear results, with expanded tests.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Press[Action press] --> ID[Fresh operation ID]
  ID --> Host[Host admission]
  Host -->|New action| Apply[Apply mutation]
  Host -->|Harmless repeat| State[Answer from recorded state]
  Host -->|Same ID retry| Ledger[Replay recorded operation]
Loading

Reviews (1) · Last reviewed commit: "fix(native-chat): every chat action pres..."

Comment on lines 122 to +124
} else if (!cancelled && input.turnId !== undefined) {
note = { kind: 'status', text: 'The provider had already finished this turn.' }
// Nothing was left to stop: a Stop that ends nothing writes no row.
note = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Named Stop refusals go silent When the provider refuses to interrupt a named turn, this branch discards the refusal note because cancelled is false and turnId is present. The RPC returns with cancelled: false, but the person sees no explanation while the turn keeps running. An already-finished turn can remain quiet without hiding a provider refusal.

Comment on lines +160 to 164
// Keyed by the turn it stopped, so another Stop of that turn rewrites this row, never adds one.
await ctx.journal.appendItem(
structuredAgentSessionStopNoteIdentity(input.clientOperationId),
structuredAgentSessionStopNoteIdentity(input.turnId ?? liveTurnId ?? input.clientOperationId),
note,
{ fence: ctx.fence, turnScope }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed Stop overwrites success Stop notes now share a key for each named turn. If one Stop records “Cancellation requested.” and a later Stop of that turn fails without confirmation, the later note replaces the successful one. That loses the accurate record of the first Stop and makes the chat appear uncertain about an interruption it had already confirmed.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Duplicate of #24301, which re-lands the same #23916 change onto main from #23916's original branch, so #23917's stacked base stays valid. Closing this one.

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