Skip to content

[Bug]: Mobile: first message of a new thread reappears in the composer after a lost launch reply (network drop) #32313

Closed

Description

@SeanHollen

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.

  1. NewTaskDraftScreen.handleStart enqueues a creation message. The outbox sends orchestration.launchThread (sendQueuedCreation in apps/mobile/src/state/threadOutboxDelivery.ts).
  2. The server creates the thread and records the message, but the socket drops before the reply arrives. The RPC fails, so completeQueuedMessageDelivery never runs and the creation stays in the outbox.
  3. After reconnecting, the shell list contains the thread. resolveThreadOutboxDeliveryAction (thread-outbox-model.ts) returns "remove" for isCreation && threadExists.
  4. For a creation, the drain maps "remove" to recoverEditedCreationAfterDelivery(nextQueuedMessage) (threadOutboxDelivery.ts, around the comment "After restart, always recover delivered creations; lost in-memory state cannot distinguish edits from duplicate payloads").
  5. recoverEditedCreationAfterDelivery unconditionally runs mergeComposerDraftContent(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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions