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/tests/compaction | |
| 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/tests/compaction')
| -rw-r--r-- | packages/core/tests/compaction/compaction.test.ts | 194 |
1 files changed, 194 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"); + }); +}); |
