diff options
| author | Adam Malczewski <[email protected]> | 2026-06-03 01:26:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-03 01:26:16 +0900 |
| commit | b26821ead97b986f886065b20d3dbde8283daa64 (patch) | |
| tree | 3c41419afb805d88080392e1c97d233af910b79d | |
| parent | 4b45d33c256cf580a53054078be6fd7148fa6302 (diff) | |
| download | dispatch-b26821ead97b986f886065b20d3dbde8283daa64.tar.gz dispatch-b26821ead97b986f886065b20d3dbde8283daa64.zip | |
feat(compaction): add UI-driven conversation compaction
Summarize a conversation's older "head" into a structured anchored Markdown
summary while preserving the most recent turns verbatim, shrinking context size
while keeping the information needed to continue coherently. Triggered by a
"Compact conversation" button in Chat Settings (not an agent tool).
Approach informed by OpenCode's session/compaction.ts:
- Ported SUMMARY_TEMPLATE (Goal / Constraints / Progress / Key Decisions /
Next Steps / Critical Context / Relevant Files) and the anchored-summary
buildPrompt (re-summarizes a prior summary when present).
- Ported the TOOL_OUTPUT_MAX_CHARS (2000) cap on tool results in the summary
request.
- Simplified tail selection to a fixed recent-turn count (DEFAULT_TAIL_TURNS=2)
instead of OpenCode's token-budget splitTurn.
core:
- New src/compaction/ module (pure, DB-free): template, prompt builder,
head/tail selection, transcript renderer with tool-output capping, prior
summary extraction. Generic over ChatMessage so callers keep turnId/seq.
- db/chunks.ts: rekeyChunks(from,to) relocates a tab's full history to a
backup tab (reversible — nothing is deleted).
- AgentEvent: compaction-started / -complete / -error variants.
api:
- AgentManager.compactTab(tempTabId, sourceTabId): side-effect-free
resolveConnection() for the compactor model (configured compaction_model_*,
else the source tab's own key+model), one-shot tool-less summary generation
via a transient Agent, then relocate full history to a fresh backup tab and
re-seed the canonical source id with [summary turn + preserved tail]. Source
tab is locked (messages queue) during the run; queue drains afterward.
- Routes: POST /tabs/:id/compact, GET/PUT /tabs/settings/compaction-model.
frontend:
- "Compact conversation" button in ModelSelector (Chat Settings), between
Working Directory and the agent toggle; idle-gated.
- Compaction-model key+model selector in Settings, beside the title model.
- Transient placeholder tab shows a large, non-faded "Please wait, compacting
conversation…" screen; closing it cancels. Source input locked while running.
- Handle compaction-* events: reload compacted source, insert backup tab,
refocus source, discard placeholder.
tests: core compaction unit tests, rekeyChunks DB test, AgentManager.compactTab
orchestration tests, and compaction route tests. All green (713 tests), biome
clean, all typechecks pass, frontend builds.
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. */ |
