Symptom
In the web composer, once a turn completes, an assistant message can render as the full reply followed by fragments of the same reply repeated as extra lines (e.g. the full text, then , here's one, :, \n\nWhy do programmers, … one per stream chunk).
Repro
Any session whose model stream interleaves non-text parts between text deltas — e.g. an OpenAI-compatible provider that sends a reasoning_content key (even empty) on every chunk, which makes _convertStreamResponse yield a think part per chunk so text parts are never merged. Then:
- The wire gets one
content.part record per text delta.
- The live projection keeps a single text frame per step, updated with the accumulated text.
- After
turn.ended, the heal pass (healTurnOps in packages/kap-server/src/services/transcript/transcriptService.ts) re-reads the cold snapshot — which has one text frame per delta — and merges it into the live store. The dedup is per-frameId: cold f2 collides with the live consolidated frame and is skipped, but cold f3…fN (the remaining deltas) have no live counterpart and are appended verbatim.
- The web UI renders every text frame as its own paragraph → full text + duplicated fragments. The same merge runs at attach-time backfill, so reopened sessions show it too.
Observed on a real session's GET /api/v1/sessions/<id>/transcript: final step frames were f1 thinking, f2 = full accumulated text, then f3…f8 = deltas[1..6] verbatim. Intermediate steps inspected before the heal debounce only show the single live frame, which is why this appears specifically on ended turns.
Expected
One consolidated text frame per step after heal, same as intermediate steps.
Fix
PR follows: teach healTurnOps to skip a snapshot text/thinking frame when any same-kind, same-role live frame in the step already contains its text.
Symptom
In the web composer, once a turn completes, an assistant message can render as the full reply followed by fragments of the same reply repeated as extra lines (e.g. the full text, then
, here's one,:,\n\nWhy do programmers, … one per stream chunk).Repro
Any session whose model stream interleaves non-text parts between text deltas — e.g. an OpenAI-compatible provider that sends a
reasoning_contentkey (even empty) on every chunk, which makes_convertStreamResponseyield athinkpart per chunk so text parts are never merged. Then:content.partrecord per text delta.turn.ended, the heal pass (healTurnOpsinpackages/kap-server/src/services/transcript/transcriptService.ts) re-reads the cold snapshot — which has one text frame per delta — and merges it into the live store. The dedup is per-frameId: coldf2collides with the live consolidated frame and is skipped, but coldf3…fN(the remaining deltas) have no live counterpart and are appended verbatim.Observed on a real session's
GET /api/v1/sessions/<id>/transcript: final step frames weref1thinking,f2= full accumulated text, thenf3…f8= deltas[1..6] verbatim. Intermediate steps inspected before the heal debounce only show the single live frame, which is why this appears specifically on ended turns.Expected
One consolidated text frame per step after heal, same as intermediate steps.
Fix
PR follows: teach
healTurnOpsto skip a snapshot text/thinking frame when any same-kind, same-role live frame in the step already contains its text.