Repository navigation
fix(mobile-chat): a failed message's text is added to the draft, and an expired resend no longer blocks that text - #25149
Conversation
… expired resend id is spent
Review and QA summaryThe problem. In a structured chat on the phone, two things went wrong after a send failed:
What changes for you.
Review.
Verified.
Not verified.
|
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. 🧰 Additional context used📚 Code guidelines (1)No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (10)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📝 WalkthroughWalkthroughExpired operation IDs are now marked spent when a retained send receives an expiration refusal, and the delivery returns a send error notice. A shared helper now combines returned text with existing draft text. Mobile draft restoration and renderer draft handling use this helper. Tests cover expiration and replay, draft appending, and withdrawn-message recovery. Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The changed send and draft-restoration behavior has no identified issue requiring a fix before merge. Architecture SummaryArchitecture risk: 🟡 Medium · up to The change affects 2 systems. Changed systems: Architecture concerns Review detailsSystems and components
Before / after behavior
Reliability and maintainability
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Returned text is appended, not dropped — the new shared
appendReturnedDraftTextputs returned text after whatever the composer holds, a blank line apart, treats a whitespace-only draft as empty, and is idempotent so the same text handed back twice leaves one copy. The phone'srestoreRejectedDraftand the desktop draft cache/use-native-chat-draftboth route through it, replacing the two divergent copies. - An expired resend id is spent —
mobileStructuredSendDeliverynow releases a retained operation id the host has expired (agent_session_operation_expired) and reports the existing "couldn't confirm" notice, so the next send of the same text mints a fresh id instead of replaying a refused one forever. Every other refusal of a replay still keeps its id. - Tests — a new unit test for the shared rule, plus updated phone and desktop tests covering append-after-typing, restore-after-an-explicit-clear, the fresh-id resend, and the crash-repeat restore.
I traced the expired branch past agentSessionRefusalOperationState (expired maps to settled-rejected, so the new branch is reachable), the operationIdSpent → clearMobileStructuredSendOperation release path, and the shared rule's idempotency/whitespace edge cases. The focused suites pass locally (shared 4, mobile 54, desktop 27) and pnpm run check:code-quality:changed is clean.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Drafts take #24905's merged store. A hand-back's text follows the shared returned-text rule (#25149) through returnNativeChatDraftText, which trims only the end and now trusts the append's durable answer; a composer is told of an append only when it changed the draft. The withdrawn-message restore test stays deleted: its one change was the rule's dedupe, which C2's returning tests already state. messagesUnsettled keeps C2's 'wait' in the moved reason-words table.

ELI5
On the phone, two things could go wrong after a chat message failed to send:
What Changed
The problem. This is about structured (native) chats in the phone app.
Before / after (phone, structured chat)
On the desktop, two places already appended text to the composer after a blank line: a Stop that gives back messages it withdrew, and Edit on a queued message card (the message queue is not switched on for users yet). Both now use the same rule as the phone. The one visible difference: if the composer already ends with exactly that text, it isn't added a second time. Two returned messages with identical text therefore leave one copy, as in the common pattern.
Mechanism.
mobile/src/session/mobile-structured-send-delivery.ts: a resend refused asagent_session_operation_expirednow marks its id as spent. The phone clears the saved id, so the next send of that text gets a new one. The words are the existing "couldn't confirm" sentence, not "not sent": the first attempt may have reached the chat a day ago.src/shared/returned-draft-text.ts(new): one rule for putting returned text back in a composer. It appends after a blank line, never replaces what's there, and does nothing if the draft already ends with that text.restoreRejectedDraft(use-mobile-native-chat-drafts.ts) now uses it instead of "only into an empty, untouched composer".native-chat-draft-cache.ts,use-native-chat-draft.ts) uses it in place of its own copy.Not in this PR: the phone still hands back the text of a message the computer recorded and then failed to deliver, so that text can appear both in the chat and in the composer. #24918 stops that, as part of showing recorded messages to every viewer. This PR doesn't touch that path; until #24918 lands, that text is appended like any other returned text.
Why
This is the "phone fix" step of the failed-send redesign. The rule there is: a message the computer has no record of, and never will, goes back to the composer, and its text is never lost; an id the computer can no longer accept is used up.
agent-session-operation-ledger.ts), so keeping it can only refuse the message again. Releasing it is the one way out of the loop.Differences from the common pattern
/commandwhose outcome the phone couldn't confirm still comes back to the composer (now appended after anything typed, where before it came back only into an empty composer). It may have run.Linked Issue
None. This is step "phone fix" of the native-chat failed-send redesign.
Visual Proof
Phone, iOS Simulator (iPhone 17 Pro, iOS 26.5), driven with
orca emulatoragainst an isolated test host running a stand-in Claude. Same app build for both columns: only the two phone files this PR changes were swapped tomainfor "Before". Steps: send "Please fix the login bug", type "Also check the tests" while it is in flight, and the computer refuses the message (the stand-in agent fails to start).main)mainit is dropped anyway).simctl io screenshot), not a desktop window.Testing
I manually tested these changes locally
Automated tests added/updated, or explained why not below
src/shared/returned-draft-text.test.ts(new): append after a blank line, keep typing, skip text that's already there, empty and whitespace-only drafts.mobile/src/session/use-mobile-native-chat-drafts.test.ts: a refused send is appended after typing done while it was in flight, and comes back even after that typing was cleared. Before, both tests asserted that the text was dropped.mobile/src/session/mobile-structured-send-delivery.test.ts,use-mobile-structured-agent-session-send.test.tsx: an expired resend spends its id and shows the "couldn't confirm" words. The next send of the same text goes out under a new id and is accepted. Any other refusal of a resend still keeps its id.structured-agent-session-withdrawn-message-restore.test.tsx: the crash-repeat test now starts from an empty composer, as a real crash does, and gets the text back once.Ablation: with the two phone source files reverted to
main, four of the new or changed tests fail.Typecheck: web and node pass. Mobile passes apart from the generated webview modules that this worktree has not built (
*.generated); the mobile tests-typecheck ratchet flags only files that import those same modules. CI builds them.Review
Round 1 (three reviewers: phone, desktop and the shared rule, and an independent design check) found no problem this PR causes at P0–P2. Fixed: the PR text now names every desktop caller of the shared rule, and two comments are more precise. Deferred, with the reason and the later step named under "Differences from the common pattern": an expired resend while the computer has the chat closed, an unconfirmed
/commandcoming back to the composer, and saved ids with no expiry of their own.Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)