diff options
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. */ |
