summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
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/tests
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/tests')
-rw-r--r--packages/core/tests/compaction/compaction.test.ts194
-rw-r--r--packages/core/tests/db/rekey-chunks.db.test.ts129
2 files changed, 323 insertions, 0 deletions
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);
+ });
+});