summaryrefslogtreecommitdiffhomepage
path: root/packages/api
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
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')
-rw-r--r--packages/api/src/agent-manager.ts268
-rw-r--r--packages/api/src/routes/tabs.ts57
-rw-r--r--packages/api/tests/agent-manager.test.ts226
-rw-r--r--packages/api/tests/routes.test.ts45
4 files changed, 587 insertions, 9 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 2532efa..38dab49 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -8,6 +8,8 @@ import {
appendEventToChunks,
BackgroundShellStore,
BackgroundTranscriptStore,
+ buildCompactionRequest,
+ buildSummaryTurnText,
type Chunk,
type ClaudeAccount,
clearSpillForTab,
@@ -24,6 +26,7 @@ import {
createSendToTabTool,
createSkillsWatcher,
createSummonTool,
+ createTab,
createTaskListTool,
createWebSearchTool,
createWriteFileTool,
@@ -34,11 +37,13 @@ import {
explodeUserText,
GLOBAL_AGENTS_DIR,
getAgentDirPaths,
+ getChunksForTab,
getClaudeAccountsFromDB,
getMessagesForTab,
getSetting,
getTab,
getUsageStatsForTab,
+ groupRowsToMessages,
LspManager,
listOpenTabs,
loadAgent,
@@ -51,6 +56,7 @@ import {
type ResolvedLspServer,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
+ rekeyChunks,
reportDiagnostics,
resolveApiKey,
resolveServersFromConfig,
@@ -251,6 +257,12 @@ interface TabAgent {
* it hits 0, further agent messages are queued but do NOT start a turn.
*/
autoWakeBudget: number;
+ /**
+ * True while this tab is the SOURCE of an in-flight compaction. New
+ * messages are queued (not started) until compaction settles so the
+ * conversation can't mutate mid-summary.
+ */
+ compacting?: boolean;
}
export class AgentManager {
@@ -1003,6 +1015,254 @@ export class AgentManager {
return tabAgent.agent;
}
+ /**
+ * Resolve connection parameters (apiKey / baseURL / model / provider /
+ * Claude OAuth credentials) for a key+model pair WITHOUT mutating any tab
+ * state. Mirrors the resolution in `getOrCreateAgentForTab` (Anthropic
+ * account refresh, env-var keys, OpenCode-Go anthropic-route detection) but
+ * is side-effect-free so it can be reused by compaction. Returns `null` when
+ * the key/model can't be resolved to a usable connection.
+ */
+ private async resolveConnection(
+ keyId: string,
+ modelId: string,
+ ): Promise<{
+ apiKey: string;
+ baseURL: string;
+ model: string;
+ provider?: string;
+ claudeCredentials?: { accessToken: string };
+ } | null> {
+ if (!keyId || !modelId || !this.modelRegistry) return null;
+ const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === keyId);
+ if (!keyState) return null;
+ const key = keyState.definition;
+
+ if (key.provider === "anthropic") {
+ const credFile = key.credentials_file;
+ const findAccount = () =>
+ this.claudeAccounts.find((a) => a.id === keyId) ??
+ (credFile
+ ? this.claudeAccounts.find((a) => a.source === credFile)
+ : this.claudeAccounts[0]);
+ let account = findAccount();
+ if (!account) {
+ this._refreshClaudeAccounts();
+ account = findAccount();
+ }
+ if (!account) return null;
+ let creds = refreshAccountCredentials(account);
+ if (!creds || creds.expiresAt <= Date.now() + 60_000) {
+ const fresh = await refreshAccountCredentialsAsync(account);
+ if (fresh) {
+ account.credentials = fresh;
+ creds = fresh;
+ }
+ }
+ const accessToken = creds?.accessToken ?? account.credentials.accessToken;
+ return {
+ apiKey: accessToken,
+ baseURL: key.base_url,
+ model: modelId,
+ provider: "anthropic",
+ claudeCredentials: { accessToken },
+ };
+ }
+
+ // Standard key resolved from env var.
+ const envKey = resolveApiKey(key.id, key.env);
+ if (!envKey) return null;
+ let provider: string | undefined;
+ if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(modelId)) {
+ provider = "opencode-anthropic";
+ }
+ return { apiKey: envKey, baseURL: key.base_url, model: modelId, provider };
+ }
+
+ /**
+ * Resolve the compactor model: the configured `compaction_model_*` setting
+ * when present, otherwise fall back to the source tab's own key+model. Used
+ * to run the summary generation request.
+ */
+ private resolveCompactorKeyModel(sourceTabId: string): { keyId: string; modelId: string } | null {
+ const cfgKey = getSetting("compaction_model_key_id");
+ const cfgModel = getSetting("compaction_model_id");
+ if (cfgKey && cfgModel) return { keyId: cfgKey, modelId: cfgModel };
+ const tabAgent = this.tabAgents.get(sourceTabId);
+ const row = getTab(sourceTabId);
+ const keyId = tabAgent?.keyId ?? row?.keyId ?? null;
+ const modelId = tabAgent?.modelId ?? row?.modelId ?? null;
+ if (keyId && modelId) return { keyId, modelId };
+ return null;
+ }
+
+ /**
+ * Run a one-shot, tool-less summary generation using a transient Agent. The
+ * Agent loop handles Claude-OAuth billing/identity/caching correctly. The
+ * prompt is the entire summary request (transcript + template); no tools are
+ * registered so the model can only produce text. Returns the concatenated
+ * assistant text, or throws on error/abort.
+ */
+ private async generateSummary(
+ conn: {
+ apiKey: string;
+ baseURL: string;
+ model: string;
+ provider?: string;
+ claudeCredentials?: { accessToken: string };
+ },
+ prompt: string,
+ abortSignal: AbortSignal,
+ ): Promise<string> {
+ const agent = new Agent({
+ model: conn.model,
+ apiKey: conn.apiKey,
+ baseURL: conn.baseURL,
+ systemPrompt:
+ "You are a conversation-summarization assistant. Follow the user's instructions and output ONLY the requested Markdown summary.",
+ tools: [],
+ workingDirectory: process.env.DISPATCH_WORKING_DIR ?? process.cwd(),
+ provider: conn.provider,
+ ...(conn.claudeCredentials ? { claudeCredentials: conn.claudeCredentials } : {}),
+ });
+ let out = "";
+ let errored: string | null = null;
+ for await (const event of agent.run(prompt, { abortSignal })) {
+ if (abortSignal.aborted) break;
+ if (event.type === "text-delta") out += event.delta;
+ else if (event.type === "error") errored = event.error;
+ }
+ if (abortSignal.aborted) throw new Error("Compaction cancelled");
+ if (errored) throw new Error(errored);
+ const trimmed = out.trim();
+ if (!trimmed) throw new Error("Compaction produced an empty summary");
+ return trimmed;
+ }
+
+ /**
+ * Compact a conversation (UI-driven). Summarizes the older "head" of
+ * `sourceTabId` into an anchored Markdown summary while preserving the last
+ * N turns verbatim, then performs the id-relocation the product requires:
+ *
+ * - The FULL pre-compaction history is moved to a fresh `backupTabId`
+ * (so nothing is destroyed — fully reversible).
+ * - `sourceTabId` (the canonical id, with its key/model/working-dir/agent
+ * and the global tool permissions intact) is re-seeded with the summary
+ * turn + the preserved tail.
+ *
+ * `tempTabId` is the frontend placeholder tab hosting the "compacting…"
+ * message; it is discarded on completion. Cancellation = the caller aborts
+ * via `tempTabId`'s abort controller (e.g. closing the placeholder tab).
+ *
+ * Returns when the compaction settles; emits `compaction-started`,
+ * `compaction-complete`, or `compaction-error`.
+ */
+ async compactTab(tempTabId: string, sourceTabId: string): Promise<void> {
+ const tempAgent = this._getOrCreateTabAgent(tempTabId);
+ const abortController = new AbortController();
+ tempAgent.abortController = abortController;
+
+ const fail = (error: string): void => {
+ const src = this.tabAgents.get(sourceTabId);
+ if (src) src.compacting = false;
+ this.emit({ type: "compaction-error", tempTabId, sourceTabId, error }, tempTabId);
+ // Drain anything queued on the source while it was locked.
+ this.continueFromQueue(sourceTabId);
+ };
+
+ try {
+ // Refuse to compact a running tab (turn must have ended).
+ if (this.getTabStatus(sourceTabId) === "running") {
+ fail("Cannot compact while a turn is in progress.");
+ return;
+ }
+
+ // Lock the source so new messages queue instead of starting turns.
+ const sourceAgent = this._getOrCreateTabAgent(sourceTabId);
+ sourceAgent.compacting = true;
+ this.emit({ type: "compaction-started", tempTabId, sourceTabId }, tempTabId);
+
+ // Read the full history as grouped messages (preserves turnId/seq).
+ const rows = groupRowsToMessages(getChunksForTab(sourceTabId));
+ const { tail, prompt } = buildCompactionRequest({ messages: rows });
+ if (!prompt) {
+ fail("Not enough conversation history to compact.");
+ return;
+ }
+
+ // Resolve the compactor model (configured, else source tab's own).
+ const compactor = this.resolveCompactorKeyModel(sourceTabId);
+ if (!compactor) {
+ fail("No model available to run compaction. Configure a compaction model in Settings.");
+ return;
+ }
+ const conn = await this.resolveConnection(compactor.keyId, compactor.modelId);
+ if (!conn) {
+ fail("Could not resolve credentials for the compaction model.");
+ return;
+ }
+
+ // Generate the summary (abortable).
+ const summary = await this.generateSummary(conn, prompt, abortController.signal);
+ if (abortController.signal.aborted) {
+ fail("Compaction cancelled");
+ return;
+ }
+
+ // Relocate the FULL history to a backup tab, then re-seed the source.
+ const sourceRow = getTab(sourceTabId);
+ const backupTabId = crypto.randomUUID();
+ const baseTitle = sourceRow?.title ?? "Conversation";
+ const backupTitle = `${baseTitle} (pre-compaction)`;
+ createTab(backupTabId, backupTitle, {
+ keyId: sourceRow?.keyId ?? null,
+ modelId: sourceRow?.modelId ?? null,
+ });
+ rekeyChunks(sourceTabId, backupTabId);
+
+ // Re-seed the canonical (source) id: a summary user turn followed by
+ // the preserved tail rows (turnId/step/role/type/data preserved).
+ const summaryTurnId = crypto.randomUUID();
+ appendChunks(sourceTabId, explodeUserText(summaryTurnId, buildSummaryTurnText(summary)));
+ for (const msg of tail) {
+ const drafts = explodeTurn(msg.turnId, msg.chunks);
+ if (msg.role === "user") {
+ // groupRowsToMessages collapses a user message to a single text
+ // chunk; explodeTurn only handles assistant/system shapes, so
+ // rebuild the user row explicitly.
+ const text = msg.chunks.find((c) => c.type === "text");
+ appendChunks(
+ sourceTabId,
+ explodeUserText(msg.turnId, text && text.type === "text" ? text.text : ""),
+ );
+ continue;
+ }
+ if (drafts.length > 0) appendChunks(sourceTabId, drafts);
+ }
+
+ // Reset the source Agent so its in-memory history reloads from the
+ // freshly re-seeded chunk log on the next turn.
+ sourceAgent.agent = null;
+ sourceAgent.compacting = false;
+
+ this.emit(
+ { type: "compaction-complete", tempTabId, sourceTabId, backupTabId, backupTitle },
+ sourceTabId,
+ );
+ // Drain any messages queued while the source was locked.
+ this.continueFromQueue(sourceTabId);
+ } catch (err) {
+ if (abortController.signal.aborted) {
+ fail("Compaction cancelled");
+ return;
+ }
+ fail(err instanceof Error ? err.message : String(err));
+ } finally {
+ // The placeholder tab is transient; drop its in-memory agent state.
+ this.tabAgents.delete(tempTabId);
+ }
+ }
+
getTabStatus(tabId: string): AgentStatus {
return this.tabAgents.get(tabId)?.status ?? "idle";
}
@@ -1564,6 +1824,14 @@ export class AgentManager {
return { status: "queued", messageId };
}
+ // Tab is mid-compaction → hold the message (queue, never start a turn)
+ // until compaction settles. continueFromQueue (called after compaction)
+ // drains it onto the compacted continuation.
+ if (this.tabAgents.get(tabId)?.compacting) {
+ const { messageId } = this.queueMessage(tabId, message, opts.queueId);
+ return { status: "queued", messageId };
+ }
+
// Idle/errored target → this delivery would WAKE the tab (start a turn).
// For agent-originated wakes, enforce the auto-wake budget first.
if (origin === "agent") {
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts
index 28a89f1..2ae60ed 100644
--- a/packages/api/src/routes/tabs.ts
+++ b/packages/api/src/routes/tabs.ts
@@ -19,11 +19,18 @@ import { Hono } from "hono";
export const tabsRoutes = new Hono();
-let getAgentManager: () => { stopTab(id: string): void; deleteTab(id: string): void } | null = () =>
- null;
+let getAgentManager: () => {
+ stopTab(id: string): void;
+ deleteTab(id: string): void;
+ compactTab(tempTabId: string, sourceTabId: string): Promise<void>;
+} | null = () => null;
export function setTabsAgentManager(
- getter: () => { stopTab(id: string): void; deleteTab(id: string): void } | null,
+ getter: () => {
+ stopTab(id: string): void;
+ deleteTab(id: string): void;
+ compactTab(tempTabId: string, sourceTabId: string): Promise<void>;
+ } | null,
): void {
getAgentManager = getter;
}
@@ -64,6 +71,28 @@ tabsRoutes.put("/settings/title-model", async (c) => {
return c.json({ success: true });
});
+// Conversation-compaction model (key+model used to generate the summary).
+// Mirrors the title-model setting. When unset, compaction falls back to the
+// source tab's own key+model.
+tabsRoutes.get("/settings/compaction-model", (c) => {
+ const keyId = getSetting("compaction_model_key_id");
+ const modelId = getSetting("compaction_model_id");
+ return c.json({ keyId, modelId });
+});
+
+tabsRoutes.put("/settings/compaction-model", async (c) => {
+ const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>();
+ if (body.keyId !== undefined) {
+ if (body.keyId) setSetting("compaction_model_key_id", body.keyId);
+ else deleteSetting("compaction_model_key_id");
+ }
+ if (body.modelId !== undefined) {
+ if (body.modelId) setSetting("compaction_model_id", body.modelId);
+ else deleteSetting("compaction_model_id");
+ }
+ return c.json({ success: true });
+});
+
// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids;
// each tab's `position` is rewritten to its index. Must be declared before the
// `/:id` routes so "reorder" isn't captured as an id param.
@@ -134,6 +163,28 @@ tabsRoutes.get("/:id/chunks", (c) => {
return c.json({ chunks, total, oldestSeq });
});
+// Trigger conversation compaction. The `:id` is the TRANSIENT placeholder tab
+// hosting the "compacting…" UI; `sourceTabId` (body) is the conversation being
+// compacted. Fire-and-forget on the server: progress/outcome is delivered via
+// the `compaction-*` WS events. Returns 202 once the run is kicked off.
+tabsRoutes.post("/:id/compact", async (c) => {
+ const tempTabId = c.req.param("id");
+ const body = await c.req
+ .json<{ sourceTabId?: string }>()
+ .catch(() => ({}) as { sourceTabId?: string });
+ const sourceTabId = body.sourceTabId;
+ if (!sourceTabId || typeof sourceTabId !== "string") {
+ return c.json({ error: "sourceTabId is required" }, 400);
+ }
+ const mgr = getAgentManager();
+ if (!mgr) return c.json({ error: "agent manager unavailable" }, 503);
+ // Run in the background; outcome is emitted over WS.
+ void mgr.compactTab(tempTabId, sourceTabId).catch((err) => {
+ console.error(`[dispatch] compactTab error for ${sourceTabId}:`, err);
+ });
+ return c.json({ success: true }, 202);
+});
+
tabsRoutes.patch("/:id", async (c) => {
const id = c.req.param("id");
const body = await c.req.json<{
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", {