Two live answers must not write into one store by vo1dLee3 · Pull Request #259 · deeplethe/utopia · GitHub
Skip to content

Two live answers must not write into one store - #259

Merged
WaylandYang merged 2 commits into
deeplethe:devfrom
vo1dLee3:fix/chat-live-answer-store
Sep 3, 2026
Merged

WaylandYang merged 2 commits into
deeplethe:devfrom
vo1dLee3:fix/chat-live-answer-store

Conversation

@vo1dLee3

@vo1dLee3 vo1dLee3 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The symptom

Start a question in conversation A, click over to conversation B while the answer is still streaming, and ask again. Both SSE handlers then write into one module-level store:

Reproduced at runtime with zero patches to shipped code: the actual liveAnswer.ts and api.ts (streamChat/reattachChat, which share consumeChatStream) executed against a wire-identical SSE server, with the guard, send handlers, stop button and claim check copied verbatim from Chat.tsx. Server-side log confirms A was never aborted; a control run with no switching is clean.

The root cause

liveAnswer is a singleton on a stated ground: 同时只会有一次进行中的回答. Nothing enforces that, and the rest of the file contradicts it on purpose — three places in Chat.tsx establish two concurrent generations as a normal, intended state:

  • switching knowledge bases does not abort («换库不该杀掉另一个库里正在写的回答»)
  • starting a new chat does not abort («开一场新的不等于放弃上一场»)
  • the send guard is deliberately per-conversation, and its comment rejects the global-block UX («发不出消息»)

So the guard's own intent creates the state the single slot cannot hold: the second start replaces the store while the first stream's callbacks keep calling patchLast — which patches the last turn of whatever is in the slot now.

What changes

liveAnswer becomes a table keyed by conversation, not a slot. Each stream gets a handle at begin(); every read and write goes through the handle, so A's deltas can only ever land in A's entry.

  • begin(kbId, conversationId, turns, abort) returns a handle (identify / patchLast / finish / setAbort); a not-yet-named conversation occupies an internal token until identify remaps it — kbId disambiguates two such windows
  • entry(kbId, conversationId) is the claim ("is the conversation I'm looking at live here"); stop(kbId, conversationId) is the stop button: abort + finish for exactly the entry on screen
  • begin evicts finished entries — eviction only happens when somebody sends again, and an evicted conversation re-reads identical content from the database
  • abortRef is gone entirely. It was the direct mechanism of the orphaned stream; abort now travels with its own entry
  • The send guard is untouched — it already asked the right question («是「这一场」在流,不是「有一场」在流»); now the store gives it a per-conversation answer
  • A side effect of the new shape: a follow-up question now builds on liveHere?.turns ?? turns — what is on screen — instead of the stale component state, so the previous answer no longer vanishes from the display when you ask again (fixed in a separate commit)

No UI or i18n changes; the interface looks exactly the same when only one answer streams.

Verification

Both checks run without a backend:

  • cd web && pnpm build green (the CI web check, includes tsc --noEmit)
  • The repro harness, re-targeted at the new API, passes 23/23 assertions: concurrent streams stay isolated; stop(B) aborts only B and the server log shows it; A is then independently stoppable (no orphan, no full-length run); finished entries remain claimable (An answer being written should still be there when you come back #171's display continuity) and are evicted by the next begin; identify remaps the pending entry; the reattach path (refresh-mid-stream) builds its entry from history + snapshot and writes only its own; the control case behaves as before

Signed-off-by: Oakkles <lmh3162066424@gmail.com>
Signed-off-by: Oakkles <lmh3162066424@gmail.com>
@WaylandYang

Copy link
Copy Markdown
Contributor

@WaylandYang

Copy link
Copy Markdown
Contributor

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified end to end (see comment above): concurrent streams isolated, stop scoped to one conversation, reattach and follow-up paths intact, web build green.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants