summaryrefslogtreecommitdiffhomepage
path: root/packages/api/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/api/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/api/tests')
-rw-r--r--packages/api/tests/agent-manager.test.ts226
-rw-r--r--packages/api/tests/routes.test.ts45
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", {