pingdotgg / pingdotgg/t3code

Assistant message projections collide across threads when message IDs are reused

Open
#871 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in apps/server/src/persistence/Layers/ProjectionThreadMessages.ts and reproduce the collision by calling listByThreadId for two threads that reuse the same messageId. Trace the upsert identity and any fallback assistant or checkpoint IDs. Done means projection_thread_messages preserves one row per (thread_id, message_id), both repro threads retain their own text, and related identities remain thread-scoped.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, database
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.