summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-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
-rw-r--r--packages/core/src/chunks/append.ts3
-rw-r--r--packages/core/src/compaction/index.ts245
-rw-r--r--packages/core/src/db/chunks.ts19
-rw-r--r--packages/core/src/index.ts16
-rw-r--r--packages/core/src/types/index.ts24
-rw-r--r--packages/core/tests/compaction/compaction.test.ts194
-rw-r--r--packages/core/tests/db/rekey-chunks.db.test.ts129
-rw-r--r--packages/frontend/src/App.svelte3
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte13
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte27
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte24
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte89
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte9
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts195
-rw-r--r--packages/frontend/src/lib/types.ts11
19 files changed, 1580 insertions, 17 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", {
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
index 4bc6b5f..4ca6fe1 100644
--- a/packages/core/src/chunks/append.ts
+++ b/packages/core/src/chunks/append.ts
@@ -210,6 +210,9 @@ export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
case "message-queued":
case "message-consumed":
case "message-cancelled":
+ case "compaction-started":
+ case "compaction-complete":
+ case "compaction-error":
return;
default: {
diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts
new file mode 100644
index 0000000..663ccb2
--- /dev/null
+++ b/packages/core/src/compaction/index.ts
@@ -0,0 +1,245 @@
+// Conversation compaction — summarize the older "head" of a conversation into
+// a structured anchor while preserving the most recent turns verbatim.
+//
+// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the
+// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The
+// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed
+// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns
+// verbatim rather than computing a token budget.
+//
+// This module is pure and DB-free so it can be unit-tested in isolation and
+// shared by the API orchestrator.
+
+import type { ChatMessage } from "../types/index.js";
+
+/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */
+export const DEFAULT_TAIL_TURNS = 2;
+
+/** Max characters of a single tool result fed into the summary request. */
+export const TOOL_OUTPUT_MAX_CHARS = 2_000;
+
+/**
+ * Marker prefixing the seeded summary turn in a compacted conversation. Lets a
+ * subsequent compaction detect a prior summary and re-summarize (anchor) it
+ * instead of treating it as ordinary conversation.
+ */
+export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]";
+
+/**
+ * Structured Markdown template the summary must follow. Ported verbatim from
+ * opencode's `SUMMARY_TEMPLATE`.
+ */
+export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
+<template>
+## Goal
+- [single-sentence task summary]
+
+## Constraints & Preferences
+- [user constraints, preferences, specs, or "(none)"]
+
+## Progress
+### Done
+- [completed work or "(none)"]
+
+### In Progress
+- [current work or "(none)"]
+
+### Blocked
+- [blockers or "(none)"]
+
+## Key Decisions
+- [decision and why, or "(none)"]
+
+## Next Steps
+- [ordered next actions or "(none)"]
+
+## Critical Context
+- [important technical facts, errors, open questions, or "(none)"]
+
+## Relevant Files
+- [file or directory path: why it matters, or "(none)"]
+</template>
+
+Rules:
+- Keep every section, even when empty.
+- Use terse bullets, not prose paragraphs.
+- Preserve exact file paths, commands, error strings, and identifiers when known.
+- Do not mention the summary process or that context was compacted.`;
+
+/**
+ * Build the compaction instruction. When `previousSummary` is provided, the
+ * model is asked to UPDATE the anchored summary rather than create a fresh one
+ * (opencode's `buildPrompt` anchor behaviour).
+ */
+export function buildCompactionPrompt(input: { previousSummary?: string }): string {
+ const anchor = input.previousSummary
+ ? [
+ "Update the anchored summary below using the conversation history above.",
+ "Preserve still-true details, remove stale details, and merge in the new facts.",
+ "<previous-summary>",
+ input.previousSummary,
+ "</previous-summary>",
+ ].join("\n")
+ : "Create a new anchored summary from the conversation history above.";
+ return `${anchor}\n\n${SUMMARY_TEMPLATE}`;
+}
+
+/**
+ * The first text chunk of a message, trimmed (empty → undefined). Used to read
+ * the seeded summary out of a compacted conversation's first user turn.
+ */
+function firstText(message: ChatMessage): string | undefined {
+ for (const chunk of message.chunks) {
+ if (chunk.type === "text") {
+ const t = chunk.text.trim();
+ if (t) return t;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Extract a prior summary from the conversation head. If the first user message
+ * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body
+ * (marker stripped) so the next compaction can anchor on it.
+ */
+export function extractPreviousSummary(messages: ChatMessage[]): string | undefined {
+ const first = messages.find((m) => m.role === "user");
+ if (!first) return undefined;
+ const text = firstText(first);
+ if (!text?.startsWith(SUMMARY_MARKER)) return undefined;
+ const body = text.slice(SUMMARY_MARKER.length).trim();
+ return body || undefined;
+}
+
+export interface HeadTailSelection<T extends ChatMessage = ChatMessage> {
+ /** Older messages to be summarized away. */
+ head: T[];
+ /** Recent messages preserved verbatim in the continuation. */
+ tail: T[];
+}
+
+/**
+ * Split a conversation into a summarizable `head` and a preserved `tail` of the
+ * last `tailTurns` turns. A "turn" begins at a user message and runs until the
+ * next user message.
+ *
+ * When the conversation has `tailTurns` or fewer turns, `head` is empty: there
+ * is nothing to compact (the caller should refuse).
+ */
+export function selectHeadTail<T extends ChatMessage>(
+ messages: T[],
+ tailTurns: number = DEFAULT_TAIL_TURNS,
+): HeadTailSelection<T> {
+ if (tailTurns <= 0) return { head: messages, tail: [] };
+ const userIndices: number[] = [];
+ for (let i = 0; i < messages.length; i++) {
+ if (messages[i]?.role === "user") userIndices.push(i);
+ }
+ if (userIndices.length <= tailTurns) return { head: [], tail: messages };
+ const tailStart = userIndices[userIndices.length - tailTurns];
+ if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages };
+ return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) };
+}
+
+/** Cap a tool result to `max` chars with a truncation marker. */
+function capToolOutput(result: string, max: number): string {
+ if (result.length <= max) return result;
+ const omitted = result.length - max;
+ return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`;
+}
+
+/**
+ * Render conversation messages into a compact, provider-agnostic plain-text
+ * transcript suitable as summary-request context. Tool results are capped at
+ * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior
+ * summary message is skipped (its content is carried by the prompt anchor).
+ * Thinking/error/system chunks are omitted as summary noise.
+ */
+export function renderTranscript(
+ messages: ChatMessage[],
+ toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS,
+): string {
+ const blocks: string[] = [];
+ for (const message of messages) {
+ // Skip a seeded prior-summary user turn — it's represented via the anchor.
+ if (message.role === "user") {
+ const t = firstText(message);
+ if (t?.startsWith(SUMMARY_MARKER)) continue;
+ }
+
+ const lines: string[] = [];
+ for (const chunk of message.chunks) {
+ if (chunk.type === "text") {
+ const t = chunk.text.trim();
+ if (t) lines.push(t);
+ } else if (chunk.type === "tool-batch") {
+ for (const call of chunk.calls) {
+ let args = "";
+ try {
+ args = JSON.stringify(call.arguments ?? {});
+ } catch {
+ args = "{}";
+ }
+ lines.push(`[tool ${call.name} ${args}]`);
+ if (call.result !== undefined) {
+ const tag = call.isError ? "tool-error" : "tool-result";
+ lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`);
+ }
+ }
+ }
+ }
+ if (lines.length === 0) continue;
+ const role =
+ message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System";
+ blocks.push(`## ${role}\n${lines.join("\n")}`);
+ }
+ return blocks.join("\n\n");
+}
+
+export interface CompactionRequest<T extends ChatMessage = ChatMessage> {
+ /** Messages selected for summarization (older head). */
+ head: T[];
+ /** Recent messages preserved verbatim (last N turns). */
+ tail: T[];
+ /** Prior summary anchored on, if the conversation was compacted before. */
+ previousSummary?: string;
+ /**
+ * The full user-message content for the summary request: rendered head
+ * transcript followed by the compaction prompt/template. `undefined` when
+ * there is nothing to compact (`head` empty).
+ */
+ prompt?: string;
+}
+
+/**
+ * Assemble everything needed to run a compaction: head/tail split, prior-summary
+ * extraction, and the combined summary-request prompt. Returns `prompt:
+ * undefined` when the conversation is too short to compact.
+ */
+export function buildCompactionRequest<T extends ChatMessage>(input: {
+ messages: T[];
+ tailTurns?: number;
+ toolOutputMaxChars?: number;
+}): CompactionRequest<T> {
+ const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS;
+ const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS;
+ const { head, tail } = selectHeadTail(input.messages, tailTurns);
+ const previousSummary = extractPreviousSummary(input.messages);
+ if (head.length === 0) {
+ return { head, tail, previousSummary };
+ }
+ const transcript = renderTranscript(head, toolMax);
+ const instruction = buildCompactionPrompt({ previousSummary });
+ const prompt = `${transcript}\n\n${instruction}`;
+ return { head, tail, previousSummary, prompt };
+}
+
+/**
+ * Wrap a generated summary as the seeded user-turn text for the continuation
+ * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can
+ * anchor on it.
+ */
+export function buildSummaryTurnText(summary: string): string {
+ return `${SUMMARY_MARKER}\n\n${summary.trim()}`;
+}
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts
index e0aadf3..b434a47 100644
--- a/packages/core/src/db/chunks.ts
+++ b/packages/core/src/db/chunks.ts
@@ -225,3 +225,22 @@ export function clearChunksForTab(tabId: string): void {
const db = getDatabase();
db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId });
}
+
+/**
+ * Relocate every chunk row from one tab to another (compaction backup path).
+ *
+ * Used by conversation compaction to move the FULL pre-compaction history off
+ * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id
+ * (`toTabId`), leaving the canonical id free to be re-seeded with the summary +
+ * preserved tail. `seq` values are preserved (they remain per-tab monotonic for
+ * the destination since it starts empty), as are turn ids, so the relocated
+ * history groups identically under its new tab. Returns the number of rows
+ * moved.
+ */
+export function rekeyChunks(fromTabId: string, toTabId: string): number {
+ const db = getDatabase();
+ const result = db
+ .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from")
+ .run({ $from: fromTabId, $to: toTabId });
+ return Number(result.changes ?? 0);
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 08b426f..2789b2c 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -20,6 +20,21 @@ export {
type IdentifiedMessage,
type SystemEventLike,
} from "./chunks/append.js";
+// Compaction
+export {
+ buildCompactionPrompt,
+ buildCompactionRequest,
+ buildSummaryTurnText,
+ type CompactionRequest,
+ DEFAULT_TAIL_TURNS,
+ extractPreviousSummary,
+ type HeadTailSelection,
+ renderTranscript,
+ SUMMARY_MARKER,
+ SUMMARY_TEMPLATE,
+ selectHeadTail,
+ TOOL_OUTPUT_MAX_CHARS,
+} from "./compaction/index.js";
// Config
export {
configToRuleset,
@@ -40,6 +55,7 @@ export {
getUsageStatsForTab,
groupRowsToMessages,
type MessageRow,
+ rekeyChunks,
} from "./db/chunks.js";
// Database
export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js";
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 607b27d..f7944c9 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -351,7 +351,29 @@ export type AgentEvent =
*/
reason?: "interrupt" | "continuation";
}
- | { type: "message-cancelled"; tabId: string; messageId: string };
+ | { type: "message-cancelled"; tabId: string; messageId: string }
+ /**
+ * Conversation-compaction lifecycle (UI-driven, not an agent tool). A
+ * compaction summarizes a tab's older history into an anchored summary while
+ * preserving the most recent turns verbatim.
+ *
+ * `compaction-started` fires on the temporary placeholder tab when the
+ * summary request begins. `compaction-complete` fires when the summary has
+ * been generated and the history relocated: the compacted continuation now
+ * lives on `sourceTabId` (the canonical id, with its key/model/working-dir
+ * preserved), the FULL pre-compaction history was moved to `backupTabId`, and
+ * `tempTabId` (the placeholder) should be discarded by the frontend.
+ * `compaction-error` reports a failure (or cancellation) on `tempTabId`.
+ */
+ | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
+ | {
+ type: "compaction-complete";
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ }
+ | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
// ─── Tool Types ──────────────────────────────────────────────────
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);
+ });
+});
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index a0b25b7..5f2b61f 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -209,6 +209,9 @@ onMount(() => {
onReasoningChange={(effort) => tabStore.setReasoningEffort(effort)}
onAgentChange={(agent) => tabStore.setAgent(agent)}
onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
+ onCompact={() => { const id = tabStore.activeTab?.id; if (id) void tabStore.startCompaction(id); }}
+ canCompact={(tabStore.activeTab?.agentStatus ?? "idle") === "idle" && (tabStore.activeTab?.chunks.length ?? 0) > 0 && !(tabStore.activeTab?.compactingSource) && !(tabStore.activeTab?.isCompacting)}
+ compacting={tabStore.activeTab?.isCompacting ?? false}
onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }}
/>
</div>
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 079ef4a..f954be8 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -17,6 +17,13 @@ const inputValue = $derived(tabStore.activeTab?.draft ?? "");
const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null);
const isRunning = $derived(agentStatus === "running");
+// Lock input while this tab is mid-compaction: either it's the source
+// conversation being summarized, or it's the transient placeholder tab.
+const compactLocked = $derived(
+ (tabStore.activeTab?.isCompacting ?? false) ||
+ (tabStore.activeTab?.compactingSource ?? null) !== null ||
+ (tabStore.activeTab?.compactionError ?? null) !== null,
+);
const hasText = $derived(inputValue.trim().length > 0);
// While generating with an empty box, the primary action is "stop". With text
// in the box, it stays "send" (the message is queued behind the live turn).
@@ -88,6 +95,7 @@ function handleKeydown(e: KeyboardEvent) {
}
function submit() {
+ if (compactLocked) return;
const text = inputValue.trim();
if (!text) return;
if (tabId) tabStore.setDraft(tabId, "");
@@ -110,7 +118,8 @@ function primaryAction() {
bind:this={inputEl}
value={inputValue}
rows="1"
- placeholder="Type a message..."
+ placeholder={compactLocked ? "Compaction in progress…" : "Type a message..."}
+ disabled={compactLocked}
class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
onkeydown={handleKeydown}
oninput={handleInput}
@@ -120,7 +129,7 @@ function primaryAction() {
<button
type="button"
class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}"
- disabled={!showStop && !hasText}
+ disabled={compactLocked || (!showStop && !hasText)}
onclick={primaryAction}
title={showStop ? "Stop generation" : "Send message"}
>
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
index abdec17..ee1b8ef 100644
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -10,6 +10,11 @@ let isLoadingMore = $state(false);
const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []);
const activeTabId = $derived(tabStore.activeTab?.id);
+// Compaction placeholder state for the active tab. `compactingSource` is set on
+// a transient placeholder tab while a conversation is being compacted;
+// `compactionError` is set if it failed.
+const compactingSource = $derived(tabStore.activeTab?.compactingSource ?? null);
+const compactionError = $derived(tabStore.activeTab?.compactionError ?? null);
// Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}`
// (n = its index among same-(turn,role) messages) rather than the underlying
@@ -138,14 +143,28 @@ $effect(() => {
{#if isLoadingMore}
<div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div>
{/if}
- {#if renderGroups.length === 0}
+ {#if compactingSource || compactionError}
+ <div class="flex flex-col items-center justify-center h-full gap-4 px-6 text-center">
+ {#if compactionError}
+ <div class="text-2xl font-semibold text-error">Compaction failed</div>
+ <div class="text-base text-base-content/70 max-w-md">{compactionError}</div>
+ <div class="text-sm text-base-content/50">Close this tab to dismiss — your conversation was not changed.</div>
+ {:else}
+ <span class="loading loading-spinner loading-lg text-primary"></span>
+ <div class="text-2xl font-semibold text-base-content">Please wait, compacting conversation…</div>
+ <div class="text-sm text-base-content/50">You can cancel by closing this tab.</div>
+ {/if}
+ </div>
+ {:else if renderGroups.length === 0}
<div class="flex items-center justify-center h-full text-base-content/40 text-sm">
Send a message to start a conversation
</div>
{/if}
- {#each keyedMessages as { m, key } (key)}
- <ChatMessageComponent message={m} tabId={activeTabId} />
- {/each}
+ {#if !compactingSource && !compactionError}
+ {#each keyedMessages as { m, key } (key)}
+ <ChatMessageComponent message={m} tabId={activeTabId} />
+ {/each}
+ {/if}
</div>
<!-- Scroll-to-bottom button -->
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index 8601795..3b43ff8 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -63,6 +63,9 @@ const modelCache = new Map<string, string[]>();
onReasoningChange,
onAgentChange = (_agent: AgentInfo | null) => {},
onWorkingDirectoryChange = (_dir: string | null) => {},
+ onCompact = () => {},
+ canCompact = false,
+ compacting = false,
}: {
keys?: KeyInfo[];
activeKeyId?: string | null;
@@ -77,6 +80,9 @@ const modelCache = new Map<string, string[]>();
onReasoningChange: (effort: string) => void;
onAgentChange?: (agent: AgentInfo | null) => void;
onWorkingDirectoryChange?: (dir: string | null) => void;
+ onCompact?: () => void;
+ canCompact?: boolean;
+ compacting?: boolean;
} = $props();
let showKeyModal = $state(false);
@@ -224,6 +230,24 @@ const modelCache = new Map<string, string[]>();
</div>
</div>
+ <!-- Compact conversation -->
+ <div class="mb-3">
+ <button
+ type="button"
+ class="btn btn-sm btn-outline w-full"
+ disabled={!canCompact || compacting}
+ onclick={onCompact}
+ title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done."
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact conversation
+ {/if}
+ </button>
+ </div>
+
<!-- Toggle -->
<div class="flex items-center gap-2 mb-3">
<button
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 7a810d5..8b28957 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -26,6 +26,10 @@ function selectTheme(theme: Theme): void {
let titleKeyId = $state<string | null>(null);
let titleModelId = $state<string | null>(null);
let availableModels = $state<string[]>([]);
+let compactionKeyId = $state<string | null>(null);
+let compactionModelId = $state<string | null>(null);
+let compactionModels = $state<string[]>([]);
+let loadingCompactionModels = $state(false);
let loadingModels = $state(false);
let autoExpandThinking = $state(appSettings.autoExpandThinking);
let localChunkLimit = $state(appSettings.chunkLimit);
@@ -132,6 +136,19 @@ async function loadSettings(): Promise<void> {
// ignore
}
try {
+ const res = await fetch(`${apiBase}/tabs/settings/compaction-model`);
+ if (res.ok) {
+ const data = (await res.json()) as { keyId: string | null; modelId: string | null };
+ compactionKeyId = data.keyId;
+ if (compactionKeyId) {
+ await loadCompactionModelsForKey(compactionKeyId);
+ }
+ compactionModelId = data.modelId;
+ }
+ } catch {
+ // ignore
+ }
+ try {
const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
if (res.ok) {
const data = (await res.json()) as { value: string | null };
@@ -319,6 +336,48 @@ async function onModelChange(e: Event): Promise<void> {
saveTitleModel();
}
+async function loadCompactionModelsForKey(keyId: string): Promise<void> {
+ loadingCompactionModels = true;
+ try {
+ const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
+ if (!res.ok) {
+ compactionModels = [];
+ return;
+ }
+ const data = (await res.json()) as { models: string[] };
+ compactionModels = data.models ?? [];
+ } catch {
+ compactionModels = [];
+ } finally {
+ loadingCompactionModels = false;
+ }
+}
+
+function saveCompactionModel(): void {
+ fetch(`${apiBase}/tabs/settings/compaction-model`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keyId: compactionKeyId, modelId: compactionModelId }),
+ }).catch(() => {});
+}
+
+async function onCompactionKeyChange(e: Event): Promise<void> {
+ const select = e.target as HTMLSelectElement;
+ compactionKeyId = select.value || null;
+ compactionModelId = null;
+ compactionModels = [];
+ if (compactionKeyId) {
+ await loadCompactionModelsForKey(compactionKeyId);
+ }
+ saveCompactionModel();
+}
+
+function onCompactionModelChange(e: Event): void {
+ const select = e.target as HTMLSelectElement;
+ compactionModelId = select.value || null;
+ saveCompactionModel();
+}
+
$effect(() => {
void loadSettings();
});
@@ -374,6 +433,36 @@ $effect(() => {
<div class="divider my-0"></div>
+ <p class="text-xs text-base-content/70">Conversation Compaction Model</p>
+ <p class="text-xs text-base-content/40">Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.</p>
+
+ <label class="text-xs text-base-content/60">
+ Key
+ <select class="select select-bordered select-sm w-full" onchange={onCompactionKeyChange} value={compactionKeyId ?? ""}>
+ <option value="">Select a key...</option>
+ {#each keys as key (key.id)}
+ <option value={key.id}>{key.id} ({key.provider})</option>
+ {/each}
+ </select>
+ </label>
+
+ <label class="text-xs text-base-content/60">
+ Model
+ <select
+ class="select select-bordered select-sm w-full"
+ onchange={onCompactionModelChange}
+ value={compactionModelId ?? ""}
+ disabled={!compactionKeyId || loadingCompactionModels}
+ >
+ <option value="">{loadingCompactionModels ? "Loading models..." : "Select a model..."}</option>
+ {#each compactionModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ </select>
+ </label>
+
+ <div class="divider my-0"></div>
+
<p class="text-xs text-base-content/70">Chat</p>
<label class="flex items-center gap-2 cursor-pointer">
<input
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 519f411..d20ac79 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -43,6 +43,9 @@ const {
onReasoningChange,
onAgentChange = (_agent: AgentInfo | null) => {},
onWorkingDirectoryChange = (_dir: string | null) => {},
+ onCompact = () => {},
+ canCompact = false,
+ compacting = false,
onAddKey = () => {},
}: {
keys?: KeyInfo[];
@@ -64,6 +67,9 @@ const {
onReasoningChange: (effort: string) => void;
onAgentChange?: (agent: AgentInfo | null) => void;
onWorkingDirectoryChange?: (dir: string | null) => void;
+ onCompact?: () => void;
+ canCompact?: boolean;
+ compacting?: boolean;
onAddKey?: () => void;
} = $props();
@@ -169,6 +175,9 @@ function contentClass(_selected: string): string {
{onAgentChange}
{workingDirectory}
{onWorkingDirectoryChange}
+ {onCompact}
+ {canCompact}
+ {compacting}
/>
{:else if panel.selected === "Key Usage"}
<KeyUsage {keys} {apiBase} />
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index 9975d7b..3edd1e3 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -195,6 +195,16 @@ export interface Tab {
* `usage` event arrives. Drives the "Cache Rate" sidebar view.
*/
cacheStats?: CacheStats;
+ /**
+ * Compaction UI state. `compactingSource` is set on a TRANSIENT placeholder
+ * tab while it hosts the "compacting…" screen, naming the conversation being
+ * compacted. `isCompacting` is set on the SOURCE tab while its compaction is
+ * in flight (input locked). Both clear when compaction settles.
+ */
+ compactingSource?: string | null;
+ isCompacting?: boolean;
+ /** Error message shown on a placeholder tab when compaction fails. */
+ compactionError?: string | null;
}
/**
@@ -315,6 +325,9 @@ export function createTabStore() {
manualTitle: false,
oldestLoadedSeq: null,
totalChunks: 0,
+ compactingSource: null,
+ isCompacting: false,
+ compactionError: null,
};
tabs = [...tabs, tab];
activeTabId = id;
@@ -947,6 +960,145 @@ export function createTabStore() {
return restored.length;
}
+ /**
+ * Start a conversation compaction (UI-driven). Creates a TRANSIENT
+ * placeholder tab that shows the "compacting…" screen, switches to it, and
+ * kicks off the backend compaction of `sourceTabId`. Outcome arrives via the
+ * `compaction-*` WS events (see handleEvent). Closing the placeholder tab
+ * before completion cancels it (DELETE aborts the in-flight summary).
+ */
+ async function startCompaction(sourceTabId: string): Promise<void> {
+ const source = getTabById(sourceTabId);
+ if (!source) return;
+ if (source.isCompacting) return;
+
+ const tempId = generateId();
+ // Create the placeholder tab on the backend (so DELETE-on-close can
+ // abort the run) and locally (so we can switch to it and show the UI).
+ try {
+ await fetch(`${config.apiBase}/tabs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ id: tempId, title: "Compacting…" }),
+ });
+ } catch {
+ // Continue — the run is driven server-side via the compact endpoint.
+ }
+
+ const placeholder: Tab = {
+ id: tempId,
+ title: "Compacting…",
+ chunks: [],
+ live: [],
+ renderGroups: [],
+ liveTurnId: null,
+ agentStatus: "idle",
+ keyId: null,
+ modelId: null,
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
+ currentAssistantId: null,
+ tasks: [],
+ injectedSkills: [],
+ parentTabId: null,
+ persistent: true,
+ agentSlug: null,
+ agentScope: null,
+ agentModels: null,
+ workingDirectory: null,
+ queuedMessages: [],
+ chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: true,
+ oldestLoadedSeq: null,
+ totalChunks: 0,
+ compactingSource: sourceTabId,
+ isCompacting: false,
+ compactionError: null,
+ };
+ tabs = [...tabs, placeholder];
+ activeTabId = tempId;
+ updateTab(sourceTabId, { isCompacting: true });
+
+ try {
+ const res = await fetch(`${config.apiBase}/tabs/${tempId}/compact`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sourceTabId }),
+ });
+ if (!res.ok) {
+ const msg = `Compaction request failed (HTTP ${res.status}).`;
+ updateTab(sourceTabId, { isCompacting: false });
+ if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
+ }
+ } catch {
+ const msg = "Could not reach the server to start compaction.";
+ updateTab(sourceTabId, { isCompacting: false });
+ if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
+ }
+ }
+
+ /**
+ * Finish a completed compaction: the canonical conversation now lives on
+ * `sourceTabId` (re-seeded with summary + preserved tail), the full prior
+ * history was relocated to `backupTabId`. Reload the source tab's chunks,
+ * insert the backup tab into the sidebar, switch focus back to the source,
+ * and discard the transient placeholder.
+ */
+ async function finishCompaction(ev: {
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ }): Promise<void> {
+ // Reload the re-seeded source conversation from the backend.
+ updateTab(ev.sourceTabId, { isCompacting: false });
+ await reloadChunksFromApi(ev.sourceTabId);
+
+ // Insert the backup tab (full pre-compaction history) if not present.
+ if (!getTabById(ev.backupTabId)) {
+ const win = await fetchChunkWindow(ev.backupTabId, { limit: 100 });
+ const src = getTabById(ev.sourceTabId);
+ const backup: Tab = {
+ id: ev.backupTabId,
+ title: ev.backupTitle,
+ chunks: win.chunks,
+ live: [],
+ renderGroups: deriveRenderGroups(win.chunks, []),
+ liveTurnId: null,
+ agentStatus: "idle",
+ keyId: src?.keyId ?? null,
+ modelId: src?.modelId ?? null,
+ reasoningEffort: src?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
+ currentAssistantId: null,
+ tasks: [],
+ injectedSkills: [],
+ parentTabId: null,
+ persistent: true,
+ agentSlug: src?.agentSlug ?? null,
+ agentScope: src?.agentScope ?? null,
+ agentModels: src?.agentModels ?? null,
+ workingDirectory: src?.workingDirectory ?? null,
+ queuedMessages: [],
+ chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: true,
+ oldestLoadedSeq: win.oldestSeq,
+ totalChunks: win.total,
+ compactingSource: null,
+ isCompacting: false,
+ compactionError: null,
+ };
+ tabs = [...tabs, backup];
+ evictChunks(ev.backupTabId);
+ }
+
+ // Switch focus back to the (compacted) source tab and drop the
+ // placeholder. If the placeholder was the active tab, focus moves to
+ // the source conversation.
+ if (activeTabId === ev.tempTabId) activeTabId = ev.sourceTabId;
+ tabs = tabs.filter((t) => t.id !== ev.tempTabId);
+ }
+
function handleEvent(event: AgentEvent & { tabId?: string }): void {
const tabId = event.tabId;
@@ -1450,6 +1602,48 @@ export function createTabStore() {
});
break;
}
+ case "compaction-started": {
+ const ev = event as AgentEvent & { tempTabId: string; sourceTabId: string };
+ // Lock the source tab's input while compaction runs.
+ if (getTabById(ev.sourceTabId)) {
+ updateTab(ev.sourceTabId, { isCompacting: true });
+ }
+ // Mark the placeholder tab so it shows the "compacting…" screen.
+ if (getTabById(ev.tempTabId)) {
+ updateTab(ev.tempTabId, { compactingSource: ev.sourceTabId });
+ }
+ break;
+ }
+ case "compaction-complete": {
+ const ev = event as AgentEvent & {
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ };
+ void finishCompaction(ev);
+ break;
+ }
+ case "compaction-error": {
+ const ev = event as AgentEvent & {
+ tempTabId: string;
+ sourceTabId: string;
+ error: string;
+ };
+ if (getTabById(ev.sourceTabId)) {
+ updateTab(ev.sourceTabId, { isCompacting: false });
+ }
+ // Surface the error on the placeholder tab (if still open) so the
+ // user sees why it failed; the source conversation is untouched.
+ const tmp = getTabById(ev.tempTabId);
+ if (tmp) {
+ updateTab(ev.tempTabId, {
+ compactingSource: null,
+ compactionError: ev.error,
+ });
+ }
+ break;
+ }
}
}
@@ -2130,6 +2324,7 @@ export function createTabStore() {
promoteTab,
openAgentTab,
setWorkingDirectory,
+ startCompaction,
// Exposed so tests can drive the real reactive code path that the
// WS callback uses in production. Not intended for use in
// components — they should rely on the WS subscription instead.
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 384028c..bb7c169 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -220,7 +220,16 @@ export type AgentEvent =
*/
reason?: "interrupt" | "continuation";
}
- | { type: "message-cancelled"; tabId: string; messageId: string };
+ | { type: "message-cancelled"; tabId: string; messageId: string }
+ | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
+ | {
+ type: "compaction-complete";
+ tempTabId: string;
+ sourceTabId: string;
+ backupTabId: string;
+ backupTitle: string;
+ }
+ | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
export interface TaskItem {
/** Stable positional id for Svelte keying only; never shown to the model. */