summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks/reducer.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/chunks/reducer.ts')
-rw-r--r--src/core/chunks/reducer.ts315
1 files changed, 288 insertions, 27 deletions
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index 64e41b9..71df4dd 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -1,5 +1,6 @@
import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire";
import { assertChunkExhaustive } from "../wire/conformance";
+import { historyBackedProvisional } from "./reconcile";
import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types";
/** The initial empty transcript state. */
@@ -40,7 +41,59 @@ 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.stepId !== undefined ? { stepId: acc.stepId } : {}) },
+ 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 })),
+ ];
+}
+
+/**
+ * Drop the previous copy of a turn that is being replayed from its start.
+ * Keeps the trailing user run — the opening this replay's `user-message` just
+ * appended (or the sender's echo that `turn-start` just tagged) — and every
+ * other turn's chunks.
+ */
+function dropReplayedTurn(
+ provisional: readonly ProvisionalChunk[],
+ turnId: string,
+): readonly ProvisionalChunk[] {
+ let tail = provisional.length;
+ while (tail > 0 && provisional[tail - 1]?.role === "user") tail--;
+ // A stale steering bubble (replayed again as its own `steering` event) can
+ // end the old copy and so sit inside the trailing user run.
+ const stale = (p: ProvisionalChunk, i: number): boolean =>
+ p.turnId === turnId && (i < tail || p.afterToolCallId !== undefined);
+ if (!provisional.some(stale)) return provisional;
+ return provisional.filter((p, i) => !stale(p, i));
}
/**
@@ -103,8 +156,8 @@ function trailingRun(
/**
* Merge authoritative seq-keyed chunks into the committed history.
* Dedupes by seq (new wins), keeps seq-monotonic order, idempotent.
- * When sealedTurnId is set, drops all provisional chunks (now superseded)
- * and clears sealedTurnId.
+ * A sealed/finally-synced turn supersedes only its own provisional chunks;
+ * unrelated turns and an already-running queued carry remain live.
*
* Chunks below the chat-limit unload watermark (`hiddenBeforeSeq`) are
* REJECTED: a full-cache or tail merge must not resurrect what the trim
@@ -113,6 +166,7 @@ function trailingRun(
export function applyHistory(
state: TranscriptState,
chunks: readonly StoredChunk[],
+ finishedTurnIds: readonly string[] = [],
): TranscriptState {
const seqMap = new Map<number, StoredChunk>();
for (const c of state.committed) seqMap.set(c.seq, c);
@@ -123,14 +177,49 @@ export function applyHistory(
seqMap.set(c.seq, c);
}
const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq);
+ const identityRows = new Map(seqMap);
+ for (const c of chunks) identityRows.set(c.seq, c);
+ const backed = historyBackedProvisional(
+ state,
+ [...identityRows.values()].sort((a, b) => a.seq - b.seq),
+ );
+ const finished = new Set(finishedTurnIds);
+ if (state.sealedTurnId !== null) finished.add(state.sealedTurnId);
+ state = {
+ ...state,
+ provisional: state.provisional.filter((p) => !backed.has(p) && !finished.has(p.turnId ?? "")),
+ accumulating:
+ state.accumulating?.turnId !== undefined && finished.has(state.accumulating.turnId)
+ ? null
+ : state.accumulating,
+ };
if (state.sealedTurnId !== null) {
+ // Legacy (non-recovery) histories may carry no turn provenance at all.
+ // Retain their seal cleanup, but never erase an already-live queued carry.
+ const legacySeal =
+ (state.historyRecoveryTurnIds?.length ?? 0) === 0 && finishedTurnIds.length === 0;
return {
...state,
committed,
- provisional: [],
- accumulating: null,
+ provisional: legacySeal
+ ? state.provisional.filter(
+ (p) =>
+ state.generating &&
+ p.turnId === state.currentTurnId &&
+ p.turnId !== state.sealedTurnId,
+ )
+ : state.provisional,
+ accumulating:
+ legacySeal && state.accumulating?.turnId === state.sealedTurnId ? null : state.accumulating,
sealedTurnId: null,
+ ...(state.historyRecoveryTurnIds !== undefined
+ ? {
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter(
+ (id) => !finishedTurnIds.includes(id),
+ ),
+ }
+ : {}),
};
}
@@ -141,7 +230,14 @@ export function applyHistory(
// match the trailing provisional user-run against the trailing committed
// user-run by content equality and drop the whole echo when it is fully
// backed. Leaves the accumulating (streaming) chunk untouched.
- if (addedNew && state.generating && state.provisional.length > 0) {
+ if (
+ addedNew &&
+ state.generating &&
+ state.provisional.length > 0 &&
+ !state.provisional.some(
+ (p) => p.turnId !== undefined && state.historyRecoveryTurnIds?.includes(p.turnId),
+ )
+ ) {
const provRun = trailingRun(state.provisional, "user");
if (provRun.length > 0) {
const commRun = trailingRun(committed, "user");
@@ -153,7 +249,11 @@ export function applyHistory(
commRun.length >= provRun.length &&
provRun.every((p, i) => {
const c = commRun[i];
- return c !== undefined && chunkContentEquals(p.chunk, c.chunk);
+ return (
+ c !== undefined &&
+ (c.turnId === undefined || p.turnId === undefined || c.turnId === p.turnId) &&
+ chunkContentEquals(p.chunk, c.chunk)
+ );
});
if (fullyBacked) {
const dropStart = state.provisional.length - provRun.length;
@@ -163,7 +263,17 @@ export function applyHistory(
}
}
- return { ...state, committed };
+ return {
+ ...state,
+ committed,
+ ...(state.historyRecoveryTurnIds !== undefined
+ ? {
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter(
+ (id) => !finishedTurnIds.includes(id),
+ ),
+ }
+ : {}),
+ };
}
/**
@@ -174,6 +284,8 @@ export function applyHistory(
* optimistic echo) so a watcher renders it mid-turn.
* - `text-delta` extends the current accumulating TextChunk (or starts one).
* - `reasoning-delta` extends the current accumulating ThinkingChunk (or starts one).
+ * - `reasoning-end` closes the open ThinkingChunk as its own chunk; with none open
+ * (a redacted/empty block) it appends an empty thinking chunk, mirroring history.
* - `tool-call` / `tool-result` / `error` finalize any accumulating chunk and
* add a new provisional chunk.
* - `steering` appends a user bubble mid-turn (drained from the message queue
@@ -198,8 +310,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "tool-output":
return state;
- case "turn-start":
- return { ...state, currentTurnId: event.turnId, generating: true };
+ case "turn-start": {
+ // A turn starts exactly once live, so a `turn-start` for a turn that
+ // already has provisional content is a late-join REPLAY restarting from
+ // the top (re-subscribe after a tab switch / WS reconnect): the server
+ // re-delivers the whole in-flight turn. Replace the stale copy instead of
+ // appending a second one (a duplicate copy rendered as a tail of thinking
+ // blocks with its tool cards collapsed onto the first copy's).
+ const stale = state.accumulating?.turnId === event.turnId;
+ const provisional = tagTrailingUserEcho(
+ flushAccumulating(state.provisional, stale ? null : state.accumulating),
+ event.turnId,
+ );
+ return {
+ ...state,
+ provisional: dropReplayedTurn(provisional, event.turnId),
+ accumulating: null,
+ currentTurnId: event.turnId,
+ generating: true,
+ };
+ }
case "user-message": {
// The turn's USER prompt, surfaced on the event stream (backend CR-3) so a
@@ -215,14 +345,37 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// text) emits no `user-message` and is not de-duped here.
if (event.text.length === 0) return state;
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 };
+ const echoed = run.find(
+ (p) =>
+ (p.turnId === undefined || p.turnId === event.turnId) &&
+ p.chunk.type === "text" &&
+ p.chunk.text === event.text,
+ );
+ if (echoed !== undefined) {
+ return {
+ ...state,
+ // Preserve the live text independently of the local send. A delayed
+ // queued ack may remove its images, but cannot erase this server event.
+ provisional: tagTrailingUserEcho(state.provisional, event.turnId).map((p) => {
+ if (p.chunk !== echoed.chunk) return p;
+ const { localRequestId: _local, ...confirmed } = p;
+ return { ...confirmed, opening: true };
+ }),
+ 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,
+ opening: true,
+ },
+ ],
accumulating: null,
generating: true,
};
@@ -230,10 +383,10 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "text-delta": {
const acc = state.accumulating;
- if (acc !== null && acc.kind === "text") {
+ if (acc !== null && acc.kind === "text" && acc.turnId === event.turnId) {
return {
...state,
- accumulating: { kind: "text", text: acc.text + event.delta },
+ accumulating: { ...acc, text: acc.text + event.delta },
generating: true,
};
}
@@ -241,17 +394,17 @@ 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,
};
}
case "reasoning-delta": {
const acc = state.accumulating;
- if (acc !== null && acc.kind === "thinking") {
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
return {
...state,
- accumulating: { kind: "thinking", text: acc.text + event.delta },
+ accumulating: { ...acc, text: acc.text + event.delta },
generating: true,
};
}
@@ -259,7 +412,31 @@ 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,
+ };
+ }
+
+ case "reasoning-end": {
+ const acc = state.accumulating;
+ // Close the open thinking block of this turn: flush it as its own provisional chunk.
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, acc),
+ accumulating: null,
+ generating: true,
+ };
+ }
+ // No open thinking (a redacted/empty block with no deltas): history persists an
+ // EMPTY thinking chunk for it, so mirror that 1:1.
+ return {
+ ...state,
+ provisional: [
+ ...flushAccumulating(state.provisional, acc),
+ { role: "assistant", chunk: { type: "thinking", text: "" }, turnId: event.turnId },
+ ],
+ accumulating: null,
generating: true,
};
}
@@ -275,7 +452,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 +470,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 +484,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,
};
@@ -317,8 +494,24 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
return { ...state, latestUsage: event.usage };
case "step-complete":
- // Timing metadata — no content chunk; handled by the telemetry reducer.
- return state;
+ // A persistence boundary may lag this notification. Stamp provenance and
+ // finish the generation chunk, but keep it until history really backs it.
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, state.accumulating),
+ accumulating: null,
+ };
+
+ case "turn-ended":
+ return {
+ ...state,
+ provisional:
+ state.accumulating?.turnId === event.turnId
+ ? flushAccumulating(state.provisional, state.accumulating)
+ : state.provisional,
+ accumulating: state.accumulating?.turnId === event.turnId ? null : state.accumulating,
+ generating: state.currentTurnId === event.turnId ? false : state.generating,
+ };
case "done": {
const provisional = flushAccumulating(state.provisional, state.accumulating);
@@ -350,9 +543,22 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// into the transcript by the sender).
if (event.text.length === 0) return state;
const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const preceding = [...state.committed, ...provisional]
+ .reverse()
+ .find((p) => p.turnId === event.turnId && p.chunk.type === "tool-result");
+ const afterToolCallId =
+ preceding?.chunk.type === "tool-result" ? preceding.chunk.toolCallId : undefined;
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,
+ ...(afterToolCallId !== undefined ? { afterToolCallId } : {}),
+ },
+ ],
accumulating: null,
generating: true,
};
@@ -367,6 +573,9 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// (already true from `turn-start`) is left untouched.
return { ...state, providerRetry: event };
}
+
+ default:
+ return state; // Forward-compatible with additive wire events.
}
}
@@ -385,14 +594,37 @@ const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([
"turn-start",
"text-delta",
"reasoning-delta",
+ "reasoning-end",
"tool-call",
"tool-result",
"error",
"done",
"turn-sealed",
+ "turn-ended",
]);
export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState {
+ if (
+ event.type === "tool-call" ||
+ event.type === "tool-result" ||
+ event.type === "step-complete"
+ ) {
+ state = {
+ ...state,
+ provisional: state.provisional.map((p) =>
+ p.turnId === event.turnId &&
+ p.role === "assistant" &&
+ p.stepId === undefined &&
+ (p.chunk.type === "text" || p.chunk.type === "thinking")
+ ? { ...p, stepId: event.stepId }
+ : p,
+ ),
+ accumulating:
+ state.accumulating?.turnId === event.turnId
+ ? { ...state.accumulating, stepId: event.stepId }
+ : state.accumulating,
+ };
+ }
const next = reduceEvent(state, event);
if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event
if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) {
@@ -419,6 +651,7 @@ export function appendUserMessage(
state: TranscriptState,
text: string,
images?: readonly ImageInput[],
+ localRequestId?: string,
): TranscriptState {
const provisional = flushAccumulating(state.provisional, state.accumulating);
const userChunks: Chunk[] = [];
@@ -440,7 +673,35 @@ export function appendUserMessage(
}
return {
...state,
- provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)],
+ provisional: [
+ ...provisional,
+ ...userChunks.map(
+ (chunk) =>
+ ({
+ role: "user",
+ chunk,
+ ...(localRequestId !== undefined ? { localRequestId } : {}),
+ }) as const,
+ ),
+ ],
accumulating: null,
};
}
+
+/** Remove only still-local artifacts of one accepted queued send; never history/live text. */
+export function removeLocalEcho(state: TranscriptState, requestId: string): TranscriptState {
+ const provisional = state.provisional.filter((p) => p.localRequestId !== requestId);
+ return provisional.length === state.provisional.length ? state : { ...state, provisional };
+}
+
+/** A resync notice is lifecycle evidence, never fabricated transcript content. */
+export function markHistoryRecovering(state: TranscriptState, turnId: string): TranscriptState {
+ return {
+ ...state,
+ currentTurnId: turnId,
+ generating: true,
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds?.includes(turnId)
+ ? state.historyRecoveryTurnIds
+ : [...(state.historyRecoveryTurnIds ?? []), turnId],
+ };
+}