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/api/tests | |
| 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/api/tests')
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 226 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 45 |
2 files changed, 265 insertions, 6 deletions
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index dbbcc65..0915d9b 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -115,6 +115,38 @@ function resetAppendChunksCalls(): void { appendChunksCalls.length = 0; } +// ── Compaction test scaffolding ──────────────────────────────────── +// Fake chunk store (per tab) feeding getChunksForTab / rekeyChunks. +const fakeChunksByTab = new Map<string, Array<{ tabId: string }>>(); +// Records of createTab(id, title, opts) and rekeyChunks(from, to) calls. +const createTabCalls: Array<{ id: string; title: string }> = []; +const rekeyCalls: Array<{ from: string; to: string }> = []; +// Configurable buildCompactionRequest result per source tab. Default: a +// compactable conversation (non-empty prompt + a one-message tail). +const fakeCompactionByTab = new Map< + string, + { prompt?: string; tail: Array<{ turnId: string; role: string; chunks: unknown[] }> } +>(); +// Configurable registry keys + env-key resolution so resolveConnection can +// succeed in compaction tests. Default empty (preserves existing behaviour). +const fakeRegistryKeys: Array<{ + id: string; + provider: string; + base_url: string; + env?: string; +}> = []; +const fakeApiKeys = new Map<string, string>(); +const fakeConfigKeys: Array<{ id: string; provider: string; base_url: string; env?: string }> = []; +function resetCompactionScaffolding(): void { + fakeRegistryKeys.length = 0; + fakeApiKeys.clear(); + fakeConfigKeys.length = 0; + fakeChunksByTab.clear(); + createTabCalls.length = 0; + rekeyCalls.length = 0; + fakeCompactionByTab.clear(); +} + // Seedable return value for the mocked getUsageStatsForTab — what the backend // reads (post-write) to attach to the `turn-sealed` event. const fakeUsageStatsByTab = new Map<string, unknown>(); @@ -267,7 +299,9 @@ vi.mock("@dispatch/core", () => ({ }; }, loadConfig(_dir: string) { - return { permissions: {} }; + return fakeConfigKeys.length > 0 + ? { permissions: {}, keys: [...fakeConfigKeys] } + : { permissions: {} }; }, configToRuleset(_config: unknown) { return []; @@ -289,7 +323,7 @@ vi.mock("@dispatch/core", () => ({ return []; } getKeys() { - return []; + return fakeRegistryKeys.map((k) => ({ definition: k, status: "active" })); } getModelsByTag(_tag: string) { return []; @@ -372,7 +406,10 @@ vi.mock("@dispatch/core", () => ({ return []; }, GLOBAL_AGENTS_DIR: "/tmp/global-agents", - createTab() {}, + createTab(id: string, title: string) { + createTabCalls.push({ id, title }); + return { id, title }; + }, getTab(id: string) { return fakeTabs.get(id) ?? null; }, @@ -417,8 +454,8 @@ vi.mock("@dispatch/core", () => ({ refreshAccountCredentialsAsync() { return Promise.resolve(null); }, - resolveApiKey() { - return null; + resolveApiKey(keyId: string) { + return fakeApiKeys.get(keyId) ?? null; }, getSetting(key: string) { return fakeSettings.get(key) ?? null; @@ -436,7 +473,35 @@ vi.mock("@dispatch/core", () => ({ return []; }, explodeTurn() { - return []; + return [{ turnId: "t", step: 0, role: "assistant", type: "text", data: { text: "" } }]; + }, + getChunksForTab(tabId: string) { + return fakeChunksByTab.get(tabId) ?? []; + }, + groupRowsToMessages(rows: Array<{ tabId: string }>) { + return rows; + }, + rekeyChunks(from: string, to: string) { + rekeyCalls.push({ from, to }); + const rows = fakeChunksByTab.get(from) ?? []; + fakeChunksByTab.set(to, rows); + fakeChunksByTab.delete(from); + return rows.length; + }, + buildCompactionRequest(input: { messages: unknown[] }) { + // Resolve the seeded result by matching the first row's tabId. + const first = (input.messages as Array<{ tabId?: string }>)[0]; + const tabId = first?.tabId ?? ""; + const seeded = fakeCompactionByTab.get(tabId); + if (seeded) return { head: [], tail: seeded.tail, prompt: seeded.prompt }; + return { + head: [], + tail: [{ turnId: "tail-turn", role: "user", chunks: [{ type: "text", text: "recent" }] }], + prompt: "SUMMARY PROMPT", + }; + }, + buildSummaryTurnText(summary: string) { + return `[CONVERSATION SUMMARY]\n\n${summary}`; }, getMessagesForTab(tabId: string) { return fakeMessagesByTab.get(tabId) ?? []; @@ -504,6 +569,7 @@ describe("AgentManager", () => { appendEventToChunksSpy.mockClear(); resetAppendChunksCalls(); resetFakeUsageStats(); + resetCompactionScaffolding(); }); it("initial status is idle", () => { @@ -1829,3 +1895,151 @@ describe("AgentManager", () => { }); }); }); + +describe("AgentManager.compactTab", () => { + beforeEach(() => { + resetFakeMessages(); + resetConstructedAgents(); + resetCapturedRunOptions(); + resetFakeTabs(); + resetFakeSettings(); + setRunImpl(null); + appendEventToChunksSpy.mockClear(); + resetAppendChunksCalls(); + resetFakeUsageStats(); + resetCompactionScaffolding(); + }); + + /** Seed a usable compaction model (config key + registry key + env key). */ + function seedCompactorModel(keyId = "k1", modelId = "m1"): void { + fakeConfigKeys.push({ id: keyId, provider: "opencode-go", base_url: "https://x", env: "K1" }); + fakeRegistryKeys.push({ id: keyId, provider: "opencode-go", base_url: "https://x", env: "K1" }); + fakeApiKeys.set(keyId, "secret"); + setFakeSetting("compaction_model_key_id", keyId); + setFakeSetting("compaction_model_id", modelId); + } + + it("errors when the conversation is too short to compact (empty prompt)", async () => { + const manager = new AgentManager(); + const events: AgentEvent[] = []; + manager.onEvent((e) => events.push(e)); + + setFakeTab({ id: "src", title: "Chat" }); + fakeChunksByTab.set("src", [{ tabId: "src" }]); + // No prompt → nothing to compact. + fakeCompactionByTab.set("src", { tail: [], prompt: undefined }); + + await manager.compactTab("temp", "src"); + + const err = events.find((e) => e.type === "compaction-error"); + expect(err).toMatchObject({ type: "compaction-error", tempTabId: "temp", sourceTabId: "src" }); + // No backup tab created, no rekey. + expect(createTabCalls).toHaveLength(0); + expect(rekeyCalls).toHaveLength(0); + }); + + it("errors when no compaction model can be resolved", async () => { + const manager = new AgentManager(); + const events: AgentEvent[] = []; + manager.onEvent((e) => events.push(e)); + + setFakeTab({ id: "src", title: "Chat", keyId: null, modelId: null }); + fakeChunksByTab.set("src", [{ tabId: "src" }]); + // Compactable, but no configured model and the tab has no key/model. + // (no seedCompactorModel call) + + await manager.compactTab("temp", "src"); + + expect(events.some((e) => e.type === "compaction-error")).toBe(true); + expect(rekeyCalls).toHaveLength(0); + }); + + it("refuses to compact while the source tab is running", async () => { + const manager = new AgentManager(); + const events: AgentEvent[] = []; + manager.onEvent((e) => events.push(e)); + + setFakeTab({ id: "src", title: "Chat" }); + fakeChunksByTab.set("src", [{ tabId: "src" }]); + // Start a (never-finishing) turn so the tab is "running". + setRunImpl(async function* () { + yield { type: "status", status: "running" } as const; + await new Promise<void>((r) => setTimeout(r, 50)); + yield { type: "status", status: "idle" } as const; + }); + void manager.processMessage("src", "hi"); + // Give processMessage a tick to flip status to running. + await new Promise<void>((r) => setTimeout(r, 5)); + + await manager.compactTab("temp", "src"); + const err = events.find((e) => e.type === "compaction-error"); + expect(err).toBeDefined(); + expect(rekeyCalls).toHaveLength(0); + }); + + it("happy path: summarizes, relocates history to a backup, re-seeds the source", async () => { + seedCompactorModel(); + const manager = new AgentManager(); + const events: AgentEvent[] = []; + manager.onEvent((e) => events.push(e)); + + setFakeTab({ id: "src", title: "My Chat", keyId: "k1", modelId: "m1" }); + fakeChunksByTab.set("src", [{ tabId: "src" }, { tabId: "src" }]); + fakeCompactionByTab.set("src", { + prompt: "SUMMARY PROMPT", + tail: [ + { turnId: "tail-u", role: "user", chunks: [{ type: "text", text: "recent q" }] }, + { turnId: "tail-a", role: "assistant", chunks: [{ type: "text", text: "recent a" }] }, + ], + }); + + await manager.compactTab("temp", "src"); + + // started + complete emitted; no error. + expect(events.some((e) => e.type === "compaction-started")).toBe(true); + const complete = events.find((e) => e.type === "compaction-complete") as + | (AgentEvent & { backupTabId: string; backupTitle: string }) + | undefined; + expect(complete).toBeDefined(); + expect(events.some((e) => e.type === "compaction-error")).toBe(false); + + // A backup tab was created and history was relocated onto it. + expect(createTabCalls).toHaveLength(1); + expect(rekeyCalls).toHaveLength(1); + expect(rekeyCalls[0]).toMatchObject({ from: "src", to: createTabCalls[0]?.id }); + expect(complete?.backupTabId).toBe(createTabCalls[0]?.id); + expect(complete?.backupTitle).toContain("pre-compaction"); + + // The source was re-seeded: appendChunks was called for "src" after rekey + // (summary turn + preserved tail). + const srcAppends = appendChunksCalls.filter((c) => c.tabId === "src"); + expect(srcAppends.length).toBeGreaterThanOrEqual(1); + }); + + it("queues messages sent to the source while compaction is in flight", async () => { + seedCompactorModel(); + const manager = new AgentManager(); + setFakeTab({ id: "src", title: "Chat", keyId: "k1", modelId: "m1" }); + fakeChunksByTab.set("src", [{ tabId: "src" }]); + fakeCompactionByTab.set("src", { + prompt: "SUMMARY PROMPT", + tail: [{ turnId: "t", role: "user", chunks: [{ type: "text", text: "recent" }] }], + }); + + // Make the summary generation slow so we can deliver mid-compaction. + setRunImpl(async function* () { + yield { type: "status", status: "running" } as const; + await new Promise<void>((r) => setTimeout(r, 40)); + yield { type: "text-delta", delta: "summary text" } as const; + yield { type: "status", status: "idle" } as const; + }); + + const compaction = manager.compactTab("temp", "src"); + await new Promise<void>((r) => setTimeout(r, 10)); + // While compacting, a human message should be QUEUED, not started. + const result = manager.deliverMessage("src", "hello during compaction"); + expect(result.status).toBe("queued"); + + await compaction; + }); +}); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 37c19ca..d6f6087 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -259,6 +259,8 @@ vi.mock("@dispatch/core", () => ({ getSetting(_key: string) { return null; }, + setSetting(_key: string, _value: string) {}, + deleteSetting(_key: string) {}, appendChunks() { return []; }, @@ -581,6 +583,49 @@ describe("GET /tabs/:id/chunks", () => { }); }); +describe("POST /tabs/:id/compact", () => { + it("returns 400 when sourceTabId is missing", async () => { + const res = await app.request("/tabs/temp-1/compact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 202 and kicks off compaction when sourceTabId is provided", async () => { + const res = await app.request("/tabs/temp-1/compact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceTabId: "src-1" }), + }); + expect(res.status).toBe(202); + const body = await res.json(); + expect(body).toEqual({ success: true }); + }); +}); + +describe("GET/PUT /tabs/settings/compaction-model", () => { + it("GET returns the persisted compaction-model setting shape", async () => { + const res = await app.request("/tabs/settings/compaction-model"); + expect(res.status).toBe(200); + const body = await res.json(); + // Mocked getSetting → null, so both fields are null. + expect(body).toEqual({ keyId: null, modelId: null }); + }); + + it("PUT accepts a key/model pair", async () => { + const res = await app.request("/tabs/settings/compaction-model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId: "k1", modelId: "m1" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true }); + }); +}); + describe("POST /chat/stop", () => { it("returns 200 with success: true for valid tabId", async () => { const res = await app.request("/chat/stop", { |
