Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/mobile
Summary
On iOS, I sent the first message of a new thread and my connection dropped for about a minute. When the app reconnected, the message showed in the thread and the agent had received it once, but the full message text was also back in the thread's composer. If you tap Send again, the message goes out a second time.
Root cause
When a creation's acknowledgment is lost, the outbox treats the delivered, unedited message as if the user had edited it and copies its text into the thread composer.
NewTaskDraftScreen.handleStartenqueues acreationmessage. The outbox sendsorchestration.launchThread(sendQueuedCreationinapps/mobile/src/state/threadOutboxDelivery.ts).- The server creates the thread and records the message, but the socket drops before the reply arrives. The RPC fails, so
completeQueuedMessageDeliverynever runs and the creation stays in the outbox. - After reconnecting, the shell list contains the thread.
resolveThreadOutboxDeliveryAction(thread-outbox-model.ts) returns"remove"forisCreation && threadExists. - For a creation, the drain maps
"remove"torecoverEditedCreationAfterDelivery(nextQueuedMessage)(threadOutboxDelivery.ts, around the comment "After restart, always recover delivered creations; lost in-memory state cannot distinguish edits from duplicate payloads"). recoverEditedCreationAfterDeliveryunconditionally runsmergeComposerDraftContent(scopedThreadKey(...), { text: kept.text, ... })and then removes the outbox entry. The composer now holds text the server already has.
Follow-up messages are protected by acknowledgedExistingThreadMessageIds, but that check is explicitly skipped for creations (nextQueuedMessage.creation === undefined && ...). The comment says this path is meant for app restarts, but it runs whenever the launch reply is lost, including a brief network drop while the app stays open.
Steps to reproduce
Deterministic unit repro on main @ 4cb6945d. Add this to apps/mobile/src/state/threadOutboxDelivery.test.ts (import resolveThreadOutboxDeliveryAction from ./thread-outbox-model):
describe("repro: lost ack for a new thread's first message", () => {
it("does not copy an unedited, already-delivered first message back into the composer", async () => {
const message = queuedMessage({ messageId: "creation-ack-lost", text: "My dictated idea" });
await harness.manager.enqueue(message);
// After reconnecting, the shell list shows the thread the server already created.
expect(
resolveThreadOutboxDeliveryAction({
isCreation: true,
threadExists: true,
shellStatus: "live",
environmentConnected: true,
}),
).toBe("remove");
// For a creation, the drain's "remove" step is recoverEditedCreationAfterDelivery.
await expect(recoverEditedCreationAfterDelivery(message)).resolves.toBe(true);
expect(remainingMessages()).toEqual([]);
expect(
composerDrafts.getComposerDraftSnapshot(scopedThreadKey(message.environmentId, message.threadId)).text,
).toBe("");
});
});vp test run --project unit src/state/threadOutboxDelivery.test.ts -t repro fails with:
AssertionError: expected 'My dictated idea' to be '' // Object.is equality
To reproduce on a device: on iOS, start a new task, send the first message, and drop the network (airplane mode) right after tapping Send, before the reply arrives. Restore the network. Once the thread loads, the composer holds the sent text.
Expected behavior
If the queued creation still matches what was sent (it wasn't edited after dispatch), the outbox entry should be removed silently and the composer should stay empty. One option: when the creation's revision is unchanged since dispatch, or the thread's first user message has the queued messageId, take the plain removal path instead of recoverEditedCreationAfterDelivery.
Actual behavior
The full message text reappears in the thread composer after the reconnect, which invites a duplicate send.
Impact
Moderate. No data is lost, but the composer looks like the message wasn't sent, and tapping Send again duplicates it. It's especially likely on mobile networks, where short drops are common.
Version or commit
Server/desktop 0.1.7. Source analyzed at main @ 4cb6945d.
Environment
iPhone T3 Code app connected to a Linux server. Provider: Claude.
Logs or stack traces
Server trace for the incident (UTC). launchThread succeeded, the client's subscriptions were interrupted, and a new websocket ticket and session were issued about 68 seconds later. No second launchThread was sent, which is consistent with the drain taking the "remove" path rather than resending.
03:40:57.594 ws.rpc.orchestration.launchThread Success (thread.create + initial message.dispatch)
03:40:57.709 ws.rpc.orchestration.subscribeThread Interrupted
03:42:05.175 EnvironmentAuth.issueWebSocketTicket Success <- client reconnects
03:42:05.552 ws.orchestrationV2.subscribeShell Success
~03:42 sent text visible again in the thread composer (screenshot)
Workaround
Clear the composer by hand before typing the next message.
Related
- [Bug]: A message sent while the socket is dead is dropped, then may reappear after resync; retry it with the same commandId instead #24659 / fix(client-runtime): retry messages after socket loss with the same command ID #24761 fix lost acknowledgments for web/desktop
message.dispatchin existing threads. They explicitly exclude initial bootstrap launches and don't touch the mobile outbox.
Activity