summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/chunks
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-30 20:06:31 +0900
committerAdam Malczewski <[email protected]>2026-05-30 20:06:31 +0900
commit0f39b6f78957aacf206012ad2193d9b0c1940c1f (patch)
treeff5f2da8b4f3cdf56cf50d44b8fec75a489ad6fe /packages/core/src/chunks
parent8c58a973b0d021689cebad5c0cc6d56956bbc2f6 (diff)
downloaddispatch-0f39b6f78957aacf206012ad2193d9b0c1940c1f.tar.gz
dispatch-0f39b6f78957aacf206012ad2193d9b0c1940c1f.zip
refactor(chunks): append-only chunk log with per-step cache-stable wire
Replace the message-as-container model with a flat, append-only chunk log. - chunks table (id, tab_id, seq, turn_id, step, role, type, data_json): one row per chunk; tool_call (assistant) and tool_result (tool) are SEPARATE rows linked by callId. Message/turn are derived groupings, not stored. - chunks/transform.ts: DB-free explode (Chunk[] -> rows) / group (rows -> messages), shared by backend and the browser frontend. - Cache fix: toModelMessages segments each turn at tool-batch boundaries into stable [assistant, tool] pairs per step, so earlier steps serialize byte-identically across requests (kills the prompt-cache churn). - agent-manager persists a turn's chunks on seal (once), discarding a failed fallback attempt's partial chunks; rebuilds agent history from the log. - GET /messages windows the log by chunk seq then groups; loadMoreMessages merges a turn split across the window boundary by turnId. - One-shot migration drops the legacy messages table and clears tabs; settings/credentials/keys/usage preserved. Full suite green (317 tests); biome, tsc, and svelte-check clean.
Diffstat (limited to 'packages/core/src/chunks')
-rw-r--r--packages/core/src/chunks/transform.ts266
1 files changed, 266 insertions, 0 deletions
diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts
new file mode 100644
index 0000000..a4c6fc8
--- /dev/null
+++ b/packages/core/src/chunks/transform.ts
@@ -0,0 +1,266 @@
+// Pure, dependency-free transforms between the render-shaped `Chunk[]` /
+// `ChatMessage` model and the flat append-only `ChunkRow` log. Kept free of any
+// DB (`bun:sqlite`) import so BOTH the backend persistence layer
+// (`db/chunks.ts`) and the browser frontend store can share the exact same
+// explode/group logic.
+
+import type {
+ Chunk,
+ ChunkRow,
+ ChunkRowDraft,
+ ErrorData,
+ MessageRole,
+ SystemData,
+ TextData,
+ ThinkingData,
+ ToolBatchChunk,
+ ToolCallData,
+ ToolResultData,
+} from "../types/index.js";
+
+/**
+ * A DERIVED message — a grouping of contiguous chunk rows reconstructed for the
+ * agent's in-memory history and for the frontend's render bubbles. NOT a stored
+ * shape: the source of truth is the flat chunk log.
+ */
+export interface MessageRow {
+ id: string;
+ tabId: string;
+ seq: number;
+ /** turn_id of the chunk rows this message was grouped from. */
+ turnId: string;
+ role: MessageRole;
+ chunks: Chunk[];
+ createdAt: number;
+}
+
+// ─── Explode: in-memory turn → flat chunk-row drafts ─────────────
+//
+// A turn's render-shaped `Chunk[]` is flattened into append-only rows.
+// `tool-batch` chunks are split into SEPARATE `tool_call` (role=assistant) and
+// `tool_result` (role=tool) rows linked by `callId`, mapping 1:1 to the
+// Anthropic wire format. `step` increments after each tool-batch: every LLM
+// round-trip emits text/thinking then (optionally) one tool-batch, so a
+// tool-batch boundary is exactly a step boundary.
+
+/** Explode a single user message's text into one row draft. */
+export function explodeUserText(turnId: string, text: string): ChunkRowDraft[] {
+ return [{ turnId, step: 0, role: "user", type: "text", data: { text } }];
+}
+
+/** Explode an assistant turn's accumulated chunks into ordered row drafts. */
+export function explodeTurn(turnId: string, chunks: Chunk[]): ChunkRowDraft[] {
+ const drafts: ChunkRowDraft[] = [];
+ let step = 0;
+ for (const chunk of chunks) {
+ switch (chunk.type) {
+ case "text":
+ drafts.push({ turnId, step, role: "assistant", type: "text", data: { text: chunk.text } });
+ break;
+ case "thinking":
+ drafts.push({
+ turnId,
+ step,
+ role: "assistant",
+ type: "thinking",
+ data: {
+ text: chunk.text,
+ ...(chunk.metadata !== undefined ? { metadata: chunk.metadata } : {}),
+ },
+ });
+ break;
+ case "tool-batch": {
+ for (const call of chunk.calls) {
+ drafts.push({
+ turnId,
+ step,
+ role: "assistant",
+ type: "tool_call",
+ data: { callId: call.id, name: call.name, arguments: call.arguments },
+ });
+ }
+ for (const call of chunk.calls) {
+ if (call.result === undefined) continue;
+ drafts.push({
+ turnId,
+ step,
+ role: "tool",
+ type: "tool_result",
+ data: {
+ callId: call.id,
+ name: call.name,
+ result: call.result,
+ isError: call.isError ?? false,
+ ...(call.shellOutput !== undefined ? { shellOutput: call.shellOutput } : {}),
+ },
+ });
+ }
+ // A tool-batch ends the current LLM step; subsequent chunks belong
+ // to the next round-trip.
+ step++;
+ break;
+ }
+ case "error":
+ drafts.push({
+ turnId,
+ step,
+ role: "assistant",
+ type: "error",
+ data: {
+ message: chunk.message,
+ ...(chunk.statusCode !== undefined ? { statusCode: chunk.statusCode } : {}),
+ },
+ });
+ break;
+ case "system":
+ drafts.push({
+ turnId,
+ step,
+ role: "system",
+ type: "system",
+ data: { kind: chunk.kind, text: chunk.text },
+ });
+ break;
+ }
+ }
+ return drafts;
+}
+
+// ─── Group: flat chunk rows → derived render messages ────────────
+//
+// The inverse of explode (best-effort over an arbitrary window, so it tolerates
+// orphan tool-results whose tool-call was paged out). `tool_result` rows
+// (role=tool) merge back into the preceding assistant message's per-step
+// `tool-batch` chunk by `callId` rather than forming their own message.
+
+export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] {
+ const messages: MessageRow[] = [];
+
+ let current: { msg: MessageRow; batches: Map<number, ToolBatchChunk> } | null = null;
+ const flush = () => {
+ if (current) {
+ messages.push(current.msg);
+ current = null;
+ }
+ };
+ const ensureAssistant = (row: ChunkRow) => {
+ if (!current) {
+ current = {
+ msg: {
+ id: row.id,
+ tabId: row.tabId,
+ seq: row.seq,
+ turnId: row.turnId,
+ role: "assistant",
+ chunks: [],
+ createdAt: row.createdAt,
+ },
+ batches: new Map(),
+ };
+ }
+ return current;
+ };
+ const ensureBatch = (step: number): ToolBatchChunk => {
+ const c = current;
+ if (!c) throw new Error("ensureBatch called without an assistant message");
+ let batch = c.batches.get(step);
+ if (!batch) {
+ batch = { type: "tool-batch", calls: [] };
+ c.batches.set(step, batch);
+ c.msg.chunks.push(batch);
+ }
+ return batch;
+ };
+
+ for (const row of rows) {
+ if (row.role === "user") {
+ flush();
+ const d = row.data as TextData;
+ messages.push({
+ id: row.id,
+ tabId: row.tabId,
+ seq: row.seq,
+ turnId: row.turnId,
+ role: "user",
+ chunks: [{ type: "text", text: d.text }],
+ createdAt: row.createdAt,
+ });
+ continue;
+ }
+ if (row.role === "system") {
+ // Coalesce consecutive system rows into one system message (multiple
+ // system chunks), matching the old applySystemEvent behaviour.
+ const prev = messages[messages.length - 1];
+ const d = row.data as SystemData;
+ if (current === null && prev && prev.role === "system") {
+ prev.chunks.push({ type: "system", kind: d.kind, text: d.text });
+ continue;
+ }
+ flush();
+ messages.push({
+ id: row.id,
+ tabId: row.tabId,
+ seq: row.seq,
+ turnId: row.turnId,
+ role: "system",
+ chunks: [{ type: "system", kind: d.kind, text: d.text }],
+ createdAt: row.createdAt,
+ });
+ continue;
+ }
+
+ // assistant / tool rows → part of the current assistant message
+ const c = ensureAssistant(row);
+ switch (row.type) {
+ case "text":
+ c.msg.chunks.push({ type: "text", text: (row.data as TextData).text });
+ break;
+ case "thinking": {
+ const d = row.data as ThinkingData;
+ c.msg.chunks.push({
+ type: "thinking",
+ text: d.text,
+ ...(d.metadata !== undefined ? { metadata: d.metadata } : {}),
+ });
+ break;
+ }
+ case "error": {
+ const d = row.data as ErrorData;
+ c.msg.chunks.push({
+ type: "error",
+ message: d.message,
+ ...(d.statusCode !== undefined ? { statusCode: d.statusCode } : {}),
+ });
+ break;
+ }
+ case "tool_call": {
+ const d = row.data as ToolCallData;
+ ensureBatch(row.step).calls.push({ id: d.callId, name: d.name, arguments: d.arguments });
+ break;
+ }
+ case "tool_result": {
+ const d = row.data as ToolResultData;
+ const batch = ensureBatch(row.step);
+ const entry = batch.calls.find((e) => e.id === d.callId);
+ if (entry) {
+ entry.result = d.result;
+ entry.isError = d.isError;
+ if (d.shellOutput !== undefined) entry.shellOutput = d.shellOutput;
+ } else {
+ // Orphan result (its tool_call was paged out of this window).
+ batch.calls.push({
+ id: d.callId,
+ name: d.name,
+ arguments: {},
+ result: d.result,
+ isError: d.isError,
+ ...(d.shellOutput !== undefined ? { shellOutput: d.shellOutput } : {}),
+ });
+ }
+ break;
+ }
+ }
+ }
+ flush();
+ return messages;
+}