diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 01:26:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 01:26:16 +0900 |
| commit | b26821ead97b986f886065b20d3dbde8283daa64 (patch) | |
| tree | 3c41419afb805d88080392e1c97d233af910b79d /packages/core | |
| parent | 4b45d33c256cf580a53054078be6fd7148fa6302 (diff) | |
| download | dispatch-b26821ead97b986f886065b20d3dbde8283daa64.tar.gz dispatch-b26821ead97b986f886065b20d3dbde8283daa64.zip | |
feat(compaction): add UI-driven conversation compaction
Summarize a conversation's older "head" into a structured anchored Markdown
summary while preserving the most recent turns verbatim, shrinking context size
while keeping the information needed to continue coherently. Triggered by a
"Compact conversation" button in Chat Settings (not an agent tool).
Approach informed by OpenCode's session/compaction.ts:
- Ported SUMMARY_TEMPLATE (Goal / Constraints / Progress / Key Decisions /
Next Steps / Critical Context / Relevant Files) and the anchored-summary
buildPrompt (re-summarizes a prior summary when present).
- Ported the TOOL_OUTPUT_MAX_CHARS (2000) cap on tool results in the summary
request.
- Simplified tail selection to a fixed recent-turn count (DEFAULT_TAIL_TURNS=2)
instead of OpenCode's token-budget splitTurn.
core:
- New src/compaction/ module (pure, DB-free): template, prompt builder,
head/tail selection, transcript renderer with tool-output capping, prior
summary extraction. Generic over ChatMessage so callers keep turnId/seq.
- db/chunks.ts: rekeyChunks(from,to) relocates a tab's full history to a
backup tab (reversible — nothing is deleted).
- AgentEvent: compaction-started / -complete / -error variants.
api:
- AgentManager.compactTab(tempTabId, sourceTabId): side-effect-free
resolveConnection() for the compactor model (configured compaction_model_*,
else the source tab's own key+model), one-shot tool-less summary generation
via a transient Agent, then relocate full history to a fresh backup tab and
re-seed the canonical source id with [summary turn + preserved tail]. Source
tab is locked (messages queue) during the run; queue drains afterward.
- Routes: POST /tabs/:id/compact, GET/PUT /tabs/settings/compaction-model.
frontend:
- "Compact conversation" button in ModelSelector (Chat Settings), between
Working Directory and the agent toggle; idle-gated.
- Compaction-model key+model selector in Settings, beside the title model.
- Transient placeholder tab shows a large, non-faded "Please wait, compacting
conversation…" screen; closing it cancels. Source input locked while running.
- Handle compaction-* events: reload compacted source, insert backup tab,
refocus source, discard placeholder.
tests: core compaction unit tests, rekeyChunks DB test, AgentManager.compactTab
orchestration tests, and compaction route tests. All green (713 tests), biome
clean, all typechecks pass, frontend builds.
Diffstat (limited to 'packages/core')
| -rw-r--r-- | packages/core/src/chunks/append.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/compaction/index.ts | 245 | ||||
| -rw-r--r-- | packages/core/src/db/chunks.ts | 19 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 16 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 24 | ||||
| -rw-r--r-- | packages/core/tests/compaction/compaction.test.ts | 194 | ||||
| -rw-r--r-- | packages/core/tests/db/rekey-chunks.db.test.ts | 129 |
7 files changed, 629 insertions, 1 deletions
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts index 4bc6b5f..4ca6fe1 100644 --- a/packages/core/src/chunks/append.ts +++ b/packages/core/src/chunks/append.ts @@ -210,6 +210,9 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void { case "message-queued": case "message-consumed": case "message-cancelled": + case "compaction-started": + case "compaction-complete": + case "compaction-error": return; default: { diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts new file mode 100644 index 0000000..663ccb2 --- /dev/null +++ b/packages/core/src/compaction/index.ts @@ -0,0 +1,245 @@ +// Conversation compaction — summarize the older "head" of a conversation into +// a structured anchor while preserving the most recent turns verbatim. +// +// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the +// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The +// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed +// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns +// verbatim rather than computing a token budget. +// +// This module is pure and DB-free so it can be unit-tested in isolation and +// shared by the API orchestrator. + +import type { ChatMessage } from "../types/index.js"; + +/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */ +export const DEFAULT_TAIL_TURNS = 2; + +/** Max characters of a single tool result fed into the summary request. */ +export const TOOL_OUTPUT_MAX_CHARS = 2_000; + +/** + * Marker prefixing the seeded summary turn in a compacted conversation. Lets a + * subsequent compaction detect a prior summary and re-summarize (anchor) it + * instead of treating it as ordinary conversation. + */ +export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]"; + +/** + * Structured Markdown template the summary must follow. Ported verbatim from + * opencode's `SUMMARY_TEMPLATE`. + */ +export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response. +<template> +## Goal +- [single-sentence task summary] + +## Constraints & Preferences +- [user constraints, preferences, specs, or "(none)"] + +## Progress +### Done +- [completed work or "(none)"] + +### In Progress +- [current work or "(none)"] + +### Blocked +- [blockers or "(none)"] + +## Key Decisions +- [decision and why, or "(none)"] + +## Next Steps +- [ordered next actions or "(none)"] + +## Critical Context +- [important technical facts, errors, open questions, or "(none)"] + +## Relevant Files +- [file or directory path: why it matters, or "(none)"] +</template> + +Rules: +- Keep every section, even when empty. +- Use terse bullets, not prose paragraphs. +- Preserve exact file paths, commands, error strings, and identifiers when known. +- Do not mention the summary process or that context was compacted.`; + +/** + * Build the compaction instruction. When `previousSummary` is provided, the + * model is asked to UPDATE the anchored summary rather than create a fresh one + * (opencode's `buildPrompt` anchor behaviour). + */ +export function buildCompactionPrompt(input: { previousSummary?: string }): string { + const anchor = input.previousSummary + ? [ + "Update the anchored summary below using the conversation history above.", + "Preserve still-true details, remove stale details, and merge in the new facts.", + "<previous-summary>", + input.previousSummary, + "</previous-summary>", + ].join("\n") + : "Create a new anchored summary from the conversation history above."; + return `${anchor}\n\n${SUMMARY_TEMPLATE}`; +} + +/** + * The first text chunk of a message, trimmed (empty → undefined). Used to read + * the seeded summary out of a compacted conversation's first user turn. + */ +function firstText(message: ChatMessage): string | undefined { + for (const chunk of message.chunks) { + if (chunk.type === "text") { + const t = chunk.text.trim(); + if (t) return t; + } + } + return undefined; +} + +/** + * Extract a prior summary from the conversation head. If the first user message + * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body + * (marker stripped) so the next compaction can anchor on it. + */ +export function extractPreviousSummary(messages: ChatMessage[]): string | undefined { + const first = messages.find((m) => m.role === "user"); + if (!first) return undefined; + const text = firstText(first); + if (!text?.startsWith(SUMMARY_MARKER)) return undefined; + const body = text.slice(SUMMARY_MARKER.length).trim(); + return body || undefined; +} + +export interface HeadTailSelection<T extends ChatMessage = ChatMessage> { + /** Older messages to be summarized away. */ + head: T[]; + /** Recent messages preserved verbatim in the continuation. */ + tail: T[]; +} + +/** + * Split a conversation into a summarizable `head` and a preserved `tail` of the + * last `tailTurns` turns. A "turn" begins at a user message and runs until the + * next user message. + * + * When the conversation has `tailTurns` or fewer turns, `head` is empty: there + * is nothing to compact (the caller should refuse). + */ +export function selectHeadTail<T extends ChatMessage>( + messages: T[], + tailTurns: number = DEFAULT_TAIL_TURNS, +): HeadTailSelection<T> { + if (tailTurns <= 0) return { head: messages, tail: [] }; + const userIndices: number[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i]?.role === "user") userIndices.push(i); + } + if (userIndices.length <= tailTurns) return { head: [], tail: messages }; + const tailStart = userIndices[userIndices.length - tailTurns]; + if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages }; + return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) }; +} + +/** Cap a tool result to `max` chars with a truncation marker. */ +function capToolOutput(result: string, max: number): string { + if (result.length <= max) return result; + const omitted = result.length - max; + return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`; +} + +/** + * Render conversation messages into a compact, provider-agnostic plain-text + * transcript suitable as summary-request context. Tool results are capped at + * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior + * summary message is skipped (its content is carried by the prompt anchor). + * Thinking/error/system chunks are omitted as summary noise. + */ +export function renderTranscript( + messages: ChatMessage[], + toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS, +): string { + const blocks: string[] = []; + for (const message of messages) { + // Skip a seeded prior-summary user turn — it's represented via the anchor. + if (message.role === "user") { + const t = firstText(message); + if (t?.startsWith(SUMMARY_MARKER)) continue; + } + + const lines: string[] = []; + for (const chunk of message.chunks) { + if (chunk.type === "text") { + const t = chunk.text.trim(); + if (t) lines.push(t); + } else if (chunk.type === "tool-batch") { + for (const call of chunk.calls) { + let args = ""; + try { + args = JSON.stringify(call.arguments ?? {}); + } catch { + args = "{}"; + } + lines.push(`[tool ${call.name} ${args}]`); + if (call.result !== undefined) { + const tag = call.isError ? "tool-error" : "tool-result"; + lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`); + } + } + } + } + if (lines.length === 0) continue; + const role = + message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System"; + blocks.push(`## ${role}\n${lines.join("\n")}`); + } + return blocks.join("\n\n"); +} + +export interface CompactionRequest<T extends ChatMessage = ChatMessage> { + /** Messages selected for summarization (older head). */ + head: T[]; + /** Recent messages preserved verbatim (last N turns). */ + tail: T[]; + /** Prior summary anchored on, if the conversation was compacted before. */ + previousSummary?: string; + /** + * The full user-message content for the summary request: rendered head + * transcript followed by the compaction prompt/template. `undefined` when + * there is nothing to compact (`head` empty). + */ + prompt?: string; +} + +/** + * Assemble everything needed to run a compaction: head/tail split, prior-summary + * extraction, and the combined summary-request prompt. Returns `prompt: + * undefined` when the conversation is too short to compact. + */ +export function buildCompactionRequest<T extends ChatMessage>(input: { + messages: T[]; + tailTurns?: number; + toolOutputMaxChars?: number; +}): CompactionRequest<T> { + const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS; + const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS; + const { head, tail } = selectHeadTail(input.messages, tailTurns); + const previousSummary = extractPreviousSummary(input.messages); + if (head.length === 0) { + return { head, tail, previousSummary }; + } + const transcript = renderTranscript(head, toolMax); + const instruction = buildCompactionPrompt({ previousSummary }); + const prompt = `${transcript}\n\n${instruction}`; + return { head, tail, previousSummary, prompt }; +} + +/** + * Wrap a generated summary as the seeded user-turn text for the continuation + * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can + * anchor on it. + */ +export function buildSummaryTurnText(summary: string): string { + return `${SUMMARY_MARKER}\n\n${summary.trim()}`; +} diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts index e0aadf3..b434a47 100644 --- a/packages/core/src/db/chunks.ts +++ b/packages/core/src/db/chunks.ts @@ -225,3 +225,22 @@ export function clearChunksForTab(tabId: string): void { const db = getDatabase(); db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId }); } + +/** + * Relocate every chunk row from one tab to another (compaction backup path). + * + * Used by conversation compaction to move the FULL pre-compaction history off + * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id + * (`toTabId`), leaving the canonical id free to be re-seeded with the summary + + * preserved tail. `seq` values are preserved (they remain per-tab monotonic for + * the destination since it starts empty), as are turn ids, so the relocated + * history groups identically under its new tab. Returns the number of rows + * moved. + */ +export function rekeyChunks(fromTabId: string, toTabId: string): number { + const db = getDatabase(); + const result = db + .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from") + .run({ $from: fromTabId, $to: toTabId }); + return Number(result.changes ?? 0); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08b426f..2789b2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,21 @@ export { type IdentifiedMessage, type SystemEventLike, } from "./chunks/append.js"; +// Compaction +export { + buildCompactionPrompt, + buildCompactionRequest, + buildSummaryTurnText, + type CompactionRequest, + DEFAULT_TAIL_TURNS, + extractPreviousSummary, + type HeadTailSelection, + renderTranscript, + SUMMARY_MARKER, + SUMMARY_TEMPLATE, + selectHeadTail, + TOOL_OUTPUT_MAX_CHARS, +} from "./compaction/index.js"; // Config export { configToRuleset, @@ -40,6 +55,7 @@ export { getUsageStatsForTab, groupRowsToMessages, type MessageRow, + rekeyChunks, } from "./db/chunks.js"; // Database export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js"; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 607b27d..f7944c9 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -351,7 +351,29 @@ export type AgentEvent = */ reason?: "interrupt" | "continuation"; } - | { type: "message-cancelled"; tabId: string; messageId: string }; + | { type: "message-cancelled"; tabId: string; messageId: string } + /** + * Conversation-compaction lifecycle (UI-driven, not an agent tool). A + * compaction summarizes a tab's older history into an anchored summary while + * preserving the most recent turns verbatim. + * + * `compaction-started` fires on the temporary placeholder tab when the + * summary request begins. `compaction-complete` fires when the summary has + * been generated and the history relocated: the compacted continuation now + * lives on `sourceTabId` (the canonical id, with its key/model/working-dir + * preserved), the FULL pre-compaction history was moved to `backupTabId`, and + * `tempTabId` (the placeholder) should be discarded by the frontend. + * `compaction-error` reports a failure (or cancellation) on `tempTabId`. + */ + | { type: "compaction-started"; tempTabId: string; sourceTabId: string } + | { + type: "compaction-complete"; + tempTabId: string; + sourceTabId: string; + backupTabId: string; + backupTitle: string; + } + | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string }; // ─── Tool Types ────────────────────────────────────────────────── diff --git a/packages/core/tests/compaction/compaction.test.ts b/packages/core/tests/compaction/compaction.test.ts new file mode 100644 index 0000000..d6edd59 --- /dev/null +++ b/packages/core/tests/compaction/compaction.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { + buildCompactionPrompt, + buildCompactionRequest, + buildSummaryTurnText, + DEFAULT_TAIL_TURNS, + extractPreviousSummary, + renderTranscript, + SUMMARY_MARKER, + SUMMARY_TEMPLATE, + selectHeadTail, +} from "../../src/compaction/index.js"; +import type { ChatMessage } from "../../src/types/index.js"; + +function user(text: string): ChatMessage { + return { role: "user", chunks: [{ type: "text", text }] }; +} +function assistant(text: string): ChatMessage { + return { role: "assistant", chunks: [{ type: "text", text }] }; +} + +describe("selectHeadTail", () => { + it("returns empty head when turns <= tailTurns (nothing to compact)", () => { + const msgs = [user("u1"), assistant("a1"), user("u2"), assistant("a2")]; + const { head, tail } = selectHeadTail(msgs, 2); + expect(head).toEqual([]); + expect(tail).toEqual(msgs); + }); + + it("keeps the last N turns verbatim and summarizes the rest", () => { + const msgs = [ + user("u1"), + assistant("a1"), + user("u2"), + assistant("a2"), + user("u3"), + assistant("a3"), + ]; + const { head, tail } = selectHeadTail(msgs, 2); + expect(tail[0]).toBe(msgs[2]); + expect(tail.at(-1)).toBe(msgs[5]); + expect(head).toEqual([msgs[0], msgs[1]]); + }); + + it("a turn includes trailing assistant/tool messages up to the next user", () => { + const msgs = [user("u1"), assistant("a1a"), assistant("a1b"), user("u2"), assistant("a2")]; + const { head, tail } = selectHeadTail(msgs, 1); + expect(head).toEqual([msgs[0], msgs[1], msgs[2]]); + expect(tail).toEqual([msgs[3], msgs[4]]); + }); + + it("tailTurns<=0 → everything is head", () => { + const msgs = [user("u1"), assistant("a1")]; + expect(selectHeadTail(msgs, 0)).toEqual({ head: msgs, tail: [] }); + }); + + it("defaults to DEFAULT_TAIL_TURNS", () => { + const msgs = [user("u1"), user("u2"), user("u3")]; + const def = selectHeadTail(msgs); + const explicit = selectHeadTail(msgs, DEFAULT_TAIL_TURNS); + expect(def).toEqual(explicit); + }); +}); + +describe("buildCompactionPrompt", () => { + it("creates a fresh-summary instruction without a previous summary", () => { + const p = buildCompactionPrompt({}); + expect(p).toContain("Create a new anchored summary"); + expect(p).toContain(SUMMARY_TEMPLATE); + expect(p).not.toContain("<previous-summary>"); + }); + + it("anchors on a previous summary when provided", () => { + const p = buildCompactionPrompt({ previousSummary: "## Goal\n- old" }); + expect(p).toContain("Update the anchored summary"); + expect(p).toContain("<previous-summary>"); + expect(p).toContain("## Goal\n- old"); + expect(p).toContain(SUMMARY_TEMPLATE); + }); +}); + +describe("extractPreviousSummary", () => { + it("returns undefined when the first user message is not a seeded summary", () => { + expect(extractPreviousSummary([user("hello"), assistant("hi")])).toBeUndefined(); + }); + + it("extracts the body of a seeded summary turn (marker stripped)", () => { + const seeded = user(buildSummaryTurnText("## Goal\n- build X")); + expect(extractPreviousSummary([seeded, assistant("ok")])).toBe("## Goal\n- build X"); + }); +}); + +describe("renderTranscript", () => { + it("renders user/assistant text blocks", () => { + const t = renderTranscript([user("hello"), assistant("hi there")]); + expect(t).toContain("## User\nhello"); + expect(t).toContain("## Assistant\nhi there"); + }); + + it("renders tool calls and caps long tool results", () => { + const big = "x".repeat(5000); + const msg: ChatMessage = { + role: "assistant", + chunks: [ + { + type: "tool-batch", + calls: [{ id: "c1", name: "read_file", arguments: { path: "a" }, result: big }], + }, + ], + }; + const t = renderTranscript([msg], 2000); + expect(t).toContain('[tool read_file {"path":"a"}]'); + expect(t).toContain("chars truncated for summary"); + expect(t.length).toBeLessThan(5000 + 200); + }); + + it("skips a seeded prior-summary user turn", () => { + const seeded = user(buildSummaryTurnText("## Goal\n- prior")); + const t = renderTranscript([seeded, assistant("work")]); + expect(t).not.toContain("prior"); + expect(t).toContain("## Assistant\nwork"); + }); + + it("omits thinking/error/system chunks", () => { + const msg: ChatMessage = { + role: "assistant", + chunks: [ + { type: "thinking", text: "secret reasoning" }, + { type: "text", text: "visible" }, + { type: "error", message: "boom" }, + ], + }; + const t = renderTranscript([msg]); + expect(t).toContain("visible"); + expect(t).not.toContain("secret reasoning"); + expect(t).not.toContain("boom"); + }); +}); + +describe("buildCompactionRequest", () => { + it("returns no prompt when there is nothing to compact", () => { + const msgs = [user("u1"), assistant("a1")]; + const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 }); + expect(req.prompt).toBeUndefined(); + expect(req.head).toEqual([]); + expect(req.tail).toEqual(msgs); + }); + + it("builds a prompt with transcript + instruction and exposes head/tail", () => { + const msgs = [ + user("first task"), + assistant("did stuff"), + user("second"), + assistant("more"), + user("third"), + assistant("done"), + ]; + const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 }); + expect(req.prompt).toBeDefined(); + expect(req.prompt).toContain("first task"); + expect(req.prompt).toContain("Create a new anchored summary"); + expect(req.tail[0]).toBe(msgs[2]); + expect(req.head[0]).toBe(msgs[0]); + }); + + it("anchors on a prior seeded summary", () => { + const msgs = [ + user(buildSummaryTurnText("## Goal\n- old goal")), + assistant("ack"), + user("new work"), + assistant("did new"), + user("more work"), + assistant("did more"), + ]; + const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 }); + expect(req.previousSummary).toBe("## Goal\n- old goal"); + expect(req.prompt).toContain("Update the anchored summary"); + // head = [seeded-summary, "ack"]; seeded summary is skipped in the + // transcript, so "ack" represents the summarized head. "new work" lives + // in the preserved tail (last 2 turns), not the summary body. + expect(req.prompt).toContain("ack"); + expect( + req.tail.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "new work")), + ).toBe(true); + }); +}); + +describe("buildSummaryTurnText", () => { + it("prefixes the marker so a later compaction can anchor", () => { + const seeded = buildSummaryTurnText("## Goal\n- x"); + expect(seeded.startsWith(SUMMARY_MARKER)).toBe(true); + expect(extractPreviousSummary([user(seeded)])).toBe("## Goal\n- x"); + }); +}); diff --git a/packages/core/tests/db/rekey-chunks.db.test.ts b/packages/core/tests/db/rekey-chunks.db.test.ts new file mode 100644 index 0000000..7cdafe3 --- /dev/null +++ b/packages/core/tests/db/rekey-chunks.db.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface ChunkRecord { + id: string; + tab_id: string; + seq: number; + turn_id: string; + step: number; + role: string; + type: string; + data_json: string; + created_at: number; +} + +/** + * Minimal in-memory fake of bun:sqlite supporting only the queries + * `appendChunks`, `getChunksForTab`, and `rekeyChunks` issue. Mirrors the + * approach in chunks.db.test.ts (exact normalized-string branches). + */ +class FakeDatabase { + rows: ChunkRecord[] = []; + + query(sql: string) { + return { + all: (params?: Record<string, unknown>) => this.execSelect(sql, params), + get: (params?: Record<string, unknown>) => this.execSelect(sql, params)[0] ?? null, + run: (params?: Record<string, unknown>) => this.execMutation(sql, params), + }; + } + + transaction(fn: () => void): () => void { + return () => fn(); + } + + private execSelect(sql: string, params?: Record<string, unknown>): unknown[] { + const norm = sql.replace(/\s+/g, " ").trim(); + const tabId = params?.$tabId as string | undefined; + const forTab = this.rows.filter((r) => r.tab_id === tabId); + const visible = forTab.filter((r) => r.type !== "usage"); + + if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") { + const seqs = forTab.map((r) => r.seq); + return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }]; + } + if ( + norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC" + ) { + return [...visible].sort((a, b) => a.seq - b.seq); + } + throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`); + } + + private execMutation(sql: string, params?: Record<string, unknown>): { changes: number } { + const norm = sql.replace(/\s+/g, " ").trim(); + if ( + norm === + "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)" + ) { + this.rows.push({ + id: params?.$id as string, + tab_id: params?.$tabId as string, + seq: params?.$seq as number, + turn_id: params?.$turnId as string, + step: (params?.$step as number) ?? 0, + role: params?.$role as string, + type: params?.$type as string, + data_json: params?.$dataJson as string, + created_at: (params?.$now as number) ?? 0, + }); + return { changes: 1 }; + } + if (norm === "UPDATE chunks SET tab_id = $to WHERE tab_id = $from") { + const from = params?.$from as string; + const to = params?.$to as string; + let changes = 0; + for (const r of this.rows) { + if (r.tab_id === from) { + r.tab_id = to; + changes++; + } + } + return { changes }; + } + throw new Error(`FakeDatabase: unsupported mutation: ${norm}`); + } +} + +let fakeDb: FakeDatabase; +vi.mock("../../src/db/index.js", () => ({ getDatabase: vi.fn(() => fakeDb) })); + +const { appendChunks, getChunksForTab, rekeyChunks } = await import("../../src/db/chunks.js"); + +beforeEach(() => { + fakeDb = new FakeDatabase(); +}); + +describe("rekeyChunks", () => { + it("relocates all rows from one tab to another and reports the count", () => { + appendChunks("src", [ + { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } }, + { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } }, + ]); + expect(getChunksForTab("src")).toHaveLength(2); + + const moved = rekeyChunks("src", "backup"); + expect(moved).toBe(2); + expect(getChunksForTab("src")).toHaveLength(0); + const dst = getChunksForTab("backup"); + expect(dst).toHaveLength(2); + // turn id + seq preserved on the destination + expect(dst.map((r) => r.turnId)).toEqual(["t1", "t1"]); + expect(dst.map((r) => r.seq)).toEqual([0, 1]); + }); + + it("returns 0 when the source tab has no rows", () => { + expect(rekeyChunks("nope", "backup")).toBe(0); + }); + + it("does not touch unrelated tabs", () => { + appendChunks("src", [ + { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "a" } }, + ]); + appendChunks("other", [ + { turnId: "t9", step: 0, role: "user", type: "text", data: { text: "b" } }, + ]); + rekeyChunks("src", "backup"); + expect(getChunksForTab("other")).toHaveLength(1); + }); +}); |
