Skip to content

fix(mobile-chat): every phone send is its own message - #23917

Draft
brennanb2025 wants to merge 9 commits into
brennanb2025/chat-op-id-per-actionfrom
brennanb2025/mobile-send-identity-per-message
Draft

brennanb2025 wants to merge 9 commits into
brennanb2025/chat-op-id-per-actionfrom
brennanb2025/mobile-send-identity-per-message

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 7 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​303 $\color{#cf222e}{\Huge{\mathbf{−}}}$​644 $\color{#cf222e}{\Huge{\mathbf{−}}}$​341
Prod 806 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​912 $\color{#cf222e}{\Huge{\mathbf{−}}}$​1184 $\color{#cf222e}{\Huge{\mathbf{−}}}$​272

ELI5

The phone app identified a chat message by its text. If you sent "yes", the reply didn't confirm, and you sent "yes" again, the second one was quietly merged into the first and never sent. The same text could also be refused forever after a day, and after about 47 unconfirmed messages every send failed. Now every tap of Send is its own message.

The plan this belongs to

Goal: one operation id per user action for every structured chat write. It ships as:

What Changed

The problem. The phone app gave a structured chat message the id of any earlier, still-unconfirmed message with the same text and attachments, looked up in a saved list on the phone keyed by that text. The list existed so that retyping a message would retry it, because the phone keeps no saved copy of the message and has no Retry button. It caused:

  • sending the same text twice while the first was unconfirmed silently dropped the second;
  • the list never expired, so after 24 hours the host refused that id and the same text in that chat was refused on every send;
  • inside the page view, about 47 unconfirmed entries outgrew the storage limit, and after that every send failed with "Message not sent";
  • a failure to save the list blocked the send.

Scope. Structured Claude and Codex chats in the phone app, on any host. Desktop sends already used their own id per message and are unchanged.

Before / After

You do Before After
Send the same text twice, the first still unconfirmed the second is silently dropped both are sent
Send a text a day after an unconfirmed send of it refused every time sent
Keep chatting with many unconfirmed messages after about 47, every send fails sends keep working
Phone storage write fails "Message not sent" sent
Retype a message after "Delivery unconfirmed", or after the app was closed mid-send, when it had actually arrived not sent again sent again (see Notes)

Mechanism

  • mobile-structured-agent-session-send.ts mints the message id once per press; it is also the operation id, as the host already expects for a send.
  • New sends no longer read or write the saved list. The text, caller and image-content fingerprints that only fed it are deleted.
  • The saved list orca:mobileStructuredSendOperations:v1 stays readable, because pages served by older hosts still write it. Its cleanup now also drops entries older than the host's replay window, besides entries the host shows as settled, and removes the key once empty. Old entries cannot be moved onto their messages, because they store only hashes of the text.
  • Nothing changes on the wire.

Why

A message is a user action, and two messages with the same words are still two messages. Keeping an id per text is what let one swallow the other and let entries pile up forever. Reusing an id is only right for a retry of that one message, and the phone has no stored message a retry could come from, so nothing is kept. The desktop and one of the reference patterns behave the same way: a retyped message is a new message.

Alternatives considered:

  • A saved phone outbox with an unconfirmed row and a Retry button that reuses the message's id. This removes the retype duplicate below, but it is new phone UI with its own storage, so it is left as a separate product decision.
  • Migrating old entries onto their messages. Not possible: the saved entries hold only hashes, not the text.

Differences from the common pattern

  • A common approach saves each queued message with its id and retries it automatically. This PR keeps no saved message and no automatic retry; a retype is a new message, as on desktop.

Linked Issue

No issue. Step 2 of the plan above.

Visual Proof

QA_PLACEHOLDER

Testing

  • I manually tested these changes locally

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

  • use-mobile-structured-agent-session-send.test.tsx: the same text after a lost reply, an error or an unknown answer gets two ids and the second is accepted; a remount mints a new id and writes nothing to storage; a re-uploaded image is a new message; a v1 entry for the same text is not joined and stays untouched; entries past the replay window are pruned; a settled v1 entry is cleared; a store that rejects reads and writes still sends.

  • The saved-list unit tests and the page storage tests are rewritten for the cleanup-only journal.

  • 9 of the 13 send-hook tests fail on the base; removing each rule fails its test.

  • pnpm tc:node, tc:web, tc:cli, the mobile typecheck and test ratchet, oxlint, check:code-quality:changed, check:react-doctor:changed and the anti-slop audit pass.

AI Disclosure

Review

REVIEW_PLACEHOLDER

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.

  • Known cost, the same as desktop: after "Delivery unconfirmed — check chat before retrying", or after the app was closed or reloaded mid-send, retyping a message that had actually arrived sends it a second time. A double tap within one send is still blocked while the first is in flight.
  • The old saved list's cleanup runs only while a structured chat is open and its messages change.
  • Mixed versions: nothing changes on the wire, so a new phone with an old host, and an old phone with a new host, both work. Pages served by older hosts keep writing the old list, and this app keeps cleaning it.

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

@brennanb2025
brennanb2025 force-pushed the brennanb2025/chat-op-id-per-action branch from de73921 to bd08f9e Compare September 29, 2026 22:11
@brennanb2025
brennanb2025 force-pushed the brennanb2025/mobile-send-identity-per-message branch from 005f27b to 8e77ca3 Compare September 29, 2026 22:11
@brennanb2025
brennanb2025 force-pushed the brennanb2025/chat-op-id-per-action branch from bd08f9e to 154cc72 Compare September 29, 2026 22:26
@brennanb2025
brennanb2025 force-pushed the brennanb2025/mobile-send-identity-per-message branch 3 times, most recently from 2ff476e to 89aca87 Compare September 29, 2026 22:34
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.
…th 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".
@brennanb2025
brennanb2025 force-pushed the brennanb2025/chat-op-id-per-action branch from 09fa9e9 to 89564af Compare September 29, 2026 22:51
The phone keyed a structured send by its text in a durable journal, so
sending the same text again while the first was unconfirmed silently
joined the first, the same text was refused for good once its entry
aged past the host's admission window, and about 47 unsettled entries
outgrew the page store so every later send failed.

Each press now mints its own operation id, which the host also keeps as
the message's client id. New sends never read or write the journal, so
a storage failure no longer blocks a send. The v1 key stays readable
for older host-served pages; reconciliation keeps clearing entries the
host shows settled and now also prunes entries past the host's replay
window, removing the key once it is empty.
…pped content fingerprint

Cause: phone sends no longer key by message text, so the image content fingerprint that only fed that key is gone from each uploaded attachment. The three native-chat-image-upload goldens lose exactly that field (a multiset compare of each shows no other added or removed value); every other golden moves only its baseline header, repinned to 764fc30, the last commit that touches a recorded path.

This branch has not been deployed

No deployments
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