summaryrefslogtreecommitdiffhomepage
path: root/packages/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 01:26:16 +0900
committerAdam Malczewski <[email protected]>2026-06-03 01:26:16 +0900
commitb26821ead97b986f886065b20d3dbde8283daa64 (patch)
tree3c41419afb805d88080392e1c97d233af910b79d /packages/core
parent4b45d33c256cf580a53054078be6fd7148fa6302 (diff)
downloaddispatch-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.ts3
-rw-r--r--packages/core/src/compaction/index.ts245
-rw-r--r--packages/core/src/db/chunks.ts19
-rw-r--r--packages/core/src/index.ts16
-rw-r--r--packages/core/src/types/index.ts24
-rw-r--r--packages/core/tests/compaction/compaction.test.ts194
-rw-r--r--packages/core/tests/db/rekey-chunks.db.test.ts129
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);
+ });
+});