summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/chunks')
-rw-r--r--src/core/chunks/groups.ts10
-rw-r--r--src/core/chunks/reducer.test.ts45
-rw-r--r--src/core/chunks/reducer.ts65
-rw-r--r--src/core/chunks/selectors.ts28
-rw-r--r--src/core/chunks/types.ts15
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;
}