diff options
Diffstat (limited to 'src/core/chunks')
| -rw-r--r-- | src/core/chunks/groups.ts | 10 | ||||
| -rw-r--r-- | src/core/chunks/reducer.test.ts | 45 | ||||
| -rw-r--r-- | src/core/chunks/reducer.ts | 65 | ||||
| -rw-r--r-- | src/core/chunks/selectors.ts | 28 | ||||
| -rw-r--r-- | src/core/chunks/types.ts | 15 |
5 files changed, 135 insertions, 28 deletions
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts index c1c80c9..9df0fa5 100644 --- a/src/core/chunks/groups.ts +++ b/src/core/chunks/groups.ts @@ -22,6 +22,8 @@ export type RenderGroup = readonly stepId: string; readonly entries: readonly ToolBatchEntry[]; readonly provisional: boolean; + /** The producing turn (from the batch's first call chunk), when known. */ + readonly turnId?: string; }; /** @@ -79,6 +81,7 @@ export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonl // (Svelte `each_key_duplicate` aborts the reactive flush — tab appears dead). const seenCallIds = new Set<string>(); let provisional = false; + let turnId: string | undefined; for (const inner of rendered) { if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { if (seenCallIds.has(inner.chunk.toolCallId)) continue; @@ -86,9 +89,14 @@ export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonl const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; entries.push({ call: inner.chunk, result }); if (inner.provisional) provisional = true; + turnId ??= inner.turnId; } } - groups.push({ kind: "tool-batch", stepId, entries, provisional }); + groups.push( + turnId !== undefined + ? { kind: "tool-batch", stepId, entries, provisional, turnId } + : { kind: "tool-batch", stepId, entries, provisional }, + ); continue; } diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index 058552e..cba6fad 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -226,7 +226,7 @@ describe("clearGenerating", () => { s = foldEvent(s, textDelta("t1", "partial")); const cleared = clearGenerating(s); expect(cleared.generating).toBe(false); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(cleared.currentTurnId).toBe("t1"); }); }); @@ -236,7 +236,7 @@ describe("foldEvent — text-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -245,7 +245,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "hello ")); s = foldEvent(s, textDelta("t1", "world")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); + expect(s.accumulating).toEqual({ kind: "text", text: "hello world", turnId: "t1" }); expect(s.provisional).toEqual([]); }); @@ -254,7 +254,7 @@ describe("foldEvent — text-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "thinking...")); s = foldEvent(s, textDelta("t1", "answer")); - expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); + expect(s.accumulating).toEqual({ kind: "text", text: "answer", turnId: "t1" }); expect(s.provisional).toHaveLength(1); expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); expect(s.provisional[0]?.role).toBe("assistant"); @@ -266,7 +266,7 @@ describe("foldEvent — reasoning-delta", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm", turnId: "t1" }); }); it("successive reasoning-deltas extend the same chunk", () => { @@ -274,7 +274,7 @@ describe("foldEvent — reasoning-delta", () => { s = foldEvent(s, turnStart("t1")); s = foldEvent(s, reasoningDelta("t1", "hmm ")); s = foldEvent(s, reasoningDelta("t1", "ok")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok", turnId: "t1" }); }); }); @@ -531,7 +531,7 @@ describe("clearGenerating also clears a stale provider-retry banner (reconnect)" s = foldEvent(s, textDelta("t1", "partial")); s = foldEvent(s, providerRetry("t1", 0, 5000)); const cleared = clearGenerating(s); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); expect(selectProviderRetry(cleared)).toBeNull(); }); }); @@ -859,7 +859,7 @@ describe("appendUserMessage", () => { let s = initialState(); s = foldEvent(s, turnStart("t1")); s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(s.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" }); s = appendUserMessage(s, "user msg"); expect(s.accumulating).toBeNull(); @@ -1022,3 +1022,32 @@ describe("applyHistory — multi-chunk image echo is superseded by committed", ( expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" }); }); }); + +describe("turnId provenance on live chunks", () => { + it("tags every live chunk with its event's turnId, and the optimistic echo on turn-start", () => { + let s = initialState(); + s = appendUserMessage(s, "q", [{ url: "data:image/png;base64,AA", mimeType: "image/png" }]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual([undefined, undefined]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a")); + const chunks = selectChunks(s); + expect(chunks.map((c) => c.turnId)).toEqual(["t1", "t1", "t1"]); + }); + + it("turn-start does not re-tag an echo already owned by an earlier turn", () => { + let s = initialState(); + s = appendUserMessage(s, "q1"); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, turnStart("t2")); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t1", "t1"]); + }); + + it("committed chunks expose StoredChunk.turnId", () => { + const s = applyHistory(initialState(), [ + { seq: 1, role: "user", chunk: { type: "text", text: "q" }, turnId: "t9" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "a" } }, + ]); + expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t9", undefined]); + }); +}); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 64e41b9..4eb08c1 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -40,7 +40,33 @@ function flushAccumulating( if (acc === null) return provisional; const chunk: Chunk = acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; - return [...provisional, { role: "assistant", chunk }]; + return [...provisional, withTurn({ role: "assistant", chunk }, acc.turnId)]; +} + +/** Attach a `turnId` to a provisional chunk (omitted when unknown). */ +function withTurn(p: ProvisionalChunk, turnId: string | undefined): ProvisionalChunk { + return turnId !== undefined ? { ...p, turnId } : p; +} + +/** + * Tag the trailing UNTAGGED user run (the sender's optimistic echo, appended + * before the server assigned a turn id) with the turn that just started. + */ +function tagTrailingUserEcho( + provisional: readonly ProvisionalChunk[], + turnId: string, +): readonly ProvisionalChunk[] { + let start = provisional.length; + while (start > 0) { + const p = provisional[start - 1]; + if (p === undefined || p.role !== "user" || p.turnId !== undefined) break; + start--; + } + if (start === provisional.length) return provisional; + return [ + ...provisional.slice(0, start), + ...provisional.slice(start).map((p) => ({ ...p, turnId })), + ]; } /** @@ -199,7 +225,12 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return state; case "turn-start": - return { ...state, currentTurnId: event.turnId, generating: true }; + return { + ...state, + provisional: tagTrailingUserEcho(state.provisional, event.turnId), + currentTurnId: event.turnId, + generating: true, + }; case "user-message": { // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a @@ -217,12 +248,19 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState const run = trailingRun(state.provisional, "user"); const alreadyEchoed = run.some((p) => p.chunk.type === "text" && p.chunk.text === event.text); if (alreadyEchoed) { - return { ...state, generating: true }; + return { + ...state, + provisional: tagTrailingUserEcho(state.provisional, event.turnId), + generating: true, + }; } const provisional = flushAccumulating(state.provisional, state.accumulating); return { ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + provisional: [ + ...provisional, + { role: "user", chunk: { type: "text", text: event.text }, turnId: event.turnId }, + ], accumulating: null, generating: true, }; @@ -233,7 +271,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState if (acc !== null && acc.kind === "text") { return { ...state, - accumulating: { kind: "text", text: acc.text + event.delta }, + accumulating: { ...acc, text: acc.text + event.delta }, generating: true, }; } @@ -241,7 +279,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, provisional, - accumulating: { kind: "text", text: event.delta }, + accumulating: { kind: "text", text: event.delta, turnId: event.turnId }, generating: true, }; } @@ -251,7 +289,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState if (acc !== null && acc.kind === "thinking") { return { ...state, - accumulating: { kind: "thinking", text: acc.text + event.delta }, + accumulating: { ...acc, text: acc.text + event.delta }, generating: true, }; } @@ -259,7 +297,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState return { ...state, provisional, - accumulating: { kind: "thinking", text: event.delta }, + accumulating: { kind: "thinking", text: event.delta, turnId: event.turnId }, generating: true, }; } @@ -275,7 +313,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState }; return { ...state, - provisional: [...provisional, { role: "assistant", chunk }], + provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }], accumulating: null, generating: true, }; @@ -293,7 +331,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState }; return { ...state, - provisional: [...provisional, { role: "tool", chunk }], + provisional: [...provisional, { role: "tool", chunk, turnId: event.turnId }], accumulating: null, generating: true, }; @@ -307,7 +345,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState : { type: "error", message: event.message }; return { ...state, - provisional: [...provisional, { role: "assistant", chunk }], + provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }], accumulating: null, generating: false, }; @@ -352,7 +390,10 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState const provisional = flushAccumulating(state.provisional, state.accumulating); return { ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + provisional: [ + ...provisional, + { role: "user", chunk: { type: "text", text: event.text }, turnId: event.turnId }, + ], accumulating: null, generating: true, }; diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index e46d5f5..8b265a5 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -8,17 +8,31 @@ import type { RenderedChunk, TranscriptState } from "./types"; export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { const result: RenderedChunk[] = []; for (const c of state.committed) { - result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); + result.push( + c.turnId !== undefined + ? { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false, turnId: c.turnId } + : { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }, + ); } for (const p of state.provisional) { - result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true }); + result.push( + p.turnId !== undefined + ? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId } + : { seq: null, role: p.role, chunk: p.chunk, provisional: true }, + ); } - if (state.accumulating !== null) { + const acc = state.accumulating; + if (acc !== null) { const chunk: Chunk = - state.accumulating.kind === "text" - ? { type: "text", text: state.accumulating.text } - : { type: "thinking", text: state.accumulating.text }; - result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true }); + acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; + result.push({ + seq: null, + role: "assistant", + chunk, + provisional: true, + streaming: true, + ...(acc.turnId !== undefined ? { turnId: acc.turnId } : {}), + }); } return result; } diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts index 2ced736..949857a 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -4,12 +4,20 @@ import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@d export interface AccumulatingChunk { readonly kind: "text" | "thinking"; readonly text: string; + /** The turn generating this chunk (from the live event's `turnId`). */ + readonly turnId?: string; } /** A provisional chunk that has no authoritative seq yet. */ export interface ProvisionalChunk { readonly role: Role; readonly chunk: Chunk; + /** + * The turn this chunk belongs to (from the live event's `turnId`). Absent + * only on the sender's optimistic user echo until its `turn-start` arrives + * (which tags it). + */ + readonly turnId?: string; } /** The transcript reducer state. Holds committed history + live in-flight turn. */ @@ -74,4 +82,11 @@ export interface RenderedChunk { * dots on streaming thinking) and drop it the moment generation moves on. */ readonly streaming?: boolean; + /** + * The turn that produced this chunk — `StoredChunk.turnId` for committed + * chunks, the live event's `turnId` for provisional ones. The exact join key + * for placing per-turn metrics. Absent on legacy rows (persisted before the + * backend stamped it) and on the optimistic echo before `turn-start`. + */ + readonly turnId?: string; } |
