Skip to content

Assistant message projections collide across threads when message IDs are reused #871

@nassimna

Description

@nassimna

Summary

projected assistant messages are effectively keyed by message_id alone. If two different threads reuse the same provider-backed assistant message identity, the second thread overwrites the first.

That breaks thread isolation and can surface after reconnect/reload as the wrong assistant reply, a missing reply, or checkpoint/diff UI linked to the wrong message.

Why this matters

This is a reliability issue in a core flow:

  • thread state stops being isolated
  • reconnect/reload becomes unpredictable
  • provider/runtime ID reuse can leak one thread's projected state into another

Deterministic repro on upstream/main

I reproduced this against fetched upstream/main at commit 065ef922 on March 11, 2026.

  1. Upsert a projected assistant message row for thread-shared-message-a with:
    • messageId = message-shared-across-threads
    • text = first thread text
  2. Upsert a second projected assistant message row for thread-shared-message-b with the same messageId and:
    • text = second thread text
  3. Call listByThreadId for both threads.

Expected

Both threads retain one projected row with their own text.

Actual

The first thread loses its row entirely, and only the second thread's row remains:

{
  "firstRows": [],
  "secondRows": [
    {
      "messageId": "message-shared-across-threads",
      "threadId": "thread-shared-message-b",
      "text": "second thread text"
    }
  ]
}

Where it happens

The persistence layer currently upserts projection_thread_messages on message_id alone in:

  • apps/server/src/persistence/Layers/ProjectionThreadMessages.ts

That means cross-thread reuse is treated as the same logical row.

Broader user-facing impact

This appears to be the same class of bug behind cross-thread assistant/checkpoint identity collisions when provider itemId or fallback turnId values are reused across threads. In the UI that can show up as:

  • one thread showing another thread's assistant reply
  • one thread losing its own assistant reply after reload/reconnect
  • checkpoint/diff linkage attaching to the wrong assistant message

Fix direction

A focused fix seems to be:

  • scope projected assistant identities by thread
  • key projection_thread_messages by (thread_id, message_id) instead of message_id alone
  • make any fallback assistant/checkpoint IDs thread-aware as well

I verified the same regression input passes in a local fix worktree once thread scoping is applied: both rows remain isolated.

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