diff options
Diffstat (limited to 'src/core/chunks/selectors.ts')
| -rw-r--r-- | src/core/chunks/selectors.ts | 83 |
1 files changed, 78 insertions, 5 deletions
diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index 8b265a5..d32a628 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -1,12 +1,83 @@ -import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire"; -import type { RenderedChunk, TranscriptState } from "./types"; +import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; +import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types"; + +/** + * Whether a provisional chunk is the same content as a committed chunk at the + * same position of the same turn (identity for de-dup, not deep equality). + * Tool chunks match by `toolCallId`; text-bearing chunks by exact text. + */ +function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean { + if (p.role !== c.role || p.chunk.type !== c.chunk.type) return false; + const a = p.chunk; + const b = c.chunk; + switch (a.type) { + case "tool-call": + case "tool-result": + return a.toolCallId === (b as typeof a).toolCallId; + case "text": + case "thinking": + case "system": + return a.text === (b as typeof a).text; + case "error": + return a.message === (b as typeof a).message; + case "image": + return true; + } +} + +/** + * How many LEADING provisional chunks are already backed by committed chunks. + * + * The backend persists each step of an in-flight turn as it completes (CR-6), + * so a client that opens a conversation mid-turn gets those steps from history + * AND again from the late-join event replay (as provisional chunks). Both + * carry the turn's chunks in the same order, so the provisional run of turn T + * is aligned position-by-position with the committed run of turn T. Skip the + * aligned prefix; stop at the first mismatch (fail-safe: a misalignment shows + * a duplicate, never hides content). + * + * Also reports whether the ACCUMULATING chunk is already committed (the step + * completed + history synced, but the next live event hasn't flushed it yet). + */ +function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } { + const first = state.provisional[0]; + const turnId = first?.turnId ?? state.accumulating?.turnId; + if (turnId === undefined) return { skip: 0, hideAccumulating: false }; + // The committed run for this turn (contiguous at the tail of `committed`). + let start = state.committed.length; + while (start > 0 && state.committed[start - 1]?.turnId === turnId) start--; + const run = state.committed.length - start; + if (run === 0) return { skip: 0, hideAccumulating: false }; + + let skip = 0; + while (skip < state.provisional.length && skip < run) { + const p = state.provisional[skip]; + const c = state.committed[start + skip]; + if (p === undefined || c === undefined || p.turnId !== turnId || !sameChunk(p, c)) break; + skip++; + } + + let hideAccumulating = false; + const acc = state.accumulating; + if (acc !== null && skip === state.provisional.length && skip < run) { + const c = state.committed[start + skip]; + hideAccumulating = + c !== undefined && + c.role === "assistant" && + c.chunk.type === acc.kind && + c.chunk.text === acc.text; + } + return { skip, hideAccumulating }; +} /** * Select all chunks for rendering: committed first (seq order), - * then provisional (seq: null). + * then provisional (seq: null) — minus provisional chunks already backed by + * committed history (see `committedOverlap`). */ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { const result: RenderedChunk[] = []; + const { skip, hideAccumulating } = committedOverlap(state); for (const c of state.committed) { result.push( c.turnId !== undefined @@ -14,7 +85,9 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { : { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }, ); } - for (const p of state.provisional) { + for (let i = skip; i < state.provisional.length; i++) { + const p = state.provisional[i]; + if (p === undefined) continue; result.push( p.turnId !== undefined ? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId } @@ -22,7 +95,7 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { ); } const acc = state.accumulating; - if (acc !== null) { + if (acc !== null && !hideAccumulating) { const chunk: Chunk = acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; result.push({ |
