diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:13:10 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:13:10 +0900 |
| commit | ad9d135e583c99a0d93327115defa43187cde1c3 (patch) | |
| tree | db704495d3a3d3e24fdadf422976367f2313486d | |
| parent | 98b0638838a8e754927d8c030ce8bded18d63e7d (diff) | |
| download | dispatch-ad9d135e583c99a0d93327115defa43187cde1c3.tar.gz dispatch-ad9d135e583c99a0d93327115defa43187cde1c3.zip | |
style: reformat heartbeat merge to 2-space indentation
26 files changed, 9543 insertions, 9543 deletions
diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json index dc3d9a2..d83e631 100644 --- a/packages/heartbeat/package.json +++ b/packages/heartbeat/package.json @@ -1,15 +1,15 @@ { - "name": "@dispatch/heartbeat", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/conversation-store": "workspace:*", - "@dispatch/kernel": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/system-prompt": "workspace:*", - "@dispatch/transport-contract": "workspace:*" - } + "name": "@dispatch/heartbeat", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/conversation-store": "workspace:*", + "@dispatch/kernel": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/system-prompt": "workspace:*", + "@dispatch/transport-contract": "workspace:*" + } } diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts index 17d3ee9..e77d10a 100644 --- a/packages/heartbeat/src/config-store.test.ts +++ b/packages/heartbeat/src/config-store.test.ts @@ -1,115 +1,115 @@ import type { StorageNamespace } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { - applyConfigUpdate, - createHeartbeatConfigStore, - DEFAULT_HEARTBEAT_CONFIG, + applyConfigUpdate, + createHeartbeatConfigStore, + DEFAULT_HEARTBEAT_CONFIG, } from "./config-store.js"; function createMemoryStorage(): StorageNamespace { - const data = new Map<string, string>(); - return { - get: async (key) => data.get(key) ?? null, - set: async (key, value) => { - data.set(key, value); - }, - delete: async (key) => { - data.delete(key); - }, - has: async (key) => data.has(key), - keys: async (prefix) => { - const all = [...data.keys()]; - if (prefix === undefined) return all; - return all.filter((k) => k.startsWith(prefix)); - }, - }; + const data = new Map<string, string>(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (prefix === undefined) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; } describe("applyConfigUpdate (pure)", () => { - it("leaves omitted fields unchanged from the default", () => { - const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {}); - expect(next).toEqual(DEFAULT_HEARTBEAT_CONFIG); - }); + it("leaves omitted fields unchanged from the default", () => { + const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {}); + expect(next).toEqual(DEFAULT_HEARTBEAT_CONFIG); + }); - it("applies provided fields", () => { - const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { - enabled: true, - systemPrompt: "you are a monitor", - taskPrompt: "check for stuck chats", - model: "opencode/gpt-4o", - }); - expect(next.enabled).toBe(true); - expect(next.systemPrompt).toBe("you are a monitor"); - expect(next.taskPrompt).toBe("check for stuck chats"); - expect(next.model).toBe("opencode/gpt-4o"); - // Untouched fields keep defaults. - expect(next.intervalMinutes).toBe(30); - expect(next.reasoningEffort).toBeNull(); - }); + it("applies provided fields", () => { + const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { + enabled: true, + systemPrompt: "you are a monitor", + taskPrompt: "check for stuck chats", + model: "opencode/gpt-4o", + }); + expect(next.enabled).toBe(true); + expect(next.systemPrompt).toBe("you are a monitor"); + expect(next.taskPrompt).toBe("check for stuck chats"); + expect(next.model).toBe("opencode/gpt-4o"); + // Untouched fields keep defaults. + expect(next.intervalMinutes).toBe(30); + expect(next.reasoningEffort).toBeNull(); + }); - it("clamps intervalMinutes to a minimum of 1", () => { - expect( - applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes, - ).toBe(1); - expect( - applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes, - ).toBe(1); - expect( - applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes, - ).toBe(7); - }); + it("clamps intervalMinutes to a minimum of 1", () => { + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes, + ).toBe(1); + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes, + ).toBe(1); + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes, + ).toBe(7); + }); - it("truncates a non-integer interval to an integer", () => { - expect( - applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 12.9 }).intervalMinutes, - ).toBe(12); - }); + it("truncates a non-integer interval to an integer", () => { + expect( + applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 12.9 }).intervalMinutes, + ).toBe(12); + }); - it("clears reasoningEffort when null is passed", () => { - const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "high" }); - expect(withEffort.reasoningEffort).toBe("high"); - const cleared = applyConfigUpdate(withEffort, { reasoningEffort: null }); - expect(cleared.reasoningEffort).toBeNull(); - }); + it("clears reasoningEffort when null is passed", () => { + const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "high" }); + expect(withEffort.reasoningEffort).toBe("high"); + const cleared = applyConfigUpdate(withEffort, { reasoningEffort: null }); + expect(cleared.reasoningEffort).toBeNull(); + }); - it("treats an absent reasoningEffort as unchanged (distinct from null)", () => { - const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "low" }); - const untouched = applyConfigUpdate(withEffort, { enabled: true }); - expect(untouched.reasoningEffort).toBe("low"); - }); + it("treats an absent reasoningEffort as unchanged (distinct from null)", () => { + const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "low" }); + const untouched = applyConfigUpdate(withEffort, { enabled: true }); + expect(untouched.reasoningEffort).toBe("low"); + }); }); describe("createHeartbeatConfigStore", () => { - it("returns the default config for an unknown workspace", async () => { - const store = createHeartbeatConfigStore(createMemoryStorage()); - expect(await store.get("ws-1")).toEqual(DEFAULT_HEARTBEAT_CONFIG); - }); + it("returns the default config for an unknown workspace", async () => { + const store = createHeartbeatConfigStore(createMemoryStorage()); + expect(await store.get("ws-1")).toEqual(DEFAULT_HEARTBEAT_CONFIG); + }); - it("persists and round-trips an update", async () => { - const storage = createMemoryStorage(); - const store = createHeartbeatConfigStore(storage); - const next = await store.update("ws-1", { enabled: true, intervalMinutes: 5 }); - expect(next.enabled).toBe(true); - expect(next.intervalMinutes).toBe(5); - // A fresh store over the same storage reads the persisted value. - const store2 = createHeartbeatConfigStore(storage); - expect(await store2.get("ws-1")).toEqual(next); - }); + it("persists and round-trips an update", async () => { + const storage = createMemoryStorage(); + const store = createHeartbeatConfigStore(storage); + const next = await store.update("ws-1", { enabled: true, intervalMinutes: 5 }); + expect(next.enabled).toBe(true); + expect(next.intervalMinutes).toBe(5); + // A fresh store over the same storage reads the persisted value. + const store2 = createHeartbeatConfigStore(storage); + expect(await store2.get("ws-1")).toEqual(next); + }); - it("applies a partial update on top of existing config", async () => { - const storage = createMemoryStorage(); - const store = createHeartbeatConfigStore(storage); - await store.update("ws-1", { enabled: true, systemPrompt: "a", taskPrompt: "b" }); - const next = await store.update("ws-1", { taskPrompt: "c" }); - expect(next.enabled).toBe(true); - expect(next.systemPrompt).toBe("a"); - expect(next.taskPrompt).toBe("c"); - }); + it("applies a partial update on top of existing config", async () => { + const storage = createMemoryStorage(); + const store = createHeartbeatConfigStore(storage); + await store.update("ws-1", { enabled: true, systemPrompt: "a", taskPrompt: "b" }); + const next = await store.update("ws-1", { taskPrompt: "c" }); + expect(next.enabled).toBe(true); + expect(next.systemPrompt).toBe("a"); + expect(next.taskPrompt).toBe("c"); + }); - it("lists persisted workspace ids", async () => { - const store = createHeartbeatConfigStore(createMemoryStorage()); - await store.update("ws-b", { enabled: true }); - await store.update("ws-a", { enabled: false }); - expect(await store.listWorkspaceIds()).toEqual(["ws-a", "ws-b"]); - }); + it("lists persisted workspace ids", async () => { + const store = createHeartbeatConfigStore(createMemoryStorage()); + await store.update("ws-b", { enabled: true }); + await store.update("ws-a", { enabled: false }); + expect(await store.listWorkspaceIds()).toEqual(["ws-a", "ws-b"]); + }); }); diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts index 5791601..f06c9e8 100644 --- a/packages/heartbeat/src/config-store.ts +++ b/packages/heartbeat/src/config-store.ts @@ -6,12 +6,12 @@ import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transpor * heartbeat. A heartbeat is OFF until explicitly enabled. */ export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = { - enabled: false, - systemPrompt: "", - taskPrompt: "", - intervalMinutes: 30, - model: "", - reasoningEffort: null, + enabled: false, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, }; /** Minimum scheduling interval, in minutes (a heartbeat can't fire faster). */ @@ -19,7 +19,7 @@ export const MIN_INTERVAL_MINUTES = 1; /** Storage key for a workspace's heartbeat config. */ function configKey(workspaceId: string): string { - return `config:${workspaceId}`; + return `config:${workspaceId}`; } /** @@ -33,71 +33,71 @@ function configKey(workspaceId: string): string { * already validated, but only ever produces a valid `HeartbeatConfig`. */ export function applyConfigUpdate( - current: HeartbeatConfig, - update: UpdateHeartbeatRequest, + current: HeartbeatConfig, + update: UpdateHeartbeatRequest, ): HeartbeatConfig { - const next: HeartbeatConfig = { - enabled: update.enabled !== undefined ? update.enabled : current.enabled, - systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt, - taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt, - intervalMinutes: - update.intervalMinutes !== undefined - ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes)) - : current.intervalMinutes, - model: update.model !== undefined ? update.model : current.model, - reasoningEffort: - update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort, - }; - return next; + const next: HeartbeatConfig = { + enabled: update.enabled !== undefined ? update.enabled : current.enabled, + systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt, + taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt, + intervalMinutes: + update.intervalMinutes !== undefined + ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes)) + : current.intervalMinutes, + model: update.model !== undefined ? update.model : current.model, + reasoningEffort: + update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort, + }; + return next; } export interface HeartbeatConfigStore { - /** The config for a workspace (the default when never set). */ - readonly get: (workspaceId: string) => Promise<HeartbeatConfig>; - /** Apply a partial update and persist it; returns the new config. */ - readonly update: ( - workspaceId: string, - update: UpdateHeartbeatRequest, - ) => Promise<HeartbeatConfig>; - /** Every workspace id that has a persisted (non-default) config. */ - readonly listWorkspaceIds: () => Promise<readonly string[]>; + /** The config for a workspace (the default when never set). */ + readonly get: (workspaceId: string) => Promise<HeartbeatConfig>; + /** Apply a partial update and persist it; returns the new config. */ + readonly update: ( + workspaceId: string, + update: UpdateHeartbeatRequest, + ) => Promise<HeartbeatConfig>; + /** Every workspace id that has a persisted (non-default) config. */ + readonly listWorkspaceIds: () => Promise<readonly string[]>; } export function createHeartbeatConfigStore(storage: StorageNamespace): HeartbeatConfigStore { - return { - async get(workspaceId: string): Promise<HeartbeatConfig> { - const raw = await storage.get(configKey(workspaceId)); - if (raw === null) return DEFAULT_HEARTBEAT_CONFIG; - try { - const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>; - return { - enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false, - systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "", - taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "", - intervalMinutes: - typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0 - ? Math.trunc(parsed.intervalMinutes) - : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes, - model: typeof parsed.model === "string" ? parsed.model : "", - reasoningEffort: parsed.reasoningEffort ?? null, - }; - } catch { - return DEFAULT_HEARTBEAT_CONFIG; - } - }, + return { + async get(workspaceId: string): Promise<HeartbeatConfig> { + const raw = await storage.get(configKey(workspaceId)); + if (raw === null) return DEFAULT_HEARTBEAT_CONFIG; + try { + const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>; + return { + enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false, + systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "", + taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "", + intervalMinutes: + typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0 + ? Math.trunc(parsed.intervalMinutes) + : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes, + model: typeof parsed.model === "string" ? parsed.model : "", + reasoningEffort: parsed.reasoningEffort ?? null, + }; + } catch { + return DEFAULT_HEARTBEAT_CONFIG; + } + }, - async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> { - const current = await this.get(workspaceId); - const next = applyConfigUpdate(current, update); - await storage.set(configKey(workspaceId), JSON.stringify(next)); - return next; - }, + async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> { + const current = await this.get(workspaceId); + const next = applyConfigUpdate(current, update); + await storage.set(configKey(workspaceId), JSON.stringify(next)); + return next; + }, - async listWorkspaceIds(): Promise<readonly string[]> { - const keys = await storage.keys("config:"); - const ids = keys.map((k) => k.slice("config:".length)); - // De-duplicate + sort for a stable boot-scan order. - return [...new Set(ids)].sort(); - }, - }; + async listWorkspaceIds(): Promise<readonly string[]> { + const keys = await storage.keys("config:"); + const ids = keys.map((k) => k.slice("config:".length)); + // De-duplicate + sort for a stable boot-scan order. + return [...new Set(ids)].sort(); + }, + }; } diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts index 81e675a..f245b7e 100644 --- a/packages/heartbeat/src/extension.ts +++ b/packages/heartbeat/src/extension.ts @@ -16,93 +16,93 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import { conversationStoreHandle } from "@dispatch/conversation-store"; import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { - type SessionOrchestrator, - sessionOrchestratorHandle, + type SessionOrchestrator, + sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; import type { SystemPromptService } from "@dispatch/system-prompt"; import { systemPromptHandle } from "@dispatch/system-prompt"; import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js"; export const manifest: Manifest = { - id: "heartbeat", - name: "Heartbeat", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - // system-prompt provides `resolveText` (the variable resolver used to - // substitute [type:name] placeholders in heartbeat prompts); conversation- - // store resolves the workspace's default cwd (the resolver runs git / reads - // files against it, mirroring the global template). Both lookups are lazy - // (at fire time, not activation), but declaring them keeps the DAG honest. - dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"], - activation: "eager", - contributes: { services: ["heartbeat"] }, + id: "heartbeat", + name: "Heartbeat", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + // system-prompt provides `resolveText` (the variable resolver used to + // substitute [type:name] placeholders in heartbeat prompts); conversation- + // store resolves the workspace's default cwd (the resolver runs git / reads + // files against it, mirroring the global template). Both lookups are lazy + // (at fire time, not activation), but declaring them keeps the DAG honest. + dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"], + activation: "eager", + contributes: { services: ["heartbeat"] }, }; // Module-scoped store for deactivate (the extension object is created once). const store: { service: { stopAll: () => void } | null } = { service: null }; export const extension: Extension = { - manifest, - async activate(host: HostAPI) { - const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle); - const storage = host.storage("heartbeat"); - const logger = host.logger; + manifest, + async activate(host: HostAPI) { + const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle); + const storage = host.storage("heartbeat"); + const logger = host.logger; - // Resolve [type:name] placeholders in heartbeat prompts via the - // system-prompt service (same resolver + variables as the global - // template). The cwd is the workspace's defaultCwd (resolved the same - // way the orchestrator resolves a new conversation's effective cwd); - // falling back to process.cwd() when the workspace has none. Both - // services are declared `dependsOn` (always activated before heartbeat). - const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle); - const conversationStore = host.getService<ConversationStore>(conversationStoreHandle); + // Resolve [type:name] placeholders in heartbeat prompts via the + // system-prompt service (same resolver + variables as the global + // template). The cwd is the workspace's defaultCwd (resolved the same + // way the orchestrator resolves a new conversation's effective cwd); + // falling back to process.cwd() when the workspace has none. Both + // services are declared `dependsOn` (always activated before heartbeat). + const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle); + const conversationStore = host.getService<ConversationStore>(conversationStoreHandle); - const resolvePrompt = async ( - template: string, - ctx: { - readonly workspaceId: string; - readonly conversationId: string; - readonly model: string; - }, - ): Promise<string> => { - const workspace = await conversationStore.getWorkspace(ctx.workspaceId); - const cwd = workspace?.defaultCwd ?? process.cwd(); - return systemPromptService.resolveText(template, cwd, { - ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}), - ...(ctx.model !== "" ? { model: ctx.model } : {}), - workspaceId: ctx.workspaceId, - }); - }; + const resolvePrompt = async ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ): Promise<string> => { + const workspace = await conversationStore.getWorkspace(ctx.workspaceId); + const cwd = workspace?.defaultCwd ?? process.cwd(); + return systemPromptService.resolveText(template, cwd, { + ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}), + ...(ctx.model !== "" ? { model: ctx.model } : {}), + workspaceId: ctx.workspaceId, + }); + }; - const service = createHeartbeatService({ - storage, - orchestrator, - logger, - resolvePrompt, - // CR-HB-2: an empty heartbeat systemPrompt inherits the global - // system prompt template (GET /system-prompt / regular - // conversations resolve) before variable resolution runs. - getGlobalSystemPrompt: () => systemPromptService.getTemplate(), - // Pin the heartbeat turn's cwd to the CONFIGURED workspace's - // defaultCwd (not the heartbeat workspace's empty defaultCwd) so - // the turn's tools run where the prompt's [prompt:cwd] advertises. - // Lazy (resolved at fire time, mirroring resolvePrompt). - getWorkspaceCwd: async (wsId) => - (await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null, - }); + const service = createHeartbeatService({ + storage, + orchestrator, + logger, + resolvePrompt, + // CR-HB-2: an empty heartbeat systemPrompt inherits the global + // system prompt template (GET /system-prompt / regular + // conversations resolve) before variable resolution runs. + getGlobalSystemPrompt: () => systemPromptService.getTemplate(), + // Pin the heartbeat turn's cwd to the CONFIGURED workspace's + // defaultCwd (not the heartbeat workspace's empty defaultCwd) so + // the turn's tools run where the prompt's [prompt:cwd] advertises. + // Lazy (resolved at fire time, mirroring resolvePrompt). + getWorkspaceCwd: async (wsId) => + (await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null, + }); - // Reconcile stale runs + arm enabled workspaces on boot. - await service.startAll(); - store.service = service; + // Reconcile stale runs + arm enabled workspaces on boot. + await service.startAll(); + store.service = service; - host.provideService(heartbeatServiceHandle, service); + host.provideService(heartbeatServiceHandle, service); - host.logger.info("heartbeat extension activated"); - }, - deactivate() { - // Stop schedulers so no new fires happen during shutdown. - store.service?.stopAll(); - store.service = null; - }, + host.logger.info("heartbeat extension activated"); + }, + deactivate() { + // Stop schedulers so no new fires happen during shutdown. + store.service?.stopAll(); + store.service = null; + }, }; diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts index 7e82611..ff1ec10 100644 --- a/packages/heartbeat/src/heartbeat.test.ts +++ b/packages/heartbeat/src/heartbeat.test.ts @@ -1,72 +1,72 @@ import type { StorageNamespace } from "@dispatch/kernel"; import type { - EnqueueInput, - EnqueueResult, - SessionOrchestrator, - StartTurnResult, - TurnEventListener, + EnqueueInput, + EnqueueResult, + SessionOrchestrator, + StartTurnResult, + TurnEventListener, } from "@dispatch/session-orchestrator"; import { describe, expect, it } from "vitest"; import { createHeartbeatService } from "./heartbeat.js"; function createMemoryStorage(): StorageNamespace { - const data = new Map<string, string>(); - return { - get: async (key) => data.get(key) ?? null, - set: async (key, value) => { - data.set(key, value); - }, - delete: async (key) => { - data.delete(key); - }, - has: async (key) => data.has(key), - keys: async (prefix) => { - const all = [...data.keys()]; - if (prefix === undefined) return all; - return all.filter((k) => k.startsWith(prefix)); - }, - }; + const data = new Map<string, string>(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (prefix === undefined) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; } /** A controllable fake clock with an `advance` to move virtual time. */ function createFakeTimers() { - let now = 0; - let nextId = 1; - const timers = new Map<number, { readonly fn: () => void; readonly firesAt: number }>(); - return { - timers: { - now: () => now, - setTimeout: (fn: () => void, ms: number) => { - const id = nextId++; - timers.set(id, { fn, firesAt: now + ms }); - return id as unknown as ReturnType<typeof setTimeout>; - }, - clearTimeout: (handle: ReturnType<typeof setTimeout> | undefined) => { - if (handle !== undefined) timers.delete(handle as unknown as number); - }, - }, - advance(ms: number): void { - now += ms; - const due = [...timers.entries()] - .filter(([, t]) => t.firesAt <= now) - .sort((a, b) => a[0] - b[0]); - for (const [id, t] of due) { - timers.delete(id); - t.fn(); - } - }, - }; + let now = 0; + let nextId = 1; + const timers = new Map<number, { readonly fn: () => void; readonly firesAt: number }>(); + return { + timers: { + now: () => now, + setTimeout: (fn: () => void, ms: number) => { + const id = nextId++; + timers.set(id, { fn, firesAt: now + ms }); + return id as unknown as ReturnType<typeof setTimeout>; + }, + clearTimeout: (handle: ReturnType<typeof setTimeout> | undefined) => { + if (handle !== undefined) timers.delete(handle as unknown as number); + }, + }, + advance(ms: number): void { + now += ms; + const due = [...timers.entries()] + .filter(([, t]) => t.firesAt <= now) + .sort((a, b) => a[0] - b[0]); + for (const [id, t] of due) { + timers.delete(id); + t.fn(); + } + }, + }; } interface PendingTurn { - readonly conversationId: string; - readonly text: string; - readonly systemPrompt?: string; - readonly modelName?: string; - readonly reasoningEffort?: unknown; - readonly workspaceId?: string; - readonly cwd?: string; - resolve: () => void; + readonly conversationId: string; + readonly text: string; + readonly systemPrompt?: string; + readonly modelName?: string; + readonly reasoningEffort?: unknown; + readonly workspaceId?: string; + readonly cwd?: string; + resolve: () => void; } /** @@ -75,65 +75,65 @@ interface PendingTurn { * on the SessionOrchestrator interface, not its implementation. */ function createFakeOrchestrator(): SessionOrchestrator & { - readonly pending: readonly PendingTurn[]; - readonly stopped: readonly string[]; + readonly pending: readonly PendingTurn[]; + readonly stopped: readonly string[]; } { - const pending: PendingTurn[] = []; - const stopped: string[] = []; - const turns = new Map<string, { resolve: () => void; reject: (e: unknown) => void }>(); - - const fake: SessionOrchestrator = { - startTurn(): StartTurnResult { - return { started: false, reason: "already-active" }; - }, - enqueue(_input: EnqueueInput): EnqueueResult { - return { startedTurn: false, queue: [] }; - }, - subscribe(_conversationId: string, _listener: TurnEventListener): () => void { - return () => {}; - }, - isActive(_conversationId: string): boolean { - return false; - }, - closeConversation(_conversationId: string): { abortedTurn: boolean } { - return { abortedTurn: false }; - }, - stopTurn(conversationId: string): { abortedTurn: boolean } { - stopped.push(conversationId); - const t = turns.get(conversationId); - if (t !== undefined) { - turns.delete(conversationId); - t.resolve(); - } - return { abortedTurn: true }; - }, - handleMessage(input): Promise<void> { - return new Promise<void>((resolve, reject) => { - const entry: PendingTurn = { - conversationId: input.conversationId, - text: input.text, - ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}), - ...(input.modelName !== undefined ? { modelName: input.modelName } : {}), - ...(input.reasoningEffort !== undefined - ? { reasoningEffort: input.reasoningEffort } - : {}), - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), - resolve, - }; - pending.push(entry); - turns.set(input.conversationId, { resolve, reject }); - }); - }, - }; - return Object.assign(fake, { - get pending(): readonly PendingTurn[] { - return pending; - }, - get stopped(): readonly string[] { - return stopped; - }, - }); + const pending: PendingTurn[] = []; + const stopped: string[] = []; + const turns = new Map<string, { resolve: () => void; reject: (e: unknown) => void }>(); + + const fake: SessionOrchestrator = { + startTurn(): StartTurnResult { + return { started: false, reason: "already-active" }; + }, + enqueue(_input: EnqueueInput): EnqueueResult { + return { startedTurn: false, queue: [] }; + }, + subscribe(_conversationId: string, _listener: TurnEventListener): () => void { + return () => {}; + }, + isActive(_conversationId: string): boolean { + return false; + }, + closeConversation(_conversationId: string): { abortedTurn: boolean } { + return { abortedTurn: false }; + }, + stopTurn(conversationId: string): { abortedTurn: boolean } { + stopped.push(conversationId); + const t = turns.get(conversationId); + if (t !== undefined) { + turns.delete(conversationId); + t.resolve(); + } + return { abortedTurn: true }; + }, + handleMessage(input): Promise<void> { + return new Promise<void>((resolve, reject) => { + const entry: PendingTurn = { + conversationId: input.conversationId, + text: input.text, + ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}), + ...(input.modelName !== undefined ? { modelName: input.modelName } : {}), + ...(input.reasoningEffort !== undefined + ? { reasoningEffort: input.reasoningEffort } + : {}), + ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + resolve, + }; + pending.push(entry); + turns.set(input.conversationId, { resolve, reject }); + }); + }, + }; + return Object.assign(fake, { + get pending(): readonly PendingTurn[] { + return pending; + }, + get stopped(): readonly string[] { + return stopped; + }, + }); } // Drain microtasks so async .finally handlers (run completion) run. `fire` has @@ -141,514 +141,514 @@ function createFakeOrchestrator(): SessionOrchestrator & { // before it reaches handleMessage, so a single queueMicrotask isn't enough — // setTimeout(0) schedules a macrotask, letting ALL pending microtasks drain. const flush = async (): Promise<void> => { - await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); }; function createService(opts: { - readonly orch: ReturnType<typeof createFakeOrchestrator>; - readonly storage?: StorageNamespace; - readonly resolvePrompt?: ( - template: string, - ctx: { - readonly workspaceId: string; - readonly conversationId: string; - readonly model: string; - }, - ) => Promise<string>; - readonly getGlobalSystemPrompt?: () => Promise<string>; - readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>; + readonly orch: ReturnType<typeof createFakeOrchestrator>; + readonly storage?: StorageNamespace; + readonly resolvePrompt?: ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ) => Promise<string>; + readonly getGlobalSystemPrompt?: () => Promise<string>; + readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>; }) { - const fake = createFakeTimers(); - let id = 0; - const storage = opts.storage ?? createMemoryStorage(); - const svc = createHeartbeatService({ - storage, - orchestrator: opts.orch, - timers: fake.timers, - generateId: () => `id-${++id}`, - ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}), - ...(opts.getGlobalSystemPrompt !== undefined - ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt } - : {}), - ...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}), - }); - return { svc, advance: fake.advance, storage }; + const fake = createFakeTimers(); + let id = 0; + const storage = opts.storage ?? createMemoryStorage(); + const svc = createHeartbeatService({ + storage, + orchestrator: opts.orch, + timers: fake.timers, + generateId: () => `id-${++id}`, + ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}), + ...(opts.getGlobalSystemPrompt !== undefined + ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt } + : {}), + ...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}), + }); + return { svc, advance: fake.advance, storage }; } describe("createHeartbeatService", () => { - it("returns the default config for an unknown workspace", async () => { - const { svc } = createService({ orch: createFakeOrchestrator() }); - const cfg = await svc.getConfig("ws-1"); - expect(cfg.enabled).toBe(false); - expect(cfg.intervalMinutes).toBe(30); - }); - - it("arming an enabled config does not fire immediately (waits for the interval)", () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - void svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" }); - // Just arming → no turn yet. - expect(orch.pending).toHaveLength(0); - // Advancing just shy of the interval still no fire. - advance(59_999); - expect(orch.pending).toHaveLength(0); - }); - - it("fire sends the task prompt with the explicit system prompt + model + effort, and marks completed on seal", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { - enabled: true, - systemPrompt: "you are a monitor", - taskPrompt: "check stuck chats", - model: "opencode/gpt-4o", - reasoningEffort: "high", - intervalMinutes: 1, - }); - - advance(60_000); // 1-minute interval → fire - await flush(); // let the async fire() reach handleMessage - expect(orch.pending).toHaveLength(1); - const turn = orch.pending[0]!; - expect(turn.text).toBe("check stuck chats"); - expect(turn.systemPrompt).toBe("you are a monitor"); - expect(turn.modelName).toBe("opencode/gpt-4o"); - expect(turn.reasoningEffort).toBe("high"); - // Spawned conversations go to the DEDICATED heartbeat workspace (not - // the configured workspace), while the run stays tracked under ws-1. - expect(turn.workspaceId).toBe("heartbeat"); - - expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); - - turn.resolve(); - await flush(); - expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed"); - }); - - it("heartbeat conversations always go to the dedicated heartbeat workspace, regardless of the configured workspace", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - // Two DIFFERENT workspaces each configure a heartbeat. - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - await svc.updateConfig("ws-2", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - - advance(60_000); // both fire (1-minute interval) - await flush(); - expect(orch.pending).toHaveLength(2); - // Every spawned conversation is filed in the heartbeat workspace — - // NOT ws-1 or ws-2 — so heartbeat runs don't clog either workspace's - // tabs. The run history, however, stays tracked per configured workspace. - for (const turn of orch.pending) { - expect(turn.workspaceId).toBe("heartbeat"); - } - // Run history is still per configured workspace. - expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); - expect((await svc.listRuns("ws-2"))[0]?.status).toBe("running"); - for (const turn of orch.pending) { - turn.resolve(); - } - await flush(); - }); - - it("the turn cwd is pinned to the CONFIGURED workspace's defaultCwd (not the heartbeat workspace's empty defaultCwd)", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ - orch, - // The configured workspace has a defaultCwd. - getWorkspaceCwd: (wsId) => Promise.resolve(wsId === "ws-1" ? "/home/proj/ws-1" : null), - }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - // The conversation is filed in the heartbeat workspace … - expect(turn.workspaceId).toBe("heartbeat"); - // … but the turn's cwd is the CONFIGURED workspace's defaultCwd, so - // tools run where the prompt's [prompt:cwd] advertises (not the - // heartbeat workspace's empty defaultCwd → process.cwd()). - expect(turn.cwd).toBe("/home/proj/ws-1"); - turn.resolve(); - await flush(); - }); - - it("omits the cwd override when the configured workspace has no defaultCwd (orchestrator falls back to the server default)", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ - orch, - // The configured workspace has NO defaultCwd. - getWorkspaceCwd: () => Promise.resolve(null), - }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - expect(turn.workspaceId).toBe("heartbeat"); - // No cwd override sent — the orchestrator resolves the turn cwd from - // the heartbeat workspace (no defaultCwd → server default cwd). - expect(turn.cwd).toBeUndefined(); - turn.resolve(); - await flush(); - }); - - it("omits the cwd override when getWorkspaceCwd is not wired (resolution is optional)", async () => { - const orch = createFakeOrchestrator(); - // No getWorkspaceCwd → no cwd override (the default). - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - expect(turn.workspaceId).toBe("heartbeat"); - expect(turn.cwd).toBeUndefined(); - turn.resolve(); - await flush(); - }); - - it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { - enabled: true, - taskPrompt: "go", - model: "", - reasoningEffort: null, - intervalMinutes: 1, - }); - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - expect(turn.modelName).toBeUndefined(); - expect(turn.reasoningEffort).toBeUndefined(); - turn.resolve(); - await flush(); - }); - - it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => { - const orch = createFakeOrchestrator(); - // A fake resolver that mirrors the real resolver's contract: substitute - // known [type:name] placeholders, leave unknown text verbatim. - const resolvePrompt = async ( - template: string, - ctx: { - readonly workspaceId: string; - readonly conversationId: string; - readonly model: string; - }, - ): Promise<string> => { - return template - .replaceAll("[system:os]", "Linux (WSL)") - .replaceAll("[prompt:cwd]", "/repo") - .replaceAll("[prompt:workspace_id]", ctx.workspaceId) - .replaceAll("[prompt:conversation_id]", ctx.conversationId) - .replaceAll("[prompt:model]", ctx.model); - }; - const { svc, advance } = createService({ orch, resolvePrompt }); - await svc.updateConfig("ws-1", { - enabled: true, - systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).", - taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].", - model: "opencode/gpt-4o", - intervalMinutes: 1, - }); - - advance(60_000); - await flush(); - expect(orch.pending).toHaveLength(1); - const turn = orch.pending[0]!; - // Variables substituted — NOT left as literal [type:name] text. - expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1)."); - expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`); - expect(turn.modelName).toBe("opencode/gpt-4o"); - turn.resolve(); - await flush(); - }); - - it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => { - const orch = createFakeOrchestrator(); - // No resolvePrompt → raw pass-through (the default). - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { - enabled: true, - systemPrompt: "raw [system:os] prompt", - taskPrompt: "raw [system:date] task", - intervalMinutes: 1, - }); - - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - // Unresolved — literals reach the orchestrator verbatim. - expect(turn.systemPrompt).toBe("raw [system:os] prompt"); - expect(turn.text).toBe("raw [system:date] task"); - turn.resolve(); - await flush(); - }); - - it("an empty systemPrompt inherits the global system prompt template (CR-HB-2)", async () => { - const orch = createFakeOrchestrator(); - // The global getter returns the workspace's regular system prompt - // template (the same one GET /system-prompt returns). No resolver → the - // inherited template reaches the orchestrator verbatim (isolates CR-HB-2 - // from CR-HB-1). - const { svc, advance } = createService({ - orch, - getGlobalSystemPrompt: () => Promise.resolve("GLOBAL DEFAULT TEMPLATE"), - }); - await svc.updateConfig("ws-1", { - enabled: true, - // Empty systemPrompt = inherit the global default, NOT "no prompt". - systemPrompt: "", - taskPrompt: "go", - intervalMinutes: 1, - }); - - advance(60_000); - await flush(); - expect(orch.pending).toHaveLength(1); - const turn = orch.pending[0]!; - expect(turn.systemPrompt).toBe("GLOBAL DEFAULT TEMPLATE"); - turn.resolve(); - await flush(); - }); - - it("CR-HB-2 composes with CR-HB-1: the inherited global template's [type:name] placeholders are resolved", async () => { - const orch = createFakeOrchestrator(); - const resolvePrompt = async ( - template: string, - ctx: { - readonly workspaceId: string; - readonly conversationId: string; - readonly model: string; - }, - ): Promise<string> => { - return template - .replaceAll("[system:os]", "Linux (WSL)") - .replaceAll("[prompt:cwd]", "/repo") - .replaceAll("[prompt:workspace_id]", ctx.workspaceId); - }; - // The global template carries [type:name] placeholders (like the real - // default template embeds [prompt:cwd] / [file:AGENTS.md]). - const { svc, advance } = createService({ - orch, - resolvePrompt, - getGlobalSystemPrompt: () => - Promise.resolve("You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id])."), - }); - await svc.updateConfig("ws-1", { - enabled: true, - // Empty → inherit the global template, THEN resolve its placeholders. - systemPrompt: "", - taskPrompt: "go", - intervalMinutes: 1, - }); - - advance(60_000); - await flush(); - expect(orch.pending).toHaveLength(1); - const turn = orch.pending[0]!; - // The global template was inherited (not empty), then its [type:name] - // placeholders were substituted — NOT left literal, NOT empty. - expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1)."); - turn.resolve(); - await flush(); - }); - - it("a non-empty systemPrompt override bypasses the global template (only CR-HB-1 applies)", async () => { - const orch = createFakeOrchestrator(); - const resolvePrompt = async (template: string): Promise<string> => - template.replaceAll("[system:os]", "Linux (WSL)"); - const { svc, advance } = createService({ - orch, - resolvePrompt, - // A DISTINCT global template — must NOT be used when overriding. - getGlobalSystemPrompt: () => Promise.resolve("GLOBAL SHOULD NOT APPEAR [system:os]"), - }); - await svc.updateConfig("ws-1", { - enabled: true, - systemPrompt: "custom override on [system:os]", - taskPrompt: "go", - intervalMinutes: 1, - }); - - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - // The override is used (and resolved), not the global template. - expect(turn.systemPrompt).toBe("custom override on Linux (WSL)"); - turn.resolve(); - await flush(); - }); - - it("an empty systemPrompt stays empty when no global getter is wired (resolution is optional)", async () => { - const orch = createFakeOrchestrator(); - // No getGlobalSystemPrompt → empty stays empty (no system prompt). - // Mirrors the no-resolver pass-through default: both deps are optional. - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { - enabled: true, - systemPrompt: "", - taskPrompt: "go", - intervalMinutes: 1, - }); - - advance(60_000); - await flush(); - const turn = orch.pending[0]!; - expect(turn.systemPrompt).toBe(""); - turn.resolve(); - await flush(); - }); - - it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - advance(60_000); - await flush(); - const conversationId = orch.pending[0]?.conversationId; - const runId = (await svc.listRuns("ws-1"))[0]?.id; - - const res = await svc.stopRun("ws-1", runId); - expect(res).toEqual({ ok: true }); - expect(orch.stopped).toEqual([conversationId]); - - await flush(); // the aborted turn's handleMessage resolves - expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped"); - }); - - it("stopRun is idempotent for an already-finished run", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - advance(60_000); - await flush(); - orch.pending[0]?.resolve(); - await flush(); - - const runId = (await svc.listRuns("ws-1"))[0]?.id; - const res = await svc.stopRun("ws-1", runId); - expect(res).toEqual({ ok: true }); - expect(orch.stopped).toEqual([]); // no abort on a completed run - }); - - it("stopRun throws for an unknown run id", async () => { - const { svc } = createService({ orch: createFakeOrchestrator() }); - await expect(svc.stopRun("ws-1", "nope")).rejects.toThrow(); - }); - - it("disabling the config stops the schedule (no further fires)", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" }); - await svc.updateConfig("ws-1", { enabled: false }); - advance(60_000); - expect(orch.pending).toHaveLength(0); - }); - - it("startAll sweeps stale running runs to stopped", async () => { - const storage = createMemoryStorage(); - await storage.set( - "run:ws-1:stale", - JSON.stringify({ - id: "stale", - conversationId: "c-stale", - triggeredAt: "2026-01-01T00:00:00.000Z", - status: "running", - }), - ); - await storage.set( - "config:ws-1", - JSON.stringify({ - enabled: false, - systemPrompt: "", - taskPrompt: "", - intervalMinutes: 30, - model: "", - reasoningEffort: null, - }), - ); - - const { svc } = createService({ orch: createFakeOrchestrator(), storage }); - await svc.startAll(); - expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped"); - }); - - it("startAll arms enabled workspaces and skips disabled ones", async () => { - const orch = createFakeOrchestrator(); - const { svc: svc1, storage } = createService({ orch }); - await svc1.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - await svc1.updateConfig("ws-2", { enabled: false, taskPrompt: "no" }); - svc1.stopAll(); - - // Re-create a fresh service over the SAME storage (simulates a reboot), - // with new fake timers we can advance. - const fake = createFakeTimers(); - let id = 1000; - const svc2 = createHeartbeatService({ - storage, - orchestrator: orch, - timers: fake.timers, - generateId: () => `id-${++id}`, - }); - await svc2.startAll(); - fake.advance(60_000); - await flush(); - // ws-1 (enabled) fired → its spawned conversation is filed in the - // heartbeat workspace; ws-2 (disabled) never fired. - expect(orch.pending).toHaveLength(1); - expect(orch.pending[0]?.workspaceId).toBe("heartbeat"); - }); - - // ─── nextRunAt (CR-HB-3: server-authoritative next-run time) ──────────────── - - it("nextRunAt returns null when the heartbeat is disabled", async () => { - const { svc } = createService({ orch: createFakeOrchestrator() }); - // Default config → disabled → no schedule armed. - expect(await svc.nextRunAt("ws-1")).toBeNull(); - }); - - it("nextRunAt returns the ISO timestamp of the next fire when enabled", async () => { - const { svc } = createService({ orch: createFakeOrchestrator() }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - // now=0, interval=1m → next fire at epoch-ms 60_000 → its ISO form. - expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); - }); - - it("nextRunAt returns null while a run is in progress, then the next fire after it completes", async () => { - const orch = createFakeOrchestrator(); - const { svc, advance } = createService({ orch }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); - - advance(60_000); // fire → run in progress - await flush(); - expect(orch.pending).toHaveLength(1); - // In flight → no next run queued yet. - expect(await svc.nextRunAt("ws-1")).toBeNull(); - - orch.pending[0]?.resolve(); - await flush(); - // Re-armed at completion-time (60_000) + interval (60_000) = 120_000. - expect(await svc.nextRunAt("ws-1")).toBe(new Date(120_000).toISOString()); - }); - - it("nextRunAt returns null after disabling the heartbeat", async () => { - const { svc } = createService({ orch: createFakeOrchestrator() }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - expect(await svc.nextRunAt("ws-1")).not.toBeNull(); - await svc.updateConfig("ws-1", { enabled: false }); - expect(await svc.nextRunAt("ws-1")).toBeNull(); - }); - - it("nextRunAt reflects a changed interval on the next re-arm", async () => { - const { svc } = createService({ orch: createFakeOrchestrator() }); - await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); - expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); - // Re-arm with a 5-minute interval (not running) → recomputed fire time. - await svc.updateConfig("ws-1", { intervalMinutes: 5 }); - expect(await svc.nextRunAt("ws-1")).toBe(new Date(5 * 60_000).toISOString()); - }); + it("returns the default config for an unknown workspace", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + const cfg = await svc.getConfig("ws-1"); + expect(cfg.enabled).toBe(false); + expect(cfg.intervalMinutes).toBe(30); + }); + + it("arming an enabled config does not fire immediately (waits for the interval)", () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + void svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" }); + // Just arming → no turn yet. + expect(orch.pending).toHaveLength(0); + // Advancing just shy of the interval still no fire. + advance(59_999); + expect(orch.pending).toHaveLength(0); + }); + + it("fire sends the task prompt with the explicit system prompt + model + effort, and marks completed on seal", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "you are a monitor", + taskPrompt: "check stuck chats", + model: "opencode/gpt-4o", + reasoningEffort: "high", + intervalMinutes: 1, + }); + + advance(60_000); // 1-minute interval → fire + await flush(); // let the async fire() reach handleMessage + expect(orch.pending).toHaveLength(1); + const turn = orch.pending[0]!; + expect(turn.text).toBe("check stuck chats"); + expect(turn.systemPrompt).toBe("you are a monitor"); + expect(turn.modelName).toBe("opencode/gpt-4o"); + expect(turn.reasoningEffort).toBe("high"); + // Spawned conversations go to the DEDICATED heartbeat workspace (not + // the configured workspace), while the run stays tracked under ws-1. + expect(turn.workspaceId).toBe("heartbeat"); + + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); + + turn.resolve(); + await flush(); + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed"); + }); + + it("heartbeat conversations always go to the dedicated heartbeat workspace, regardless of the configured workspace", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + // Two DIFFERENT workspaces each configure a heartbeat. + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + await svc.updateConfig("ws-2", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); // both fire (1-minute interval) + await flush(); + expect(orch.pending).toHaveLength(2); + // Every spawned conversation is filed in the heartbeat workspace — + // NOT ws-1 or ws-2 — so heartbeat runs don't clog either workspace's + // tabs. The run history, however, stays tracked per configured workspace. + for (const turn of orch.pending) { + expect(turn.workspaceId).toBe("heartbeat"); + } + // Run history is still per configured workspace. + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running"); + expect((await svc.listRuns("ws-2"))[0]?.status).toBe("running"); + for (const turn of orch.pending) { + turn.resolve(); + } + await flush(); + }); + + it("the turn cwd is pinned to the CONFIGURED workspace's defaultCwd (not the heartbeat workspace's empty defaultCwd)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ + orch, + // The configured workspace has a defaultCwd. + getWorkspaceCwd: (wsId) => Promise.resolve(wsId === "ws-1" ? "/home/proj/ws-1" : null), + }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + // The conversation is filed in the heartbeat workspace … + expect(turn.workspaceId).toBe("heartbeat"); + // … but the turn's cwd is the CONFIGURED workspace's defaultCwd, so + // tools run where the prompt's [prompt:cwd] advertises (not the + // heartbeat workspace's empty defaultCwd → process.cwd()). + expect(turn.cwd).toBe("/home/proj/ws-1"); + turn.resolve(); + await flush(); + }); + + it("omits the cwd override when the configured workspace has no defaultCwd (orchestrator falls back to the server default)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ + orch, + // The configured workspace has NO defaultCwd. + getWorkspaceCwd: () => Promise.resolve(null), + }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.workspaceId).toBe("heartbeat"); + // No cwd override sent — the orchestrator resolves the turn cwd from + // the heartbeat workspace (no defaultCwd → server default cwd). + expect(turn.cwd).toBeUndefined(); + turn.resolve(); + await flush(); + }); + + it("omits the cwd override when getWorkspaceCwd is not wired (resolution is optional)", async () => { + const orch = createFakeOrchestrator(); + // No getWorkspaceCwd → no cwd override (the default). + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.workspaceId).toBe("heartbeat"); + expect(turn.cwd).toBeUndefined(); + turn.resolve(); + await flush(); + }); + + it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + taskPrompt: "go", + model: "", + reasoningEffort: null, + intervalMinutes: 1, + }); + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.modelName).toBeUndefined(); + expect(turn.reasoningEffort).toBeUndefined(); + turn.resolve(); + await flush(); + }); + + it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => { + const orch = createFakeOrchestrator(); + // A fake resolver that mirrors the real resolver's contract: substitute + // known [type:name] placeholders, leave unknown text verbatim. + const resolvePrompt = async ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ): Promise<string> => { + return template + .replaceAll("[system:os]", "Linux (WSL)") + .replaceAll("[prompt:cwd]", "/repo") + .replaceAll("[prompt:workspace_id]", ctx.workspaceId) + .replaceAll("[prompt:conversation_id]", ctx.conversationId) + .replaceAll("[prompt:model]", ctx.model); + }; + const { svc, advance } = createService({ orch, resolvePrompt }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).", + taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].", + model: "opencode/gpt-4o", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + expect(orch.pending).toHaveLength(1); + const turn = orch.pending[0]!; + // Variables substituted — NOT left as literal [type:name] text. + expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1)."); + expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`); + expect(turn.modelName).toBe("opencode/gpt-4o"); + turn.resolve(); + await flush(); + }); + + it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => { + const orch = createFakeOrchestrator(); + // No resolvePrompt → raw pass-through (the default). + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "raw [system:os] prompt", + taskPrompt: "raw [system:date] task", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + // Unresolved — literals reach the orchestrator verbatim. + expect(turn.systemPrompt).toBe("raw [system:os] prompt"); + expect(turn.text).toBe("raw [system:date] task"); + turn.resolve(); + await flush(); + }); + + it("an empty systemPrompt inherits the global system prompt template (CR-HB-2)", async () => { + const orch = createFakeOrchestrator(); + // The global getter returns the workspace's regular system prompt + // template (the same one GET /system-prompt returns). No resolver → the + // inherited template reaches the orchestrator verbatim (isolates CR-HB-2 + // from CR-HB-1). + const { svc, advance } = createService({ + orch, + getGlobalSystemPrompt: () => Promise.resolve("GLOBAL DEFAULT TEMPLATE"), + }); + await svc.updateConfig("ws-1", { + enabled: true, + // Empty systemPrompt = inherit the global default, NOT "no prompt". + systemPrompt: "", + taskPrompt: "go", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + expect(orch.pending).toHaveLength(1); + const turn = orch.pending[0]!; + expect(turn.systemPrompt).toBe("GLOBAL DEFAULT TEMPLATE"); + turn.resolve(); + await flush(); + }); + + it("CR-HB-2 composes with CR-HB-1: the inherited global template's [type:name] placeholders are resolved", async () => { + const orch = createFakeOrchestrator(); + const resolvePrompt = async ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ): Promise<string> => { + return template + .replaceAll("[system:os]", "Linux (WSL)") + .replaceAll("[prompt:cwd]", "/repo") + .replaceAll("[prompt:workspace_id]", ctx.workspaceId); + }; + // The global template carries [type:name] placeholders (like the real + // default template embeds [prompt:cwd] / [file:AGENTS.md]). + const { svc, advance } = createService({ + orch, + resolvePrompt, + getGlobalSystemPrompt: () => + Promise.resolve("You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id])."), + }); + await svc.updateConfig("ws-1", { + enabled: true, + // Empty → inherit the global template, THEN resolve its placeholders. + systemPrompt: "", + taskPrompt: "go", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + expect(orch.pending).toHaveLength(1); + const turn = orch.pending[0]!; + // The global template was inherited (not empty), then its [type:name] + // placeholders were substituted — NOT left literal, NOT empty. + expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1)."); + turn.resolve(); + await flush(); + }); + + it("a non-empty systemPrompt override bypasses the global template (only CR-HB-1 applies)", async () => { + const orch = createFakeOrchestrator(); + const resolvePrompt = async (template: string): Promise<string> => + template.replaceAll("[system:os]", "Linux (WSL)"); + const { svc, advance } = createService({ + orch, + resolvePrompt, + // A DISTINCT global template — must NOT be used when overriding. + getGlobalSystemPrompt: () => Promise.resolve("GLOBAL SHOULD NOT APPEAR [system:os]"), + }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "custom override on [system:os]", + taskPrompt: "go", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + // The override is used (and resolved), not the global template. + expect(turn.systemPrompt).toBe("custom override on Linux (WSL)"); + turn.resolve(); + await flush(); + }); + + it("an empty systemPrompt stays empty when no global getter is wired (resolution is optional)", async () => { + const orch = createFakeOrchestrator(); + // No getGlobalSystemPrompt → empty stays empty (no system prompt). + // Mirrors the no-resolver pass-through default: both deps are optional. + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { + enabled: true, + systemPrompt: "", + taskPrompt: "go", + intervalMinutes: 1, + }); + + advance(60_000); + await flush(); + const turn = orch.pending[0]!; + expect(turn.systemPrompt).toBe(""); + turn.resolve(); + await flush(); + }); + + it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + advance(60_000); + await flush(); + const conversationId = orch.pending[0]?.conversationId; + const runId = (await svc.listRuns("ws-1"))[0]?.id; + + const res = await svc.stopRun("ws-1", runId); + expect(res).toEqual({ ok: true }); + expect(orch.stopped).toEqual([conversationId]); + + await flush(); // the aborted turn's handleMessage resolves + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped"); + }); + + it("stopRun is idempotent for an already-finished run", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + advance(60_000); + await flush(); + orch.pending[0]?.resolve(); + await flush(); + + const runId = (await svc.listRuns("ws-1"))[0]?.id; + const res = await svc.stopRun("ws-1", runId); + expect(res).toEqual({ ok: true }); + expect(orch.stopped).toEqual([]); // no abort on a completed run + }); + + it("stopRun throws for an unknown run id", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await expect(svc.stopRun("ws-1", "nope")).rejects.toThrow(); + }); + + it("disabling the config stops the schedule (no further fires)", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" }); + await svc.updateConfig("ws-1", { enabled: false }); + advance(60_000); + expect(orch.pending).toHaveLength(0); + }); + + it("startAll sweeps stale running runs to stopped", async () => { + const storage = createMemoryStorage(); + await storage.set( + "run:ws-1:stale", + JSON.stringify({ + id: "stale", + conversationId: "c-stale", + triggeredAt: "2026-01-01T00:00:00.000Z", + status: "running", + }), + ); + await storage.set( + "config:ws-1", + JSON.stringify({ + enabled: false, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, + }), + ); + + const { svc } = createService({ orch: createFakeOrchestrator(), storage }); + await svc.startAll(); + expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped"); + }); + + it("startAll arms enabled workspaces and skips disabled ones", async () => { + const orch = createFakeOrchestrator(); + const { svc: svc1, storage } = createService({ orch }); + await svc1.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + await svc1.updateConfig("ws-2", { enabled: false, taskPrompt: "no" }); + svc1.stopAll(); + + // Re-create a fresh service over the SAME storage (simulates a reboot), + // with new fake timers we can advance. + const fake = createFakeTimers(); + let id = 1000; + const svc2 = createHeartbeatService({ + storage, + orchestrator: orch, + timers: fake.timers, + generateId: () => `id-${++id}`, + }); + await svc2.startAll(); + fake.advance(60_000); + await flush(); + // ws-1 (enabled) fired → its spawned conversation is filed in the + // heartbeat workspace; ws-2 (disabled) never fired. + expect(orch.pending).toHaveLength(1); + expect(orch.pending[0]?.workspaceId).toBe("heartbeat"); + }); + + // ─── nextRunAt (CR-HB-3: server-authoritative next-run time) ──────────────── + + it("nextRunAt returns null when the heartbeat is disabled", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + // Default config → disabled → no schedule armed. + expect(await svc.nextRunAt("ws-1")).toBeNull(); + }); + + it("nextRunAt returns the ISO timestamp of the next fire when enabled", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + // now=0, interval=1m → next fire at epoch-ms 60_000 → its ISO form. + expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); + }); + + it("nextRunAt returns null while a run is in progress, then the next fire after it completes", async () => { + const orch = createFakeOrchestrator(); + const { svc, advance } = createService({ orch }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); + + advance(60_000); // fire → run in progress + await flush(); + expect(orch.pending).toHaveLength(1); + // In flight → no next run queued yet. + expect(await svc.nextRunAt("ws-1")).toBeNull(); + + orch.pending[0]?.resolve(); + await flush(); + // Re-armed at completion-time (60_000) + interval (60_000) = 120_000. + expect(await svc.nextRunAt("ws-1")).toBe(new Date(120_000).toISOString()); + }); + + it("nextRunAt returns null after disabling the heartbeat", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + expect(await svc.nextRunAt("ws-1")).not.toBeNull(); + await svc.updateConfig("ws-1", { enabled: false }); + expect(await svc.nextRunAt("ws-1")).toBeNull(); + }); + + it("nextRunAt reflects a changed interval on the next re-arm", async () => { + const { svc } = createService({ orch: createFakeOrchestrator() }); + await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 }); + expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString()); + // Re-arm with a 5-minute interval (not running) → recomputed fire time. + await svc.updateConfig("ws-1", { intervalMinutes: 5 }); + expect(await svc.nextRunAt("ws-1")).toBe(new Date(5 * 60_000).toISOString()); + }); }); diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts index 8623907..12e70e3 100644 --- a/packages/heartbeat/src/heartbeat.ts +++ b/packages/heartbeat/src/heartbeat.ts @@ -2,10 +2,10 @@ import type { Logger, ServiceHandle } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import type { SessionOrchestrator } from "@dispatch/session-orchestrator"; import type { - HeartbeatConfig, - HeartbeatRun, - StopHeartbeatRunResponse, - UpdateHeartbeatRequest, + HeartbeatConfig, + HeartbeatRun, + StopHeartbeatRunResponse, + UpdateHeartbeatRequest, } from "@dispatch/transport-contract"; import { createHeartbeatConfigStore, type HeartbeatConfigStore } from "./config-store.js"; import { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js"; @@ -26,300 +26,300 @@ export const HEARTBEAT_WORKSPACE_ID = "heartbeat"; * extension wires into the host. */ export interface HeartbeatService { - /** The per-workspace heartbeat config (defaults when never set). */ - readonly getConfig: (workspaceId: string) => Promise<HeartbeatConfig>; - /** - * Apply a partial config update. Side effect: arms/disarms the scheduler - * for this workspace (enabled → schedule; disabled → stop). Returns the new - * config. - */ - readonly updateConfig: ( - workspaceId: string, - update: UpdateHeartbeatRequest, - ) => Promise<HeartbeatConfig>; - /** Heartbeat runs for a workspace, most-recent first. */ - readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; - /** - * The server-authoritative next-fire time for a workspace's heartbeat, as - * an ISO 8601 string — the moment the scheduler will fire the next run - * (the last run's completion + `intervalMinutes`, or the moment `enabled` - * was toggled on + `intervalMinutes` for the first run). `null` when the - * heartbeat is disabled/disarmed, or when a run is in flight and the next - * hasn't been queued yet (no countdown to show). A cheap read of the - * scheduler's pending fire time. - */ - readonly nextRunAt: (workspaceId: string) => Promise<string | null>; - /** - * Stop an in-flight run (abort its turn). Idempotent for an already-finished - * run. Throws when the run id is unknown (→ HTTP 404). - */ - readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>; - /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */ - readonly startAll: () => Promise<void>; - /** Shutdown: stop every scheduler. */ - readonly stopAll: () => void; + /** The per-workspace heartbeat config (defaults when never set). */ + readonly getConfig: (workspaceId: string) => Promise<HeartbeatConfig>; + /** + * Apply a partial config update. Side effect: arms/disarms the scheduler + * for this workspace (enabled → schedule; disabled → stop). Returns the new + * config. + */ + readonly updateConfig: ( + workspaceId: string, + update: UpdateHeartbeatRequest, + ) => Promise<HeartbeatConfig>; + /** Heartbeat runs for a workspace, most-recent first. */ + readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; + /** + * The server-authoritative next-fire time for a workspace's heartbeat, as + * an ISO 8601 string — the moment the scheduler will fire the next run + * (the last run's completion + `intervalMinutes`, or the moment `enabled` + * was toggled on + `intervalMinutes` for the first run). `null` when the + * heartbeat is disabled/disarmed, or when a run is in flight and the next + * hasn't been queued yet (no countdown to show). A cheap read of the + * scheduler's pending fire time. + */ + readonly nextRunAt: (workspaceId: string) => Promise<string | null>; + /** + * Stop an in-flight run (abort its turn). Idempotent for an already-finished + * run. Throws when the run id is unknown (→ HTTP 404). + */ + readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>; + /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */ + readonly startAll: () => Promise<void>; + /** Shutdown: stop every scheduler. */ + readonly stopAll: () => void; } /** Typed service handle the heartbeat extension provides and transport consumes. */ export const heartbeatServiceHandle: ServiceHandle<HeartbeatService> = - defineService<HeartbeatService>("heartbeat"); + defineService<HeartbeatService>("heartbeat"); export interface HeartbeatServiceDeps { - /** Namespaced storage (from `host.storage("heartbeat")`). */ - readonly storage: import("@dispatch/kernel").StorageNamespace; - /** The session orchestrator (drives heartbeat turns). */ - readonly orchestrator: SessionOrchestrator; - readonly logger?: Logger; - /** Injectable timers (default: real). */ - readonly timers?: Timers; - /** Injectable id generator (default: crypto.randomUUID). */ - readonly generateId?: () => string; - /** - * Resolve `[type:name]` variable placeholders in a prompt template against - * the current environment — the SAME resolver + variable catalog the global - * system-prompt template uses (system/file/prompt/git groups). Applied once - * per run, when the turn is constructed (mirrors the global template's - * construct-once-per-conversation resolution). When omitted, templates pass - * through UNRESOLVED (raw) — the extension wires the real resolver; tests - * inject a fake. - */ - readonly resolvePrompt?: ( - template: string, - ctx: { - readonly workspaceId: string; - readonly conversationId: string; - readonly model: string; - }, - ) => Promise<string>; - /** - * Resolve an empty heartbeat `systemPrompt` to the GLOBAL system prompt - * template — the same one `GET /system-prompt` returns / that regular - * conversations resolve. Applied ONLY when the heartbeat's persisted - * `systemPrompt` is `""` (inherit), and BEFORE variable resolution - * (`resolvePrompt` / CR-HB-1) runs on the result, so both apply in order: - * empty ⇒ global template, then `[type:name]` placeholders resolved. A - * non-empty `systemPrompt` is an explicit override and bypasses this. - * When omitted, empty stays empty (no system prompt) — the extension - * wires the real getter; tests inject a fake. - */ - readonly getGlobalSystemPrompt?: () => Promise<string>; - /** - * The configured workspace's `defaultCwd` (or `null` when the workspace has - * none). Used to pin the heartbeat turn's cwd to the CONFIGURED workspace's - * directory — NOT the heartbeat workspace's (empty) defaultCwd — so the - * turn's tools run in the same directory the prompt's `[prompt:cwd]` - * variable advertises. Passed to the orchestrator as an explicit `cwd` - * override only when non-null; when `null` no override is sent and the - * orchestrator falls back to the server default cwd (matching the - * pre-heartbeat-workspace behavior for a workspace without a defaultCwd). - * When omitted, `null` (no override) — the extension wires the real getter - * (against `conversationStore.getWorkspace`); tests inject a fake. - */ - readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>; + /** Namespaced storage (from `host.storage("heartbeat")`). */ + readonly storage: import("@dispatch/kernel").StorageNamespace; + /** The session orchestrator (drives heartbeat turns). */ + readonly orchestrator: SessionOrchestrator; + readonly logger?: Logger; + /** Injectable timers (default: real). */ + readonly timers?: Timers; + /** Injectable id generator (default: crypto.randomUUID). */ + readonly generateId?: () => string; + /** + * Resolve `[type:name]` variable placeholders in a prompt template against + * the current environment — the SAME resolver + variable catalog the global + * system-prompt template uses (system/file/prompt/git groups). Applied once + * per run, when the turn is constructed (mirrors the global template's + * construct-once-per-conversation resolution). When omitted, templates pass + * through UNRESOLVED (raw) — the extension wires the real resolver; tests + * inject a fake. + */ + readonly resolvePrompt?: ( + template: string, + ctx: { + readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + }, + ) => Promise<string>; + /** + * Resolve an empty heartbeat `systemPrompt` to the GLOBAL system prompt + * template — the same one `GET /system-prompt` returns / that regular + * conversations resolve. Applied ONLY when the heartbeat's persisted + * `systemPrompt` is `""` (inherit), and BEFORE variable resolution + * (`resolvePrompt` / CR-HB-1) runs on the result, so both apply in order: + * empty ⇒ global template, then `[type:name]` placeholders resolved. A + * non-empty `systemPrompt` is an explicit override and bypasses this. + * When omitted, empty stays empty (no system prompt) — the extension + * wires the real getter; tests inject a fake. + */ + readonly getGlobalSystemPrompt?: () => Promise<string>; + /** + * The configured workspace's `defaultCwd` (or `null` when the workspace has + * none). Used to pin the heartbeat turn's cwd to the CONFIGURED workspace's + * directory — NOT the heartbeat workspace's (empty) defaultCwd — so the + * turn's tools run in the same directory the prompt's `[prompt:cwd]` + * variable advertises. Passed to the orchestrator as an explicit `cwd` + * override only when non-null; when `null` no override is sent and the + * orchestrator falls back to the server default cwd (matching the + * pre-heartbeat-workspace behavior for a workspace without a defaultCwd). + * When omitted, `null` (no override) — the extension wires the real getter + * (against `conversationStore.getWorkspace`); tests inject a fake. + */ + readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>; } interface ActiveRun { - readonly conversationId: string; - readonly workspaceId: string; - stopped: boolean; + readonly conversationId: string; + readonly workspaceId: string; + stopped: boolean; } export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatService { - const logger = deps.logger; - const timers = deps.timers ?? realTimers; - const generateId = deps.generateId ?? (() => crypto.randomUUID()); - const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage); - const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage); - const orchestrator = deps.orchestrator; - // Default: pass templates through UNRESOLVED (raw). The extension wires the - // real resolver so [type:name] placeholders are substituted like the global - // system-prompt template; tests inject a fake. - const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template)); - // Default: an empty systemPrompt stays empty (no system prompt). The - // extension wires the real getter so empty INHERITS the global system - // prompt template (GET /system-prompt); tests inject a fake. - const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve("")); - // Default: no cwd override (the orchestrator resolves the turn cwd from the - // conversation's workspace). The extension wires the real getter so the - // turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake. - const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null)); + const logger = deps.logger; + const timers = deps.timers ?? realTimers; + const generateId = deps.generateId ?? (() => crypto.randomUUID()); + const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage); + const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage); + const orchestrator = deps.orchestrator; + // Default: pass templates through UNRESOLVED (raw). The extension wires the + // real resolver so [type:name] placeholders are substituted like the global + // system-prompt template; tests inject a fake. + const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template)); + // Default: an empty systemPrompt stays empty (no system prompt). The + // extension wires the real getter so empty INHERITS the global system + // prompt template (GET /system-prompt); tests inject a fake. + const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve("")); + // Default: no cwd override (the orchestrator resolves the turn cwd from the + // conversation's workspace). The extension wires the real getter so the + // turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake. + const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null)); - // runId → active-run tracking (in-memory; the durable record lives in the - // run store). Used to (a) map a stop request to its conversation, and - // (b) keep a "stopped" flag so the turn's completion doesn't clobber a - // user-initiated stop. - const activeRuns = new Map<string, ActiveRun>(); + // runId → active-run tracking (in-memory; the durable record lives in the + // run store). Used to (a) map a stop request to its conversation, and + // (b) keep a "stopped" flag so the turn's completion doesn't clobber a + // user-initiated stop. + const activeRuns = new Map<string, ActiveRun>(); - const scheduler = new HeartbeatScheduler({ - timers, - fire: (workspaceId) => fire(workspaceId), - }); + const scheduler = new HeartbeatScheduler({ + timers, + fire: (workspaceId) => fire(workspaceId), + }); - async function fire(workspaceId: string): Promise<void> { - const config = await configStore.get(workspaceId); - // Race: disabled/disarmed between the timer firing and now. - if (!config.enabled) return; + async function fire(workspaceId: string): Promise<void> { + const config = await configStore.get(workspaceId); + // Race: disabled/disarmed between the timer firing and now. + if (!config.enabled) return; - const conversationId = generateId(); - const runId = generateId(); - const triggeredAt = new Date(timers.now()).toISOString(); - const run: HeartbeatRun = { - id: runId, - conversationId, - triggeredAt, - status: "running", - }; - await runStore.create(workspaceId, run); - activeRuns.set(runId, { conversationId, workspaceId, stopped: false }); + const conversationId = generateId(); + const runId = generateId(); + const triggeredAt = new Date(timers.now()).toISOString(); + const run: HeartbeatRun = { + id: runId, + conversationId, + triggeredAt, + status: "running", + }; + await runStore.create(workspaceId, run); + activeRuns.set(runId, { conversationId, workspaceId, stopped: false }); - logger?.info("heartbeat: run started", { workspaceId, runId, conversationId }); + logger?.info("heartbeat: run started", { workspaceId, runId, conversationId }); - // Resolve the heartbeat's prompts ONCE, when the turn is constructed. - // - // CR-HB-2: an empty `systemPrompt` INHERITS the global system prompt - // template (the same one `GET /system-prompt` returns / that regular - // conversations resolve) — empty is an override-means-inherit flag, not - // "no system prompt". A non-empty `systemPrompt` is an explicit override. - // This step runs FIRST. - // - // CR-HB-1: then `[type:name]` variable placeholders in whichever prompt is - // in effect are resolved via the same resolver + variable catalog the - // global system-prompt template uses. The orchestrator sends an explicit - // systemPrompt override AS-IS (bypassing its own templated prompt), so - // resolution must happen HERE, before handleMessage. For prompts using - // stable variables (os/cwd/git) this yields a stable, cache-warm prompt - // across runs; time-bearing variables refresh per run (mirroring the - // global template's per-conversation resolution). - const baseSystemPrompt = - config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt; - const resolveCtx = { workspaceId, conversationId, model: config.model }; - // resolveCtx uses the CONFIGURED workspaceId — the heartbeat operates - // ON BEHALF OF the configured workspace, so `[prompt:workspace_id]` and - // `[prompt:cwd]` refer to it (not the heartbeat workspace the spawned - // conversation is filed in). The turn's cwd is pinned to the SAME - // configured workspace's defaultCwd (below) so tools run where the - // prompt's `[prompt:cwd]` advertises. - const [systemPrompt, taskPrompt, configuredWorkspaceCwd] = await Promise.all([ - resolvePrompt(baseSystemPrompt, resolveCtx), - resolvePrompt(config.taskPrompt, resolveCtx), - getWorkspaceCwd(workspaceId), - ]); + // Resolve the heartbeat's prompts ONCE, when the turn is constructed. + // + // CR-HB-2: an empty `systemPrompt` INHERITS the global system prompt + // template (the same one `GET /system-prompt` returns / that regular + // conversations resolve) — empty is an override-means-inherit flag, not + // "no system prompt". A non-empty `systemPrompt` is an explicit override. + // This step runs FIRST. + // + // CR-HB-1: then `[type:name]` variable placeholders in whichever prompt is + // in effect are resolved via the same resolver + variable catalog the + // global system-prompt template uses. The orchestrator sends an explicit + // systemPrompt override AS-IS (bypassing its own templated prompt), so + // resolution must happen HERE, before handleMessage. For prompts using + // stable variables (os/cwd/git) this yields a stable, cache-warm prompt + // across runs; time-bearing variables refresh per run (mirroring the + // global template's per-conversation resolution). + const baseSystemPrompt = + config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt; + const resolveCtx = { workspaceId, conversationId, model: config.model }; + // resolveCtx uses the CONFIGURED workspaceId — the heartbeat operates + // ON BEHALF OF the configured workspace, so `[prompt:workspace_id]` and + // `[prompt:cwd]` refer to it (not the heartbeat workspace the spawned + // conversation is filed in). The turn's cwd is pinned to the SAME + // configured workspace's defaultCwd (below) so tools run where the + // prompt's `[prompt:cwd]` advertises. + const [systemPrompt, taskPrompt, configuredWorkspaceCwd] = await Promise.all([ + resolvePrompt(baseSystemPrompt, resolveCtx), + resolvePrompt(config.taskPrompt, resolveCtx), + getWorkspaceCwd(workspaceId), + ]); - try { - await orchestrator.handleMessage({ - conversationId, - text: taskPrompt, - // Fire-and-forget: the heartbeat loop does not consume the - // streamed events (it only awaits turn completion to mark the - // run done). A no-op onEvent satisfies the required callback. - onEvent: () => {}, - // Always passed explicitly — bypasses the orchestrator's templated - // workspace prompt. Resolved above: an empty config systemPrompt - // inherited the global template (CR-HB-2), then [type:name] - // placeholders were substituted (CR-HB-1). Still "" when the global - // template itself is empty (no system prompt). - systemPrompt, - ...(config.model !== "" ? { modelName: config.model } : {}), - ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}), - // Pin the turn cwd to the CONFIGURED workspace's defaultCwd so - // the heartbeat's tools run in the same directory its prompt - // variables advertise — NOT the heartbeat workspace's (empty) - // defaultCwd → process.cwd(). Omitted when the configured - // workspace has no defaultCwd (the orchestrator then falls back - // to the server default cwd, matching the pre-heartbeat- - // workspace behavior for a workspace without one). - ...(configuredWorkspaceCwd !== null ? { cwd: configuredWorkspaceCwd } : {}), - // File the spawned conversation in the DEDICATED heartbeat - // workspace (not the configured workspace) so heartbeat - // conversations don't clog the configured workspace's tabs. The - // orchestrator auto-creates this workspace on first fire - // (ensureWorkspace), so it appears on the workspaces home page. - workspaceId: HEARTBEAT_WORKSPACE_ID, - }); - } finally { - const entry = activeRuns.get(runId); - activeRuns.delete(runId); - // If the user stopped it, stopRun already set "stopped"; don't - // clobber. Otherwise the turn sealed (normally or via abort) → done. - if (entry !== undefined && !entry.stopped) { - await runStore.setStatus(workspaceId, runId, "completed"); - logger?.info("heartbeat: run completed", { workspaceId, runId }); - } - } - } + try { + await orchestrator.handleMessage({ + conversationId, + text: taskPrompt, + // Fire-and-forget: the heartbeat loop does not consume the + // streamed events (it only awaits turn completion to mark the + // run done). A no-op onEvent satisfies the required callback. + onEvent: () => {}, + // Always passed explicitly — bypasses the orchestrator's templated + // workspace prompt. Resolved above: an empty config systemPrompt + // inherited the global template (CR-HB-2), then [type:name] + // placeholders were substituted (CR-HB-1). Still "" when the global + // template itself is empty (no system prompt). + systemPrompt, + ...(config.model !== "" ? { modelName: config.model } : {}), + ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}), + // Pin the turn cwd to the CONFIGURED workspace's defaultCwd so + // the heartbeat's tools run in the same directory its prompt + // variables advertise — NOT the heartbeat workspace's (empty) + // defaultCwd → process.cwd(). Omitted when the configured + // workspace has no defaultCwd (the orchestrator then falls back + // to the server default cwd, matching the pre-heartbeat- + // workspace behavior for a workspace without one). + ...(configuredWorkspaceCwd !== null ? { cwd: configuredWorkspaceCwd } : {}), + // File the spawned conversation in the DEDICATED heartbeat + // workspace (not the configured workspace) so heartbeat + // conversations don't clog the configured workspace's tabs. The + // orchestrator auto-creates this workspace on first fire + // (ensureWorkspace), so it appears on the workspaces home page. + workspaceId: HEARTBEAT_WORKSPACE_ID, + }); + } finally { + const entry = activeRuns.get(runId); + activeRuns.delete(runId); + // If the user stopped it, stopRun already set "stopped"; don't + // clobber. Otherwise the turn sealed (normally or via abort) → done. + if (entry !== undefined && !entry.stopped) { + await runStore.setStatus(workspaceId, runId, "completed"); + logger?.info("heartbeat: run completed", { workspaceId, runId }); + } + } + } - return { - async getConfig(workspaceId) { - return configStore.get(workspaceId); - }, + return { + async getConfig(workspaceId) { + return configStore.get(workspaceId); + }, - async updateConfig(workspaceId, update) { - const next = await configStore.update(workspaceId, update); - // Arm/disarm from the new config. An in-progress run is left alone; - // the new interval takes effect on the next re-arm. - scheduler.arm(workspaceId, next); - logger?.info("heartbeat: config updated", { - workspaceId, - enabled: next.enabled, - intervalMinutes: next.intervalMinutes, - }); - return next; - }, + async updateConfig(workspaceId, update) { + const next = await configStore.update(workspaceId, update); + // Arm/disarm from the new config. An in-progress run is left alone; + // the new interval takes effect on the next re-arm. + scheduler.arm(workspaceId, next); + logger?.info("heartbeat: config updated", { + workspaceId, + enabled: next.enabled, + intervalMinutes: next.intervalMinutes, + }); + return next; + }, - async listRuns(workspaceId) { - return runStore.list(workspaceId); - }, + async listRuns(workspaceId) { + return runStore.list(workspaceId); + }, - async nextRunAt(workspaceId) { - const ms = scheduler.nextFireAt(workspaceId); - return ms === null ? null : new Date(ms).toISOString(); - }, + async nextRunAt(workspaceId) { + const ms = scheduler.nextFireAt(workspaceId); + return ms === null ? null : new Date(ms).toISOString(); + }, - async stopRun(workspaceId, runId) { - const run = await runStore.get(workspaceId, runId); - if (run === null) { - throw new Error("Heartbeat run not found"); - } - // Idempotent: an already-finished run is a no-op. - if (run.status !== "running") { - return { ok: true }; - } - const entry = activeRuns.get(runId); - const conversationId = entry?.conversationId ?? run.conversationId; - if (entry !== undefined) { - entry.stopped = true; - } - await runStore.setStatus(workspaceId, runId, "stopped"); - orchestrator.stopTurn(conversationId); - logger?.info("heartbeat: run stopped", { workspaceId, runId, conversationId }); - return { ok: true }; - }, + async stopRun(workspaceId, runId) { + const run = await runStore.get(workspaceId, runId); + if (run === null) { + throw new Error("Heartbeat run not found"); + } + // Idempotent: an already-finished run is a no-op. + if (run.status !== "running") { + return { ok: true }; + } + const entry = activeRuns.get(runId); + const conversationId = entry?.conversationId ?? run.conversationId; + if (entry !== undefined) { + entry.stopped = true; + } + await runStore.setStatus(workspaceId, runId, "stopped"); + orchestrator.stopTurn(conversationId); + logger?.info("heartbeat: run stopped", { workspaceId, runId, conversationId }); + return { ok: true }; + }, - async startAll() { - // Sweep stale "running" runs (orphaned by a prior crash/restart) → - // "stopped". Never leave the system showing an in-flight run that - // can never finish. - const workspaceIds = await configStore.listWorkspaceIds(); - for (const workspaceId of workspaceIds) { - const runs = await runStore.list(workspaceId); - for (const run of runs) { - if (run.status === "running") { - await runStore.setStatus(workspaceId, run.id, "stopped"); - } - } - const config = await configStore.get(workspaceId); - if (config.enabled) { - scheduler.arm(workspaceId, config); - logger?.info("heartbeat: scheduler armed on boot", { - workspaceId, - intervalMinutes: config.intervalMinutes, - }); - } - } - }, + async startAll() { + // Sweep stale "running" runs (orphaned by a prior crash/restart) → + // "stopped". Never leave the system showing an in-flight run that + // can never finish. + const workspaceIds = await configStore.listWorkspaceIds(); + for (const workspaceId of workspaceIds) { + const runs = await runStore.list(workspaceId); + for (const run of runs) { + if (run.status === "running") { + await runStore.setStatus(workspaceId, run.id, "stopped"); + } + } + const config = await configStore.get(workspaceId); + if (config.enabled) { + scheduler.arm(workspaceId, config); + logger?.info("heartbeat: scheduler armed on boot", { + workspaceId, + intervalMinutes: config.intervalMinutes, + }); + } + } + }, - stopAll() { - scheduler.disarmAll(); - }, - }; + stopAll() { + scheduler.disarmAll(); + }, + }; } diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts index 3c2cbaa..d25e338 100644 --- a/packages/heartbeat/src/index.ts +++ b/packages/heartbeat/src/index.ts @@ -1,17 +1,17 @@ export { - applyConfigUpdate, - createHeartbeatConfigStore, - DEFAULT_HEARTBEAT_CONFIG, - type HeartbeatConfigStore, - MIN_INTERVAL_MINUTES, + applyConfigUpdate, + createHeartbeatConfigStore, + DEFAULT_HEARTBEAT_CONFIG, + type HeartbeatConfigStore, + MIN_INTERVAL_MINUTES, } from "./config-store.js"; export { extension, manifest } from "./extension.js"; export { - createHeartbeatService, - HEARTBEAT_WORKSPACE_ID, - type HeartbeatService, - type HeartbeatServiceDeps, - heartbeatServiceHandle, + createHeartbeatService, + HEARTBEAT_WORKSPACE_ID, + type HeartbeatService, + type HeartbeatServiceDeps, + heartbeatServiceHandle, } from "./heartbeat.js"; export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js"; export { HeartbeatScheduler, realTimers, type TimerHandle, type Timers } from "./scheduler.js"; diff --git a/packages/heartbeat/src/run-store.test.ts b/packages/heartbeat/src/run-store.test.ts index 9448838..8be21f5 100644 --- a/packages/heartbeat/src/run-store.test.ts +++ b/packages/heartbeat/src/run-store.test.ts @@ -4,69 +4,69 @@ import { describe, expect, it } from "vitest"; import { createHeartbeatRunStore } from "./run-store.js"; function createMemoryStorage(): StorageNamespace { - const data = new Map<string, string>(); - return { - get: async (key) => data.get(key) ?? null, - set: async (key, value) => { - data.set(key, value); - }, - delete: async (key) => { - data.delete(key); - }, - has: async (key) => data.has(key), - keys: async (prefix) => { - const all = [...data.keys()]; - if (prefix === undefined) return all; - return all.filter((k) => k.startsWith(prefix)); - }, - }; + const data = new Map<string, string>(); + return { + get: async (key) => data.get(key) ?? null, + set: async (key, value) => { + data.set(key, value); + }, + delete: async (key) => { + data.delete(key); + }, + has: async (key) => data.has(key), + keys: async (prefix) => { + const all = [...data.keys()]; + if (prefix === undefined) return all; + return all.filter((k) => k.startsWith(prefix)); + }, + }; } function run(id: string, conversationId: string, triggeredAt: string): HeartbeatRun { - return { id, conversationId, triggeredAt, status: "running" }; + return { id, conversationId, triggeredAt, status: "running" }; } describe("createHeartbeatRunStore", () => { - it("creates and reads back a run", async () => { - const store = createHeartbeatRunStore(createMemoryStorage()); - const created = await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); - expect(created.status).toBe("running"); - const read = await store.get("ws-1", "r1"); - expect(read).toEqual(created); - }); + it("creates and reads back a run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + const created = await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + expect(created.status).toBe("running"); + const read = await store.get("ws-1", "r1"); + expect(read).toEqual(created); + }); - it("returns null for an unknown run", async () => { - const store = createHeartbeatRunStore(createMemoryStorage()); - expect(await store.get("ws-1", "nope")).toBeNull(); - }); + it("returns null for an unknown run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + expect(await store.get("ws-1", "nope")).toBeNull(); + }); - it("updates the status of a run", async () => { - const store = createHeartbeatRunStore(createMemoryStorage()); - await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); - const updated = await store.setStatus("ws-1", "r1", "completed"); - expect(updated?.status).toBe("completed"); - expect((await store.get("ws-1", "r1"))?.status).toBe("completed"); - }); + it("updates the status of a run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + const updated = await store.setStatus("ws-1", "r1", "completed"); + expect(updated?.status).toBe("completed"); + expect((await store.get("ws-1", "r1"))?.status).toBe("completed"); + }); - it("setStatus is a no-op (returns null) for an unknown run", async () => { - const store = createHeartbeatRunStore(createMemoryStorage()); - expect(await store.setStatus("ws-1", "ghost", "stopped")).toBeNull(); - }); + it("setStatus is a no-op (returns null) for an unknown run", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + expect(await store.setStatus("ws-1", "ghost", "stopped")).toBeNull(); + }); - it("lists runs most-recent first by triggeredAt", async () => { - const store = createHeartbeatRunStore(createMemoryStorage()); - await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); - await store.create("ws-1", run("r2", "c2", "2026-02-01T00:00:00.000Z")); - await store.create("ws-1", run("r3", "c3", "2026-01-15T00:00:00.000Z")); - const runs = await store.list("ws-1"); - expect(runs.map((r) => r.id)).toEqual(["r2", "r3", "r1"]); - }); + it("lists runs most-recent first by triggeredAt", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + await store.create("ws-1", run("r2", "c2", "2026-02-01T00:00:00.000Z")); + await store.create("ws-1", run("r3", "c3", "2026-01-15T00:00:00.000Z")); + const runs = await store.list("ws-1"); + expect(runs.map((r) => r.id)).toEqual(["r2", "r3", "r1"]); + }); - it("scopes runs per workspace", async () => { - const store = createHeartbeatRunStore(createMemoryStorage()); - await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); - await store.create("ws-2", run("r2", "c2", "2026-01-01T00:00:00.000Z")); - expect((await store.list("ws-1")).map((r) => r.id)).toEqual(["r1"]); - expect((await store.list("ws-2")).map((r) => r.id)).toEqual(["r2"]); - }); + it("scopes runs per workspace", async () => { + const store = createHeartbeatRunStore(createMemoryStorage()); + await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z")); + await store.create("ws-2", run("r2", "c2", "2026-01-01T00:00:00.000Z")); + expect((await store.list("ws-1")).map((r) => r.id)).toEqual(["r1"]); + expect((await store.list("ws-2")).map((r) => r.id)).toEqual(["r2"]); + }); }); diff --git a/packages/heartbeat/src/run-store.ts b/packages/heartbeat/src/run-store.ts index 819ae00..9650181 100644 --- a/packages/heartbeat/src/run-store.ts +++ b/packages/heartbeat/src/run-store.ts @@ -3,93 +3,93 @@ import type { HeartbeatRun, HeartbeatRunStatus } from "@dispatch/transport-contr /** Storage key for a single heartbeat run record. */ function runKey(workspaceId: string, runId: string): string { - return `run:${workspaceId}:${runId}`; + return `run:${workspaceId}:${runId}`; } /** Prefix matching every run record for a workspace (for enumeration). */ function runPrefix(workspaceId: string): string { - return `run:${workspaceId}:`; + return `run:${workspaceId}:`; } /** Extract the runId from a full `run:<workspaceId>:<runId>` key. */ function parseRunId(key: string, workspaceId: string): string { - const prefix = runPrefix(workspaceId); - return key.startsWith(prefix) ? key.slice(prefix.length) : key; + const prefix = runPrefix(workspaceId); + return key.startsWith(prefix) ? key.slice(prefix.length) : key; } export interface HeartbeatRunStore { - /** Create a new run record (status `"running"`) and persist it. */ - readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>; - /** Update the status of an existing run. No-op if the run is unknown. */ - readonly setStatus: ( - workspaceId: string, - runId: string, - status: HeartbeatRunStatus, - ) => Promise<HeartbeatRun | null>; - /** A single run by id, or `null` when unknown. */ - readonly get: (workspaceId: string, runId: string) => Promise<HeartbeatRun | null>; - /** All runs for a workspace, most-recent first (by `triggeredAt`). */ - readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; + /** Create a new run record (status `"running"`) and persist it. */ + readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>; + /** Update the status of an existing run. No-op if the run is unknown. */ + readonly setStatus: ( + workspaceId: string, + runId: string, + status: HeartbeatRunStatus, + ) => Promise<HeartbeatRun | null>; + /** A single run by id, or `null` when unknown. */ + readonly get: (workspaceId: string, runId: string) => Promise<HeartbeatRun | null>; + /** All runs for a workspace, most-recent first (by `triggeredAt`). */ + readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>; } export function createHeartbeatRunStore(storage: StorageNamespace): HeartbeatRunStore { - async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> { - const raw = await storage.get(runKey(workspaceId, runId)); - if (raw === null) return null; - try { - const parsed = JSON.parse(raw) as Partial<HeartbeatRun>; - if ( - typeof parsed.id !== "string" || - typeof parsed.conversationId !== "string" || - typeof parsed.triggeredAt !== "string" || - typeof parsed.status !== "string" - ) { - return null; - } - return { - id: parsed.id, - conversationId: parsed.conversationId, - triggeredAt: parsed.triggeredAt, - status: parsed.status as HeartbeatRunStatus, - }; - } catch { - return null; - } - } + async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> { + const raw = await storage.get(runKey(workspaceId, runId)); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw) as Partial<HeartbeatRun>; + if ( + typeof parsed.id !== "string" || + typeof parsed.conversationId !== "string" || + typeof parsed.triggeredAt !== "string" || + typeof parsed.status !== "string" + ) { + return null; + } + return { + id: parsed.id, + conversationId: parsed.conversationId, + triggeredAt: parsed.triggeredAt, + status: parsed.status as HeartbeatRunStatus, + }; + } catch { + return null; + } + } - return { - async create(workspaceId: string, run: HeartbeatRun): Promise<HeartbeatRun> { - await storage.set(runKey(workspaceId, run.id), JSON.stringify(run)); - return run; - }, + return { + async create(workspaceId: string, run: HeartbeatRun): Promise<HeartbeatRun> { + await storage.set(runKey(workspaceId, run.id), JSON.stringify(run)); + return run; + }, - async setStatus( - workspaceId: string, - runId: string, - status: HeartbeatRunStatus, - ): Promise<HeartbeatRun | null> { - const run = await readRun(workspaceId, runId); - if (run === null) return null; - const updated: HeartbeatRun = { ...run, status }; - await storage.set(runKey(workspaceId, runId), JSON.stringify(updated)); - return updated; - }, + async setStatus( + workspaceId: string, + runId: string, + status: HeartbeatRunStatus, + ): Promise<HeartbeatRun | null> { + const run = await readRun(workspaceId, runId); + if (run === null) return null; + const updated: HeartbeatRun = { ...run, status }; + await storage.set(runKey(workspaceId, runId), JSON.stringify(updated)); + return updated; + }, - async get(workspaceId: string, runId: string): Promise<HeartbeatRun | null> { - return readRun(workspaceId, runId); - }, + async get(workspaceId: string, runId: string): Promise<HeartbeatRun | null> { + return readRun(workspaceId, runId); + }, - async list(workspaceId: string): Promise<readonly HeartbeatRun[]> { - const keys = await storage.keys(runPrefix(workspaceId)); - const runs: HeartbeatRun[] = []; - for (const key of keys) { - const runId = parseRunId(key, workspaceId); - const run = await readRun(workspaceId, runId); - if (run !== null) runs.push(run); - } - // Most-recent first by triggeredAt (ISO-8601 sorts lexicographically). - runs.sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt)); - return runs; - }, - }; + async list(workspaceId: string): Promise<readonly HeartbeatRun[]> { + const keys = await storage.keys(runPrefix(workspaceId)); + const runs: HeartbeatRun[] = []; + for (const key of keys) { + const runId = parseRunId(key, workspaceId); + const run = await readRun(workspaceId, runId); + if (run !== null) runs.push(run); + } + // Most-recent first by triggeredAt (ISO-8601 sorts lexicographically). + runs.sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt)); + return runs; + }, + }; } diff --git a/packages/heartbeat/src/scheduler.test.ts b/packages/heartbeat/src/scheduler.test.ts index 826322c..82e5c4d 100644 --- a/packages/heartbeat/src/scheduler.test.ts +++ b/packages/heartbeat/src/scheduler.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { HeartbeatScheduler, type Timers } from "./scheduler.js"; interface FakeTimer { - readonly fn: () => void; - readonly firesAt: number; + readonly fn: () => void; + readonly firesAt: number; } /** @@ -13,331 +13,331 @@ interface FakeTimer { * happens only AFTER the run completes. */ function createFakeTimers() { - let now = 0; - let nextId = 1; - const timers = new Map<number, FakeTimer>(); - const timersApi: Timers = { - now: () => now, - setTimeout: (fn, ms) => { - const id = nextId++; - timers.set(id, { fn, firesAt: now + ms }); - return id as unknown as ReturnType<typeof setTimeout>; - }, - clearTimeout: (handle) => { - if (handle !== undefined) timers.delete(handle as unknown as number); - }, - }; - return { - timers: timersApi, - advance(ms: number): number { - now += ms; - let fired = 0; - const due = [...timers.entries()] - .filter(([, t]) => t.firesAt <= now) - .sort((a, b) => a[0] - b[0]); - for (const [id, t] of due) { - timers.delete(id); - t.fn(); - fired++; - } - return fired; - }, - pendingCount(): number { - return timers.size; - }, - }; + let now = 0; + let nextId = 1; + const timers = new Map<number, FakeTimer>(); + const timersApi: Timers = { + now: () => now, + setTimeout: (fn, ms) => { + const id = nextId++; + timers.set(id, { fn, firesAt: now + ms }); + return id as unknown as ReturnType<typeof setTimeout>; + }, + clearTimeout: (handle) => { + if (handle !== undefined) timers.delete(handle as unknown as number); + }, + }; + return { + timers: timersApi, + advance(ms: number): number { + now += ms; + let fired = 0; + const due = [...timers.entries()] + .filter(([, t]) => t.firesAt <= now) + .sort((a, b) => a[0] - b[0]); + for (const [id, t] of due) { + timers.delete(id); + t.fn(); + fired++; + } + return fired; + }, + pendingCount(): number { + return timers.size; + }, + }; } /** A deferred promise the test resolves to signal run completion. */ function createDeferred(): { promise: Promise<void>; resolve: () => void } { - let resolve!: () => void; - const promise = new Promise<void>((r) => { - resolve = r; - }); - return { promise, resolve }; + let resolve!: () => void; + const promise = new Promise<void>((r) => { + resolve = r; + }); + return { promise, resolve }; } // Flush pending microtasks (the scheduler's .finally re-arm runs as a microtask // after the fire promise resolves). const flush = async (): Promise<void> => { - await new Promise((r) => queueMicrotask(r)); + await new Promise((r) => queueMicrotask(r)); }; describe("HeartbeatScheduler", () => { - it("arms a timer that fires after intervalMinutes", () => { - const fake = createFakeTimers(); - const fires: string[] = []; - const deferreds: Array<{ resolve: () => void }> = []; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: (ws) => { - fires.push(ws); - const d = createDeferred(); - deferreds.push(d); - return d.promise; - }, - }); + it("arms a timer that fires after intervalMinutes", () => { + const fake = createFakeTimers(); + const fires: string[] = []; + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: (ws) => { + fires.push(ws); + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - expect(scheduler.isArmed("ws-1")).toBe(true); - expect(fake.pendingCount()).toBe(1); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.isArmed("ws-1")).toBe(true); + expect(fake.pendingCount()).toBe(1); - // Just shy of the interval → no fire. - expect(fake.advance(59_999)).toBe(0); - expect(fires).toEqual([]); + // Just shy of the interval → no fire. + expect(fake.advance(59_999)).toBe(0); + expect(fires).toEqual([]); - // Exactly the interval (1 minute = 60_000ms) → fires. - expect(fake.advance(1)).toBe(1); - expect(fires).toEqual(["ws-1"]); - // While the run is in progress: running, no pending timer. - expect(scheduler.isRunning("ws-1")).toBe(true); - expect(fake.pendingCount()).toBe(0); - }); + // Exactly the interval (1 minute = 60_000ms) → fires. + expect(fake.advance(1)).toBe(1); + expect(fires).toEqual(["ws-1"]); + // While the run is in progress: running, no pending timer. + expect(scheduler.isRunning("ws-1")).toBe(true); + expect(fake.pendingCount()).toBe(0); + }); - it("re-arms only after the run completes (reset timer after each run)", async () => { - const fake = createFakeTimers(); - const fires: string[] = []; - const deferreds: Array<{ resolve: () => void }> = []; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: (ws) => { - fires.push(ws); - const d = createDeferred(); - deferreds.push(d); - return d.promise; - }, - }); + it("re-arms only after the run completes (reset timer after each run)", async () => { + const fake = createFakeTimers(); + const fires: string[] = []; + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: (ws) => { + fires.push(ws); + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - fake.advance(60_000); // first fire - expect(fires).toEqual(["ws-1"]); - // Run in progress — no new timer yet. - expect(fake.pendingCount()).toBe(0); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); // first fire + expect(fires).toEqual(["ws-1"]); + // Run in progress — no new timer yet. + expect(fake.pendingCount()).toBe(0); - // Completing the run schedules the next fire. - deferreds[0]?.resolve(); - await flush(); - expect(scheduler.isRunning("ws-1")).toBe(false); - expect(fake.pendingCount()).toBe(1); + // Completing the run schedules the next fire. + deferreds[0]?.resolve(); + await flush(); + expect(scheduler.isRunning("ws-1")).toBe(false); + expect(fake.pendingCount()).toBe(1); - // Next fire after another interval. - fake.advance(60_000); - expect(fires).toEqual(["ws-1", "ws-1"]); - }); + // Next fire after another interval. + fake.advance(60_000); + expect(fires).toEqual(["ws-1", "ws-1"]); + }); - it("uses a longer interval for a higher intervalMinutes", () => { - const fake = createFakeTimers(); - let fireCount = 0; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => { - fireCount++; - return createDeferred().promise; - }, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 }); - // 1 minute wouldn't fire a 30-minute schedule. - expect(fake.advance(60_000)).toBe(0); - // 28 more minutes (29 total) still wouldn't fire. - expect(fake.advance(28 * 60_000)).toBe(0); - // The remaining minute completes the 30 minutes → fires. - expect(fake.advance(60_000)).toBe(1); - expect(fireCount).toBe(1); - }); + it("uses a longer interval for a higher intervalMinutes", () => { + const fake = createFakeTimers(); + let fireCount = 0; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + fireCount++; + return createDeferred().promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 }); + // 1 minute wouldn't fire a 30-minute schedule. + expect(fake.advance(60_000)).toBe(0); + // 28 more minutes (29 total) still wouldn't fire. + expect(fake.advance(28 * 60_000)).toBe(0); + // The remaining minute completes the 30 minutes → fires. + expect(fake.advance(60_000)).toBe(1); + expect(fireCount).toBe(1); + }); - it("disarms (clears the pending timer) when disabled", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - expect(fake.pendingCount()).toBe(1); - scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 }); - expect(scheduler.isArmed("ws-1")).toBe(false); - expect(fake.pendingCount()).toBe(0); - }); + it("disarms (clears the pending timer) when disabled", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(fake.pendingCount()).toBe(1); + scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 }); + expect(scheduler.isArmed("ws-1")).toBe(false); + expect(fake.pendingCount()).toBe(0); + }); - it("disarmAll stops every schedule", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 }); - expect(fake.pendingCount()).toBe(2); - scheduler.disarmAll(); - expect(fake.pendingCount()).toBe(0); - expect(scheduler.isArmed("ws-1")).toBe(false); - expect(scheduler.isArmed("ws-2")).toBe(false); - }); + it("disarmAll stops every schedule", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 }); + expect(fake.pendingCount()).toBe(2); + scheduler.disarmAll(); + expect(fake.pendingCount()).toBe(0); + expect(scheduler.isArmed("ws-1")).toBe(false); + expect(scheduler.isArmed("ws-2")).toBe(false); + }); - it("does not re-arm after a disarm during a run", async () => { - const fake = createFakeTimers(); - const deferreds: Array<{ resolve: () => void }> = []; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => { - const d = createDeferred(); - deferreds.push(d); - return d.promise; - }, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - fake.advance(60_000); // fire in progress - scheduler.disarm("ws-1"); // disarm mid-run - deferreds[0]?.resolve(); - await flush(); - // Disarmed → no re-arm scheduled. - expect(fake.pendingCount()).toBe(0); - expect(scheduler.isArmed("ws-1")).toBe(false); - }); + it("does not re-arm after a disarm during a run", async () => { + const fake = createFakeTimers(); + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); // fire in progress + scheduler.disarm("ws-1"); // disarm mid-run + deferreds[0]?.resolve(); + await flush(); + // Disarmed → no re-arm scheduled. + expect(fake.pendingCount()).toBe(0); + expect(scheduler.isArmed("ws-1")).toBe(false); + }); - it("a config update during a run applies the new interval on the next re-arm", async () => { - const fake = createFakeTimers(); - const deferreds: Array<{ resolve: () => void }> = []; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => { - const d = createDeferred(); - deferreds.push(d); - return d.promise; - }, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - fake.advance(60_000); // first fire (interval=1m) - // Update interval to 5m while the run is in progress. - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 }); - expect(scheduler.isRunning("ws-1")).toBe(true); - expect(fake.pendingCount()).toBe(0); // no new timer while running - deferreds[0]?.resolve(); - await flush(); - // Re-armed with the NEW 5-minute interval. - expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now - expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires - }); + it("a config update during a run applies the new interval on the next re-arm", async () => { + const fake = createFakeTimers(); + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); // first fire (interval=1m) + // Update interval to 5m while the run is in progress. + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 }); + expect(scheduler.isRunning("ws-1")).toBe(true); + expect(fake.pendingCount()).toBe(0); // no new timer while running + deferreds[0]?.resolve(); + await flush(); + // Re-armed with the NEW 5-minute interval. + expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now + expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires + }); - it("a thrown fire is swallowed and the loop continues", async () => { - const fake = createFakeTimers(); - let fireCount = 0; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => { - fireCount++; - return Promise.reject(new Error("boom")); - }, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - fake.advance(60_000); - await flush(); - expect(fireCount).toBe(1); - // Loop continues: next fire scheduled. - expect(fake.pendingCount()).toBe(1); - fake.advance(60_000); - expect(fireCount).toBe(2); - }); + it("a thrown fire is swallowed and the loop continues", async () => { + const fake = createFakeTimers(); + let fireCount = 0; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + fireCount++; + return Promise.reject(new Error("boom")); + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); + await flush(); + expect(fireCount).toBe(1); + // Loop continues: next fire scheduled. + expect(fake.pendingCount()).toBe(1); + fake.advance(60_000); + expect(fireCount).toBe(2); + }); - // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ────────────── + // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ────────────── - it("nextFireAt returns null for a workspace with no schedule", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - expect(scheduler.nextFireAt("unknown")).toBeNull(); - }); + it("nextFireAt returns null for a workspace with no schedule", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + expect(scheduler.nextFireAt("unknown")).toBeNull(); + }); - it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - // now=0, interval=1m → next fire at epoch-ms 60_000. - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - expect(scheduler.nextFireAt("ws-1")).toBe(60_000); - }); + it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + // now=0, interval=1m → next fire at epoch-ms 60_000. + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + }); - it("nextFireAt reflects a longer interval", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 }); - expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000); - }); + it("nextFireAt reflects a longer interval", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 }); + expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000); + }); - it("nextFireAt reflects a new interval on re-arm (not running)", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - expect(scheduler.nextFireAt("ws-1")).toBe(60_000); - // Re-arm with a 5-minute interval (armed, not running) → recomputed. - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 }); - expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000); - }); + it("nextFireAt reflects a new interval on re-arm (not running)", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + // Re-arm with a 5-minute interval (armed, not running) → recomputed. + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 }); + expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000); + }); - it("nextFireAt returns null when disarmed", () => { - const fake = createFakeTimers(); - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => createDeferred().promise, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - expect(scheduler.nextFireAt("ws-1")).toBe(60_000); - scheduler.disarm("ws-1"); - expect(scheduler.nextFireAt("ws-1")).toBeNull(); - }); + it("nextFireAt returns null when disarmed", () => { + const fake = createFakeTimers(); + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => createDeferred().promise, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + scheduler.disarm("ws-1"); + expect(scheduler.nextFireAt("ws-1")).toBeNull(); + }); - it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => { - const fake = createFakeTimers(); - const deferreds: Array<{ resolve: () => void }> = []; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => { - const d = createDeferred(); - deferreds.push(d); - return d.promise; - }, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - expect(scheduler.nextFireAt("ws-1")).toBe(60_000); + it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => { + const fake = createFakeTimers(); + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + expect(scheduler.nextFireAt("ws-1")).toBe(60_000); - fake.advance(60_000); // fire → run in progress - expect(scheduler.isRunning("ws-1")).toBe(true); - // In flight → no next run queued yet. - expect(scheduler.nextFireAt("ws-1")).toBeNull(); + fake.advance(60_000); // fire → run in progress + expect(scheduler.isRunning("ws-1")).toBe(true); + // In flight → no next run queued yet. + expect(scheduler.nextFireAt("ws-1")).toBeNull(); - deferreds[0]?.resolve(); - await flush(); - // Re-armed at completion-time (60_000) + interval (60_000) = 120_000. - expect(scheduler.nextFireAt("ws-1")).toBe(120_000); - }); + deferreds[0]?.resolve(); + await flush(); + // Re-armed at completion-time (60_000) + interval (60_000) = 120_000. + expect(scheduler.nextFireAt("ws-1")).toBe(120_000); + }); - it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => { - const fake = createFakeTimers(); - const deferreds: Array<{ resolve: () => void }> = []; - const scheduler = new HeartbeatScheduler({ - timers: fake.timers, - fire: () => { - const d = createDeferred(); - deferreds.push(d); - return d.promise; - }, - }); - scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); - fake.advance(60_000); // fire in progress - expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running - scheduler.disarm("ws-1"); // disarm mid-run - deferreds[0]?.resolve(); - await flush(); - // Disarmed → no re-arm, no fire time. - expect(scheduler.nextFireAt("ws-1")).toBeNull(); - }); + it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => { + const fake = createFakeTimers(); + const deferreds: Array<{ resolve: () => void }> = []; + const scheduler = new HeartbeatScheduler({ + timers: fake.timers, + fire: () => { + const d = createDeferred(); + deferreds.push(d); + return d.promise; + }, + }); + scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 }); + fake.advance(60_000); // fire in progress + expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running + scheduler.disarm("ws-1"); // disarm mid-run + deferreds[0]?.resolve(); + await flush(); + // Disarmed → no re-arm, no fire time. + expect(scheduler.nextFireAt("ws-1")).toBeNull(); + }); }); diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts index c495b1e..8b00b61 100644 --- a/packages/heartbeat/src/scheduler.ts +++ b/packages/heartbeat/src/scheduler.ts @@ -19,43 +19,43 @@ export type TimerHandle = ReturnType<typeof setTimeout>; /** Injectable timers — the only I/O effect the scheduler touches. */ export interface Timers { - readonly now: () => number; - readonly setTimeout: (fn: () => void, ms: number) => TimerHandle; - readonly clearTimeout: (handle: TimerHandle | undefined) => void; + readonly now: () => number; + readonly setTimeout: (fn: () => void, ms: number) => TimerHandle; + readonly clearTimeout: (handle: TimerHandle | undefined) => void; } /** The real timers (used at runtime; overridable in tests). */ export const realTimers: Timers = { - now: () => Date.now(), - setTimeout: (fn, ms) => setTimeout(fn, ms), - clearTimeout: (handle) => { - if (handle !== undefined) clearTimeout(handle); - }, + now: () => Date.now(), + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => { + if (handle !== undefined) clearTimeout(handle); + }, }; export interface SchedulerDeps { - readonly timers: Timers; - /** Do the run work for a workspace. Resolves when the turn seals. */ - readonly fire: (workspaceId: string) => Promise<void>; + readonly timers: Timers; + /** Do the run work for a workspace. Resolves when the turn seals. */ + readonly fire: (workspaceId: string) => Promise<void>; } interface WorkspaceSchedule { - /** Current interval (minutes), updated by `arm` on config changes. */ - intervalMinutes: number; - /** Pending wait timer (null while a fire is in progress). */ - timer: TimerHandle | undefined; - /** A fire is in progress for this workspace. */ - running: boolean; - /** Scheduling is active (the workspace's heartbeat is enabled). */ - armed: boolean; - /** - * Absolute epoch-ms timestamp of the next scheduled fire, or null when no - * fire is pending — the schedule is disarmed, or a run is in progress - * (the next fire is scheduled only after the run completes). This is the - * server-authoritative next-run time the `/heartbeat/next-run` endpoint - * derives its response from. - */ - nextFireMs: number | null; + /** Current interval (minutes), updated by `arm` on config changes. */ + intervalMinutes: number; + /** Pending wait timer (null while a fire is in progress). */ + timer: TimerHandle | undefined; + /** A fire is in progress for this workspace. */ + running: boolean; + /** Scheduling is active (the workspace's heartbeat is enabled). */ + armed: boolean; + /** + * Absolute epoch-ms timestamp of the next scheduled fire, or null when no + * fire is pending — the schedule is disarmed, or a run is in progress + * (the next fire is scheduled only after the run completes). This is the + * server-authoritative next-run time the `/heartbeat/next-run` endpoint + * derives its response from. + */ + nextFireMs: number | null; } const MINUTE_MS = 60_000; @@ -65,129 +65,129 @@ const MINUTE_MS = 60_000; * heartbeat service for its lifetime. */ export class HeartbeatScheduler { - private readonly schedules = new Map<string, WorkspaceSchedule>(); - private readonly timers: Timers; - private readonly fire: (workspaceId: string) => Promise<void>; - - constructor(deps: SchedulerDeps) { - this.timers = deps.timers; - this.fire = deps.fire; - } - - /** - * Arm (or re-arm) a workspace's schedule from its config. When `enabled` - * is false the schedule is disarmed. When a config update arrives while a - * run is in progress, the new `intervalMinutes` takes effect on the next - * re-arm (the in-progress run is never cancelled here). - */ - arm( - workspaceId: string, - config: { - readonly enabled: boolean; - readonly intervalMinutes: number; - }, - ): void { - if (!config.enabled) { - this.disarm(workspaceId); - return; - } - let schedule = this.schedules.get(workspaceId); - if (schedule === undefined) { - schedule = { - intervalMinutes: config.intervalMinutes, - timer: undefined, - running: false, - armed: true, - nextFireMs: null, - }; - this.schedules.set(workspaceId, schedule); - } else { - schedule.intervalMinutes = config.intervalMinutes; - schedule.armed = true; - } - // If a fire is in progress, let it finish — it will re-arm with the - // (possibly new) interval. Otherwise schedule the next fire now. - if (!schedule.running) { - this.clearTimer(schedule); - this.scheduleNext(workspaceId, schedule); - } - } - - /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */ - disarm(workspaceId: string): void { - const schedule = this.schedules.get(workspaceId); - if (schedule === undefined) return; - schedule.armed = false; - this.clearTimer(schedule); - this.schedules.delete(workspaceId); - } - - /** Stop all schedules (deactivate). */ - disarmAll(): void { - for (const workspaceId of [...this.schedules.keys()]) { - this.disarm(workspaceId); - } - } - - /** Whether a schedule is currently armed (enabled) for a workspace. */ - isArmed(workspaceId: string): boolean { - return this.schedules.get(workspaceId)?.armed ?? false; - } - - /** Whether a fire is currently in progress for a workspace. */ - isRunning(workspaceId: string): boolean { - return this.schedules.get(workspaceId)?.running ?? false; - } - - /** - * The absolute epoch-ms timestamp of the next scheduled fire for a workspace, - * or null when no fire is pending — the heartbeat is disabled/disarmed, or a - * run is in progress (the next fire is scheduled only after the run - * completes). This is the server-authoritative next-run time. - */ - nextFireAt(workspaceId: string): number | null { - return this.schedules.get(workspaceId)?.nextFireMs ?? null; - } - - private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void { - const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS); - // Record the absolute fire time (now + delay) BEFORE arming the timer - // so `nextFireAt` reports it while the timer is pending. - schedule.nextFireMs = this.timers.now() + ms; - schedule.timer = this.timers.setTimeout(() => { - this.onTick(workspaceId); - }, ms); - } - - private onTick(workspaceId: string): void { - const schedule = this.schedules.get(workspaceId); - // Race: the schedule was disarmed after the timer was queued. - if (schedule === undefined || !schedule.armed) return; - schedule.timer = undefined; - // A fire is now in progress — no next run is queued yet (it's scheduled - // only after the run completes), so report no pending fire time. - schedule.nextFireMs = null; - schedule.running = true; - this.fire(workspaceId) - .catch(() => { - // The service records the run outcome; a thrown fire is logged - // by the service. Swallow so the loop continues. - }) - .finally(() => { - const current = this.schedules.get(workspaceId); - if (current === undefined) return; - current.running = false; - if (current.armed) { - this.scheduleNext(workspaceId, current); - } - }); - } - - private clearTimer(schedule: WorkspaceSchedule): void { - if (schedule.timer !== undefined) { - this.timers.clearTimeout(schedule.timer); - schedule.timer = undefined; - } - schedule.nextFireMs = null; - } + private readonly schedules = new Map<string, WorkspaceSchedule>(); + private readonly timers: Timers; + private readonly fire: (workspaceId: string) => Promise<void>; + + constructor(deps: SchedulerDeps) { + this.timers = deps.timers; + this.fire = deps.fire; + } + + /** + * Arm (or re-arm) a workspace's schedule from its config. When `enabled` + * is false the schedule is disarmed. When a config update arrives while a + * run is in progress, the new `intervalMinutes` takes effect on the next + * re-arm (the in-progress run is never cancelled here). + */ + arm( + workspaceId: string, + config: { + readonly enabled: boolean; + readonly intervalMinutes: number; + }, + ): void { + if (!config.enabled) { + this.disarm(workspaceId); + return; + } + let schedule = this.schedules.get(workspaceId); + if (schedule === undefined) { + schedule = { + intervalMinutes: config.intervalMinutes, + timer: undefined, + running: false, + armed: true, + nextFireMs: null, + }; + this.schedules.set(workspaceId, schedule); + } else { + schedule.intervalMinutes = config.intervalMinutes; + schedule.armed = true; + } + // If a fire is in progress, let it finish — it will re-arm with the + // (possibly new) interval. Otherwise schedule the next fire now. + if (!schedule.running) { + this.clearTimer(schedule); + this.scheduleNext(workspaceId, schedule); + } + } + + /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */ + disarm(workspaceId: string): void { + const schedule = this.schedules.get(workspaceId); + if (schedule === undefined) return; + schedule.armed = false; + this.clearTimer(schedule); + this.schedules.delete(workspaceId); + } + + /** Stop all schedules (deactivate). */ + disarmAll(): void { + for (const workspaceId of [...this.schedules.keys()]) { + this.disarm(workspaceId); + } + } + + /** Whether a schedule is currently armed (enabled) for a workspace. */ + isArmed(workspaceId: string): boolean { + return this.schedules.get(workspaceId)?.armed ?? false; + } + + /** Whether a fire is currently in progress for a workspace. */ + isRunning(workspaceId: string): boolean { + return this.schedules.get(workspaceId)?.running ?? false; + } + + /** + * The absolute epoch-ms timestamp of the next scheduled fire for a workspace, + * or null when no fire is pending — the heartbeat is disabled/disarmed, or a + * run is in progress (the next fire is scheduled only after the run + * completes). This is the server-authoritative next-run time. + */ + nextFireAt(workspaceId: string): number | null { + return this.schedules.get(workspaceId)?.nextFireMs ?? null; + } + + private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void { + const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS); + // Record the absolute fire time (now + delay) BEFORE arming the timer + // so `nextFireAt` reports it while the timer is pending. + schedule.nextFireMs = this.timers.now() + ms; + schedule.timer = this.timers.setTimeout(() => { + this.onTick(workspaceId); + }, ms); + } + + private onTick(workspaceId: string): void { + const schedule = this.schedules.get(workspaceId); + // Race: the schedule was disarmed after the timer was queued. + if (schedule === undefined || !schedule.armed) return; + schedule.timer = undefined; + // A fire is now in progress — no next run is queued yet (it's scheduled + // only after the run completes), so report no pending fire time. + schedule.nextFireMs = null; + schedule.running = true; + this.fire(workspaceId) + .catch(() => { + // The service records the run outcome; a thrown fire is logged + // by the service. Swallow so the loop continues. + }) + .finally(() => { + const current = this.schedules.get(workspaceId); + if (current === undefined) return; + current.running = false; + if (current.armed) { + this.scheduleNext(workspaceId, current); + } + }); + } + + private clearTimer(schedule: WorkspaceSchedule): void { + if (schedule.timer !== undefined) { + this.timers.clearTimeout(schedule.timer); + schedule.timer = undefined; + } + schedule.nextFireMs = null; + } } diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json index 0c18985..b947b31 100644 --- a/packages/heartbeat/tsconfig.json +++ b/packages/heartbeat/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../kernel" }, - { "path": "../transport-contract" }, - { "path": "../session-orchestrator" }, - { "path": "../system-prompt" }, - { "path": "../conversation-store" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../kernel" }, + { "path": "../transport-contract" }, + { "path": "../session-orchestrator" }, + { "path": "../system-prompt" }, + { "path": "../conversation-store" } + ] } diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json index f27bf06..65ea305 100644 --- a/packages/host-bin/package.json +++ b/packages/host-bin/package.json @@ -1,38 +1,38 @@ { - "name": "@dispatch/host-bin", - "version": "0.0.0", - "type": "module", - "private": true, - "dependencies": { - "@dispatch/kernel": "workspace:*", - "@dispatch/storage-sqlite": "workspace:*", - "@dispatch/conversation-store": "workspace:*", - "@dispatch/auth-apikey": "workspace:*", - "@dispatch/cache-warming": "workspace:*", - "@dispatch/credential-store": "workspace:*", - "@dispatch/exec-backend": "workspace:*", - "@dispatch/heartbeat": "workspace:*", - "@dispatch/provider-openai-compat": "workspace:*", - "@dispatch/provider-umans": "workspace:*", - "@dispatch/message-queue": "workspace:*", - "@dispatch/mcp": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/skills": "workspace:*", - "@dispatch/ssh": "workspace:*", - "@dispatch/throughput-store": "workspace:*", - "@dispatch/todo": "workspace:*", - "@dispatch/transport-http": "workspace:*", - "@dispatch/tool-read-file": "workspace:*", - "@dispatch/tool-shell": "workspace:*", - "@dispatch/tool-edit-file": "workspace:*", - "@dispatch/tool-write-file": "workspace:*", - "@dispatch/tool-web-search": "workspace:*", - "@dispatch/tool-youtube-transcript": "workspace:*", - "@dispatch/journal-sink": "workspace:*", - "@dispatch/lsp": "workspace:*", - "@dispatch/surface-loaded-extensions": "workspace:*", - "@dispatch/surface-registry": "workspace:*", - "@dispatch/transport-ws": "workspace:*", - "@dispatch/system-prompt": "workspace:*" - } + "name": "@dispatch/host-bin", + "version": "0.0.0", + "type": "module", + "private": true, + "dependencies": { + "@dispatch/kernel": "workspace:*", + "@dispatch/storage-sqlite": "workspace:*", + "@dispatch/conversation-store": "workspace:*", + "@dispatch/auth-apikey": "workspace:*", + "@dispatch/cache-warming": "workspace:*", + "@dispatch/credential-store": "workspace:*", + "@dispatch/exec-backend": "workspace:*", + "@dispatch/heartbeat": "workspace:*", + "@dispatch/provider-openai-compat": "workspace:*", + "@dispatch/provider-umans": "workspace:*", + "@dispatch/message-queue": "workspace:*", + "@dispatch/mcp": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/skills": "workspace:*", + "@dispatch/ssh": "workspace:*", + "@dispatch/throughput-store": "workspace:*", + "@dispatch/todo": "workspace:*", + "@dispatch/transport-http": "workspace:*", + "@dispatch/tool-read-file": "workspace:*", + "@dispatch/tool-shell": "workspace:*", + "@dispatch/tool-edit-file": "workspace:*", + "@dispatch/tool-write-file": "workspace:*", + "@dispatch/tool-web-search": "workspace:*", + "@dispatch/tool-youtube-transcript": "workspace:*", + "@dispatch/journal-sink": "workspace:*", + "@dispatch/lsp": "workspace:*", + "@dispatch/surface-loaded-extensions": "workspace:*", + "@dispatch/surface-registry": "workspace:*", + "@dispatch/transport-ws": "workspace:*", + "@dispatch/system-prompt": "workspace:*" + } } diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index 9919432..8633052 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -8,18 +8,18 @@ import { createExecBackendExtension } from "@dispatch/exec-backend"; import { extension as heartbeatExt } from "@dispatch/heartbeat"; import { createJournalSink } from "@dispatch/journal-sink"; import { - type ConfigAccess, - createBus, - createHost, - createLogger, - type EventsEmitter, - type Extension, - type HostDeps, - type LogDeps, - type PermissionGate, - type ScheduledJob, - type SecretsAccess, - type StorageNamespace, + type ConfigAccess, + createBus, + createHost, + createLogger, + type EventsEmitter, + type Extension, + type HostDeps, + type LogDeps, + type PermissionGate, + type ScheduledJob, + type SecretsAccess, + type StorageNamespace, } from "@dispatch/kernel"; import { extension as lspExt } from "@dispatch/lsp"; import { extension as mcpExt } from "@dispatch/mcp"; @@ -49,192 +49,192 @@ import { configMapToAccess, envToConfigMap } from "./config.js"; import { loadExternalExtensions } from "./load-external.js"; function createEmptySecrets(): SecretsAccess { - return { - get: async () => null, - set: async () => {}, - delete: async () => {}, - }; + return { + get: async () => null, + set: async () => {}, + delete: async () => {}, + }; } function createAllowAllPermissions(): PermissionGate { - return { - check: async () => ({ allowed: true }), - }; + return { + check: async () => ({ allowed: true }), + }; } function createNoopScheduler(): { readonly register: (job: ScheduledJob) => void } { - return { register: () => {} }; + return { register: () => {} }; } function createNoopEvents(): EventsEmitter { - return { emit: () => {} }; + return { emit: () => {} }; } // Core extensions EXCEPT the credential-store, which is assembled in boot() so // its credential list can include any credentials backed by external providers // (e.g. a `claude` credential once the external Anthropic provider is loaded). const CORE_EXTENSIONS: readonly Extension[] = [ - storageSqliteExt, - conversationStoreExt, - authApikeyExt, - providerOpenaiCompatExt, - providerUmansExt, - // exec-backend must precede the tool extensions that - // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It - // provides the ExecBackendResolver the tools resolve through; placing it - // here keeps the activation DAG honest (it depends only on kernel). - createExecBackendExtension(), - toolEditFileExt, - toolReadFileExt, - toolShellExt, - toolWriteFileExt, - toolWebSearchExt, - toolYoutubeTranscriptExt, - throughputStoreExt, - todoExt, - messageQueueExt, - mcpExt, - sessionOrchestratorExt, - skillsExt, - systemPromptExt, - cacheWarmingExt, - lspExt, - // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote - // exec-backend factory + the ComputerService the HTTP routes delegate to. - // Its lookups are lazy (tool-/request-time), but it is placed after - // exec-backend and the tool extensions (alongside the other standard - // tool-serving extensions) to keep the DAG honest — and before - // transport-http, whose routes consume the ComputerService it provides. - sshExt, - // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the - // HTTP routes delegate to. Placed before transport-http (which depends on - // it) — mirrors how ssh precedes transport-http for the same reason. - heartbeatExt, - createTransportHttpExtension(), - // Surface extensions — dependency order: surface-registry first, then consumers. - createSurfaceRegistryExtension(), - createTransportWsExtension(), - createLoadedExtensionsExtension(), + storageSqliteExt, + conversationStoreExt, + authApikeyExt, + providerOpenaiCompatExt, + providerUmansExt, + // exec-backend must precede the tool extensions that + // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It + // provides the ExecBackendResolver the tools resolve through; placing it + // here keeps the activation DAG honest (it depends only on kernel). + createExecBackendExtension(), + toolEditFileExt, + toolReadFileExt, + toolShellExt, + toolWriteFileExt, + toolWebSearchExt, + toolYoutubeTranscriptExt, + throughputStoreExt, + todoExt, + messageQueueExt, + mcpExt, + sessionOrchestratorExt, + skillsExt, + systemPromptExt, + cacheWarmingExt, + lspExt, + // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote + // exec-backend factory + the ComputerService the HTTP routes delegate to. + // Its lookups are lazy (tool-/request-time), but it is placed after + // exec-backend and the tool extensions (alongside the other standard + // tool-serving extensions) to keep the DAG honest — and before + // transport-http, whose routes consume the ComputerService it provides. + sshExt, + // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the + // HTTP routes delegate to. Placed before transport-http (which depends on + // it) — mirrors how ssh precedes transport-http for the same reason. + heartbeatExt, + createTransportHttpExtension(), + // Surface extensions — dependency order: surface-registry first, then consumers. + createSurfaceRegistryExtension(), + createTransportWsExtension(), + createLoadedExtensionsExtension(), ]; /** Parse the comma-separated list of external extension module specifiers. */ function parseExternalSpecifiers(env: Readonly<Record<string, string | undefined>>): string[] { - return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "") - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); + return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); } async function boot(): Promise<void> { - const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson"; - mkdirSync(dirname(journalPath), { recursive: true }); - const logSink = createJournalSink({ path: journalPath }); - const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() }; - const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps); - - const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db"; - - // Only start the collector supervisor in dev mode (source files available). - // Compiled binaries don't have the source tree, so the collector can't spawn. - let supervisor: ReturnType<typeof createCollectorSupervisor> | undefined; - if (existsSync("packages/observability-collector/src/main.ts")) { - supervisor = createCollectorSupervisor({ - spawn: (cmd: string[]) => { - const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); - const handle: ChildHandle = { - kill: (signal?: string) => proc.kill(signal as NodeJS.Signals), - exited: proc.exited, - }; - return handle; - }, - journalPath, - dbPath: traceDbPath, - logger: logger.child({ extensionId: "collector-supervisor" }), - }); - supervisor.start(); - } - - const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db"; - mkdirSync(dirname(dbPath), { recursive: true }); - const sqliteBackend = createSqliteStorage({ path: dbPath }); - const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace); - - const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>); - const config: ConfigAccess = configMapToAccess(configMap); - - const deps: HostDeps = { - logger, - config, - storageFactory, - secrets: createEmptySecrets(), - permissions: createAllowAllPermissions(), - scheduler: createNoopScheduler(), - bus: createBus(logger), - events: createNoopEvents(), - logSink, - logDeps, - }; - - // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. - const externalSpecifiers = parseExternalSpecifiers( - process.env as Readonly<Record<string, string | undefined>>, - ); - const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger); - - // Assemble the credential list. MVP keeps the hardcoded `opencode` credential - // and adds a `claude` credential when an external Anthropic provider is loaded. - const credentials = [{ name: "opencode", providerId: "openai-compat" }]; - - // The umans credential is always listed (it's the model-catalog index); the - // provider itself only registers when UMANS_API_KEY is set, so listCatalog - // gracefully skips it when the provider is absent. - if (process.env.UMANS_API_KEY) { - credentials.push({ name: "umans", providerId: "umans" }); - logger.info(`Registered credential "umans" → umans provider`); - } - const hasAnthropic = externalExtensions.some((e) => - e.manifest.contributes?.providers?.includes("anthropic"), - ); - if (hasAnthropic) { - const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude"; - credentials.push({ name: claudeName, providerId: "anthropic" }); - logger.info(`Registered credential "${claudeName}" → anthropic provider`); - } - - const extensions: Extension[] = [ - ...CORE_EXTENSIONS, - createCredentialStoreExtension({ credentials }), - ...externalExtensions, - ]; - - const host = createHost(extensions, deps); - await host.activate(); - - const disabled = host.getDisabled(); - if (disabled.length > 0) { - for (const d of disabled) { - logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`); - } - } - - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - logger.info("Shutting down — deactivating extensions"); - await host.deactivate(); - logger.info("Draining collector"); - await supervisor?.stop(); - process.exit(0); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - logger.info("Dispatch booted"); - console.info("Dispatch booted"); + const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson"; + mkdirSync(dirname(journalPath), { recursive: true }); + const logSink = createJournalSink({ path: journalPath }); + const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() }; + const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps); + + const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db"; + + // Only start the collector supervisor in dev mode (source files available). + // Compiled binaries don't have the source tree, so the collector can't spawn. + let supervisor: ReturnType<typeof createCollectorSupervisor> | undefined; + if (existsSync("packages/observability-collector/src/main.ts")) { + supervisor = createCollectorSupervisor({ + spawn: (cmd: string[]) => { + const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); + const handle: ChildHandle = { + kill: (signal?: string) => proc.kill(signal as NodeJS.Signals), + exited: proc.exited, + }; + return handle; + }, + journalPath, + dbPath: traceDbPath, + logger: logger.child({ extensionId: "collector-supervisor" }), + }); + supervisor.start(); + } + + const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db"; + mkdirSync(dirname(dbPath), { recursive: true }); + const sqliteBackend = createSqliteStorage({ path: dbPath }); + const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace); + + const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>); + const config: ConfigAccess = configMapToAccess(configMap); + + const deps: HostDeps = { + logger, + config, + storageFactory, + secrets: createEmptySecrets(), + permissions: createAllowAllPermissions(), + scheduler: createNoopScheduler(), + bus: createBus(logger), + events: createNoopEvents(), + logSink, + logDeps, + }; + + // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. + const externalSpecifiers = parseExternalSpecifiers( + process.env as Readonly<Record<string, string | undefined>>, + ); + const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger); + + // Assemble the credential list. MVP keeps the hardcoded `opencode` credential + // and adds a `claude` credential when an external Anthropic provider is loaded. + const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + + // The umans credential is always listed (it's the model-catalog index); the + // provider itself only registers when UMANS_API_KEY is set, so listCatalog + // gracefully skips it when the provider is absent. + if (process.env.UMANS_API_KEY) { + credentials.push({ name: "umans", providerId: "umans" }); + logger.info(`Registered credential "umans" → umans provider`); + } + const hasAnthropic = externalExtensions.some((e) => + e.manifest.contributes?.providers?.includes("anthropic"), + ); + if (hasAnthropic) { + const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude"; + credentials.push({ name: claudeName, providerId: "anthropic" }); + logger.info(`Registered credential "${claudeName}" → anthropic provider`); + } + + const extensions: Extension[] = [ + ...CORE_EXTENSIONS, + createCredentialStoreExtension({ credentials }), + ...externalExtensions, + ]; + + const host = createHost(extensions, deps); + await host.activate(); + + const disabled = host.getDisabled(); + if (disabled.length > 0) { + for (const d of disabled) { + logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`); + } + } + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info("Shutting down — deactivating extensions"); + await host.deactivate(); + logger.info("Draining collector"); + await supervisor?.stop(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + logger.info("Dispatch booted"); + console.info("Dispatch booted"); } boot().catch((err) => { - console.error("Fatal boot error:", err); - process.exit(1); + console.error("Fatal boot error:", err); + process.exit(1); }); diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index ca9ce93..96cd3a3 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -1,88 +1,88 @@ import type { ConversationStore } from "@dispatch/conversation-store"; import type { - AgentEvent, - ChatMessage, - CompactionResult, - ConversationStatus, - EventHookDescriptor, - Logger, - ModelInfo, - ProviderContract, - ProviderEvent, - ProviderStreamOptions, - ReasoningEffort, - RetryStrategy, - RunTurnInput, - RunTurnResult, - ToolContract, - ToolDispatchPolicy, - UsageEvent, + AgentEvent, + ChatMessage, + CompactionResult, + ConversationStatus, + EventHookDescriptor, + Logger, + ModelInfo, + ProviderContract, + ProviderEvent, + ProviderStreamOptions, + ReasoningEffort, + RetryStrategy, + RunTurnInput, + RunTurnResult, + ToolContract, + ToolDispatchPolicy, + UsageEvent, } from "@dispatch/kernel"; import { defineEventHook, defineService, type ServiceHandle } from "@dispatch/kernel"; import type { MessageQueueService, QueuedMessage } from "@dispatch/message-queue"; import type { SystemPromptService } from "@dispatch/system-prompt"; import { createMetricsAccumulator } from "./metrics.js"; import { - buildUserMessage, - defaultDispatchPolicy, - delayFor, - generateTurnId, - resolveModelName, - resolveReasoningEffort, + buildUserMessage, + defaultDispatchPolicy, + delayFor, + generateTurnId, + resolveModelName, + resolveReasoningEffort, } from "./pure.js"; import type { ToolAssembly } from "./tools-filter.js"; // --- Broadcast hub types --- export interface StartTurnInput { - readonly conversationId: string; - readonly text: string; - readonly modelName?: string; - readonly cwd?: string; - /** - * The computer to execute this turn's tools on (SSH config alias). Mirrors - * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`. - * Omitted/`undefined` = use the persisted per-conversation / workspace - * default (LOCAL when none set). The orchestrator never interprets it — it - * forwards the alias string verbatim (like cwd forwards a path). - */ - readonly computerId?: string; - readonly reasoningEffort?: ReasoningEffort; - /** - * The workspace this conversation belongs to. Defaults to `"default"` when - * omitted. On the first turn for a new conversation, the workspaceId is - * persisted (the workspace is auto-created if missing) so subsequent turns - * resolve the effective cwd from the workspace's `defaultCwd`. - */ - readonly workspaceId?: string; - /** - * An EXPLICIT system-prompt override. When provided (a string, including the - * empty string), it is sent to the provider AS-IS and the system-prompt - * SERVICE is bypassed entirely (no template construction, no - * persist/reuse). This is how a caller that owns its own prompt (e.g. the - * heartbeat extension) drives a turn without the templated workspace prompt. - * Omitted/`undefined` = the existing behavior (construct/reuse via the - * system-prompt service when loaded). - */ - readonly systemPrompt?: string; + readonly conversationId: string; + readonly text: string; + readonly modelName?: string; + readonly cwd?: string; + /** + * The computer to execute this turn's tools on (SSH config alias). Mirrors + * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`. + * Omitted/`undefined` = use the persisted per-conversation / workspace + * default (LOCAL when none set). The orchestrator never interprets it — it + * forwards the alias string verbatim (like cwd forwards a path). + */ + readonly computerId?: string; + readonly reasoningEffort?: ReasoningEffort; + /** + * The workspace this conversation belongs to. Defaults to `"default"` when + * omitted. On the first turn for a new conversation, the workspaceId is + * persisted (the workspace is auto-created if missing) so subsequent turns + * resolve the effective cwd from the workspace's `defaultCwd`. + */ + readonly workspaceId?: string; + /** + * An EXPLICIT system-prompt override. When provided (a string, including the + * empty string), it is sent to the provider AS-IS and the system-prompt + * SERVICE is bypassed entirely (no template construction, no + * persist/reuse). This is how a caller that owns its own prompt (e.g. the + * heartbeat extension) drives a turn without the templated workspace prompt. + * Omitted/`undefined` = the existing behavior (construct/reuse via the + * system-prompt service when loaded). + */ + readonly systemPrompt?: string; } export type StartTurnResult = - | { readonly started: true; readonly turnId: string } - | { readonly started: false; readonly reason: "already-active" }; + | { readonly started: true; readonly turnId: string } + | { readonly started: false; readonly reason: "already-active" }; /** Input to `SessionOrchestrator.enqueue` — the single entry transports call. */ export interface EnqueueInput { - readonly conversationId: string; - readonly text: string; - /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ - readonly workspaceId?: string; - /** - * Per-turn computer override (SSH alias), threaded to `startTurn` when the - * conversation is idle (the message starts a turn). Additive optional — - * mirrors `workspaceId` on this type (enqueue does not carry `cwd`). - */ - readonly computerId?: string; + readonly conversationId: string; + readonly text: string; + /** Workspace to stamp on a new conversation. Defaults to `"default"`. */ + readonly workspaceId?: string; + /** + * Per-turn computer override (SSH alias), threaded to `startTurn` when the + * conversation is idle (the message starts a turn). Additive optional — + * mirrors `workspaceId` on this type (enqueue does not carry `cwd`). + */ + readonly computerId?: string; } /** @@ -94,41 +94,41 @@ export interface EnqueueInput { * is dropped, see `enqueue` docs). */ export interface EnqueueResult { - readonly startedTurn: boolean; - readonly queue: readonly QueuedMessage[]; + readonly startedTurn: boolean; + readonly queue: readonly QueuedMessage[]; } export type TurnEventListener = (event: AgentEvent) => void; interface ActiveTurn { - buffer: AgentEvent[]; - turnId: string; - /** Aborts this turn's kernel runTurn (closeConversation). */ - controller: AbortController; + buffer: AgentEvent[]; + turnId: string; + /** Aborts this turn's kernel runTurn (closeConversation). */ + controller: AbortController; } // --- Lifecycle event hooks --- /** Context carried on turn-lifecycle events, enough to replicate the turn's request prefix. */ export interface TurnLifecyclePayload { - readonly conversationId: string; - readonly cwd?: string; - /** The computer this turn executes on (SSH alias), mirroring `cwd`. */ - readonly computerId?: string; - readonly modelName?: string; + readonly conversationId: string; + readonly cwd?: string; + /** The computer this turn executes on (SSH alias), mirroring `cwd`. */ + readonly computerId?: string; + readonly modelName?: string; } /** Fired when a turn STARTS driving a conversation (consumers cancel warming timers). */ export const turnStarted: EventHookDescriptor<TurnLifecyclePayload> = - defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started"); + defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started"); /** Fired when a turn SETTLES (sealed) for a conversation (consumers arm warming timers). */ export const turnSettled: EventHookDescriptor<TurnLifecyclePayload> = - defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled"); + defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled"); /** Payload for the conversationClosed bus event. */ export interface ConversationClosedPayload { - readonly conversationId: string; + readonly conversationId: string; } /** @@ -137,17 +137,17 @@ export interface ConversationClosedPayload { * disables its schedule). Emitted by `SessionOrchestrator.closeConversation`. */ export const conversationClosed: EventHookDescriptor<ConversationClosedPayload> = - defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed"); + defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed"); /** Payload for the conversationOpened bus event. */ export interface ConversationOpenedPayload { - readonly conversationId: string; - /** - * The conversation's actual persisted workspace id (resolved from the - * store, not the per-turn start option), so a frontend can open/focus the - * tab in the correct workspace. Falls back to `"default"`. - */ - readonly workspaceId: string; + readonly conversationId: string; + /** + * The conversation's actual persisted workspace id (resolved from the + * store, not the per-turn start option), so a frontend can open/focus the + * tab in the correct workspace. Falls back to `"default"`. + */ + readonly workspaceId: string; } /** @@ -157,18 +157,18 @@ export interface ConversationOpenedPayload { * open/focus a tab — the backend just signals. */ export const conversationOpened: EventHookDescriptor<ConversationOpenedPayload> = - defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened"); + defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened"); /** Payload for the conversationStatusChanged bus event. */ export interface ConversationStatusChangedPayload { - readonly conversationId: string; - readonly status: ConversationStatus; - /** - * The conversation's actual persisted workspace id (resolved from the - * store, not the per-turn start option), so a frontend can sync the tab - * in the correct workspace. Falls back to `"default"`. - */ - readonly workspaceId: string; + readonly conversationId: string; + readonly status: ConversationStatus; + /** + * The conversation's actual persisted workspace id (resolved from the + * store, not the per-turn start option), so a frontend can sync the tab + * in the correct workspace. Falls back to `"default"`. + */ + readonly workspaceId: string; } /** @@ -177,16 +177,16 @@ export interface ConversationStatusChangedPayload { * message to all connected frontend clients so tabs sync across devices. */ export const conversationStatusChanged: EventHookDescriptor<ConversationStatusChangedPayload> = - defineEventHook<ConversationStatusChangedPayload>( - "session-orchestrator/conversation-status-changed", - ); + defineEventHook<ConversationStatusChangedPayload>( + "session-orchestrator/conversation-status-changed", + ); /** Payload for the conversationCompacted bus event. */ export interface ConversationCompactedPayload { - readonly conversationId: string; - readonly newConversationId: string; - readonly messagesSummarized: number; - readonly messagesKept: number; + readonly conversationId: string; + readonly newConversationId: string; + readonly messagesSummarized: number; + readonly messagesKept: number; } /** @@ -195,165 +195,165 @@ export interface ConversationCompactedPayload { * broadcasts a `conversation.compacted` WS message so the FE reloads history. */ export const conversationCompacted: EventHookDescriptor<ConversationCompactedPayload> = - defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted"); + defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted"); /** Payload for the warmCompleted bus event. */ export interface WarmCompletedPayload { - readonly conversationId: string; - readonly usage: WarmResult; + readonly conversationId: string; + readonly usage: WarmResult; } /** Fired when a warm probe succeeds (both automatic and manual paths). */ export const warmCompleted: EventHookDescriptor<WarmCompletedPayload> = - defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed"); + defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed"); // --- Warm service --- export interface WarmResult { - readonly inputTokens: number; - readonly outputTokens: number; - readonly cacheReadTokens: number; - readonly cacheWriteTokens: number; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; } export interface WarmService { - readonly warm: ( - conversationId: string, - opts?: { readonly cwd?: string; readonly modelName?: string }, - ) => Promise<WarmResult | { readonly error: string }>; + readonly warm: ( + conversationId: string, + opts?: { readonly cwd?: string; readonly modelName?: string }, + ) => Promise<WarmResult | { readonly error: string }>; } export const cacheWarmHandle: ServiceHandle<WarmService> = defineService<WarmService>( - "session-orchestrator/warm", + "session-orchestrator/warm", ); // --- Compaction service --- export interface CompactionService { - /** - * Compact a conversation: summarize old messages and replace history with - * the summary + the most recent `keepLastN` messages. Returns the result - * or an error object. No-ops if the conversation is too short (≤ keepLastN - * messages). When `auto` is true, checks the compact-threshold setting and - * only compacts if the last turn's input tokens exceeded it. - */ - readonly compact: ( - conversationId: string, - opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean }, - ) => Promise<CompactionResult | { readonly error: string }>; + /** + * Compact a conversation: summarize old messages and replace history with + * the summary + the most recent `keepLastN` messages. Returns the result + * or an error object. No-ops if the conversation is too short (≤ keepLastN + * messages). When `auto` is true, checks the compact-threshold setting and + * only compacts if the last turn's input tokens exceeded it. + */ + readonly compact: ( + conversationId: string, + opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean }, + ) => Promise<CompactionResult | { readonly error: string }>; } export const compactionHandle: ServiceHandle<CompactionService> = defineService<CompactionService>( - "session-orchestrator/compaction", + "session-orchestrator/compaction", ); export interface SessionOrchestrator { - startTurn(input: StartTurnInput): StartTurnResult; - /** - * The single entry transports call to deliver a user message. Owns the - * idle→startTurn vs active→queue decision (no separate `isActive` race — - * `startTurn`'s single-flight guard is authoritative). When the conversation - * is idle, starts a turn (the message is the opening prompt). When active, - * enqueues onto the steering queue (if the message-queue extension is - * loaded); with no queue extension loaded the message is dropped and the - * returned snapshot is empty (degraded — feature off). - */ - enqueue(input: EnqueueInput): EnqueueResult; - subscribe(conversationId: string, listener: TurnEventListener): () => void; - isActive(conversationId: string): boolean; - /** - * Explicitly close a conversation (the user closed its tab — distinct from a - * socket disconnect, which never touches the turn): aborts any in-flight turn - * (the kernel finishes with `finishReason: "aborted"`, partial messages are - * persisted and the turn seals normally) and emits the `conversationClosed` - * hook so per-conversation background work (cache-warming) stops. - * Idempotent — closing an idle/unknown conversation just emits the hook. - */ - closeConversation(conversationId: string): { readonly abortedTurn: boolean }; - /** - * Stop an in-flight generation WITHOUT closing the conversation. Aborts - * the turn's AbortController — the kernel finishes with - * `finishReason: "aborted"`, partial messages are persisted, and the turn - * seals normally (status transitions active → idle via the normal settle - * path). Idempotent — stopping an idle/unknown conversation is a no-op. - */ - stopTurn(conversationId: string): { readonly abortedTurn: boolean }; - handleMessage(input: { - conversationId: string; - text: string; - onEvent: (event: AgentEvent) => void; - modelName?: string; - cwd?: string; - computerId?: string; - reasoningEffort?: ReasoningEffort; - workspaceId?: string; - /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */ - systemPrompt?: string; - }): Promise<void>; + startTurn(input: StartTurnInput): StartTurnResult; + /** + * The single entry transports call to deliver a user message. Owns the + * idle→startTurn vs active→queue decision (no separate `isActive` race — + * `startTurn`'s single-flight guard is authoritative). When the conversation + * is idle, starts a turn (the message is the opening prompt). When active, + * enqueues onto the steering queue (if the message-queue extension is + * loaded); with no queue extension loaded the message is dropped and the + * returned snapshot is empty (degraded — feature off). + */ + enqueue(input: EnqueueInput): EnqueueResult; + subscribe(conversationId: string, listener: TurnEventListener): () => void; + isActive(conversationId: string): boolean; + /** + * Explicitly close a conversation (the user closed its tab — distinct from a + * socket disconnect, which never touches the turn): aborts any in-flight turn + * (the kernel finishes with `finishReason: "aborted"`, partial messages are + * persisted and the turn seals normally) and emits the `conversationClosed` + * hook so per-conversation background work (cache-warming) stops. + * Idempotent — closing an idle/unknown conversation just emits the hook. + */ + closeConversation(conversationId: string): { readonly abortedTurn: boolean }; + /** + * Stop an in-flight generation WITHOUT closing the conversation. Aborts + * the turn's AbortController — the kernel finishes with + * `finishReason: "aborted"`, partial messages are persisted, and the turn + * seals normally (status transitions active → idle via the normal settle + * path). Idempotent — stopping an idle/unknown conversation is a no-op. + */ + stopTurn(conversationId: string): { readonly abortedTurn: boolean }; + handleMessage(input: { + conversationId: string; + text: string; + onEvent: (event: AgentEvent) => void; + modelName?: string; + cwd?: string; + computerId?: string; + reasoningEffort?: ReasoningEffort; + workspaceId?: string; + /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */ + systemPrompt?: string; + }): Promise<void>; } export const sessionOrchestratorHandle = defineService<SessionOrchestrator>( - "session-orchestrator/orchestrator", + "session-orchestrator/orchestrator", ); export interface SessionOrchestratorDeps { - readonly conversationStore: ConversationStore; - readonly resolveProvider: () => ProviderContract; - readonly resolveTools: () => readonly ToolContract[]; - readonly resolveDispatch?: () => ToolDispatchPolicy; - readonly resolveModel?: ( - modelName: string, - ) => { provider: ProviderContract; model: string } | undefined; - /** - * Resolve full `ModelInfo` (including `contextWindow`) for a model name. - * Used by the compaction service to calculate the auto-compact threshold - * as a percentage of the context window. - */ - readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>; - readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; - /** - * Lazily resolves the message-queue service (the steering queue), or - * `undefined` when the message-queue extension isn't loaded (the feature - * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops - * messages when active). host-bin wires this via `host.getService`; the - * orchestrator calls it per-turn / per-enqueue so activation order with the - * message-queue extension doesn't matter. Injected (not ambient) so a turn - * stays reproducible from its inputs and tests use a fake queue. - */ - readonly resolveQueue?: () => MessageQueueService | undefined; - /** - * Lazily resolves the compaction service, or `undefined` when not loaded. - * Used for automatic compaction after a turn settles (if the compact - * threshold is exceeded). Lazy so activation order doesn't matter. - */ - readonly resolveCompaction?: () => CompactionService | undefined; - /** - * Lazily resolves the system-prompt service, or `undefined` when the - * system-prompt extension isn't loaded. Used to construct the per- - * conversation system prompt once (first turn) and reuse it (cache-safe) on - * subsequent turns, and to reconstruct it on compaction. Lazy so activation - * order doesn't matter. - */ - readonly resolveSystemPrompt?: () => SystemPromptService | undefined; - /** Apply the per-turn tools filter chain. Injected for testability. */ - readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>; - /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ - readonly logger?: Logger; - /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */ - readonly now?: () => number; - /** Emit a lifecycle event hook to subscribers. Injected from host. */ - readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; + readonly conversationStore: ConversationStore; + readonly resolveProvider: () => ProviderContract; + readonly resolveTools: () => readonly ToolContract[]; + readonly resolveDispatch?: () => ToolDispatchPolicy; + readonly resolveModel?: ( + modelName: string, + ) => { provider: ProviderContract; model: string } | undefined; + /** + * Resolve full `ModelInfo` (including `contextWindow`) for a model name. + * Used by the compaction service to calculate the auto-compact threshold + * as a percentage of the context window. + */ + readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>; + readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; + /** + * Lazily resolves the message-queue service (the steering queue), or + * `undefined` when the message-queue extension isn't loaded (the feature + * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops + * messages when active). host-bin wires this via `host.getService`; the + * orchestrator calls it per-turn / per-enqueue so activation order with the + * message-queue extension doesn't matter. Injected (not ambient) so a turn + * stays reproducible from its inputs and tests use a fake queue. + */ + readonly resolveQueue?: () => MessageQueueService | undefined; + /** + * Lazily resolves the compaction service, or `undefined` when not loaded. + * Used for automatic compaction after a turn settles (if the compact + * threshold is exceeded). Lazy so activation order doesn't matter. + */ + readonly resolveCompaction?: () => CompactionService | undefined; + /** + * Lazily resolves the system-prompt service, or `undefined` when the + * system-prompt extension isn't loaded. Used to construct the per- + * conversation system prompt once (first turn) and reuse it (cache-safe) on + * subsequent turns, and to reconstruct it on compaction. Lazy so activation + * order doesn't matter. + */ + readonly resolveSystemPrompt?: () => SystemPromptService | undefined; + /** Apply the per-turn tools filter chain. Injected for testability. */ + readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>; + /** Base logger (auto-scoped to this extension); childed per turn for span capture. */ + readonly logger?: Logger; + /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */ + readonly now?: () => number; + /** Emit a lifecycle event hook to subscribers. Injected from host. */ + readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; } /** Deps for the warm service — emit is REQUIRED so warmCompleted is never silently dropped. */ export type WarmServiceDeps = SessionOrchestratorDeps & { - readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; + readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; }; export interface SessionOrchestratorBundle { - readonly orchestrator: SessionOrchestrator; - /** The shared active-conversations set, for use by createWarmService. */ - readonly activeConversations: ReadonlySet<string>; + readonly orchestrator: SessionOrchestrator; + /** The shared active-conversations set, for use by createWarmService. */ + readonly activeConversations: ReadonlySet<string>; } /** @@ -366,871 +366,871 @@ export interface SessionOrchestratorBundle { * turn `aborted`). The kernel imports no timer; this is the shell-provided I/O. */ export function createRetryStrategy(): RetryStrategy { - const sleep = (ms: number, signal: AbortSignal): Promise<void> => { - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(new Error("aborted")); - return; - } - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { - clearTimeout(timer); - reject(new Error("aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); - }; - return { delayFor, sleep }; + const sleep = (ms: number, signal: AbortSignal): Promise<void> => { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("aborted")); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(new Error("aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + }; + return { delayFor, sleep }; } export function createSessionOrchestrator( - deps: SessionOrchestratorDeps, + deps: SessionOrchestratorDeps, ): SessionOrchestratorBundle { - const activeConversations = new Set<string>(); - const subscribers = new Map<string, Set<TurnEventListener>>(); - const activeTurns = new Map<string, ActiveTurn>(); - // One stateless retry strategy shared by every turn (delayFor is pure; sleep - // is a stateless setTimeout closure). Wired into each RunTurnInput.retry. - const retryStrategy = createRetryStrategy(); - - function emitToHub(conversationId: string, event: AgentEvent): void { - const turn = activeTurns.get(conversationId); - if (turn !== undefined) { - turn.buffer.push(event); - } - const listeners = subscribers.get(conversationId); - if (listeners !== undefined) { - for (const listener of listeners) { - listener(event); - } - } - } - - /** - * Post-seal carry: if a steering queue is available and non-empty, drain it, - * combine, and start a NEW detached turn whose opening `user-message` carries - * the combined text (no `steering` event — that's only for mid-turn drain). - * Returns true iff a new turn was started. Called from `runTurnDetached`'s - * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard - * passes) and BEFORE `activeConversations.delete` (skipped when carried, since - * the new turn re-adds it). May chain — the new turn's own finally re-checks. - */ - function tryCarryQueue(conversationId: string): boolean { - const queue = deps.resolveQueue?.(); - if (queue === undefined) return false; - if (queue.getQueue(conversationId).length === 0) return false; - const drained = queue.drain(conversationId); - const combined = drained.map((q) => q.text).join("\n\n"); - const result = orchestrator.startTurn({ conversationId, text: combined }); - return result.started; - } - - function runTurnDetached( - conversationId: string, - text: string, - modelName: string | undefined, - cwd: string | undefined, - computerId: string | undefined, - reasoningEffortOverride: ReasoningEffort | undefined, - workspaceId: string, - systemPromptOverride: string | undefined, - ): void { - const turnId = generateTurnId(); - const controller = new AbortController(); - activeTurns.set(conversationId, { buffer: [], turnId, controller }); - activeConversations.add(conversationId); - - emitToHub(conversationId, { type: "user-message", conversationId, turnId, text }); - - // For a NEW conversation the workspace MUST be assigned (persisted) - // BEFORE getEffectiveCwd runs, so the effective cwd resolves against - // the intended workspace's defaultCwd rather than the stale "default" - // workspace returned by getWorkspaceId for a not-yet-persisted - // conversation. Detect newness via getConversationMeta === null - // (equivalent to history.length === 0 in practice). Existing - // conversations keep their assigned workspace — never overwritten. - // The newness flag is also reused to decide whether to construct - // (first turn) or get (subsequent turn) the system prompt — see the - // providerOpts assembly below. - const workspaceSetupPromise = (async (): Promise<boolean> => { - const meta = await deps.conversationStore.getConversationMeta(conversationId); - if (meta === null) { - await deps.conversationStore.ensureWorkspace(workspaceId); - await deps.conversationStore.setWorkspaceId(conversationId, workspaceId); - return true; - } - return false; - })(); - - // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the - // per-turn cwd as the overrideCwd when present. A relative per-turn cwd - // (e.g. "arch-rewrite") must be resolved against the workspace's - // defaultCwd via the same workspace-relative algorithm the persisted cwd - // uses — NOT used raw (which would resolve against process.cwd() and - // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd. - // Chained after workspaceSetupPromise so the workspace is assigned - // first for new conversations (the timing invariant this enforces). - const effectiveCwdPromise = workspaceSetupPromise.then(() => - deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined), - ); - - // Resolve the effective computer the SAME way cwd resolves — pass the - // per-turn computerId as the overrideAlias. When computerId is - // undefined, getEffectiveComputer reads the persisted per-conversation - // computerId → workspace defaultComputerId → null (LOCAL). Chained - // after workspaceSetupPromise (same timing invariant as cwd). The - // orchestrator never interprets the alias — it forwards the string - // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise. - const effectiveComputerIdPromise = workspaceSetupPromise.then(() => - deps.conversationStore - .getEffectiveComputer(conversationId, computerId) - .then((c) => c ?? undefined), - ); - - const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); - // Resolve the persisted model (if any) in parallel with the other - // per-conversation reads. The effective model name is - // per-turn override → persisted → (undefined → default provider), the - // same resolution chain as `resolveReasoningEffort`. - const storedModelPromise = deps.conversationStore.getModel(conversationId); - - const payloadPromise = Promise.all([ - effectiveCwdPromise, - effectiveComputerIdPromise, - storedEffortPromise, - storedModelPromise, - ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => { - const effectiveModelName = resolveModelName(modelName, storedModel); - return { - conversationId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}), - }; - }); - - payloadPromise.then((payload) => { - deps.emit?.(turnStarted, payload); - // Resolve the persisted workspace id (not the per-turn start option) - // before emitting so the broadcast carries the correct workspace. - void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "active", - workspaceId, - }); - }); - void deps.conversationStore.setConversationStatus(conversationId, "active"); - }); - - void (async () => { - let sealed = false; - try { - const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] = - await Promise.all([ - effectiveCwdPromise, - effectiveComputerIdPromise, - storedEffortPromise, - workspaceSetupPromise, - storedModelPromise, - ]); - - if (cwd !== undefined) { - await deps.conversationStore.setCwd(conversationId, cwd); - } - - // Persist the per-turn computer override, mirroring the cwd - // persistence above. Only stamped when a computerId was actually - // provided — NOT when it resolved to undefined (LOCAL) via the - // workspace default. Idempotent when the value is unchanged. - if (computerId !== undefined) { - await deps.conversationStore.setComputerId(conversationId, computerId); - } - - const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); - // Effective model name: per-turn override → persisted → undefined - // (→ default provider). Resolved here so every downstream consumer - // (resolveModel, system prompt, payload) sees the same model as if - // the caller had passed it explicitly. - const effectiveModelName = resolveModelName(modelName, storedModel); - - const history = await deps.conversationStore.load(conversationId); - const userMsg = buildUserMessage(text); - - // Workspace assignment for new conversations happens BEFORE - // effective-cwd resolution (see workspaceSetupPromise above) so - // getEffectiveCwd resolves against the intended workspace, not - // the stale "default". The history-load + append flow below is - // otherwise unchanged. - - let provider: ProviderContract; - let modelOverride: string | undefined; - - if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { - const resolved = deps.resolveModel(effectiveModelName); - if (resolved === undefined) { - emitToHub(conversationId, { - type: "error", - conversationId, - turnId, - message: `unknown model: ${effectiveModelName}`, - }); - return; - } - provider = resolved.provider; - modelOverride = resolved.model; - // Persist the resolved model so it sticks for future turns - // and browser sessions (per-conversation model persistence). - // Only stamped when a model was actually used — NOT on the - // default-provider fallthrough (nothing to persist). Idempotent - // when the value is unchanged (re-stamps the same persisted - // model). The early `return` above means an unknown model is - // never persisted. - await deps.conversationStore.setModel(conversationId, effectiveModelName); - } else { - provider = deps.resolveProvider(); - } - - const baseTools = deps.resolveTools(); - const assembled = await deps.applyToolsFilter({ - tools: baseTools, - conversationId, - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }); - const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); - const turnLogger = deps.logger?.child({ conversationId, turnId }); - const metrics = createMetricsAccumulator(); - - const emitAndAccumulate = (event: AgentEvent): void => { - metrics.ingest(event); - emitToHub(conversationId, event); - }; - - // Resolve the system prompt for this turn (cache-safe). On the - // FIRST turn of a new conversation, construct it once (resolves all - // template variables + persists the result). On subsequent turns, - // reuse the persisted prompt via `getWithMeta` — but ONLY when the - // stored cwd matches the current effective cwd. If the cwd changed - // since the prompt was constructed (or no prompt was ever stored), - // reconstruct against the new cwd so the prompt is never stale. - // This preserves the cache-safe design (construct once per cwd, - // reuse on subsequent turns with the same cwd) while fixing the bug - // where a cwd change left the prompt stale. When the system-prompt - // service isn't loaded, no system prompt is sent (current behavior - // preserved). - // - // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a - // string, incl. empty), it is sent to the provider AS-IS and the - // system-prompt service is bypassed entirely (no construct/reuse). - // This lets a caller that owns its own prompt (e.g. the heartbeat - // extension) drive a turn without the templated workspace prompt. - let systemPrompt: string | undefined; - if (systemPromptOverride !== undefined) { - systemPrompt = systemPromptOverride; - } else { - const systemPromptService = deps.resolveSystemPrompt?.(); - if (systemPromptService !== undefined) { - if (isNewConversation) { - systemPrompt = await systemPromptService.construct( - conversationId, - effectiveCwd ?? process.cwd(), - { - ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }, - ); - } else { - const meta = await systemPromptService.getWithMeta(conversationId); - const currentCwd = effectiveCwd ?? process.cwd(); - const currentComputerId = effectiveComputerId ?? null; - // Invalidate when cwd OR computerId changed (switching computers - // must rebuild the prompt against the remote OS/hostname). - if ( - meta.prompt !== null && - meta.cwd === currentCwd && - meta.computerId === currentComputerId - ) { - systemPrompt = meta.prompt; - } else { - systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { - ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - }); - } - } - } - } - - const providerOpts: ProviderStreamOptions = { - reasoningEffort: resolvedEffort, - ...(modelOverride !== undefined ? { model: modelOverride } : {}), - ...(systemPrompt !== undefined ? { systemPrompt } : {}), - }; - - // Resolve the steering queue once for this turn. When present, wire - // `drainSteering`: the kernel calls it at the tool-result boundary and - // appends whatever it returns as user-role messages alongside the tool - // results (mid-turn steering). The wrapper emits a `steering` AgentEvent - // into the hub (buffered for late-join like `user-message`) so a - // frontend can place a user bubble in the transcript live; the kernel - // only appends the returned messages — it does NOT emit the event. - const queue = deps.resolveQueue?.(); - const drainSteering = - queue === undefined - ? undefined - : (): readonly ChatMessage[] => { - const queued = queue.drain(conversationId); - if (queued.length === 0) return []; - const steerText = queued.map((q) => q.text).join("\n\n"); - emitToHub(conversationId, { - type: "steering", - conversationId, - turnId, - text: steerText, - }); - return [{ role: "user", chunks: [{ type: "text", text: steerText }] }]; - }; - - const opts: RunTurnInput = { - provider, - messages: [...history, userMsg], - tools: assembled.tools, - dispatch, - emit: emitAndAccumulate, - conversationId, - turnId, - signal: controller.signal, - providerOpts, - retry: retryStrategy, - ...(turnLogger !== undefined ? { logger: turnLogger } : {}), - ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), - ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), - ...(deps.now !== undefined ? { now: deps.now } : {}), - ...(drainSteering !== undefined ? { drainSteering } : {}), - }; - - // Persist the user message at turn start so it has a seq - // number before the first step generates. This enables the - // FE to syncTail during generation (CR-6). - await deps.conversationStore.append(conversationId, [userMsg]); - - let stepsPersisted = false; - const result = await deps.runTurn({ - ...opts, - // Incremental persistence: persist each step's messages - // as they are finalized. Seq numbers are assigned during - // generation, so the FE can GET /conversations/:id?sinceSeq=N - // mid-turn and pick up committed chunks (CR-6). - onStepComplete: async (stepMessages) => { - await deps.conversationStore.append(conversationId, stepMessages); - stepsPersisted = true; - }, - }); - - // Fallback: if onStepComplete was never called (e.g., a fake - // runTurn in tests), persist all result messages as a batch. - if (!stepsPersisted && result.messages.length > 0) { - await deps.conversationStore.append(conversationId, result.messages); - } - - const turnMetrics = metrics.build(turnId); - await deps.conversationStore.appendMetrics(conversationId, turnMetrics); - - emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId }); - sealed = true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - emitToHub(conversationId, { - type: "error", - conversationId, - turnId, - message, - }); - } finally { - activeTurns.delete(conversationId); - // Post-seal carry: if the turn sealed with a non-empty steering queue - // (no tool call fired → drainSteering never drained it), start a NEW - // detached turn whose opening user-message carries the combined text. - // The new turn re-adds to activeTurns + activeConversations, so skip - // the activeConversations.delete when carried. May chain (user keeps - // steering) — each carried turn's own finally re-checks the queue. - const carried = sealed && tryCarryQueue(conversationId); - if (!carried) { - activeConversations.delete(conversationId); - } - void payloadPromise.then((payload) => { - deps.emit?.(turnSettled, payload); - if (!carried) { - // Resolve the persisted workspace id before emitting so the - // broadcast carries the correct workspace. - void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "idle", - workspaceId, - }); - }); - void deps.conversationStore.setConversationStatus(conversationId, "idle"); - // Fire-and-forget auto-compaction: check threshold and - // compact if exceeded. Non-blocking — the next turn - // starts fresh either way. - const compaction = deps.resolveCompaction?.(); - if (compaction !== undefined) { - void compaction - .compact(conversationId, { - auto: true, - ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), - }) - .catch(() => {}); - } - } - }); - } - })(); - } - - const orchestrator: SessionOrchestrator = { - startTurn({ - conversationId, - text, - modelName, - cwd, - computerId, - reasoningEffort, - workspaceId, - systemPrompt, - }) { - if (activeTurns.has(conversationId)) { - return { started: false, reason: "already-active" }; - } - runTurnDetached( - conversationId, - text, - modelName, - cwd, - computerId, - reasoningEffort, - workspaceId ?? "default", - systemPrompt, - ); - const turn = activeTurns.get(conversationId); - const turnId = turn !== undefined ? turn.turnId : ""; - return { started: true, turnId }; - }, - - enqueue({ conversationId, text, workspaceId, computerId }) { - const result = orchestrator.startTurn({ - conversationId, - text, - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(computerId !== undefined ? { computerId } : {}), - }); - if (result.started) { - return { startedTurn: true, queue: [] }; - } - // Already active → enqueue onto the steering queue. When the - // message-queue extension isn't loaded this degrades: the message is - // dropped and the snapshot is empty (feature off). - const queue = deps.resolveQueue?.(); - const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : []; - return { startedTurn: false, queue: snapshot }; - }, - - subscribe(conversationId, listener) { - let listeners = subscribers.get(conversationId); - if (listeners === undefined) { - listeners = new Set(); - subscribers.set(conversationId, listeners); - } - const turn = activeTurns.get(conversationId); - if (turn !== undefined) { - const snapshot = [...turn.buffer]; - listeners.add(listener); - for (const event of snapshot) { - listener(event); - } - } else { - listeners.add(listener); - } - return () => { - const set = subscribers.get(conversationId); - if (set !== undefined) { - set.delete(listener); - if (set.size === 0) { - subscribers.delete(conversationId); - } - } - }; - }, - - isActive(conversationId) { - return activeTurns.has(conversationId); - }, - - closeConversation(conversationId) { - const turn = activeTurns.get(conversationId); - const abortedTurn = turn !== undefined; - if (turn !== undefined) { - turn.controller.abort(); - } - deps.emit?.(conversationClosed, { conversationId }); - // Resolve the persisted workspace id before emitting so the - // broadcast carries the correct workspace. The hook is - // fire-and-forget; closeConversation stays synchronous (returns - // immediately) while the status-changed emit resolves async. - void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { - deps.emit?.(conversationStatusChanged, { - conversationId, - status: "closed", - workspaceId, - }); - }); - void deps.conversationStore.setConversationStatus(conversationId, "closed"); - return { abortedTurn }; - }, - - stopTurn(conversationId) { - const turn = activeTurns.get(conversationId); - const abortedTurn = turn !== undefined; - if (turn !== undefined) { - turn.controller.abort(); - } - return { abortedTurn }; - }, - - async handleMessage({ - conversationId, - text, - onEvent, - modelName, - cwd, - computerId, - reasoningEffort, - workspaceId, - systemPrompt, - }) { - const turnInput: StartTurnInput = { - conversationId, - text, - ...(modelName !== undefined ? { modelName } : {}), - ...(cwd !== undefined ? { cwd } : {}), - ...(computerId !== undefined ? { computerId } : {}), - ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - ...(systemPrompt !== undefined ? { systemPrompt } : {}), - }; - const result = orchestrator.startTurn(turnInput); - if (!result.started) { - const errorTurnId = generateTurnId(); - onEvent({ - type: "error", - conversationId, - turnId: errorTurnId, - message: "turn already active for this conversation", - }); - return; - } - - await new Promise<void>((resolve) => { - const unsubscribe = orchestrator.subscribe(conversationId, (event) => { - onEvent(event); - if (event.type === "turn-sealed" || event.type === "error") { - unsubscribe(); - resolve(); - } - }); - }); - }, - }; - - return { orchestrator, activeConversations }; + const activeConversations = new Set<string>(); + const subscribers = new Map<string, Set<TurnEventListener>>(); + const activeTurns = new Map<string, ActiveTurn>(); + // One stateless retry strategy shared by every turn (delayFor is pure; sleep + // is a stateless setTimeout closure). Wired into each RunTurnInput.retry. + const retryStrategy = createRetryStrategy(); + + function emitToHub(conversationId: string, event: AgentEvent): void { + const turn = activeTurns.get(conversationId); + if (turn !== undefined) { + turn.buffer.push(event); + } + const listeners = subscribers.get(conversationId); + if (listeners !== undefined) { + for (const listener of listeners) { + listener(event); + } + } + } + + /** + * Post-seal carry: if a steering queue is available and non-empty, drain it, + * combine, and start a NEW detached turn whose opening `user-message` carries + * the combined text (no `steering` event — that's only for mid-turn drain). + * Returns true iff a new turn was started. Called from `runTurnDetached`'s + * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard + * passes) and BEFORE `activeConversations.delete` (skipped when carried, since + * the new turn re-adds it). May chain — the new turn's own finally re-checks. + */ + function tryCarryQueue(conversationId: string): boolean { + const queue = deps.resolveQueue?.(); + if (queue === undefined) return false; + if (queue.getQueue(conversationId).length === 0) return false; + const drained = queue.drain(conversationId); + const combined = drained.map((q) => q.text).join("\n\n"); + const result = orchestrator.startTurn({ conversationId, text: combined }); + return result.started; + } + + function runTurnDetached( + conversationId: string, + text: string, + modelName: string | undefined, + cwd: string | undefined, + computerId: string | undefined, + reasoningEffortOverride: ReasoningEffort | undefined, + workspaceId: string, + systemPromptOverride: string | undefined, + ): void { + const turnId = generateTurnId(); + const controller = new AbortController(); + activeTurns.set(conversationId, { buffer: [], turnId, controller }); + activeConversations.add(conversationId); + + emitToHub(conversationId, { type: "user-message", conversationId, turnId, text }); + + // For a NEW conversation the workspace MUST be assigned (persisted) + // BEFORE getEffectiveCwd runs, so the effective cwd resolves against + // the intended workspace's defaultCwd rather than the stale "default" + // workspace returned by getWorkspaceId for a not-yet-persisted + // conversation. Detect newness via getConversationMeta === null + // (equivalent to history.length === 0 in practice). Existing + // conversations keep their assigned workspace — never overwritten. + // The newness flag is also reused to decide whether to construct + // (first turn) or get (subsequent turn) the system prompt — see the + // providerOpts assembly below. + const workspaceSetupPromise = (async (): Promise<boolean> => { + const meta = await deps.conversationStore.getConversationMeta(conversationId); + if (meta === null) { + await deps.conversationStore.ensureWorkspace(workspaceId); + await deps.conversationStore.setWorkspaceId(conversationId, workspaceId); + return true; + } + return false; + })(); + + // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the + // per-turn cwd as the overrideCwd when present. A relative per-turn cwd + // (e.g. "arch-rewrite") must be resolved against the workspace's + // defaultCwd via the same workspace-relative algorithm the persisted cwd + // uses — NOT used raw (which would resolve against process.cwd() and + // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd. + // Chained after workspaceSetupPromise so the workspace is assigned + // first for new conversations (the timing invariant this enforces). + const effectiveCwdPromise = workspaceSetupPromise.then(() => + deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined), + ); + + // Resolve the effective computer the SAME way cwd resolves — pass the + // per-turn computerId as the overrideAlias. When computerId is + // undefined, getEffectiveComputer reads the persisted per-conversation + // computerId → workspace defaultComputerId → null (LOCAL). Chained + // after workspaceSetupPromise (same timing invariant as cwd). The + // orchestrator never interprets the alias — it forwards the string + // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise. + const effectiveComputerIdPromise = workspaceSetupPromise.then(() => + deps.conversationStore + .getEffectiveComputer(conversationId, computerId) + .then((c) => c ?? undefined), + ); + + const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId); + // Resolve the persisted model (if any) in parallel with the other + // per-conversation reads. The effective model name is + // per-turn override → persisted → (undefined → default provider), the + // same resolution chain as `resolveReasoningEffort`. + const storedModelPromise = deps.conversationStore.getModel(conversationId); + + const payloadPromise = Promise.all([ + effectiveCwdPromise, + effectiveComputerIdPromise, + storedEffortPromise, + storedModelPromise, + ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => { + const effectiveModelName = resolveModelName(modelName, storedModel); + return { + conversationId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}), + }; + }); + + payloadPromise.then((payload) => { + deps.emit?.(turnStarted, payload); + // Resolve the persisted workspace id (not the per-turn start option) + // before emitting so the broadcast carries the correct workspace. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "active", + workspaceId, + }); + }); + void deps.conversationStore.setConversationStatus(conversationId, "active"); + }); + + void (async () => { + let sealed = false; + try { + const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] = + await Promise.all([ + effectiveCwdPromise, + effectiveComputerIdPromise, + storedEffortPromise, + workspaceSetupPromise, + storedModelPromise, + ]); + + if (cwd !== undefined) { + await deps.conversationStore.setCwd(conversationId, cwd); + } + + // Persist the per-turn computer override, mirroring the cwd + // persistence above. Only stamped when a computerId was actually + // provided — NOT when it resolved to undefined (LOCAL) via the + // workspace default. Idempotent when the value is unchanged. + if (computerId !== undefined) { + await deps.conversationStore.setComputerId(conversationId, computerId); + } + + const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort); + // Effective model name: per-turn override → persisted → undefined + // (→ default provider). Resolved here so every downstream consumer + // (resolveModel, system prompt, payload) sees the same model as if + // the caller had passed it explicitly. + const effectiveModelName = resolveModelName(modelName, storedModel); + + const history = await deps.conversationStore.load(conversationId); + const userMsg = buildUserMessage(text); + + // Workspace assignment for new conversations happens BEFORE + // effective-cwd resolution (see workspaceSetupPromise above) so + // getEffectiveCwd resolves against the intended workspace, not + // the stale "default". The history-load + append flow below is + // otherwise unchanged. + + let provider: ProviderContract; + let modelOverride: string | undefined; + + if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(effectiveModelName); + if (resolved === undefined) { + emitToHub(conversationId, { + type: "error", + conversationId, + turnId, + message: `unknown model: ${effectiveModelName}`, + }); + return; + } + provider = resolved.provider; + modelOverride = resolved.model; + // Persist the resolved model so it sticks for future turns + // and browser sessions (per-conversation model persistence). + // Only stamped when a model was actually used — NOT on the + // default-provider fallthrough (nothing to persist). Idempotent + // when the value is unchanged (re-stamps the same persisted + // model). The early `return` above means an unknown model is + // never persisted. + await deps.conversationStore.setModel(conversationId, effectiveModelName); + } else { + provider = deps.resolveProvider(); + } + + const baseTools = deps.resolveTools(); + const assembled = await deps.applyToolsFilter({ + tools: baseTools, + conversationId, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }); + const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy(); + const turnLogger = deps.logger?.child({ conversationId, turnId }); + const metrics = createMetricsAccumulator(); + + const emitAndAccumulate = (event: AgentEvent): void => { + metrics.ingest(event); + emitToHub(conversationId, event); + }; + + // Resolve the system prompt for this turn (cache-safe). On the + // FIRST turn of a new conversation, construct it once (resolves all + // template variables + persists the result). On subsequent turns, + // reuse the persisted prompt via `getWithMeta` — but ONLY when the + // stored cwd matches the current effective cwd. If the cwd changed + // since the prompt was constructed (or no prompt was ever stored), + // reconstruct against the new cwd so the prompt is never stale. + // This preserves the cache-safe design (construct once per cwd, + // reuse on subsequent turns with the same cwd) while fixing the bug + // where a cwd change left the prompt stale. When the system-prompt + // service isn't loaded, no system prompt is sent (current behavior + // preserved). + // + // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a + // string, incl. empty), it is sent to the provider AS-IS and the + // system-prompt service is bypassed entirely (no construct/reuse). + // This lets a caller that owns its own prompt (e.g. the heartbeat + // extension) drive a turn without the templated workspace prompt. + let systemPrompt: string | undefined; + if (systemPromptOverride !== undefined) { + systemPrompt = systemPromptOverride; + } else { + const systemPromptService = deps.resolveSystemPrompt?.(); + if (systemPromptService !== undefined) { + if (isNewConversation) { + systemPrompt = await systemPromptService.construct( + conversationId, + effectiveCwd ?? process.cwd(), + { + ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }, + ); + } else { + const meta = await systemPromptService.getWithMeta(conversationId); + const currentCwd = effectiveCwd ?? process.cwd(); + const currentComputerId = effectiveComputerId ?? null; + // Invalidate when cwd OR computerId changed (switching computers + // must rebuild the prompt against the remote OS/hostname). + if ( + meta.prompt !== null && + meta.cwd === currentCwd && + meta.computerId === currentComputerId + ) { + systemPrompt = meta.prompt; + } else { + systemPrompt = await systemPromptService.construct(conversationId, currentCwd, { + ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + }); + } + } + } + } + + const providerOpts: ProviderStreamOptions = { + reasoningEffort: resolvedEffort, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), + }; + + // Resolve the steering queue once for this turn. When present, wire + // `drainSteering`: the kernel calls it at the tool-result boundary and + // appends whatever it returns as user-role messages alongside the tool + // results (mid-turn steering). The wrapper emits a `steering` AgentEvent + // into the hub (buffered for late-join like `user-message`) so a + // frontend can place a user bubble in the transcript live; the kernel + // only appends the returned messages — it does NOT emit the event. + const queue = deps.resolveQueue?.(); + const drainSteering = + queue === undefined + ? undefined + : (): readonly ChatMessage[] => { + const queued = queue.drain(conversationId); + if (queued.length === 0) return []; + const steerText = queued.map((q) => q.text).join("\n\n"); + emitToHub(conversationId, { + type: "steering", + conversationId, + turnId, + text: steerText, + }); + return [{ role: "user", chunks: [{ type: "text", text: steerText }] }]; + }; + + const opts: RunTurnInput = { + provider, + messages: [...history, userMsg], + tools: assembled.tools, + dispatch, + emit: emitAndAccumulate, + conversationId, + turnId, + signal: controller.signal, + providerOpts, + retry: retryStrategy, + ...(turnLogger !== undefined ? { logger: turnLogger } : {}), + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), + ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}), + ...(deps.now !== undefined ? { now: deps.now } : {}), + ...(drainSteering !== undefined ? { drainSteering } : {}), + }; + + // Persist the user message at turn start so it has a seq + // number before the first step generates. This enables the + // FE to syncTail during generation (CR-6). + await deps.conversationStore.append(conversationId, [userMsg]); + + let stepsPersisted = false; + const result = await deps.runTurn({ + ...opts, + // Incremental persistence: persist each step's messages + // as they are finalized. Seq numbers are assigned during + // generation, so the FE can GET /conversations/:id?sinceSeq=N + // mid-turn and pick up committed chunks (CR-6). + onStepComplete: async (stepMessages) => { + await deps.conversationStore.append(conversationId, stepMessages); + stepsPersisted = true; + }, + }); + + // Fallback: if onStepComplete was never called (e.g., a fake + // runTurn in tests), persist all result messages as a batch. + if (!stepsPersisted && result.messages.length > 0) { + await deps.conversationStore.append(conversationId, result.messages); + } + + const turnMetrics = metrics.build(turnId); + await deps.conversationStore.appendMetrics(conversationId, turnMetrics); + + emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId }); + sealed = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + emitToHub(conversationId, { + type: "error", + conversationId, + turnId, + message, + }); + } finally { + activeTurns.delete(conversationId); + // Post-seal carry: if the turn sealed with a non-empty steering queue + // (no tool call fired → drainSteering never drained it), start a NEW + // detached turn whose opening user-message carries the combined text. + // The new turn re-adds to activeTurns + activeConversations, so skip + // the activeConversations.delete when carried. May chain (user keeps + // steering) — each carried turn's own finally re-checks the queue. + const carried = sealed && tryCarryQueue(conversationId); + if (!carried) { + activeConversations.delete(conversationId); + } + void payloadPromise.then((payload) => { + deps.emit?.(turnSettled, payload); + if (!carried) { + // Resolve the persisted workspace id before emitting so the + // broadcast carries the correct workspace. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "idle", + workspaceId, + }); + }); + void deps.conversationStore.setConversationStatus(conversationId, "idle"); + // Fire-and-forget auto-compaction: check threshold and + // compact if exceeded. Non-blocking — the next turn + // starts fresh either way. + const compaction = deps.resolveCompaction?.(); + if (compaction !== undefined) { + void compaction + .compact(conversationId, { + auto: true, + ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), + }) + .catch(() => {}); + } + } + }); + } + })(); + } + + const orchestrator: SessionOrchestrator = { + startTurn({ + conversationId, + text, + modelName, + cwd, + computerId, + reasoningEffort, + workspaceId, + systemPrompt, + }) { + if (activeTurns.has(conversationId)) { + return { started: false, reason: "already-active" }; + } + runTurnDetached( + conversationId, + text, + modelName, + cwd, + computerId, + reasoningEffort, + workspaceId ?? "default", + systemPrompt, + ); + const turn = activeTurns.get(conversationId); + const turnId = turn !== undefined ? turn.turnId : ""; + return { started: true, turnId }; + }, + + enqueue({ conversationId, text, workspaceId, computerId }) { + const result = orchestrator.startTurn({ + conversationId, + text, + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(computerId !== undefined ? { computerId } : {}), + }); + if (result.started) { + return { startedTurn: true, queue: [] }; + } + // Already active → enqueue onto the steering queue. When the + // message-queue extension isn't loaded this degrades: the message is + // dropped and the snapshot is empty (feature off). + const queue = deps.resolveQueue?.(); + const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : []; + return { startedTurn: false, queue: snapshot }; + }, + + subscribe(conversationId, listener) { + let listeners = subscribers.get(conversationId); + if (listeners === undefined) { + listeners = new Set(); + subscribers.set(conversationId, listeners); + } + const turn = activeTurns.get(conversationId); + if (turn !== undefined) { + const snapshot = [...turn.buffer]; + listeners.add(listener); + for (const event of snapshot) { + listener(event); + } + } else { + listeners.add(listener); + } + return () => { + const set = subscribers.get(conversationId); + if (set !== undefined) { + set.delete(listener); + if (set.size === 0) { + subscribers.delete(conversationId); + } + } + }; + }, + + isActive(conversationId) { + return activeTurns.has(conversationId); + }, + + closeConversation(conversationId) { + const turn = activeTurns.get(conversationId); + const abortedTurn = turn !== undefined; + if (turn !== undefined) { + turn.controller.abort(); + } + deps.emit?.(conversationClosed, { conversationId }); + // Resolve the persisted workspace id before emitting so the + // broadcast carries the correct workspace. The hook is + // fire-and-forget; closeConversation stays synchronous (returns + // immediately) while the status-changed emit resolves async. + void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => { + deps.emit?.(conversationStatusChanged, { + conversationId, + status: "closed", + workspaceId, + }); + }); + void deps.conversationStore.setConversationStatus(conversationId, "closed"); + return { abortedTurn }; + }, + + stopTurn(conversationId) { + const turn = activeTurns.get(conversationId); + const abortedTurn = turn !== undefined; + if (turn !== undefined) { + turn.controller.abort(); + } + return { abortedTurn }; + }, + + async handleMessage({ + conversationId, + text, + onEvent, + modelName, + cwd, + computerId, + reasoningEffort, + workspaceId, + systemPrompt, + }) { + const turnInput: StartTurnInput = { + conversationId, + text, + ...(modelName !== undefined ? { modelName } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + ...(systemPrompt !== undefined ? { systemPrompt } : {}), + }; + const result = orchestrator.startTurn(turnInput); + if (!result.started) { + const errorTurnId = generateTurnId(); + onEvent({ + type: "error", + conversationId, + turnId: errorTurnId, + message: "turn already active for this conversation", + }); + return; + } + + await new Promise<void>((resolve) => { + const unsubscribe = orchestrator.subscribe(conversationId, (event) => { + onEvent(event); + if (event.type === "turn-sealed" || event.type === "error") { + unsubscribe(); + resolve(); + } + }); + }); + }, + }; + + return { orchestrator, activeConversations }; } export function createWarmService( - deps: WarmServiceDeps, - activeConversations: ReadonlySet<string>, + deps: WarmServiceDeps, + activeConversations: ReadonlySet<string>, ): WarmService { - return { - async warm(conversationId, opts) { - if (activeConversations.has(conversationId)) { - return { error: "conversation is generating" }; - } - - const history = await deps.conversationStore.load(conversationId); - if (history.length === 0) { - return { error: "no history" }; - } - - let provider: ProviderContract; - let modelOverride: string | undefined; - - // Resolve the model the SAME way the real turn does: per-turn override - // → persisted per-conversation model → default provider. A mismatch here - // silently busts the prompt cache (the model block of the prompt prefix - // diverges from the real turn's). Warm is a probe — it does NOT persist - // (no setModel), it only reads so it sends the same model the next real - // turn will. See notes/observability-design.md §3.1. - const storedModel = await deps.conversationStore.getModel(conversationId); - const effectiveModelName = resolveModelName(opts?.modelName, storedModel); - - if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { - const resolved = deps.resolveModel(effectiveModelName); - if (resolved === undefined) { - return { error: `unknown model: ${effectiveModelName}` }; - } - provider = resolved.provider; - modelOverride = resolved.model; - } else { - provider = deps.resolveProvider(); - } - - const baseTools = deps.resolveTools(); - // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd - // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the - // `load_skill` description per-cwd). If the warm assembles tools under a - // different cwd than the real turn, the tools block — the FIRST bytes of - // the prompt-cache prefix — diverges and the cache misses entirely (0%). - // A manual reheat sends no cwd, so without this fallback it would warm the - // wrong prefix. See notes/observability-design.md §3.1. - const cwd = - (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined; - const assembled = await deps.applyToolsFilter({ - tools: baseTools, - conversationId, - ...(cwd !== undefined ? { cwd } : {}), - }); - - // Resolve reasoning effort the SAME way the real turn does (stored → "high"; - // no per-turn override on warm). A mismatch here silently busts the prompt cache. - const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId); - const resolvedEffort = resolveReasoningEffort(undefined, storedEffort); - - const probeMsg: ChatMessage = { - role: "user", - chunks: [{ type: "text", text: "reply with just a ." }], - }; - const messages = [...history, probeMsg]; - - // Capture the warm send as a `provider.request` span, flagged `warm: true` - // so it can be diffed against the corresponding real turn's request (the - // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md - // §3.1). Without this the warm body is invisible and the cache bust is - // undebuggable. The child-bound `warm` attribute flows into the span the - // provider opens (kernel logger merges child attrs into span attributes). - const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); - const providerOpts: ProviderStreamOptions = { - maxTokens: 1, - reasoningEffort: resolvedEffort, - ...(modelOverride !== undefined ? { model: modelOverride } : {}), - ...(warmLogger !== undefined ? { logger: warmLogger } : {}), - }; - - let inputTokens = 0; - let outputTokens = 0; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; - - for await (const event of provider.stream(messages, assembled.tools, providerOpts)) { - if ((event as ProviderEvent).type === "usage") { - const usageEvent = event as UsageEvent; - inputTokens = usageEvent.usage.inputTokens; - outputTokens = usageEvent.usage.outputTokens; - cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0; - cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0; - } - } - - const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }; - deps.emit(warmCompleted, { conversationId, usage: result }); - return result; - }, - }; + return { + async warm(conversationId, opts) { + if (activeConversations.has(conversationId)) { + return { error: "conversation is generating" }; + } + + const history = await deps.conversationStore.load(conversationId); + if (history.length === 0) { + return { error: "no history" }; + } + + let provider: ProviderContract; + let modelOverride: string | undefined; + + // Resolve the model the SAME way the real turn does: per-turn override + // → persisted per-conversation model → default provider. A mismatch here + // silently busts the prompt cache (the model block of the prompt prefix + // diverges from the real turn's). Warm is a probe — it does NOT persist + // (no setModel), it only reads so it sends the same model the next real + // turn will. See notes/observability-design.md §3.1. + const storedModel = await deps.conversationStore.getModel(conversationId); + const effectiveModelName = resolveModelName(opts?.modelName, storedModel); + + if (effectiveModelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(effectiveModelName); + if (resolved === undefined) { + return { error: `unknown model: ${effectiveModelName}` }; + } + provider = resolved.provider; + modelOverride = resolved.model; + } else { + provider = deps.resolveProvider(); + } + + const baseTools = deps.resolveTools(); + // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd + // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the + // `load_skill` description per-cwd). If the warm assembles tools under a + // different cwd than the real turn, the tools block — the FIRST bytes of + // the prompt-cache prefix — diverges and the cache misses entirely (0%). + // A manual reheat sends no cwd, so without this fallback it would warm the + // wrong prefix. See notes/observability-design.md §3.1. + const cwd = + (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined; + const assembled = await deps.applyToolsFilter({ + tools: baseTools, + conversationId, + ...(cwd !== undefined ? { cwd } : {}), + }); + + // Resolve reasoning effort the SAME way the real turn does (stored → "high"; + // no per-turn override on warm). A mismatch here silently busts the prompt cache. + const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId); + const resolvedEffort = resolveReasoningEffort(undefined, storedEffort); + + const probeMsg: ChatMessage = { + role: "user", + chunks: [{ type: "text", text: "reply with just a ." }], + }; + const messages = [...history, probeMsg]; + + // Capture the warm send as a `provider.request` span, flagged `warm: true` + // so it can be diffed against the corresponding real turn's request (the + // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md + // §3.1). Without this the warm body is invisible and the cache bust is + // undebuggable. The child-bound `warm` attribute flows into the span the + // provider opens (kernel logger merges child attrs into span attributes). + const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } }); + const providerOpts: ProviderStreamOptions = { + maxTokens: 1, + reasoningEffort: resolvedEffort, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(warmLogger !== undefined ? { logger: warmLogger } : {}), + }; + + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; + + for await (const event of provider.stream(messages, assembled.tools, providerOpts)) { + if ((event as ProviderEvent).type === "usage") { + const usageEvent = event as UsageEvent; + inputTokens = usageEvent.usage.inputTokens; + outputTokens = usageEvent.usage.outputTokens; + cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0; + cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0; + } + } + + const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }; + deps.emit(warmCompleted, { conversationId, usage: result }); + return result; + }, + }; } const DEFAULT_KEEP_LAST_N = 10; const DEFAULT_COMPACT_PERCENT = 85; const COMPACTION_SYSTEM_PROMPT = - "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " + - "Focus on key decisions, context, file paths, and any unresolved questions. " + - "The summary must preserve enough detail for the conversation to continue with full context."; + "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " + + "Focus on key decisions, context, file paths, and any unresolved questions. " + + "The summary must preserve enough detail for the conversation to continue with full context."; function formatMessagesForSummary(messages: readonly ChatMessage[]): string { - return messages - .map((msg) => { - const text = msg.chunks - .map((c) => { - if (c.type === "text") return c.text; - if (c.type === "tool-call") return `[tool: ${c.toolName}]`; - if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`; - return ""; - }) - .join(""); - return `${msg.role}: ${text}`; - }) - .join("\n\n"); + return messages + .map((msg) => { + const text = msg.chunks + .map((c) => { + if (c.type === "text") return c.text; + if (c.type === "tool-call") return `[tool: ${c.toolName}]`; + if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`; + return ""; + }) + .join(""); + return `${msg.role}: ${text}`; + }) + .join("\n\n"); } export function createCompactionService( - deps: SessionOrchestratorDeps & { - readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; - }, - activeConversations: ReadonlySet<string>, + deps: SessionOrchestratorDeps & { + readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void; + }, + activeConversations: ReadonlySet<string>, ): CompactionService { - return { - async compact(conversationId, opts) { - if (activeConversations.has(conversationId)) { - return { error: "conversation is generating" }; - } - - const history = await deps.conversationStore.load(conversationId); - const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N; - - if (history.length <= keepLastN) { - return { error: "conversation too short to compact" }; - } - - // Auto mode: check if contextSize exceeds percent of contextWindow. - if (opts?.auto === true) { - const stored = await deps.conversationStore.getCompactPercent(conversationId); - const percent = stored ?? DEFAULT_COMPACT_PERCENT; - if (percent <= 0) return { error: "auto-compact disabled" }; - const metrics = await deps.conversationStore.loadMetrics(conversationId); - const lastTurn = metrics[metrics.length - 1]; - if (lastTurn === undefined) return { error: "no metrics" }; - const contextSize = lastTurn.contextSize; - if (contextSize === undefined) return { error: "no context size" }; - - // Resolve the model's context window. - const modelName = opts.modelName; - if (modelName === undefined || deps.resolveModelInfo === undefined) { - return { error: "cannot resolve model info" }; - } - const info = await deps.resolveModelInfo(modelName); - if (info?.contextWindow === undefined) { - return { error: "model context window unknown" }; - } - const threshold = Math.floor(info.contextWindow * (percent / 100)); - if (contextSize < threshold) return { error: "threshold not exceeded" }; - } - - // Split: old messages to summarize + recent messages to keep. - const toSummarize = history.slice(0, history.length - keepLastN); - const toKeep = history.slice(history.length - keepLastN); - - // Resolve provider - let provider: ProviderContract; - let modelOverride: string | undefined; - if (opts?.modelName !== undefined && deps.resolveModel !== undefined) { - const resolved = deps.resolveModel(opts.modelName); - if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` }; - provider = resolved.provider; - modelOverride = resolved.model; - } else { - provider = deps.resolveProvider(); - } - - // Build the summarization request: system prompt + conversation text + instruction - const conversationText = formatMessagesForSummary(toSummarize); - const summaryRequest: ChatMessage = { - role: "user", - chunks: [ - { - type: "text", - text: `Please summarize the following conversation:\n\n${conversationText}`, - }, - ], - }; - - const providerOpts: ProviderStreamOptions = { - maxTokens: 2000, - ...(modelOverride !== undefined ? { model: modelOverride } : {}), - ...(deps.logger !== undefined - ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) } - : {}), - }; - - // Reconstruct the system prompt on compaction (fresh variable - // resolution — files/cwd/time may have changed since construction). - // The construct call also persists the result for future turns. When - // the system-prompt service is unavailable, fall back to the - // compaction-only system prompt (current behavior, no regression). - const systemPromptService = deps.resolveSystemPrompt?.(); - let compactionSystemPrompt: string; - if (systemPromptService !== undefined) { - const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd(); - const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId); - const computerId = await deps.conversationStore.getEffectiveComputer(conversationId); - const constructed = await systemPromptService.construct(conversationId, cwd, { - ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}), - workspaceId, - ...(computerId !== null ? { computerId } : {}), - }); - compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`; - } else { - compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT; - } - - // Call the provider and accumulate the summary - let summary = ""; - for await (const event of provider.stream([summaryRequest], [], { - ...providerOpts, - systemPrompt: compactionSystemPrompt, - })) { - if ((event as ProviderEvent).type === "text-delta") { - summary += (event as { delta: string }).delta; - } else if ((event as ProviderEvent).type === "error") { - return { error: (event as { message: string }).message }; - } - } - - if (summary.trim().length === 0) { - return { error: "model produced empty summary" }; - } - - // Non-destructive: fork the full pre-compaction history to a new - // archive conversation. The original conversation keeps its ID - // (so messaging between agents still works) and gets the compacted - // content. The archive inherits the original's compactedFrom, - // creating a chain: A → Y → X → ... - const archiveId = crypto.randomUUID(); - await deps.conversationStore.forkHistory(conversationId, archiveId); - - // Replace history: [system: summary] + recent messages - const summaryMessage: ChatMessage = { - role: "system", - chunks: [ - { - type: "text", - text: `The following is a summary of the previous conversation:\n\n${summary}`, - }, - ], - }; - - await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]); - await deps.conversationStore.setCompactedFrom(conversationId, archiveId); - - const result: CompactionResult = { - summary, - newConversationId: archiveId, - messagesSummarized: toSummarize.length, - messagesKept: toKeep.length, - }; - - deps.emit(conversationCompacted, { - conversationId, - newConversationId: archiveId, - messagesSummarized: toSummarize.length, - messagesKept: toKeep.length, - }); - - return result; - }, - }; + return { + async compact(conversationId, opts) { + if (activeConversations.has(conversationId)) { + return { error: "conversation is generating" }; + } + + const history = await deps.conversationStore.load(conversationId); + const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N; + + if (history.length <= keepLastN) { + return { error: "conversation too short to compact" }; + } + + // Auto mode: check if contextSize exceeds percent of contextWindow. + if (opts?.auto === true) { + const stored = await deps.conversationStore.getCompactPercent(conversationId); + const percent = stored ?? DEFAULT_COMPACT_PERCENT; + if (percent <= 0) return { error: "auto-compact disabled" }; + const metrics = await deps.conversationStore.loadMetrics(conversationId); + const lastTurn = metrics[metrics.length - 1]; + if (lastTurn === undefined) return { error: "no metrics" }; + const contextSize = lastTurn.contextSize; + if (contextSize === undefined) return { error: "no context size" }; + + // Resolve the model's context window. + const modelName = opts.modelName; + if (modelName === undefined || deps.resolveModelInfo === undefined) { + return { error: "cannot resolve model info" }; + } + const info = await deps.resolveModelInfo(modelName); + if (info?.contextWindow === undefined) { + return { error: "model context window unknown" }; + } + const threshold = Math.floor(info.contextWindow * (percent / 100)); + if (contextSize < threshold) return { error: "threshold not exceeded" }; + } + + // Split: old messages to summarize + recent messages to keep. + const toSummarize = history.slice(0, history.length - keepLastN); + const toKeep = history.slice(history.length - keepLastN); + + // Resolve provider + let provider: ProviderContract; + let modelOverride: string | undefined; + if (opts?.modelName !== undefined && deps.resolveModel !== undefined) { + const resolved = deps.resolveModel(opts.modelName); + if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` }; + provider = resolved.provider; + modelOverride = resolved.model; + } else { + provider = deps.resolveProvider(); + } + + // Build the summarization request: system prompt + conversation text + instruction + const conversationText = formatMessagesForSummary(toSummarize); + const summaryRequest: ChatMessage = { + role: "user", + chunks: [ + { + type: "text", + text: `Please summarize the following conversation:\n\n${conversationText}`, + }, + ], + }; + + const providerOpts: ProviderStreamOptions = { + maxTokens: 2000, + ...(modelOverride !== undefined ? { model: modelOverride } : {}), + ...(deps.logger !== undefined + ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) } + : {}), + }; + + // Reconstruct the system prompt on compaction (fresh variable + // resolution — files/cwd/time may have changed since construction). + // The construct call also persists the result for future turns. When + // the system-prompt service is unavailable, fall back to the + // compaction-only system prompt (current behavior, no regression). + const systemPromptService = deps.resolveSystemPrompt?.(); + let compactionSystemPrompt: string; + if (systemPromptService !== undefined) { + const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd(); + const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId); + const computerId = await deps.conversationStore.getEffectiveComputer(conversationId); + const constructed = await systemPromptService.construct(conversationId, cwd, { + ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}), + workspaceId, + ...(computerId !== null ? { computerId } : {}), + }); + compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`; + } else { + compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT; + } + + // Call the provider and accumulate the summary + let summary = ""; + for await (const event of provider.stream([summaryRequest], [], { + ...providerOpts, + systemPrompt: compactionSystemPrompt, + })) { + if ((event as ProviderEvent).type === "text-delta") { + summary += (event as { delta: string }).delta; + } else if ((event as ProviderEvent).type === "error") { + return { error: (event as { message: string }).message }; + } + } + + if (summary.trim().length === 0) { + return { error: "model produced empty summary" }; + } + + // Non-destructive: fork the full pre-compaction history to a new + // archive conversation. The original conversation keeps its ID + // (so messaging between agents still works) and gets the compacted + // content. The archive inherits the original's compactedFrom, + // creating a chain: A → Y → X → ... + const archiveId = crypto.randomUUID(); + await deps.conversationStore.forkHistory(conversationId, archiveId); + + // Replace history: [system: summary] + recent messages + const summaryMessage: ChatMessage = { + role: "system", + chunks: [ + { + type: "text", + text: `The following is a summary of the previous conversation:\n\n${summary}`, + }, + ], + }; + + await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]); + await deps.conversationStore.setCompactedFrom(conversationId, archiveId); + + const result: CompactionResult = { + summary, + newConversationId: archiveId, + messagesSummarized: toSummarize.length, + messagesKept: toKeep.length, + }; + + deps.emit(conversationCompacted, { + conversationId, + newConversationId: archiveId, + messagesSummarized: toSummarize.length, + messagesKept: toKeep.length, + }); + + return result; + }, + }; } diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts index 31c55da..16591ec 100644 --- a/packages/system-prompt/src/service.test.ts +++ b/packages/system-prompt/src/service.test.ts @@ -5,323 +5,323 @@ import { createSystemPromptService, DEFAULT_TEMPLATE } from "./service.js"; /** In-memory StorageNamespace for tests. */ function memoryStorage(): StorageNamespace { - const store = new Map<string, string>(); - return { - get: async (key: string) => store.get(key) ?? null, - set: async (key: string, value: string) => { - store.set(key, value); - }, - delete: async (key: string) => { - store.delete(key); - }, - has: async (key: string) => store.has(key), - keys: async (prefix?: string) => - [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const store = new Map<string, string>(); + return { + get: async (key: string) => store.get(key) ?? null, + set: async (key: string, value: string) => { + store.set(key, value); + }, + delete: async (key: string) => { + store.delete(key); + }, + has: async (key: string) => store.has(key), + keys: async (prefix?: string) => + [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } function fakeFs(files: ReadonlyMap<string, string>): ResolverFs { - return { - readText: async (path: string) => files.get(path) ?? "", - exists: async (path: string) => files.has(path), - }; + return { + readText: async (path: string) => files.get(path) ?? "", + exists: async (path: string) => files.has(path), + }; } const failSpawn = async (): Promise<GitSpawnResult> => ({ - stdout: "", - stderr: "", - exitCode: 128, + stdout: "", + stderr: "", + exitCode: 128, }); function adapters(files: ReadonlyMap<string, string>): ResolverAdapters { - return { - spawn: failSpawn, - fs: fakeFs(files), - now: () => new Date("2024-06-15T12:30:00.000Z"), - platform: () => "linux", - hostname: () => "myhost", - }; + return { + spawn: failSpawn, + fs: fakeFs(files), + now: () => new Date("2024-06-15T12:30:00.000Z"), + platform: () => "linux", + hostname: () => "myhost", + }; } describe("system-prompt service", () => { - it("construct persists and returns the resolved string", async () => { - // 14. construct writes to storage and returns the resolved string. - const storage = memoryStorage(); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), - }); - - const result = await service.construct("conv-1", "/proj", { model: "gpt-4" }); - - expect(result).toContain("You are a helpful coding assistant."); - expect(result).toContain("RULES"); - expect(result).toContain("/proj"); - // persisted under resolved:<conversationId> - expect(await storage.get("resolved:conv-1")).toBe(result); - }); - - it("get returns persisted value after construct", async () => { - // 15. after construct, get returns the same string. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // before construct → null - expect(await service.get("conv-2")).toBeNull(); - - const result = await service.construct("conv-2", "/proj"); - expect(await service.get("conv-2")).toBe(result); - }); - - it("get returns null before construct", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - expect(await service.get("never-constructed")).toBeNull(); - }); - - it("empty/no template stored → default template → non-empty", async () => { - // 16. no template stored → default template used → resolves to non-empty. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), // no AGENTS.md - }); - - const result = await service.construct("conv-3", "/proj"); - - expect(result.length).toBeGreaterThan(0); - expect(result).toContain("You are a helpful coding assistant."); - expect(result).toContain("/proj"); - // no AGENTS.md file → the [if file:AGENTS.md] block is omitted - expect(result).not.toContain("AGENTS.md"); - }); - - it("stored template is used instead of default", async () => { - const storage = memoryStorage(); - await storage.set("template", "cwd=[prompt:cwd] os=[system:os]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const result = await service.construct("conv-4", "/work"); - expect(result).toBe("cwd=/work os=linux"); - }); - - it("empty stored template → empty string", async () => { - const storage = memoryStorage(); - await storage.set("template", ""); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const result = await service.construct("conv-5", "/proj"); - expect(result).toBe(""); - expect(await service.get("conv-5")).toBe(""); - }); - - it("construct is independent per conversation", async () => { - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const a = await service.construct("conv-a", "/dir-a"); - const b = await service.construct("conv-b", "/dir-b"); - - expect(a).toBe("/dir-a"); - expect(b).toBe("/dir-b"); - expect(await service.get("conv-a")).toBe("/dir-a"); - expect(await service.get("conv-b")).toBe("/dir-b"); - }); - - it("DEFAULT_TEMPLATE contains the expected structure", () => { - expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant."); - expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]"); - expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); - expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); - }); - - it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { - // 1. never constructed → both fields null. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - const meta = await service.getWithMeta("never-constructed"); - expect(meta).toEqual({ prompt: null, cwd: null, computerId: null }); - }); - - it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { - // 2. after construct → prompt + exact cwd passed to construct. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), - }); - - const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); - const meta = await service.getWithMeta("conv-meta"); - - expect(meta.prompt).toBe(result); - expect(meta.cwd).toBe("/proj"); - }); - - it("get still returns the same value as before (backward compat)", async () => { - // 3. get() behavior is unchanged by the additive getWithMeta. - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // before construct → null - expect(await service.get("conv-bc")).toBeNull(); - - const result = await service.construct("conv-bc", "/proj"); - expect(await service.get("conv-bc")).toBe(result); - }); - - it("construct called twice with different cwds stores the latest cwd", async () => { - // 4. second construct overwrites the cwd (not the first). - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - await service.construct("conv-twice", "/first"); - expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); - - const second = await service.construct("conv-twice", "/second"); - expect(second).toBe("/second"); - expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); - expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); - }); - - it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { - // 5. getWithMeta reflects the latest construct, not the first. - const storage = memoryStorage(); - await storage.set("template", "[prompt:cwd]"); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const first = await service.construct("conv-second", "/dir-a"); - const firstMeta = await service.getWithMeta("conv-second"); - expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null }); - - const second = await service.construct("conv-second", "/dir-b"); - const secondMeta = await service.getWithMeta("conv-second"); - expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); - expect(secondMeta.cwd).not.toBe("/dir-a"); - }); - - // ── resolveText: resolve an arbitrary template (no persistence) ──────────── - - it("resolveText substitutes [type:name] variables in an arbitrary template", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - const result = await service.resolveText( - "os=[system:os] cwd=[prompt:cwd] ws=[prompt:workspace_id] conv=[prompt:conversation_id] model=[prompt:model]", - "/work", - { model: "gpt-4", conversationId: "c1", workspaceId: "ws-1" }, - ); - - expect(result).toBe("os=linux cwd=/work ws=ws-1 conv=c1 model=gpt-4"); - }); - - it("resolveText uses the SAME resolution as construct (same resolver + variables)", async () => { - // A template resolved via resolveText must equal the same template - // resolved via construct (both go through the shared resolveTemplate). - const storage = memoryStorage(); - const template = "os=[system:os] cwd=[prompt:cwd] date=[system:date]"; - await storage.set("template", template); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - const viaConstruct = await service.construct("conv-rt", "/work"); - const viaResolveText = await service.resolveText(template, "/work"); - expect(viaResolveText).toBe(viaConstruct); - }); - - it("resolveText does NOT persist (unlike construct)", async () => { - const storage = memoryStorage(); - const service = createSystemPromptService({ - storage, - adapters: adapters(new Map()), - }); - - await service.resolveText("[prompt:cwd]", "/work", { conversationId: "no-persist" }); - - // No resolved:* / resolved-cwd:* / resolved-computer:* keys written. - expect(await storage.get("resolved:no-persist")).toBeNull(); - expect(await storage.get("resolved-cwd:no-persist")).toBeNull(); - expect(await storage.get("resolved-computer:no-persist")).toBeNull(); - }); - - it("resolveText resolves dynamic file:<path> variables referenced by the template", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map([["/work/AGENTS.md", "RULES"]])), - }); - - const result = await service.resolveText("[file:AGENTS.md]", "/work"); - expect(result).toBe("RULES"); - }); - - it("resolveText handles conditional blocks ([if]/[else]/[endif])", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // [prompt:model] exists (string) → then-branch renders. - const withModel = await service.resolveText( - "[if prompt:model]has model[else]no model[endif]", - "/work", - { model: "gpt-4" }, - ); - expect(withModel).toBe("has model"); - - // model absent → else-branch renders. - const noModel = await service.resolveText( - "[if prompt:model]has model[else]no model[endif]", - "/work", - ); - expect(noModel).toBe("no model"); - }); - - it("resolveText leaves unknown [type:name] tags substituted as empty (mirrors parser)", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - - // An unknown variable (not in the catalog) → empty string, matching - // parseTemplate's "key absent from the map → empty string" rule. - const result = await service.resolveText("x=[unknown:thing]", "/work"); - expect(result).toBe("x="); - }); - - it("resolveText on an empty template returns an empty string", async () => { - const service = createSystemPromptService({ - storage: memoryStorage(), - adapters: adapters(new Map()), - }); - expect(await service.resolveText("", "/work")).toBe(""); - }); + it("construct persists and returns the resolved string", async () => { + // 14. construct writes to storage and returns the resolved string. + const storage = memoryStorage(); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-1", "/proj", { model: "gpt-4" }); + + expect(result).toContain("You are a helpful coding assistant."); + expect(result).toContain("RULES"); + expect(result).toContain("/proj"); + // persisted under resolved:<conversationId> + expect(await storage.get("resolved:conv-1")).toBe(result); + }); + + it("get returns persisted value after construct", async () => { + // 15. after construct, get returns the same string. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-2")).toBeNull(); + + const result = await service.construct("conv-2", "/proj"); + expect(await service.get("conv-2")).toBe(result); + }); + + it("get returns null before construct", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + expect(await service.get("never-constructed")).toBeNull(); + }); + + it("empty/no template stored → default template → non-empty", async () => { + // 16. no template stored → default template used → resolves to non-empty. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), // no AGENTS.md + }); + + const result = await service.construct("conv-3", "/proj"); + + expect(result.length).toBeGreaterThan(0); + expect(result).toContain("You are a helpful coding assistant."); + expect(result).toContain("/proj"); + // no AGENTS.md file → the [if file:AGENTS.md] block is omitted + expect(result).not.toContain("AGENTS.md"); + }); + + it("stored template is used instead of default", async () => { + const storage = memoryStorage(); + await storage.set("template", "cwd=[prompt:cwd] os=[system:os]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const result = await service.construct("conv-4", "/work"); + expect(result).toBe("cwd=/work os=linux"); + }); + + it("empty stored template → empty string", async () => { + const storage = memoryStorage(); + await storage.set("template", ""); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const result = await service.construct("conv-5", "/proj"); + expect(result).toBe(""); + expect(await service.get("conv-5")).toBe(""); + }); + + it("construct is independent per conversation", async () => { + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const a = await service.construct("conv-a", "/dir-a"); + const b = await service.construct("conv-b", "/dir-b"); + + expect(a).toBe("/dir-a"); + expect(b).toBe("/dir-b"); + expect(await service.get("conv-a")).toBe("/dir-a"); + expect(await service.get("conv-b")).toBe("/dir-b"); + }); + + it("DEFAULT_TEMPLATE contains the expected structure", () => { + expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant."); + expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]"); + expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]"); + expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]"); + }); + + it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => { + // 1. never constructed → both fields null. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const meta = await service.getWithMeta("never-constructed"); + expect(meta).toEqual({ prompt: null, cwd: null, computerId: null }); + }); + + it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => { + // 2. after construct → prompt + exact cwd passed to construct. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])), + }); + + const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" }); + const meta = await service.getWithMeta("conv-meta"); + + expect(meta.prompt).toBe(result); + expect(meta.cwd).toBe("/proj"); + }); + + it("get still returns the same value as before (backward compat)", async () => { + // 3. get() behavior is unchanged by the additive getWithMeta. + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // before construct → null + expect(await service.get("conv-bc")).toBeNull(); + + const result = await service.construct("conv-bc", "/proj"); + expect(await service.get("conv-bc")).toBe(result); + }); + + it("construct called twice with different cwds stores the latest cwd", async () => { + // 4. second construct overwrites the cwd (not the first). + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.construct("conv-twice", "/first"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first"); + + const second = await service.construct("conv-twice", "/second"); + expect(second).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second"); + expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first"); + }); + + it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => { + // 5. getWithMeta reflects the latest construct, not the first. + const storage = memoryStorage(); + await storage.set("template", "[prompt:cwd]"); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const first = await service.construct("conv-second", "/dir-a"); + const firstMeta = await service.getWithMeta("conv-second"); + expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null }); + + const second = await service.construct("conv-second", "/dir-b"); + const secondMeta = await service.getWithMeta("conv-second"); + expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null }); + expect(secondMeta.cwd).not.toBe("/dir-a"); + }); + + // ── resolveText: resolve an arbitrary template (no persistence) ──────────── + + it("resolveText substitutes [type:name] variables in an arbitrary template", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + const result = await service.resolveText( + "os=[system:os] cwd=[prompt:cwd] ws=[prompt:workspace_id] conv=[prompt:conversation_id] model=[prompt:model]", + "/work", + { model: "gpt-4", conversationId: "c1", workspaceId: "ws-1" }, + ); + + expect(result).toBe("os=linux cwd=/work ws=ws-1 conv=c1 model=gpt-4"); + }); + + it("resolveText uses the SAME resolution as construct (same resolver + variables)", async () => { + // A template resolved via resolveText must equal the same template + // resolved via construct (both go through the shared resolveTemplate). + const storage = memoryStorage(); + const template = "os=[system:os] cwd=[prompt:cwd] date=[system:date]"; + await storage.set("template", template); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + const viaConstruct = await service.construct("conv-rt", "/work"); + const viaResolveText = await service.resolveText(template, "/work"); + expect(viaResolveText).toBe(viaConstruct); + }); + + it("resolveText does NOT persist (unlike construct)", async () => { + const storage = memoryStorage(); + const service = createSystemPromptService({ + storage, + adapters: adapters(new Map()), + }); + + await service.resolveText("[prompt:cwd]", "/work", { conversationId: "no-persist" }); + + // No resolved:* / resolved-cwd:* / resolved-computer:* keys written. + expect(await storage.get("resolved:no-persist")).toBeNull(); + expect(await storage.get("resolved-cwd:no-persist")).toBeNull(); + expect(await storage.get("resolved-computer:no-persist")).toBeNull(); + }); + + it("resolveText resolves dynamic file:<path> variables referenced by the template", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map([["/work/AGENTS.md", "RULES"]])), + }); + + const result = await service.resolveText("[file:AGENTS.md]", "/work"); + expect(result).toBe("RULES"); + }); + + it("resolveText handles conditional blocks ([if]/[else]/[endif])", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // [prompt:model] exists (string) → then-branch renders. + const withModel = await service.resolveText( + "[if prompt:model]has model[else]no model[endif]", + "/work", + { model: "gpt-4" }, + ); + expect(withModel).toBe("has model"); + + // model absent → else-branch renders. + const noModel = await service.resolveText( + "[if prompt:model]has model[else]no model[endif]", + "/work", + ); + expect(noModel).toBe("no model"); + }); + + it("resolveText leaves unknown [type:name] tags substituted as empty (mirrors parser)", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + + // An unknown variable (not in the catalog) → empty string, matching + // parseTemplate's "key absent from the map → empty string" rule. + const result = await service.resolveText("x=[unknown:thing]", "/work"); + expect(result).toBe("x="); + }); + + it("resolveText on an empty template returns an empty string", async () => { + const service = createSystemPromptService({ + storage: memoryStorage(), + adapters: adapters(new Map()), + }); + expect(await service.resolveText("", "/work")).toBe(""); + }); }); diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts index f695f49..2741c98 100644 --- a/packages/system-prompt/src/service.ts +++ b/packages/system-prompt/src/service.ts @@ -29,20 +29,20 @@ const TEMPLATE_KEY = "template"; const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`; const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`; const resolvedComputerIdKey = (conversationId: string): string => - `resolved-computer:${conversationId}`; + `resolved-computer:${conversationId}`; export interface SystemPromptServiceDeps { - /** Namespaced KV (`host.storage("system-prompt")`). */ - readonly storage: StorageNamespace; - /** Injected effects for variable resolution (local). */ - readonly adapters: ResolverAdapters; - /** - * Optional: build remote-backed adapters for a given computerId. When - * `construct` is called with a `computerId`, this is invoked to obtain - * adapters that read/run commands on the REMOTE machine (via the - * ExecBackend/SSH). Absent → falls back to the local `adapters`. - */ - readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>; + /** Namespaced KV (`host.storage("system-prompt")`). */ + readonly storage: StorageNamespace; + /** Injected effects for variable resolution (local). */ + readonly adapters: ResolverAdapters; + /** + * Optional: build remote-backed adapters for a given computerId. When + * `construct` is called with a `computerId`, this is invoked to obtain + * adapters that read/run commands on the REMOTE machine (via the + * ExecBackend/SSH). Absent → falls back to the local `adapters`. + */ + readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>; } /** @@ -54,37 +54,37 @@ export interface SystemPromptServiceDeps { * set, mirroring `construct`. */ async function resolveTemplate( - deps: SystemPromptServiceDeps, - template: string, - cwd: string, - context?: { - readonly model?: string; - readonly conversationId?: string; - readonly workspaceId?: string; - readonly computerId?: string; - }, + deps: SystemPromptServiceDeps, + template: string, + cwd: string, + context?: { + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, ): Promise<string> { - const referencedKeys = extractVariables(template); - const resolverContext: ResolverContext = { - ...(context?.conversationId !== undefined ? { conversationId: context.conversationId } : {}), - ...(context?.model !== undefined ? { model: context.model } : {}), - ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), - }; - - // Select adapters: when computerId is set, use remote-backed adapters - // (read files / run commands on the REMOTE machine via SSH). Otherwise - // use the local adapters. - const computerId = context?.computerId; - const adapters = - computerId !== undefined && deps.resolveRemoteAdapters !== undefined - ? await deps.resolveRemoteAdapters(computerId, cwd) - : deps.adapters; - - const vars = await resolveVariables(cwd, adapters, { - context: resolverContext, - referencedKeys, - }); - return parseTemplate(template, vars); + const referencedKeys = extractVariables(template); + const resolverContext: ResolverContext = { + ...(context?.conversationId !== undefined ? { conversationId: context.conversationId } : {}), + ...(context?.model !== undefined ? { model: context.model } : {}), + ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), + }; + + // Select adapters: when computerId is set, use remote-backed adapters + // (read files / run commands on the REMOTE machine via SSH). Otherwise + // use the local adapters. + const computerId = context?.computerId; + const adapters = + computerId !== undefined && deps.resolveRemoteAdapters !== undefined + ? await deps.resolveRemoteAdapters(computerId, cwd) + : deps.adapters; + + const vars = await resolveVariables(cwd, adapters, { + context: resolverContext, + referencedKeys, + }); + return parseTemplate(template, vars); } /** @@ -92,56 +92,56 @@ async function resolveTemplate( * State is owned (not ambient): the storage reference lives in this closure. */ export function createSystemPromptService(deps: SystemPromptServiceDeps): SystemPromptService { - return { - async construct(conversationId, cwd, context) { - let template = await deps.storage.get(TEMPLATE_KEY); - if (template === null) template = DEFAULT_TEMPLATE; - - const result = await resolveTemplate(deps, template, cwd, { - conversationId, - ...(context?.model !== undefined ? { model: context.model } : {}), - ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), - ...(context?.computerId !== undefined ? { computerId: context.computerId } : {}), - }); - - await deps.storage.set(resolvedKey(conversationId), result); - await deps.storage.set(resolvedCwdKey(conversationId), cwd); - // Store the computerId (or empty string for local) so the cache can be - // invalidated when the computer changes. - await deps.storage.set(resolvedComputerIdKey(conversationId), context?.computerId ?? ""); - return result; - }, - - async resolveText(template, cwd, context) { - // An empty template has no variables to resolve; short-circuit to - // avoid spawning git / reading files for nothing (the heartbeat's - // default-empty prompts hit this every run). - if (template === "") return ""; - return resolveTemplate(deps, template, cwd, context); - }, - - async get(conversationId) { - return deps.storage.get(resolvedKey(conversationId)); - }, - - async getWithMeta(conversationId) { - const [prompt, cwd, computerIdStored] = await Promise.all([ - deps.storage.get(resolvedKey(conversationId)), - deps.storage.get(resolvedCwdKey(conversationId)), - deps.storage.get(resolvedComputerIdKey(conversationId)), - ]); - // Empty string → null (local, no computerId). Non-empty → the alias. - const computerId = computerIdStored === null ? null : computerIdStored || null; - return { prompt, cwd, computerId }; - }, - - async getTemplate() { - const stored = await deps.storage.get(TEMPLATE_KEY); - return stored ?? DEFAULT_TEMPLATE; - }, - - async setTemplate(template) { - await deps.storage.set(TEMPLATE_KEY, template); - }, - }; + return { + async construct(conversationId, cwd, context) { + let template = await deps.storage.get(TEMPLATE_KEY); + if (template === null) template = DEFAULT_TEMPLATE; + + const result = await resolveTemplate(deps, template, cwd, { + conversationId, + ...(context?.model !== undefined ? { model: context.model } : {}), + ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}), + ...(context?.computerId !== undefined ? { computerId: context.computerId } : {}), + }); + + await deps.storage.set(resolvedKey(conversationId), result); + await deps.storage.set(resolvedCwdKey(conversationId), cwd); + // Store the computerId (or empty string for local) so the cache can be + // invalidated when the computer changes. + await deps.storage.set(resolvedComputerIdKey(conversationId), context?.computerId ?? ""); + return result; + }, + + async resolveText(template, cwd, context) { + // An empty template has no variables to resolve; short-circuit to + // avoid spawning git / reading files for nothing (the heartbeat's + // default-empty prompts hit this every run). + if (template === "") return ""; + return resolveTemplate(deps, template, cwd, context); + }, + + async get(conversationId) { + return deps.storage.get(resolvedKey(conversationId)); + }, + + async getWithMeta(conversationId) { + const [prompt, cwd, computerIdStored] = await Promise.all([ + deps.storage.get(resolvedKey(conversationId)), + deps.storage.get(resolvedCwdKey(conversationId)), + deps.storage.get(resolvedComputerIdKey(conversationId)), + ]); + // Empty string → null (local, no computerId). Non-empty → the alias. + const computerId = computerIdStored === null ? null : computerIdStored || null; + return { prompt, cwd, computerId }; + }, + + async getTemplate() { + const stored = await deps.storage.get(TEMPLATE_KEY); + return stored ?? DEFAULT_TEMPLATE; + }, + + async setTemplate(template) { + await deps.storage.set(TEMPLATE_KEY, template); + }, + }; } diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts index 670430a..ef57ee0 100644 --- a/packages/system-prompt/src/types.ts +++ b/packages/system-prompt/src/types.ts @@ -15,68 +15,68 @@ import { defineService, type ServiceHandle } from "@dispatch/kernel"; * no per-turn reconstruction). */ export interface SystemPromptService { - /** - * Resolve the template against the current environment and persist the - * result under `resolved:<conversationId>`. Returns the resolved string. - * When no template is stored, the built-in default template is used. An - * empty template yields an empty string. - * - * When `context.computerId` is set, the resolver uses remote-backed adapters - * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via - * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine. - */ - construct( - conversationId: string, - cwd: string, - context?: { - readonly model?: string; - readonly workspaceId?: string; - readonly computerId?: string; - }, - ): Promise<string>; + /** + * Resolve the template against the current environment and persist the + * result under `resolved:<conversationId>`. Returns the resolved string. + * When no template is stored, the built-in default template is used. An + * empty template yields an empty string. + * + * When `context.computerId` is set, the resolver uses remote-backed adapters + * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via + * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine. + */ + construct( + conversationId: string, + cwd: string, + context?: { + readonly model?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, + ): Promise<string>; - /** - * Resolve an ARBITRARY template string against the current environment, - * using the SAME variable resolver + adapter set as `construct` (so a - * caller that owns its own prompt — e.g. the heartbeat extension — gets - * `[type:name]` placeholders substituted identically to the global - * template). Pure resolution: nothing is persisted (the result is the - * caller's to use). An empty template yields an empty string. - * - * Like `construct`, when `context.computerId` is set the resolver uses - * remote-backed adapters (reading the remote's OS/hostname/git via SSH). - */ - resolveText( - template: string, - cwd: string, - context?: { - readonly model?: string; - readonly conversationId?: string; - readonly workspaceId?: string; - readonly computerId?: string; - }, - ): Promise<string>; + /** + * Resolve an ARBITRARY template string against the current environment, + * using the SAME variable resolver + adapter set as `construct` (so a + * caller that owns its own prompt — e.g. the heartbeat extension — gets + * `[type:name]` placeholders substituted identically to the global + * template). Pure resolution: nothing is persisted (the result is the + * caller's to use). An empty template yields an empty string. + * + * Like `construct`, when `context.computerId` is set the resolver uses + * remote-backed adapters (reading the remote's OS/hostname/git via SSH). + */ + resolveText( + template: string, + cwd: string, + context?: { + readonly model?: string; + readonly conversationId?: string; + readonly workspaceId?: string; + readonly computerId?: string; + }, + ): Promise<string>; - /** Read the persisted resolved system prompt, or `null` if never constructed. */ - get(conversationId: string): Promise<string | null>; + /** Read the persisted resolved system prompt, or `null` if never constructed. */ + get(conversationId: string): Promise<string | null>; - /** - * Read the persisted resolved system prompt AND the cwd + computerId it was - * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if - * never constructed. Consumers use this to detect whether the cached prompt - * is stale relative to the current effective cwd or computerId. - */ - getWithMeta(conversationId: string): Promise<{ - readonly prompt: string | null; - readonly cwd: string | null; - readonly computerId: string | null; - }>; + /** + * Read the persisted resolved system prompt AND the cwd + computerId it was + * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if + * never constructed. Consumers use this to detect whether the cached prompt + * is stale relative to the current effective cwd or computerId. + */ + getWithMeta(conversationId: string): Promise<{ + readonly prompt: string | null; + readonly cwd: string | null; + readonly computerId: string | null; + }>; - /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ - getTemplate(): Promise<string>; + /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */ + getTemplate(): Promise<string>; - /** Set (upsert) the global template. An empty string means "no system prompt". */ - setTemplate(template: string): Promise<void>; + /** Set (upsert) the global template. An empty string means "no system prompt". */ + setTemplate(template: string): Promise<void>; } /** @@ -84,4 +84,4 @@ export interface SystemPromptService { * session-orchestrator imports to reach the builder — no string-keyed lookup. */ export const systemPromptHandle: ServiceHandle<SystemPromptService> = - defineService<SystemPromptService>("system-prompt"); + defineService<SystemPromptService>("system-prompt"); diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json index 7f58c63..660898a 100644 --- a/packages/transport-contract/package.json +++ b/packages/transport-contract/package.json @@ -1,12 +1,12 @@ { - "name": "@dispatch/transport-contract", - "version": "0.23.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/ui-contract": "workspace:*", - "@dispatch/wire": "workspace:*" - } + "name": "@dispatch/transport-contract", + "version": "0.23.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/ui-contract": "workspace:*", + "@dispatch/wire": "workspace:*" + } } diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 34f3ffb..6a9a29f 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -929,18 +929,18 @@ export interface TestComputerResponse { * heartbeats. */ export interface HeartbeatConfig { - /** Whether the heartbeat loop is active for this workspace. */ - readonly enabled: boolean; - /** Custom system prompt for the heartbeat AI (empty = no system prompt). */ - readonly systemPrompt: string; - /** Task prompt sent as the first user message when the heartbeat fires. */ - readonly taskPrompt: string; - /** How often to fire, in minutes (default 30). */ - readonly intervalMinutes: number; - /** Model name (`<credentialName>/<model>`), or empty string = server default. */ - readonly model: string; - /** Reasoning-effort level, or `null` = inherit the workspace default. */ - readonly reasoningEffort: ReasoningEffort | null; + /** Whether the heartbeat loop is active for this workspace. */ + readonly enabled: boolean; + /** Custom system prompt for the heartbeat AI (empty = no system prompt). */ + readonly systemPrompt: string; + /** Task prompt sent as the first user message when the heartbeat fires. */ + readonly taskPrompt: string; + /** How often to fire, in minutes (default 30). */ + readonly intervalMinutes: number; + /** Model name (`<credentialName>/<model>`), or empty string = server default. */ + readonly model: string; + /** Reasoning-effort level, or `null` = inherit the workspace default. */ + readonly reasoningEffort: ReasoningEffort | null; } /** @@ -951,12 +951,12 @@ export interface HeartbeatConfig { * `reasoningEffort` → HTTP 400. */ export interface UpdateHeartbeatRequest { - readonly enabled?: boolean; - readonly systemPrompt?: string; - readonly taskPrompt?: string; - readonly intervalMinutes?: number; - readonly model?: string; - readonly reasoningEffort?: ReasoningEffort | null; + readonly enabled?: boolean; + readonly systemPrompt?: string; + readonly taskPrompt?: string; + readonly intervalMinutes?: number; + readonly model?: string; + readonly reasoningEffort?: ReasoningEffort | null; } /** The status of a single heartbeat run. */ @@ -970,18 +970,18 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped"; * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`). */ export interface HeartbeatRun { - readonly id: string; - readonly conversationId: string; - readonly triggeredAt: string; - readonly status: HeartbeatRunStatus; + readonly id: string; + readonly conversationId: string; + readonly triggeredAt: string; + readonly status: HeartbeatRunStatus; } /** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */ export interface HeartbeatRunsResponse { - readonly runs: readonly HeartbeatRun[]; + readonly runs: readonly HeartbeatRun[]; } /** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */ export interface StopHeartbeatRunResponse { - readonly ok: true; + readonly ok: true; } diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index 7990c2b..3c722e2 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -1,22 +1,22 @@ { - "name": "@dispatch/transport-http", - "version": "0.0.0", - "type": "module", - "private": true, - "main": "dist/index.js", - "types": "dist/index.d.ts", - "dependencies": { - "@dispatch/conversation-store": "workspace:*", - "@dispatch/credential-store": "workspace:*", - "@dispatch/heartbeat": "workspace:*", - "@dispatch/kernel": "workspace:*", - "@dispatch/lsp": "workspace:*", - "@dispatch/mcp": "workspace:*", - "@dispatch/session-orchestrator": "workspace:*", - "@dispatch/throughput-store": "workspace:*", - "@dispatch/transport-contract": "workspace:*", - "@dispatch/wire": "workspace:*", - "hono": "^4.0.0", - "@dispatch/system-prompt": "workspace:*" - } + "name": "@dispatch/transport-http", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/conversation-store": "workspace:*", + "@dispatch/credential-store": "workspace:*", + "@dispatch/heartbeat": "workspace:*", + "@dispatch/kernel": "workspace:*", + "@dispatch/lsp": "workspace:*", + "@dispatch/mcp": "workspace:*", + "@dispatch/session-orchestrator": "workspace:*", + "@dispatch/throughput-store": "workspace:*", + "@dispatch/transport-contract": "workspace:*", + "@dispatch/wire": "workspace:*", + "hono": "^4.0.0", + "@dispatch/system-prompt": "workspace:*" + } } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 316ab67..7ae0354 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,229 +1,229 @@ import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat"; import type { - AgentEvent, - ChatMessage, - ConversationMeta, - HostAPI, - Logger, - ReasoningEffort, - StepId, - StorageNamespace, - StoredChunk, - TurnMetrics, + AgentEvent, + ChatMessage, + ConversationMeta, + HostAPI, + Logger, + ReasoningEffort, + StepId, + StorageNamespace, + StoredChunk, + TurnMetrics, } from "@dispatch/kernel"; import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt"; import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store"; import type { - DeleteWorkspaceResponse, - QueuedMessage, - QueueResponse, - SystemPromptVariable, - ThroughputResponse, - WorkspaceListResponse, - WorkspaceResponse, + DeleteWorkspaceResponse, + QueuedMessage, + QueueResponse, + SystemPromptVariable, + ThroughputResponse, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { createApp } from "./app.js"; import { extractLastAssistantText } from "./logic.js"; import type { - ComputerService, - ConversationStore, - CredentialStore, - HeartbeatService, - LspService, - McpService, - SessionOrchestrator, - SystemPromptService, - WarmService, + ComputerService, + ConversationStore, + CredentialStore, + HeartbeatService, + LspService, + McpService, + SessionOrchestrator, + SystemPromptService, + WarmService, } from "./seam.js"; import { conversationOpened } from "./seam.js"; function createMemStorage(): StorageNamespace { - const map = new Map<string, string>(); - return { - get: async (k) => map.get(k) ?? null, - set: async (k, v) => { - map.set(k, v); - }, - delete: async (k) => { - map.delete(k); - }, - has: async (k) => map.has(k), - keys: async (prefix) => - [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), - }; + const map = new Map<string, string>(); + return { + get: async (k) => map.get(k) ?? null, + set: async (k, v) => { + map.set(k, v); + }, + delete: async (k) => { + map.delete(k); + }, + has: async (k) => map.has(k), + keys: async (prefix) => + [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))), + }; } interface CapturedLog { - readonly level: "debug" | "info" | "warn" | "error"; - readonly msg: string; - readonly attrs?: Record<string, unknown>; + readonly level: "debug" | "info" | "warn" | "error"; + readonly msg: string; + readonly attrs?: Record<string, unknown>; } function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[] } { - const records: CapturedLog[] = []; - return { - get records() { - return records; - }, - debug(msg, attrs) { - records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) }); - }, - info(msg, attrs) { - records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) }); - }, - warn(msg, attrs) { - records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) }); - }, - error(msg, attrs) { - records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) }); - }, - child() { - return createFakeLogger(); - }, - span() { - return { - id: "fake-span", - log: createFakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + const records: CapturedLog[] = []; + return { + get records() { + return records; + }, + debug(msg, attrs) { + records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) }); + }, + info(msg, attrs) { + records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) }); + }, + warn(msg, attrs) { + records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) }); + }, + error(msg, attrs) { + records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) }); + }, + child() { + return createFakeLogger(); + }, + span() { + return { + id: "fake-span", + log: createFakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function createFakeConversationStore( - store: Map<string, StoredChunk[]> = new Map(), - metricsStore: Map<string, TurnMetrics[]> = new Map(), - cwdStore: Map<string, string> = new Map(), - reasoningEffortStore: Map<string, ReasoningEffort> = new Map(), - modelStore: Map<string, string> = new Map(), - computerStore: Map<string, string> = new Map(), + store: Map<string, StoredChunk[]> = new Map(), + metricsStore: Map<string, TurnMetrics[]> = new Map(), + cwdStore: Map<string, string> = new Map(), + reasoningEffortStore: Map<string, ReasoningEffort> = new Map(), + modelStore: Map<string, string> = new Map(), + computerStore: Map<string, string> = new Map(), ): ConversationStore { - const sampleWorkspace = { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - return { - async append() {}, - async load() { - return []; - }, - async loadSince(conversationId, sinceSeq, window) { - const chunks = store.get(conversationId) ?? []; - const minSeq = sinceSeq ?? 0; - const beforeSeq = window?.beforeSeq; - const limit = window?.limit; - const selected = chunks.filter( - (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq), - ); - // Window: keep only the NEWEST `limit`, still ascending by seq. - if (limit !== undefined && selected.length > limit) { - return selected.slice(selected.length - limit); - } - return selected; - }, - async appendMetrics() {}, - async loadMetrics(conversationId) { - return metricsStore.get(conversationId) ?? []; - }, - async getCwd(conversationId) { - return cwdStore.get(conversationId) ?? null; - }, - async setCwd(conversationId, cwd) { - cwdStore.set(conversationId, cwd); - }, - async clearCwd(conversationId) { - cwdStore.delete(conversationId); - }, - async getComputerId(conversationId) { - return computerStore.get(conversationId) ?? null; - }, - async setComputerId(conversationId, alias) { - if (alias === null) { - computerStore.delete(conversationId); - } else { - computerStore.set(conversationId, alias); - } - }, - async clearComputerId(conversationId) { - computerStore.delete(conversationId); - }, - async getReasoningEffort(conversationId) { - return reasoningEffortStore.get(conversationId) ?? null; - }, - async setReasoningEffort(conversationId, effort) { - reasoningEffortStore.set(conversationId, effort); - }, - async getModel(conversationId) { - return modelStore.get(conversationId) ?? null; - }, - async setModel(conversationId, model) { - if (model === "") { - modelStore.delete(conversationId); - } else { - modelStore.set(conversationId, model); - } - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return sampleWorkspace; - }, - async setWorkspaceTitle() { - return sampleWorkspace; - }, - async setWorkspaceDefaultCwd() { - return sampleWorkspace; - }, - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { ...sampleWorkspace, id, defaultComputerId }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd(conversationId) { - return cwdStore.get(conversationId) ?? null; - }, - async getEffectiveComputer(conversationId) { - return computerStore.get(conversationId) ?? null; - }, - }; + const sampleWorkspace = { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + return { + async append() {}, + async load() { + return []; + }, + async loadSince(conversationId, sinceSeq, window) { + const chunks = store.get(conversationId) ?? []; + const minSeq = sinceSeq ?? 0; + const beforeSeq = window?.beforeSeq; + const limit = window?.limit; + const selected = chunks.filter( + (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq), + ); + // Window: keep only the NEWEST `limit`, still ascending by seq. + if (limit !== undefined && selected.length > limit) { + return selected.slice(selected.length - limit); + } + return selected; + }, + async appendMetrics() {}, + async loadMetrics(conversationId) { + return metricsStore.get(conversationId) ?? []; + }, + async getCwd(conversationId) { + return cwdStore.get(conversationId) ?? null; + }, + async setCwd(conversationId, cwd) { + cwdStore.set(conversationId, cwd); + }, + async clearCwd(conversationId) { + cwdStore.delete(conversationId); + }, + async getComputerId(conversationId) { + return computerStore.get(conversationId) ?? null; + }, + async setComputerId(conversationId, alias) { + if (alias === null) { + computerStore.delete(conversationId); + } else { + computerStore.set(conversationId, alias); + } + }, + async clearComputerId(conversationId) { + computerStore.delete(conversationId); + }, + async getReasoningEffort(conversationId) { + return reasoningEffortStore.get(conversationId) ?? null; + }, + async setReasoningEffort(conversationId, effort) { + reasoningEffortStore.set(conversationId, effort); + }, + async getModel(conversationId) { + return modelStore.get(conversationId) ?? null; + }, + async setModel(conversationId, model) { + if (model === "") { + modelStore.delete(conversationId); + } else { + modelStore.set(conversationId, model); + } + }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return sampleWorkspace; + }, + async setWorkspaceTitle() { + return sampleWorkspace; + }, + async setWorkspaceDefaultCwd() { + return sampleWorkspace; + }, + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...sampleWorkspace, id, defaultComputerId }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd(conversationId) { + return cwdStore.get(conversationId) ?? null; + }, + async getEffectiveComputer(conversationId) { + return computerStore.get(conversationId) ?? null; + }, + }; } /** @@ -232,290 +232,290 @@ function createFakeConversationStore( * assert that workspace assignment happens BEFORE setCwd. */ function createCallTrackingStore( - base: ConversationStore, + base: ConversationStore, ): ConversationStore & { readonly calls: readonly string[] } { - const calls: string[] = []; - return { - ...base, - get calls() { - return calls; - }, - async ensureWorkspace(id, opts) { - calls.push(`ensureWorkspace:${id}`); - return base.ensureWorkspace(id, opts); - }, - async setWorkspaceId(conversationId, workspaceId) { - calls.push(`setWorkspaceId:${workspaceId}`); - await base.setWorkspaceId(conversationId, workspaceId); - }, - async setCwd(conversationId, cwd) { - calls.push(`setCwd:${cwd}`); - await base.setCwd(conversationId, cwd); - }, - }; + const calls: string[] = []; + return { + ...base, + get calls() { + return calls; + }, + async ensureWorkspace(id, opts) { + calls.push(`ensureWorkspace:${id}`); + return base.ensureWorkspace(id, opts); + }, + async setWorkspaceId(conversationId, workspaceId) { + calls.push(`setWorkspaceId:${workspaceId}`); + await base.setWorkspaceId(conversationId, workspaceId); + }, + async setCwd(conversationId, cwd) { + calls.push(`setCwd:${cwd}`); + await base.setCwd(conversationId, cwd); + }, + }; } function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { - return { - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage(input) { - for (const event of events) { - input.onEvent(event); - } - }, - }; + return { + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage(input) { + for (const event of events) { + input.onEvent(event); + } + }, + }; } function createCapturingOrchestrator(): SessionOrchestrator & { - received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined; + received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined; } { - const state: { - received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined; - } = { received: undefined }; - return { - get received() { - return state.received; - }, - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage(input) { - state.received = input; - }, - }; + const state: { + received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined; + } = { received: undefined }; + return { + get received() { + return state.received; + }, + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage(input) { + state.received = input; + }, + }; } function createThrowingOrchestrator(error: Error): SessionOrchestrator { - return { - startTurn() { - return { started: true, turnId: "fake-turn" }; - }, - subscribe() { - return () => {}; - }, - isActive() { - return false; - }, - enqueue() { - return { startedTurn: false, queue: [] }; - }, - closeConversation() { - return { abortedTurn: false }; - }, - stopTurn() { - return { abortedTurn: false }; - }, - async handleMessage() { - throw error; - }, - }; + return { + startTurn() { + return { started: true, turnId: "fake-turn" }; + }, + subscribe() { + return () => {}; + }, + isActive() { + return false; + }, + enqueue() { + return { startedTurn: false, queue: [] }; + }, + closeConversation() { + return { abortedTurn: false }; + }, + stopTurn() { + return { abortedTurn: false }; + }, + async handleMessage() { + throw error; + }, + }; } function createFakeCredentialStore(models: string[]): CredentialStore { - return { - resolve() { - return undefined; - }, - async getModelInfo() { - return undefined; - }, - async listCatalog() { - return models; - }, - }; + return { + resolve() { + return undefined; + }, + async getModelInfo() { + return undefined; + }, + async listCatalog() { + return models; + }, + }; } function createThrowingCredentialStore(error: Error): CredentialStore { - return { - resolve() { - return undefined; - }, - async getModelInfo() { - return undefined; - }, - async listCatalog() { - throw error; - }, - }; + return { + resolve() { + return undefined; + }, + async getModelInfo() { + return undefined; + }, + async listCatalog() { + throw error; + }, + }; } function createFakeWarmService( - result: - | { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - } - | { error: string }, + result: + | { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + } + | { error: string }, ): WarmService { - return { - async warm() { - return result; - }, - }; + return { + async warm() { + return result; + }, + }; } function createFakeLspService( - statuses: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: "connected" | "starting" | "error" | "not-started"; - readonly error?: string; - readonly configSource?: string; - }[] = [], + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: "connected" | "starting" | "error" | "not-started"; + readonly error?: string; + readonly configSource?: string; + }[] = [], ): LspService { - return { - async status() { - return statuses; - }, - }; + return { + async status() { + return statuses; + }, + }; } function createCapturingLspService( - statuses: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: "connected" | "starting" | "error" | "not-started"; - readonly error?: string; - readonly configSource?: string; - }[] = [], + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: "connected" | "starting" | "error" | "not-started"; + readonly error?: string; + readonly configSource?: string; + }[] = [], ): LspService & { readonly statusCalls: readonly string[] } { - const calls: string[] = []; - return { - get statusCalls() { - return calls; - }, - async status(cwd) { - calls.push(cwd); - return statuses; - }, - }; + const calls: string[] = []; + return { + get statusCalls() { + return calls; + }, + async status(cwd) { + calls.push(cwd); + return statuses; + }, + }; } function createFakeMcpService( - statuses: readonly { - readonly id: string; - readonly state: "connecting" | "connected" | "error" | "disconnected"; - readonly error?: string; - readonly toolCount: number; - }[] = [], + statuses: readonly { + readonly id: string; + readonly state: "connecting" | "connected" | "error" | "disconnected"; + readonly error?: string; + readonly toolCount: number; + }[] = [], ): McpService { - return { - async status() { - return statuses; - }, - }; + return { + async status() { + return statuses; + }, + }; } function createCapturingMcpService( - statuses: readonly { - readonly id: string; - readonly state: "connecting" | "connected" | "error" | "disconnected"; - readonly error?: string; - readonly toolCount: number; - }[] = [], + statuses: readonly { + readonly id: string; + readonly state: "connecting" | "connected" | "error" | "disconnected"; + readonly error?: string; + readonly toolCount: number; + }[] = [], ): McpService & { readonly statusCalls: readonly string[] } { - const calls: string[] = []; - return { - get statusCalls() { - return calls; - }, - async status(cwd) { - calls.push(cwd); - return statuses; - }, - }; + const calls: string[] = []; + return { + get statusCalls() { + return calls; + }, + async status(cwd) { + calls.push(cwd); + return statuses; + }, + }; } function createFakeSystemPromptService( - template: string = "custom template", + template: string = "custom template", ): SystemPromptService & { - readonly setTemplateCalls: readonly string[]; - readonly getTemplateCalls: number; + readonly setTemplateCalls: readonly string[]; + readonly getTemplateCalls: number; } { - const setCalls: string[] = []; - let getTemplateCount = 0; - let currentTemplate = template; - return { - get setTemplateCalls() { - return setCalls; - }, - get getTemplateCalls() { - return getTemplateCount; - }, - async construct() { - return currentTemplate; - }, - async get() { - return currentTemplate; - }, - async getTemplate() { - getTemplateCount++; - return currentTemplate; - }, - async setTemplate(t) { - setCalls.push(t); - currentTemplate = t; - }, - }; + const setCalls: string[] = []; + let getTemplateCount = 0; + let currentTemplate = template; + return { + get setTemplateCalls() { + return setCalls; + }, + get getTemplateCalls() { + return getTemplateCount; + }, + async construct() { + return currentTemplate; + }, + async get() { + return currentTemplate; + }, + async getTemplate() { + getTemplateCount++; + return currentTemplate; + }, + async setTemplate(t) { + setCalls.push(t); + currentTemplate = t; + }, + }; } function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService { - const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c])); - return { - async listComputers() { - return computers; - }, - async getComputer(alias) { - return byAlias.get(alias) ?? null; - }, - async getStatus(alias) { - const known = byAlias.has(alias); - return { alias, state: "disconnected", knownHost: known }; - }, - async test(alias) { - return byAlias.has(alias) - ? { alias, ok: true } - : { alias, ok: false, error: "Computer not found" }; - }, - }; + const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c])); + return { + async listComputers() { + return computers; + }, + async getComputer(alias) { + return byAlias.get(alias) ?? null; + }, + async getStatus(alias) { + const known = byAlias.has(alias); + return { alias, state: "disconnected", knownHost: known }; + }, + async test(alias) { + return byAlias.has(alias) + ? { alias, ok: true } + : { alias, ok: false, error: "Computer not found" }; + }, + }; } /** @@ -524,3871 +524,3871 @@ function createFakeComputerService(computers: readonly ComputerEntry[] = []): Co * satisfies the interface without dragging in real stores/scheduler. */ function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService { - return { - getConfig: async () => DEFAULT_HEARTBEAT_CONFIG, - updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG, - listRuns: async () => [], - stopRun: async () => ({ ok: true }), - startAll: async () => {}, - stopAll: () => {}, - nextRunAt: async () => nextRunAt, - }; + return { + getConfig: async () => DEFAULT_HEARTBEAT_CONFIG, + updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG, + listRuns: async () => [], + stopRun: async () => ({ ok: true }), + startAll: async () => {}, + stopAll: () => {}, + nextRunAt: async () => nextRunAt, + }; } const noopLogger = createFakeLogger(); describe("GET /health", () => { - it("returns ok", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/health"); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toEqual({ ok: true }); - }); + it("returns ok", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/health"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true }); + }); }); describe("GET /models", () => { - it("returns model catalog", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), - logger: noopLogger, - }); - const res = await app.request("/models"); - expect(res.status).toBe(200); - const body = (await res.json()) as { models: readonly string[] }; - expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]); - }); - - it("returns empty array when no models", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/models"); - expect(res.status).toBe(200); - const body = (await res.json()) as { models: readonly string[] }; - expect(body.models).toEqual([]); - }); - - it("returns 502 when listCatalog throws", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createThrowingCredentialStore(new Error("db down")), - logger: noopLogger, - }); - const res = await app.request("/models"); - expect(res.status).toBe(502); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Failed to retrieve model catalog"); - }); + it("returns model catalog", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]), + logger: noopLogger, + }); + const res = await app.request("/models"); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]); + }); + + it("returns empty array when no models", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/models"); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); + + it("returns 502 when listCatalog throws", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createThrowingCredentialStore(new Error("db down")), + logger: noopLogger, + }); + const res = await app.request("/models"); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to retrieve model catalog"); + }); }); describe("POST /chat", () => { - it("returns 400 for invalid JSON", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - }); - - it("returns 400 for missing message", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "c1" }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("message"); - }); - - it("returns 400 for empty message", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "" }), - }); - expect(res.status).toBe(400); - }); - - it("streams events as NDJSON", async () => { - const events: AgentEvent[] = [ - { type: "turn-start", conversationId: "tab1", turnId: "turn1" }, - { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" }, - { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" }, - { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, - ]; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator(events), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); - expect(res.headers.get("X-Conversation-Id")).toBe("conv1"); - - const text = await res.text(); - const lines = text.trim().split("\n"); - expect(lines).toHaveLength(4); - - const parsed = lines.map((line) => JSON.parse(line) as AgentEvent); - expect(parsed[0]?.type).toBe("turn-start"); - expect(parsed[1]?.type).toBe("text-delta"); - expect((parsed[1] as { delta: string }).delta).toBe("Hello"); - expect(parsed[2]?.type).toBe("text-delta"); - expect(parsed[3]?.type).toBe("done"); - }); - - it("generates conversationId when not provided", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, - ]), - credentialStore: createFakeCredentialStore([]), - generateId: () => "generated-uuid", - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi" }), - }); - - expect(res.status).toBe(200); - expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid"); - }); - - it("emits error event when orchestrator throws", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const text = await res.text(); - const lines = text.trim().split("\n"); - expect(lines.length).toBeGreaterThanOrEqual(1); - - const lastLine = lines[lines.length - 1]; - if (!lastLine) throw new Error("expected at least one line"); - const lastEvent = JSON.parse(lastLine) as AgentEvent; - expect(lastEvent.type).toBe("error"); - if (lastEvent.type === "error") { - expect(lastEvent.message).toContain("provider unavailable"); - } - }); - - it("handles empty event list", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi" }), - }); - - expect(res.status).toBe(200); - const text = await res.text(); - expect(text).toBe(""); - }); - - it("forwards modelName and cwd to orchestrator", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - model: "opencode/m1", - cwd: "/tmp", - }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.conversationId).toBe("conv1"); - expect(cap.received?.text).toBe("hi"); - expect(cap.received?.modelName).toBe("opencode/m1"); - expect(cap.received?.cwd).toBe("/tmp"); - }); - - it("omits modelName and cwd when not provided", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.modelName).toBeUndefined(); - expect(cap.received?.cwd).toBeUndefined(); - }); + it("returns 400 for invalid JSON", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing message", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "c1" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("message"); + }); + + it("returns 400 for empty message", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "" }), + }); + expect(res.status).toBe(400); + }); + + it("streams events as NDJSON", async () => { + const events: AgentEvent[] = [ + { type: "turn-start", conversationId: "tab1", turnId: "turn1" }, + { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" }, + { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" }, + { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, + ]; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator(events), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); + expect(res.headers.get("X-Conversation-Id")).toBe("conv1"); + + const text = await res.text(); + const lines = text.trim().split("\n"); + expect(lines).toHaveLength(4); + + const parsed = lines.map((line) => JSON.parse(line) as AgentEvent); + expect(parsed[0]?.type).toBe("turn-start"); + expect(parsed[1]?.type).toBe("text-delta"); + expect((parsed[1] as { delta: string }).delta).toBe("Hello"); + expect(parsed[2]?.type).toBe("text-delta"); + expect(parsed[3]?.type).toBe("done"); + }); + + it("generates conversationId when not provided", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" }, + ]), + credentialStore: createFakeCredentialStore([]), + generateId: () => "generated-uuid", + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid"); + }); + + it("emits error event when orchestrator throws", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + const lines = text.trim().split("\n"); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const lastLine = lines[lines.length - 1]; + if (!lastLine) throw new Error("expected at least one line"); + const lastEvent = JSON.parse(lastLine) as AgentEvent; + expect(lastEvent.type).toBe("error"); + if (lastEvent.type === "error") { + expect(lastEvent.message).toContain("provider unavailable"); + } + }); + + it("handles empty event list", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe(""); + }); + + it("forwards modelName and cwd to orchestrator", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + model: "opencode/m1", + cwd: "/tmp", + }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.text).toBe("hi"); + expect(cap.received?.modelName).toBe("opencode/m1"); + expect(cap.received?.cwd).toBe("/tmp"); + }); + + it("omits modelName and cwd when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.modelName).toBeUndefined(); + expect(cap.received?.cwd).toBeUndefined(); + }); }); describe("POST /chat/warm", () => { - it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 1000, - outputTokens: 200, - cacheReadTokens: 800, - cacheWriteTokens: 100, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - cachePct: number; - expectedCacheRate: number; - }; - expect(body.inputTokens).toBe(1000); - expect(body.outputTokens).toBe(200); - expect(body.cacheReadTokens).toBe(800); - expect(body.cacheWriteTokens).toBe(100); - expect(body.cachePct).toBe(80); - expect(body.expectedCacheRate).toBe(89); - }); - - it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 500, - outputTokens: 100, - cacheReadTokens: 400, - cacheWriteTokens: 100, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { expectedCacheRate: number }; - expect(body.expectedCacheRate).toBe(80); - }); - - it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { expectedCacheRate: number }; - expect(body.expectedCacheRate).toBe(0); - }); - - it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ error: "conversation is generating" }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ conversationId: "conv1" }), - }); - - expect(res.status).toBe(409); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("conversation is generating"); - }); - - it("POST /chat/warm returns 400 when conversationId is missing", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - warmService: createFakeWarmService({ - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }), - logger: noopLogger, - }); - - const res = await app.request("/chat/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("conversationId"); - }); + it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 1000, + outputTokens: 200, + cacheReadTokens: 800, + cacheWriteTokens: 100, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + cachePct: number; + expectedCacheRate: number; + }; + expect(body.inputTokens).toBe(1000); + expect(body.outputTokens).toBe(200); + expect(body.cacheReadTokens).toBe(800); + expect(body.cacheWriteTokens).toBe(100); + expect(body.cachePct).toBe(80); + expect(body.expectedCacheRate).toBe(89); + }); + + it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 500, + outputTokens: 100, + cacheReadTokens: 400, + cacheWriteTokens: 100, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { expectedCacheRate: number }; + expect(body.expectedCacheRate).toBe(80); + }); + + it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { expectedCacheRate: number }; + expect(body.expectedCacheRate).toBe(0); + }); + + it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ error: "conversation is generating" }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "conv1" }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("conversation is generating"); + }); + + it("POST /chat/warm returns 400 when conversationId is missing", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + warmService: createFakeWarmService({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }), + logger: noopLogger, + }); + + const res = await app.request("/chat/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("conversationId"); + }); }); describe("GET /conversations/:id", () => { - const sampleChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, - { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } }, - { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } }, - ]; - - it("returns the full seq-ordered StoredChunk history", async () => { - const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(4); - expect(body.chunks[0]?.seq).toBe(1); - expect(body.chunks[3]?.seq).toBe(4); - expect(body.latestSeq).toBe(4); - }); - - it("returns only chunks with seq > N and latestSeq = last seq", async () => { - const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=2"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(2); - expect(body.chunks[0]?.seq).toBe(3); - expect(body.chunks[1]?.seq).toBe(4); - expect(body.latestSeq).toBe(4); - }); - - it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => { - const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=4"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(0); - expect(body.latestSeq).toBe(4); - }); - - it("returns empty chunks and latestSeq 0 for unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/unknown"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks).toHaveLength(0); - expect(body.latestSeq).toBe(0); - }); - - it("returns 400 for invalid sinceSeq", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=abc"); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("sinceSeq"); - }); - - it("returns 400 for negative sinceSeq", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1?sinceSeq=-1"); - expect(res.status).toBe(400); - }); - - const sixChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "one" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } }, - { seq: 3, role: "user", chunk: { type: "text", text: "three" } }, - { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } }, - { seq: 5, role: "user", chunk: { type: "text", text: "five" } }, - { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } }, - ]; - - function appWithChunks(chunks: StoredChunk[]) { - const store = new Map<string, StoredChunk[]>([["conv1", chunks]]); - return createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - } - - it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?limit=2"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]); - expect(body.latestSeq).toBe(6); - }); - - it("?limit=N with N >= conversation size returns the full log", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?limit=10"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]); - expect(body.latestSeq).toBe(6); - }); - - it("?beforeSeq=S returns only chunks with seq < S", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?beforeSeq=3"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]); - expect(body.latestSeq).toBe(2); - }); - - it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - // selection = seq 1..4; newest 2 = [3, 4] - expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); - expect(body.latestSeq).toBe(4); - }); - - it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => { - const app = appWithChunks(sixChunks); - const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); - expect(body.latestSeq).toBe(4); - }); - - describe("window param validation → 400 and store not called with an invalid window", () => { - function appCapturingWindow() { - const calls: { - readonly sinceSeq: number | undefined; - readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined; - }[] = []; - const store: ConversationStore = { - async append() {}, - async load() { - return []; - }, - async loadSince(_conversationId, sinceSeq, window) { - calls.push({ sinceSeq, window }); - return []; - }, - async appendMetrics() {}, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - return { app, calls }; - } - - const cases: readonly { readonly name: string; readonly query: string }[] = [ - { name: "limit=0", query: "limit=0" }, - { name: "limit=-1", query: "limit=-1" }, - { name: "limit=abc", query: "limit=abc" }, - { name: "beforeSeq=0", query: "beforeSeq=0" }, - { name: "beforeSeq=1.5", query: "beforeSeq=1.5" }, - ]; - - for (const { name, query } of cases) { - it(`${name} → 400 { error } and loadSince is never called`, async () => { - const { app, calls } = appCapturingWindow(); - const res = await app.request(`/conversations/conv1?${query}`); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(typeof body.error).toBe("string"); - expect(body.error.length).toBeGreaterThan(0); - expect(calls).toHaveLength(0); - }); - } - }); - - it("no params → byte-identical to the no-window read (regression guard)", async () => { - // Rest-param spy: record how many args the route actually passes, so we - // can prove the third (window) arg is OMITTED entirely — not merely - // forwarded as undefined — preserving the existing two-arg call shape. - const argCounts: number[] = []; - const store: ConversationStore = { - async append() {}, - async load() { - return []; - }, - loadSince(...args: Parameters<ConversationStore["loadSince"]>) { - argCounts.push(args.length); - const sinceSeq = args[1] ?? 0; - return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq)); - }, - async appendMetrics() {}, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1"); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; - expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]); - expect(body.latestSeq).toBe(4); - // Called once, with exactly two arguments (no window arg). - expect(argCounts).toEqual([2]); - }); + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, + { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } }, + { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } }, + ]; + + it("returns the full seq-ordered StoredChunk history", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(4); + expect(body.chunks[0]?.seq).toBe(1); + expect(body.chunks[3]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns only chunks with seq > N and latestSeq = last seq", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(2); + expect(body.chunks[0]?.seq).toBe(3); + expect(body.chunks[1]?.seq).toBe(4); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => { + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=4"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(4); + }); + + it("returns empty chunks and latestSeq 0 for unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/unknown"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks).toHaveLength(0); + expect(body.latestSeq).toBe(0); + }); + + it("returns 400 for invalid sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=abc"); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("sinceSeq"); + }); + + it("returns 400 for negative sinceSeq", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1?sinceSeq=-1"); + expect(res.status).toBe(400); + }); + + const sixChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "one" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } }, + { seq: 3, role: "user", chunk: { type: "text", text: "three" } }, + { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } }, + { seq: 5, role: "user", chunk: { type: "text", text: "five" } }, + { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } }, + ]; + + function appWithChunks(chunks: StoredChunk[]) { + const store = new Map<string, StoredChunk[]>([["conv1", chunks]]); + return createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + } + + it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?limit=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]); + expect(body.latestSeq).toBe(6); + }); + + it("?limit=N with N >= conversation size returns the full log", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?limit=10"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]); + expect(body.latestSeq).toBe(6); + }); + + it("?beforeSeq=S returns only chunks with seq < S", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?beforeSeq=3"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]); + expect(body.latestSeq).toBe(2); + }); + + it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + // selection = seq 1..4; newest 2 = [3, 4] + expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); + expect(body.latestSeq).toBe(4); + }); + + it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => { + const app = appWithChunks(sixChunks); + const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]); + expect(body.latestSeq).toBe(4); + }); + + describe("window param validation → 400 and store not called with an invalid window", () => { + function appCapturingWindow() { + const calls: { + readonly sinceSeq: number | undefined; + readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined; + }[] = []; + const store: ConversationStore = { + async append() {}, + async load() { + return []; + }, + async loadSince(_conversationId, sinceSeq, window) { + calls.push({ sinceSeq, window }); + return []; + }, + async appendMetrics() {}, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + return { app, calls }; + } + + const cases: readonly { readonly name: string; readonly query: string }[] = [ + { name: "limit=0", query: "limit=0" }, + { name: "limit=-1", query: "limit=-1" }, + { name: "limit=abc", query: "limit=abc" }, + { name: "beforeSeq=0", query: "beforeSeq=0" }, + { name: "beforeSeq=1.5", query: "beforeSeq=1.5" }, + ]; + + for (const { name, query } of cases) { + it(`${name} → 400 { error } and loadSince is never called`, async () => { + const { app, calls } = appCapturingWindow(); + const res = await app.request(`/conversations/conv1?${query}`); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + expect(calls).toHaveLength(0); + }); + } + }); + + it("no params → byte-identical to the no-window read (regression guard)", async () => { + // Rest-param spy: record how many args the route actually passes, so we + // can prove the third (window) arg is OMITTED entirely — not merely + // forwarded as undefined — preserving the existing two-arg call shape. + const argCounts: number[] = []; + const store: ConversationStore = { + async append() {}, + async load() { + return []; + }, + loadSince(...args: Parameters<ConversationStore["loadSince"]>) { + argCounts.push(args.length); + const sinceSeq = args[1] ?? 0; + return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq)); + }, + async appendMetrics() {}, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number }; + expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]); + expect(body.latestSeq).toBe(4); + // Called once, with exactly two arguments (no window arg). + expect(argCounts).toEqual([2]); + }); }); describe("GET /conversations/:id/metrics", () => { - const sampleMetrics: TurnMetrics[] = [ - { - turnId: "turn1", - usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, - durationMs: 1000, - steps: [ - { - stepId: "step1" as StepId, - usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, - ttftMs: 200, - decodeMs: 300, - genTotalMs: 500, - }, - ], - }, - { - turnId: "turn2", - usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, - durationMs: 1500, - steps: [ - { - stepId: "step2" as StepId, - usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, - ttftMs: 300, - decodeMs: 500, - genTotalMs: 800, - }, - ], - }, - ]; - - it("returns persisted turn metrics as { turns }", async () => { - const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]); - const app = createApp({ - conversationStore: createFakeConversationStore(new Map(), metricsStore), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/conv1/metrics"); - expect(res.status).toBe(200); - const body = (await res.json()) as { turns: readonly TurnMetrics[] }; - expect(body.turns).toHaveLength(2); - expect(body.turns[0]?.turnId).toBe("turn1"); - expect(body.turns[1]?.turnId).toBe("turn2"); - }); - - it("returns { turns: [] } for an unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/conversations/unknown/metrics"); - expect(res.status).toBe(200); - const body = (await res.json()) as { turns: readonly TurnMetrics[] }; - expect(body.turns).toHaveLength(0); - }); - - it("the metrics route does not collide with GET /conversations/:id history route", async () => { - const sampleChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, - ]; - const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); - const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store, metricsStore), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const metricsRes = await app.request("/conversations/conv1/metrics"); - expect(metricsRes.status).toBe(200); - const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] }; - expect(metricsBody.turns).toHaveLength(2); - - const historyRes = await app.request("/conversations/conv1"); - expect(historyRes.status).toBe(200); - const historyBody = (await historyRes.json()) as { - chunks: readonly StoredChunk[]; - latestSeq: number; - }; - expect(historyBody.chunks).toHaveLength(1); - }); - - it("a store failure on the metrics read returns an error status + logs an error", async () => { - const logger = createFakeLogger(); - const brokenStore: ConversationStore = { - async append() {}, - async load() { - return []; - }, - async loadSince() { - return []; - }, - async appendMetrics() {}, - async loadMetrics() { - throw new Error("storage exploded"); - }, - async getCwd() { - return null; - }, - async setCwd() {}, - async clearCwd() {}, - async getReasoningEffort() { - return null; - }, - async setReasoningEffort() {}, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: brokenStore, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - const res = await app.request("/conversations/conv1/metrics"); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Failed to load conversation metrics"); - - const errorLogs = logger.records.filter((r) => r.level === "error"); - expect(errorLogs).toHaveLength(1); - expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure"); - expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); - }); + const sampleMetrics: TurnMetrics[] = [ + { + turnId: "turn1", + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, + durationMs: 1000, + steps: [ + { + stepId: "step1" as StepId, + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 }, + ttftMs: 200, + decodeMs: 300, + genTotalMs: 500, + }, + ], + }, + { + turnId: "turn2", + usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, + durationMs: 1500, + steps: [ + { + stepId: "step2" as StepId, + usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 }, + ttftMs: 300, + decodeMs: 500, + genTotalMs: 800, + }, + ], + }, + ]; + + it("returns persisted turn metrics as { turns }", async () => { + const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]); + const app = createApp({ + conversationStore: createFakeConversationStore(new Map(), metricsStore), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/conv1/metrics"); + expect(res.status).toBe(200); + const body = (await res.json()) as { turns: readonly TurnMetrics[] }; + expect(body.turns).toHaveLength(2); + expect(body.turns[0]?.turnId).toBe("turn1"); + expect(body.turns[1]?.turnId).toBe("turn2"); + }); + + it("returns { turns: [] } for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/conversations/unknown/metrics"); + expect(res.status).toBe(200); + const body = (await res.json()) as { turns: readonly TurnMetrics[] }; + expect(body.turns).toHaveLength(0); + }); + + it("the metrics route does not collide with GET /conversations/:id history route", async () => { + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + ]; + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store, metricsStore), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const metricsRes = await app.request("/conversations/conv1/metrics"); + expect(metricsRes.status).toBe(200); + const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] }; + expect(metricsBody.turns).toHaveLength(2); + + const historyRes = await app.request("/conversations/conv1"); + expect(historyRes.status).toBe(200); + const historyBody = (await historyRes.json()) as { + chunks: readonly StoredChunk[]; + latestSeq: number; + }; + expect(historyBody.chunks).toHaveLength(1); + }); + + it("a store failure on the metrics read returns an error status + logs an error", async () => { + const logger = createFakeLogger(); + const brokenStore: ConversationStore = { + async append() {}, + async load() { + return []; + }, + async loadSince() { + return []; + }, + async appendMetrics() {}, + async loadMetrics() { + throw new Error("storage exploded"); + }, + async getCwd() { + return null; + }, + async setCwd() {}, + async clearCwd() {}, + async getReasoningEffort() { + return null; + }, + async setReasoningEffort() {}, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: brokenStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + const res = await app.request("/conversations/conv1/metrics"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to load conversation metrics"); + + const errorLogs = logger.records.filter((r) => r.level === "error"); + expect(errorLogs).toHaveLength(1); + expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure"); + expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); + }); }); describe("POST /chat logging", () => { - it("POST /chat logs an info line when a request is accepted", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, - ]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - model: "opencode/m1", - cwd: "/tmp", - }), - }); - - const infoLogs = logger.records.filter((r) => r.level === "info"); - expect(infoLogs).toHaveLength(1); - expect(infoLogs[0]?.msg).toBe("chat: request accepted"); - expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); - expect(infoLogs[0]?.attrs?.hasModel).toBe(true); - expect(infoLogs[0]?.attrs?.hasCwd).toBe(true); - }); - - it("POST /chat logs a warn on a malformed body (400)", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - - const warnLogs = logger.records.filter((r) => r.level === "warn"); - expect(warnLogs.length).toBeGreaterThanOrEqual(1); - expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body"); - }); - - it("POST /chat logs an error when the turn fails", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createThrowingOrchestrator(new Error("boom")), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - const errorLogs = logger.records.filter((r) => r.level === "error"); - expect(errorLogs).toHaveLength(1); - expect(errorLogs[0]?.msg).toBe("chat: turn failed"); - expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); - }); + it("POST /chat logs an info line when a request is accepted", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, + ]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + model: "opencode/m1", + cwd: "/tmp", + }), + }); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("chat: request accepted"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.hasModel).toBe(true); + expect(infoLogs[0]?.attrs?.hasCwd).toBe(true); + }); + + it("POST /chat logs a warn on a malformed body (400)", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + + const warnLogs = logger.records.filter((r) => r.level === "warn"); + expect(warnLogs.length).toBeGreaterThanOrEqual(1); + expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body"); + }); + + it("POST /chat logs an error when the turn fails", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createThrowingOrchestrator(new Error("boom")), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + const errorLogs = logger.records.filter((r) => r.level === "error"); + expect(errorLogs).toHaveLength(1); + expect(errorLogs[0]?.msg).toBe("chat: turn failed"); + expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error); + }); }); describe("GET /conversations/:id logging", () => { - it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => { - const logger = createFakeLogger(); - const sampleChunks: StoredChunk[] = [ - { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, - ]; - const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); - const app = createApp({ - conversationStore: createFakeConversationStore(store), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/conversations/conv1?sinceSeq=0"); - - const infoLogs = logger.records.filter((r) => r.level === "info"); - expect(infoLogs).toHaveLength(1); - expect(infoLogs[0]?.msg).toBe("conversations: read"); - expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); - expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0); - expect(infoLogs[0]?.attrs?.count).toBe(2); - }); + it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => { + const logger = createFakeLogger(); + const sampleChunks: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } }, + ]; + const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]); + const app = createApp({ + conversationStore: createFakeConversationStore(store), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1?sinceSeq=0"); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("conversations: read"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0); + expect(infoLogs[0]?.attrs?.count).toBe(2); + }); }); describe("CORS", () => { - function createTestApp() { - return createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([ - { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, - ]), - credentialStore: createFakeCredentialStore(["opencode/m1"]), - }); - } - - it("POST /chat response carries Access-Control-Allow-Origin: *", async () => { - const app = createTestApp(); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - expect(res.status).toBe(200); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - }); - - it("GET /models response carries the CORS headers", async () => { - const app = createTestApp(); - const res = await app.request("/models"); - expect(res.status).toBe(200); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); - }); - - it("GET /conversations/:id response carries the CORS headers", async () => { - const app = createTestApp(); - const res = await app.request("/conversations/conv1"); - expect(res.status).toBe(200); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); - }); - - it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => { - const app = createTestApp(); - const res = await app.request("/chat", { method: "OPTIONS" }); - expect(res.status).toBe(204); - expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET"); - expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST"); - expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS"); - expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type"); - }); + function createTestApp() { + return createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([ + { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" }, + ]), + credentialStore: createFakeCredentialStore(["opencode/m1"]), + }); + } + + it("POST /chat response carries Access-Control-Allow-Origin: *", async () => { + const app = createTestApp(); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("GET /models response carries the CORS headers", async () => { + const app = createTestApp(); + const res = await app.request("/models"); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); + }); + + it("GET /conversations/:id response carries the CORS headers", async () => { + const app = createTestApp(); + const res = await app.request("/conversations/conv1"); + expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined(); + }); + + it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => { + const app = createTestApp(); + const res = await app.request("/chat", { method: "OPTIONS" }); + expect(res.status).toBe(204); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET"); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS"); + expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type"); + }); }); describe("throughput recording + GET /metrics/throughput", () => { - const ts = new Date(2026, 5, 10, 12, 0, 0).getTime(); - const day = dayKeyOf(ts); - - function appWith( - throughputStore: ReturnType<typeof createThroughputStore>, - events: AgentEvent[], - ) { - return createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator(events), - credentialStore: createFakeCredentialStore([]), - throughputStore, - now: () => ts, - }); - } - - async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) { - return app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - } - - it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => { - const store = createThroughputStore({ storage: createMemStorage() }); - const events: AgentEvent[] = [ - { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - genTotalMs: 2000, - }, - { - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 10, outputTokens: 400 }, - }, - ]; - const app = appWith(store, events); - - const chat = await postChat(app, { - conversationId: "c1", - message: "hi", - model: "claude/haiku", - }); - expect(chat.status).toBe(200); - - const res = await app.request(`/metrics/throughput?period=day&date=${day}`); - expect(res.status).toBe(200); - const report = (await res.json()) as ThroughputResponse; - expect(report.period).toBe("day"); - expect(report.models).toHaveLength(1); - expect(report.models[0]).toMatchObject({ - model: "claude/haiku", - totalOutputTokens: 400, - totalGenMs: 2000, - tokensPerSecond: 200, // 400 tokens / 2s - turns: 1, - }); - }); - - it("does not record a sample when no model is selected", async () => { - const store = createThroughputStore({ storage: createMemStorage() }); - const events: AgentEvent[] = [ - { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - genTotalMs: 2000, - }, - { - type: "done", - conversationId: "c1", - turnId: "t1", - reason: "stop", - usage: { inputTokens: 1, outputTokens: 5 }, - }, - ]; - const app = appWith(store, events); - - await postChat(app, { conversationId: "c1", message: "hi" }); // no model - const res = await app.request(`/metrics/throughput?period=day&date=${day}`); - const report = (await res.json()) as { models: unknown[] }; - expect(report.models).toEqual([]); - }); - - it("returns 400 for an invalid period", async () => { - const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); - const res = await app.request("/metrics/throughput?period=year&date=2026"); - expect(res.status).toBe(400); - }); - - it("returns 400 for a malformed date", async () => { - const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); - const res = await app.request("/metrics/throughput?period=day&date=nope"); - expect(res.status).toBe(400); - }); - - it("returns 400 when date is missing", async () => { - const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); - const res = await app.request("/metrics/throughput?period=day"); - expect(res.status).toBe(400); - }); + const ts = new Date(2026, 5, 10, 12, 0, 0).getTime(); + const day = dayKeyOf(ts); + + function appWith( + throughputStore: ReturnType<typeof createThroughputStore>, + events: AgentEvent[], + ) { + return createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator(events), + credentialStore: createFakeCredentialStore([]), + throughputStore, + now: () => ts, + }); + } + + async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) { + return app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => { + const store = createThroughputStore({ storage: createMemStorage() }); + const events: AgentEvent[] = [ + { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + genTotalMs: 2000, + }, + { + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 10, outputTokens: 400 }, + }, + ]; + const app = appWith(store, events); + + const chat = await postChat(app, { + conversationId: "c1", + message: "hi", + model: "claude/haiku", + }); + expect(chat.status).toBe(200); + + const res = await app.request(`/metrics/throughput?period=day&date=${day}`); + expect(res.status).toBe(200); + const report = (await res.json()) as ThroughputResponse; + expect(report.period).toBe("day"); + expect(report.models).toHaveLength(1); + expect(report.models[0]).toMatchObject({ + model: "claude/haiku", + totalOutputTokens: 400, + totalGenMs: 2000, + tokensPerSecond: 200, // 400 tokens / 2s + turns: 1, + }); + }); + + it("does not record a sample when no model is selected", async () => { + const store = createThroughputStore({ storage: createMemStorage() }); + const events: AgentEvent[] = [ + { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + genTotalMs: 2000, + }, + { + type: "done", + conversationId: "c1", + turnId: "t1", + reason: "stop", + usage: { inputTokens: 1, outputTokens: 5 }, + }, + ]; + const app = appWith(store, events); + + await postChat(app, { conversationId: "c1", message: "hi" }); // no model + const res = await app.request(`/metrics/throughput?period=day&date=${day}`); + const report = (await res.json()) as { models: unknown[] }; + expect(report.models).toEqual([]); + }); + + it("returns 400 for an invalid period", async () => { + const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); + const res = await app.request("/metrics/throughput?period=year&date=2026"); + expect(res.status).toBe(400); + }); + + it("returns 400 for a malformed date", async () => { + const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); + const res = await app.request("/metrics/throughput?period=day&date=nope"); + expect(res.status).toBe(400); + }); + + it("returns 400 when date is missing", async () => { + const app = appWith(createThroughputStore({ storage: createMemStorage() }), []); + const res = await app.request("/metrics/throughput?period=day"); + expect(res.status).toBe(400); + }); }); describe("POST /conversations/:id/close", () => { - it("closes via the orchestrator and returns CloseConversationResponse", async () => { - const closeCalls: string[] = []; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - closeConversation(conversationId) { - closeCalls.push(conversationId); - return { abortedTurn: true }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-9/close", { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true }); - expect(closeCalls).toEqual(["conv-9"]); - }); - - it("reports abortedTurn false for an idle conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-idle/close", { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false }); - }); + it("closes via the orchestrator and returns CloseConversationResponse", async () => { + const closeCalls: string[] = []; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + closeConversation(conversationId) { + closeCalls.push(conversationId); + return { abortedTurn: true }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-9/close", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true }); + expect(closeCalls).toEqual(["conv-9"]); + }); + + it("reports abortedTurn false for an idle conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-idle/close", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false }); + }); }); describe("POST /conversations/:id/queue", () => { - it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => { - const queue: readonly QueuedMessage[] = [ - { id: "q1", text: "queued-msg", queuedAt: 1700000000000 }, - ]; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: false, queue }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "hello" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as QueueResponse; - expect(body.conversationId).toBe("conv1"); - expect(body.startedTurn).toBe(false); - expect(body.queue).toEqual(queue); - }); - - it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => { - let enqueueCalled = false; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - enqueueCalled = true; - return { startedTurn: false, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: " " }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("text"); - expect(enqueueCalled).toBe(false); - }); - - it("with missing text field → 400 { error } and enqueue is never called", async () => { - let enqueueCalled = false; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - enqueueCalled = true; - return { startedTurn: false, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("text"); - expect(enqueueCalled).toBe(false); - }); - - it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => { - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: true, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-idle/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "go" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as QueueResponse; - expect(body.conversationId).toBe("conv-idle"); - expect(body.startedTurn).toBe(true); - expect(body.queue).toEqual([]); - }); - - it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => { - const queue: readonly QueuedMessage[] = [ - { id: "q1", text: "second", queuedAt: 1700000000000 }, - { id: "q2", text: "third", queuedAt: 1700000001000 }, - ]; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: false, queue }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-active/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "steer" }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as QueueResponse; - expect(body.conversationId).toBe("conv-active"); - expect(body.startedTurn).toBe(false); - expect(body.queue).toEqual(queue); - }); - - it("forwards the path conversationId and trimmed text to enqueue", async () => { - const calls: { conversationId: string; text: string }[] = []; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue(input) { - calls.push(input); - return { startedTurn: false, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv-1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: " hello world " }), - }); - - expect(res.status).toBe(200); - expect(calls).toHaveLength(1); - expect(calls[0]?.conversationId).toBe("conv-1"); - expect(calls[0]?.text).toBe("hello world"); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("JSON"); - }); - - it("returns 400 for a non-string text", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: 42 }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("text"); - }); - - it("logs an info line on success and never logs the enqueued text", async () => { - const logger = createFakeLogger(); - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - enqueue() { - return { startedTurn: true, queue: [] }; - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "secret-ish user message" }), - }); - - const infoLogs = logger.records.filter((r) => r.level === "info"); - expect(infoLogs).toHaveLength(1); - expect(infoLogs[0]?.msg).toBe("conversations: enqueued"); - expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); - expect(infoLogs[0]?.attrs?.startedTurn).toBe(true); - expect(infoLogs[0]?.attrs?.queueLength).toBe(0); - // Restraint: the user's message text is never logged (mirrors POST /chat). - expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message"); - }); - - it("logs a warn on a malformed body (400)", async () => { - const logger = createFakeLogger(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger, - }); - - await app.request("/conversations/conv1/queue", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "" }), - }); - - const warnLogs = logger.records.filter((r) => r.level === "warn"); - expect(warnLogs.length).toBeGreaterThanOrEqual(1); - expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed"); - }); + it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "queued-msg", queuedAt: 1700000000000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "hello" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv1"); + expect(body.startedTurn).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => { + let enqueueCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + enqueueCalled = true; + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: " " }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + expect(enqueueCalled).toBe(false); + }); + + it("with missing text field → 400 { error } and enqueue is never called", async () => { + let enqueueCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + enqueueCalled = true; + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + expect(enqueueCalled).toBe(false); + }); + + it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => { + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-idle/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "go" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv-idle"); + expect(body.startedTurn).toBe(true); + expect(body.queue).toEqual([]); + }); + + it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "second", queuedAt: 1700000000000 }, + { id: "q2", text: "third", queuedAt: 1700000001000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-active/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "steer" }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueResponse; + expect(body.conversationId).toBe("conv-active"); + expect(body.startedTurn).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("forwards the path conversationId and trimmed text to enqueue", async () => { + const calls: { conversationId: string; text: string }[] = []; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue(input) { + calls.push(input); + return { startedTurn: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv-1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: " hello world " }), + }); + + expect(res.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]?.conversationId).toBe("conv-1"); + expect(calls[0]?.text).toBe("hello world"); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("JSON"); + }); + + it("returns 400 for a non-string text", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: 42 }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("text"); + }); + + it("logs an info line on success and never logs the enqueued text", async () => { + const logger = createFakeLogger(); + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + enqueue() { + return { startedTurn: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "secret-ish user message" }), + }); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("conversations: enqueued"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.startedTurn).toBe(true); + expect(infoLogs[0]?.attrs?.queueLength).toBe(0); + // Restraint: the user's message text is never logged (mirrors POST /chat). + expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message"); + }); + + it("logs a warn on a malformed body (400)", async () => { + const logger = createFakeLogger(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "" }), + }); + + const warnLogs = logger.records.filter((r) => r.level === "warn"); + expect(warnLogs.length).toBeGreaterThanOrEqual(1); + expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed"); + }); }); describe("GET /conversations/:id/cwd", () => { - it("returns null when unset", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; cwd: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - }); + it("returns null when unset", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; cwd: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + }); }); describe("PUT then GET /conversations/:id/cwd", () => { - it("round-trips the value", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project" }), - }); - expect(putRes.status).toBe(200); - const putBody = (await putRes.json()) as { conversationId: string; cwd: string }; - expect(putBody.conversationId).toBe("conv1"); - expect(putBody.cwd).toBe("/home/user/project"); - - const getRes = await app.request("/conversations/conv1/cwd"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; - expect(getBody.cwd).toBe("/home/user/project"); - }); + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; cwd: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.cwd).toBe("/home/user/project"); + + const getRes = await app.request("/conversations/conv1/cwd"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; + expect(getBody.cwd).toBe("/home/user/project"); + }); }); describe("DELETE /conversations/:id/cwd", () => { - it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project" }), - }); - expect(putRes.status).toBe(200); - - const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; - expect(deleteBody.conversationId).toBe("conv1"); - expect(deleteBody.cwd).toBeNull(); - - const getRes = await app.request("/conversations/conv1/cwd"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; - expect(getBody.cwd).toBeNull(); - }); - - it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; - expect(deleteBody.conversationId).toBe("conv1"); - expect(deleteBody.cwd).toBeNull(); - }); - - it("does NOT affect other conversations' cwds (isolation)", async () => { - const cwdStore = new Map<string, string>([ - ["conv1", "/home/user/project"], - ["conv2", "/other/path"], - ]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - - const get1Res = await app.request("/conversations/conv1/cwd"); - expect(get1Res.status).toBe(200); - const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null }; - expect(get1Body.cwd).toBeNull(); - - const get2Res = await app.request("/conversations/conv2/cwd"); - expect(get2Res.status).toBe(200); - const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null }; - expect(get2Body.cwd).toBe("/other/path"); - }); + it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.cwd).toBeNull(); + + const getRes = await app.request("/conversations/conv1/cwd"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null }; + expect(getBody.cwd).toBeNull(); + }); + + it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.cwd).toBeNull(); + }); + + it("does NOT affect other conversations' cwds (isolation)", async () => { + const cwdStore = new Map<string, string>([ + ["conv1", "/home/user/project"], + ["conv2", "/other/path"], + ]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1Res = await app.request("/conversations/conv1/cwd"); + expect(get1Res.status).toBe(200); + const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null }; + expect(get1Body.cwd).toBeNull(); + + const get2Res = await app.request("/conversations/conv2/cwd"); + expect(get2Res.status).toBe(200); + const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null }; + expect(get2Body.cwd).toBe("/other/path"); + }); }); describe("PUT /conversations/:id/cwd", () => { - it("with missing cwd returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("cwd"); - }); - - it("with empty cwd returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "" }), - }); - expect(res.status).toBe(400); - }); - - it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => { - const base = createFakeConversationStore(); - const store = createCallTrackingStore(base); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; cwd: string }; - expect(body.cwd).toBe("/home/user/project"); - // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd. - expect(store.calls).toEqual([ - "ensureWorkspace:my-team", - "setWorkspaceId:my-team", - "setCwd:/home/user/project", - ]); - }); - - it("PUT cwd without workspaceId: only setCwd", async () => { - const base = createFakeConversationStore(); - const store = createCallTrackingStore(base); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project" }), - }); - expect(res.status).toBe(200); - // ensureWorkspace and setWorkspaceId NOT called. - expect(store.calls).toEqual(["setCwd:/home/user/project"]); - }); - - it("PUT cwd with invalid workspaceId: returns 400", async () => { - const base = createFakeConversationStore(); - const store = createCallTrackingStore(base); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - // Uppercase is not a valid workspace slug. - const res = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("Invalid workspaceId"); - // No mutating calls should have been made. - expect(store.calls).toEqual([]); - - // Empty string is also invalid. - const res2 = await app.request("/conversations/conv1/cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }), - }); - expect(res2.status).toBe(400); - }); + it("with missing cwd returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("cwd"); + }); + + it("with empty cwd returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "" }), + }); + expect(res.status).toBe(400); + }); + + it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; cwd: string }; + expect(body.cwd).toBe("/home/user/project"); + // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd. + expect(store.calls).toEqual([ + "ensureWorkspace:my-team", + "setWorkspaceId:my-team", + "setCwd:/home/user/project", + ]); + }); + + it("PUT cwd without workspaceId: only setCwd", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project" }), + }); + expect(res.status).toBe(200); + // ensureWorkspace and setWorkspaceId NOT called. + expect(store.calls).toEqual(["setCwd:/home/user/project"]); + }); + + it("PUT cwd with invalid workspaceId: returns 400", async () => { + const base = createFakeConversationStore(); + const store = createCallTrackingStore(base); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + // Uppercase is not a valid workspace slug. + const res = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("Invalid workspaceId"); + // No mutating calls should have been made. + expect(store.calls).toEqual([]); + + // Empty string is also invalid. + const res2 = await app.request("/conversations/conv1/cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }), + }); + expect(res2.status).toBe(400); + }); }); describe("GET /conversations/:id/lsp", () => { - it("returns empty servers when cwd is unset", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: createFakeLspService(), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - expect(body.servers).toEqual([]); - }); - - it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => { - const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const lspStatuses = [ - { - id: "typescript", - name: "TypeScript", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected" as const, - }, - { - id: "lua-lsp", - name: "Lua LSP", - root: "/home/user/project", - extensions: [".luau"], - state: "error" as const, - error: "spawn failed", - }, - ]; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: createFakeLspService(lspStatuses), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: string; - readonly error?: string; - }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/home/user/project"); - expect(body.servers).toHaveLength(2); - expect(body.servers[0]?.id).toBe("typescript"); - expect(body.servers[0]?.state).toBe("connected"); - expect(body.servers[0]?.error).toBeUndefined(); - expect(body.servers[1]?.id).toBe("lua-lsp"); - expect(body.servers[1]?.state).toBe("error"); - expect(body.servers[1]?.error).toBe("spawn failed"); - }); - - it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => { - const cwdStore = new Map<string, string>(); // no persisted cwd - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const lsp = createCapturingLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/irrelevant", - extensions: [".ts"], - state: "connected" as const, - }, - ]); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lsp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - expect(body.servers).toEqual([]); - expect(lsp.statusCalls).toEqual([]); // status NOT called - }); - - it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { - // Persisted (relative) cwd differs from the resolved effective cwd. - const cwdStore = new Map<string, string>([["conv1", "subdir"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - // Override getEffectiveCwd to return the resolved (absolute) value. - const resolvedStore: ConversationStore = { - ...store, - async getEffectiveCwd() { - return "/workspace/subdir"; - }, - }; - const lsp = createCapturingLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/workspace/subdir", - extensions: [".ts", ".tsx"], - state: "connected" as const, - }, - ]); - const app = createApp({ - conversationStore: resolvedStore, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lsp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { readonly id: string }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted - expect(lsp.statusCalls).toEqual(["/workspace/subdir"]); - expect(body.servers).toHaveLength(1); - expect(body.servers[0]?.id).toBe("typescript"); - }); - - it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => { - const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - // Case 1: configSource is defined → reaches the wire verbatim. - const lspWithSource = createFakeLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected" as const, - configSource: ".dispatch/lsp.json", - }, - ]); - const appWithSource = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lspWithSource, - logger: noopLogger, - }); - const resWithSource = await appWithSource.request("/conversations/conv1/lsp"); - expect(resWithSource.status).toBe(200); - const bodyWithSource = (await resWithSource.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { - readonly id: string; - readonly configSource?: string; - }[]; - }; - expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json"); - - // Case 2: configSource is undefined → the field is OMITTED from the - // response (proves exactOptionalPropertyTypes is respected — never - // stamping `undefined` onto the wire object). - const lspWithoutSource = createFakeLspService([ - { - id: "typescript", - name: "TypeScript", - root: "/home/user/project", - extensions: [".ts", ".tsx"], - state: "connected" as const, - }, - ]); - const appWithoutSource = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lspWithoutSource, - logger: noopLogger, - }); - const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp"); - expect(resWithoutSource.status).toBe(200); - const bodyWithoutSource = (await resWithoutSource.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly Record<string, unknown>[]; - }; - expect(bodyWithoutSource.servers).toHaveLength(1); - expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource"); - }); + it("returns empty servers when cwd is unset", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: createFakeLspService(), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + }); + + it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => { + const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const lspStatuses = [ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + { + id: "lua-lsp", + name: "Lua LSP", + root: "/home/user/project", + extensions: [".luau"], + state: "error" as const, + error: "spawn failed", + }, + ]; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: createFakeLspService(lspStatuses), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: string; + readonly error?: string; + }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/home/user/project"); + expect(body.servers).toHaveLength(2); + expect(body.servers[0]?.id).toBe("typescript"); + expect(body.servers[0]?.state).toBe("connected"); + expect(body.servers[0]?.error).toBeUndefined(); + expect(body.servers[1]?.id).toBe("lua-lsp"); + expect(body.servers[1]?.state).toBe("error"); + expect(body.servers[1]?.error).toBe("spawn failed"); + }); + + it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => { + const cwdStore = new Map<string, string>(); // no persisted cwd + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const lsp = createCapturingLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/irrelevant", + extensions: [".ts"], + state: "connected" as const, + }, + ]); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + expect(lsp.statusCalls).toEqual([]); // status NOT called + }); + + it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { + // Persisted (relative) cwd differs from the resolved effective cwd. + const cwdStore = new Map<string, string>([["conv1", "subdir"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + // Override getEffectiveCwd to return the resolved (absolute) value. + const resolvedStore: ConversationStore = { + ...store, + async getEffectiveCwd() { + return "/workspace/subdir"; + }, + }; + const lsp = createCapturingLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/workspace/subdir", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + ]); + const app = createApp({ + conversationStore: resolvedStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { readonly id: string }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted + expect(lsp.statusCalls).toEqual(["/workspace/subdir"]); + expect(body.servers).toHaveLength(1); + expect(body.servers[0]?.id).toBe("typescript"); + }); + + it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => { + const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + // Case 1: configSource is defined → reaches the wire verbatim. + const lspWithSource = createFakeLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + configSource: ".dispatch/lsp.json", + }, + ]); + const appWithSource = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lspWithSource, + logger: noopLogger, + }); + const resWithSource = await appWithSource.request("/conversations/conv1/lsp"); + expect(resWithSource.status).toBe(200); + const bodyWithSource = (await resWithSource.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly configSource?: string; + }[]; + }; + expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json"); + + // Case 2: configSource is undefined → the field is OMITTED from the + // response (proves exactOptionalPropertyTypes is respected — never + // stamping `undefined` onto the wire object). + const lspWithoutSource = createFakeLspService([ + { + id: "typescript", + name: "TypeScript", + root: "/home/user/project", + extensions: [".ts", ".tsx"], + state: "connected" as const, + }, + ]); + const appWithoutSource = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lspWithoutSource, + logger: noopLogger, + }); + const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp"); + expect(resWithoutSource.status).toBe(200); + const bodyWithoutSource = (await resWithoutSource.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly Record<string, unknown>[]; + }; + expect(bodyWithoutSource.servers).toHaveLength(1); + expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource"); + }); }); describe("GET /conversations/:id/mcp", () => { - it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => { - const cwdStore = new Map<string, string>(); // no persisted cwd - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const mcp = createCapturingMcpService([ - { - id: "freecad", - state: "connected" as const, - toolCount: 3, - }, - ]); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - mcpService: mcp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBeNull(); - expect(body.servers).toEqual([]); - expect(mcp.statusCalls).toEqual([]); // status NOT called - }); - - it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => { - const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const mcpStatuses = [ - { - id: "freecad", - state: "connected" as const, - toolCount: 5, - }, - { - id: "broken", - state: "error" as const, - toolCount: 0, - error: "spawn failed", - }, - ]; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - mcpService: createFakeMcpService(mcpStatuses), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { - readonly id: string; - readonly state: string; - readonly toolCount: number; - readonly error?: string; - }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/home/user/project"); - expect(body.servers).toHaveLength(2); - expect(body.servers[0]?.id).toBe("freecad"); - expect(body.servers[0]?.state).toBe("connected"); - expect(body.servers[0]?.toolCount).toBe(5); - expect(body.servers[0]?.error).toBeUndefined(); - expect(body.servers[1]?.id).toBe("broken"); - expect(body.servers[1]?.state).toBe("error"); - expect(body.servers[1]?.toolCount).toBe(0); - expect(body.servers[1]?.error).toBe("spawn failed"); - }); - - it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { - const cwdStore = new Map<string, string>([["conv1", "subdir"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const resolvedStore: ConversationStore = { - ...store, - async getEffectiveCwd() { - return "/workspace/subdir"; - }, - }; - const mcp = createCapturingMcpService([ - { - id: "freecad", - state: "connected" as const, - toolCount: 2, - }, - ]); - const app = createApp({ - conversationStore: resolvedStore, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - mcpService: mcp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly { readonly id: string }[]; - }; - expect(body.conversationId).toBe("conv1"); - expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted - expect(mcp.statusCalls).toEqual(["/workspace/subdir"]); - expect(body.servers).toHaveLength(1); - expect(body.servers[0]?.id).toBe("freecad"); - }); - - it("MCP: returns 503 when mcpService is undefined", async () => { - const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); - const store = createFakeConversationStore(new Map(), new Map(), cwdStore); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - // mcpService intentionally omitted - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/mcp"); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("MCP service not available"); - }); + it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => { + const cwdStore = new Map<string, string>(); // no persisted cwd + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const mcp = createCapturingMcpService([ + { + id: "freecad", + state: "connected" as const, + toolCount: 3, + }, + ]); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: mcp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + expect(mcp.statusCalls).toEqual([]); // status NOT called + }); + + it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => { + const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const mcpStatuses = [ + { + id: "freecad", + state: "connected" as const, + toolCount: 5, + }, + { + id: "broken", + state: "error" as const, + toolCount: 0, + error: "spawn failed", + }, + ]; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: createFakeMcpService(mcpStatuses), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly state: string; + readonly toolCount: number; + readonly error?: string; + }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/home/user/project"); + expect(body.servers).toHaveLength(2); + expect(body.servers[0]?.id).toBe("freecad"); + expect(body.servers[0]?.state).toBe("connected"); + expect(body.servers[0]?.toolCount).toBe(5); + expect(body.servers[0]?.error).toBeUndefined(); + expect(body.servers[1]?.id).toBe("broken"); + expect(body.servers[1]?.state).toBe("error"); + expect(body.servers[1]?.toolCount).toBe(0); + expect(body.servers[1]?.error).toBe("spawn failed"); + }); + + it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { + const cwdStore = new Map<string, string>([["conv1", "subdir"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const resolvedStore: ConversationStore = { + ...store, + async getEffectiveCwd() { + return "/workspace/subdir"; + }, + }; + const mcp = createCapturingMcpService([ + { + id: "freecad", + state: "connected" as const, + toolCount: 2, + }, + ]); + const app = createApp({ + conversationStore: resolvedStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: mcp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { readonly id: string }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted + expect(mcp.statusCalls).toEqual(["/workspace/subdir"]); + expect(body.servers).toHaveLength(1); + expect(body.servers[0]?.id).toBe("freecad"); + }); + + it("MCP: returns 503 when mcpService is undefined", async () => { + const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + // mcpService intentionally omitted + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("MCP service not available"); + }); }); describe("POST /chat reasoningEffort", () => { - const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; - - for (const level of allLevels) { - it(`forwards reasoningEffort="${level}" to orchestrator`, async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - reasoningEffort: level, - }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.reasoningEffort).toBe(level); - }); - } - - it("omits reasoningEffort from orchestrator input when not provided", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.reasoningEffort).toBeUndefined(); - }); - - it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => { - let handleMessageCalled = false; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - async handleMessage(input) { - handleMessageCalled = true; - return createFakeOrchestrator([]).handleMessage(input); - }, - }; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator, - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - reasoningEffort: "banana", - }), - }); - - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("reasoningEffort"); - expect(handleMessageCalled).toBe(false); - }); - - it("returns 400 for non-string reasoningEffort", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - }); - - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - reasoningEffort: 42, - }), - }); - - expect(res.status).toBe(400); - }); + const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; + + for (const level of allLevels) { + it(`forwards reasoningEffort="${level}" to orchestrator`, async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: level, + }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.reasoningEffort).toBe(level); + }); + } + + it("omits reasoningEffort from orchestrator input when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.reasoningEffort).toBeUndefined(); + }); + + it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => { + let handleMessageCalled = false; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + async handleMessage(input) { + handleMessageCalled = true; + return createFakeOrchestrator([]).handleMessage(input); + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: "banana", + }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("reasoningEffort"); + expect(handleMessageCalled).toBe(false); + }); + + it("returns 400 for non-string reasoningEffort", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + reasoningEffort: 42, + }), + }); + + expect(res.status).toBe(400); + }); }); describe("GET /conversations/:id/reasoning-effort", () => { - it("returns null when never set", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/reasoning-effort"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.reasoningEffort).toBeNull(); - }); - - it("returns the level after PUT", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "xhigh" }), - }); - - const res = await app.request("/conversations/conv1/reasoning-effort"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; - expect(body.reasoningEffort).toBe("xhigh"); - }); - - it("returns null for an unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/unknown/reasoning-effort"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; - expect(body.conversationId).toBe("unknown"); - expect(body.reasoningEffort).toBeNull(); - }); + it("returns null when never set", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.reasoningEffort).toBeNull(); + }); + + it("returns the level after PUT", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "xhigh" }), + }); + + const res = await app.request("/conversations/conv1/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.reasoningEffort).toBe("xhigh"); + }); + + it("returns null for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/unknown/reasoning-effort"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null }; + expect(body.conversationId).toBe("unknown"); + expect(body.reasoningEffort).toBeNull(); + }); }); describe("PUT /conversations/:id/reasoning-effort", () => { - it("persists a valid level and returns it", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "low" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; reasoningEffort: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.reasoningEffort).toBe("low"); - }); - - it("returns 400 for an invalid level", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "banana" }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("reasoningEffort"); - }); - - it("returns 400 when reasoningEffort is missing from body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - }); - - it("does not call store on validation failure", async () => { - let storeCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setReasoningEffort() { - storeCalled = true; - }, - async listConversations() { - return []; - }, - async getConversationMeta() { - return null; - }, - async setConversationTitle() {}, - async getConversationStatus() { - return null; - }, - async setConversationStatus() {}, - async replaceHistory() {}, - async getCompactPercent() { - return null; - }, - async setCompactPercent() {}, - async forkHistory() {}, - async setCompactedFrom() {}, - async getWorkspace() { - return null; - }, - async ensureWorkspace() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceTitle() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async setWorkspaceDefaultCwd() { - return { - id: "default", - title: "default", - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/reasoning-effort", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reasoningEffort: "invalid" }), - }); - expect(res.status).toBe(400); - expect(storeCalled).toBe(false); - }); + it("persists a valid level and returns it", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "low" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; reasoningEffort: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.reasoningEffort).toBe("low"); + }); + + it("returns 400 for an invalid level", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "banana" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("reasoningEffort"); + }); + + it("returns 400 when reasoningEffort is missing from body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("does not call store on validation failure", async () => { + let storeCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setReasoningEffort() { + storeCalled = true; + }, + async listConversations() { + return []; + }, + async getConversationMeta() { + return null; + }, + async setConversationTitle() {}, + async getConversationStatus() { + return null; + }, + async setConversationStatus() {}, + async replaceHistory() {}, + async getCompactPercent() { + return null; + }, + async setCompactPercent() {}, + async forkHistory() {}, + async setCompactedFrom() {}, + async getWorkspace() { + return null; + }, + async ensureWorkspace() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceTitle() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async setWorkspaceDefaultCwd() { + return { + id: "default", + title: "default", + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/reasoning-effort", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reasoningEffort: "invalid" }), + }); + expect(res.status).toBe(400); + expect(storeCalled).toBe(false); + }); }); describe("GET /conversations/:id/model", () => { - it("returns null when never set", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/model"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.model).toBeNull(); - }); - - it("returns the model after PUT", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "umans/umans-glm-5.2" }), - }); - - const res = await app.request("/conversations/conv1/model"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.model).toBe("umans/umans-glm-5.2"); - }); - - it("returns null for an unknown conversation", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/unknown/model"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.conversationId).toBe("unknown"); - expect(body.model).toBeNull(); - }); + it("returns null when never set", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/model"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.model).toBeNull(); + }); + + it("returns the model after PUT", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "umans/umans-glm-5.2" }), + }); + + const res = await app.request("/conversations/conv1/model"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.model).toBe("umans/umans-glm-5.2"); + }); + + it("returns null for an unknown conversation", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/unknown/model"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.conversationId).toBe("unknown"); + expect(body.model).toBeNull(); + }); }); describe("PUT /conversations/:id/model", () => { - it("persists a non-empty model and returns it", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "umans/umans-glm-5.2" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.conversationId).toBe("conv1"); - expect(body.model).toBe("umans/umans-glm-5.2"); - - // A subsequent GET reflects the persisted value. - const getRes = await app.request("/conversations/conv1/model"); - const getBody = (await getRes.json()) as { model: string | null }; - expect(getBody.model).toBe("umans/umans-glm-5.2"); - }); - - it("clears the model when model is null and GET returns null", async () => { - const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]); - const store = createFakeConversationStore( - new Map(), - new Map(), - new Map(), - new Map(), - modelStore, - ); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - // Preconditions: a model is set. - const before = await app.request("/conversations/conv1/model"); - expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2"); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: null }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.model).toBeNull(); - - const getRes = await app.request("/conversations/conv1/model"); - const getBody = (await getRes.json()) as { model: string | null }; - expect(getBody.model).toBeNull(); - }); - - it("clears the model when model is an empty string and GET returns null", async () => { - const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]); - const store = createFakeConversationStore( - new Map(), - new Map(), - new Map(), - new Map(), - modelStore, - ); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; model: string | null }; - expect(body.model).toBeNull(); - - const getRes = await app.request("/conversations/conv1/model"); - const getBody = (await getRes.json()) as { model: string | null }; - expect(getBody.model).toBeNull(); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - }); - - it("returns 400 when model field is missing", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("model"); - }); - - it("returns 400 when model is a non-string non-null type", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: 42 }), - }); - expect(res.status).toBe(400); - }); - - it("does not call store on validation failure", async () => { - let storeCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setModel() { - storeCalled = true; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/model", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - expect(storeCalled).toBe(false); - }); + it("persists a non-empty model and returns it", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "umans/umans-glm-5.2" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.conversationId).toBe("conv1"); + expect(body.model).toBe("umans/umans-glm-5.2"); + + // A subsequent GET reflects the persisted value. + const getRes = await app.request("/conversations/conv1/model"); + const getBody = (await getRes.json()) as { model: string | null }; + expect(getBody.model).toBe("umans/umans-glm-5.2"); + }); + + it("clears the model when model is null and GET returns null", async () => { + const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + modelStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + // Preconditions: a model is set. + const before = await app.request("/conversations/conv1/model"); + expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2"); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.model).toBeNull(); + + const getRes = await app.request("/conversations/conv1/model"); + const getBody = (await getRes.json()) as { model: string | null }; + expect(getBody.model).toBeNull(); + }); + + it("clears the model when model is an empty string and GET returns null", async () => { + const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + modelStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; model: string | null }; + expect(body.model).toBeNull(); + + const getRes = await app.request("/conversations/conv1/model"); + const getBody = (await getRes.json()) as { model: string | null }; + expect(getBody.model).toBeNull(); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when model field is missing", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("model"); + }); + + it("returns 400 when model is a non-string non-null type", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: 42 }), + }); + expect(res.status).toBe(400); + }); + + it("does not call store on validation failure", async () => { + let storeCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setModel() { + storeCalled = true; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/model", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(storeCalled).toBe(false); + }); }); describe("GET /conversations", () => { - const sampleConvos: ConversationMeta[] = [ - { - id: "conv-1", - createdAt: 1000, - lastActivityAt: 2000, - title: "First", - status: "idle", - workspaceId: "default", - }, - { - id: "conv-2", - createdAt: 1500, - lastActivityAt: 2500, - title: "Second", - status: "idle", - workspaceId: "default", - }, - { - id: "other-1", - createdAt: 3000, - lastActivityAt: 4000, - title: "Other", - status: "idle", - workspaceId: "default", - }, - ]; - - function appWithList(list: ConversationMeta[]) { - const store: ConversationStore = { - ...createFakeConversationStore(), - async listConversations() { - return list; - }, - }; - return createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - } - - it("returns 200 with list", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(3); - expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]); - }); - - it("?q= filters by id prefix", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations?q=conv-"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(2); - expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]); - }); - - it("?q= returns all when q is empty", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations?q="); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(3); - }); - - it("?q= with whitespace-only returns all (trimmed to empty)", async () => { - const app = appWithList(sampleConvos); - const res = await app.request("/conversations?q=%20%20%20"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversations: ConversationMeta[] }; - expect(body.conversations).toHaveLength(3); - }); - - it("returns 500 when listConversations throws", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async listConversations() { - throw new Error("db down"); - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations"); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Failed to list conversations"); - }); + const sampleConvos: ConversationMeta[] = [ + { + id: "conv-1", + createdAt: 1000, + lastActivityAt: 2000, + title: "First", + status: "idle", + workspaceId: "default", + }, + { + id: "conv-2", + createdAt: 1500, + lastActivityAt: 2500, + title: "Second", + status: "idle", + workspaceId: "default", + }, + { + id: "other-1", + createdAt: 3000, + lastActivityAt: 4000, + title: "Other", + status: "idle", + workspaceId: "default", + }, + ]; + + function appWithList(list: ConversationMeta[]) { + const store: ConversationStore = { + ...createFakeConversationStore(), + async listConversations() { + return list; + }, + }; + return createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + } + + it("returns 200 with list", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(3); + expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]); + }); + + it("?q= filters by id prefix", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations?q=conv-"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(2); + expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]); + }); + + it("?q= returns all when q is empty", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations?q="); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(3); + }); + + it("?q= with whitespace-only returns all (trimmed to empty)", async () => { + const app = appWithList(sampleConvos); + const res = await app.request("/conversations?q=%20%20%20"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversations: ConversationMeta[] }; + expect(body.conversations).toHaveLength(3); + }); + + it("returns 500 when listConversations throws", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async listConversations() { + throw new Error("db down"); + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("Failed to list conversations"); + }); }); describe("GET /conversations/:id/last", () => { - function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) { - const store: ConversationStore = { - ...createFakeConversationStore(), - async load(conversationId) { - return messagesByConv.get(conversationId) ?? []; - }, - }; - return createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - } - - it("returns last assistant text", async () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, - { role: "user", chunks: [{ type: "text", text: "more" }] }, - { role: "assistant", chunks: [{ type: "text", text: "final reply" }] }, - ]; - const app = appWithMessages(new Map([["conv1", messages]])); - const res = await app.request("/conversations/conv1/last"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.content).toBe("final reply"); - expect(body.turnId).toBeUndefined(); - }); - - it("returns empty content for unknown conversation", async () => { - const app = appWithMessages(new Map()); - const res = await app.request("/conversations/unknown/last"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; - expect(body.conversationId).toBe("unknown"); - expect(body.content).toBe(""); - expect(body.turnId).toBeUndefined(); - }); - - it("blocks until turn settles", async () => { - const turnId = "sealed-turn"; - const orchestrator: SessionOrchestrator = { - ...createFakeOrchestrator([]), - subscribe(conversationId, listener) { - const event = { type: "turn-sealed" as const, conversationId, turnId }; - setTimeout(() => listener(event), 0); - return () => {}; - }, - isActive() { - return true; - }, - }; - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "after seal" }] }, - ]; - const store: ConversationStore = { - ...createFakeConversationStore(), - async load() { - return messages; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator, - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const res = await app.request("/conversations/conv1/last"); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.content).toBe("after seal"); - expect(body.turnId).toBe(turnId); - }); + function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) { + const store: ConversationStore = { + ...createFakeConversationStore(), + async load(conversationId) { + return messagesByConv.get(conversationId) ?? []; + }, + }; + return createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + } + + it("returns last assistant text", async () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, + { role: "user", chunks: [{ type: "text", text: "more" }] }, + { role: "assistant", chunks: [{ type: "text", text: "final reply" }] }, + ]; + const app = appWithMessages(new Map([["conv1", messages]])); + const res = await app.request("/conversations/conv1/last"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.content).toBe("final reply"); + expect(body.turnId).toBeUndefined(); + }); + + it("returns empty content for unknown conversation", async () => { + const app = appWithMessages(new Map()); + const res = await app.request("/conversations/unknown/last"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; + expect(body.conversationId).toBe("unknown"); + expect(body.content).toBe(""); + expect(body.turnId).toBeUndefined(); + }); + + it("blocks until turn settles", async () => { + const turnId = "sealed-turn"; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + subscribe(conversationId, listener) { + const event = { type: "turn-sealed" as const, conversationId, turnId }; + setTimeout(() => listener(event), 0); + return () => {}; + }, + isActive() { + return true; + }, + }; + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "after seal" }] }, + ]; + const store: ConversationStore = { + ...createFakeConversationStore(), + async load() { + return messages; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/last"); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; content: string; turnId?: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.content).toBe("after seal"); + expect(body.turnId).toBe(turnId); + }); }); describe("POST /conversations/:id/open", () => { - it("returns 200", async () => { - const emit: HostAPI["emit"] = () => {}; - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - emit, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/open", { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string }; - expect(body.conversationId).toBe("conv1"); - }); - - it("calls emit with conversationOpened", async () => { - const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = []; - const emit: HostAPI["emit"] = (hook, payload) => { - emitCalls.push({ hook, payload }); - }; - // A store whose getWorkspaceId returns a non-default id, so the test - // proves the handler resolves and forwards the PERSISTED workspace id - // (not a hard-coded "default"). - const store = createFakeConversationStore(); - store.getWorkspaceId = async () => "open-workspace"; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - emit, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/open", { method: "POST" }); - expect(res.status).toBe(200); - expect(emitCalls).toHaveLength(1); - expect(emitCalls[0]?.hook).toBe(conversationOpened); - expect(emitCalls[0]?.payload).toEqual({ - conversationId: "conv1", - workspaceId: "open-workspace", - }); - }); - - it("returns 500 when emit is absent", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/open", { method: "POST" }); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("not available"); - }); + it("returns 200", async () => { + const emit: HostAPI["emit"] = () => {}; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + emit, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/open", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string }; + expect(body.conversationId).toBe("conv1"); + }); + + it("calls emit with conversationOpened", async () => { + const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = []; + const emit: HostAPI["emit"] = (hook, payload) => { + emitCalls.push({ hook, payload }); + }; + // A store whose getWorkspaceId returns a non-default id, so the test + // proves the handler resolves and forwards the PERSISTED workspace id + // (not a hard-coded "default"). + const store = createFakeConversationStore(); + store.getWorkspaceId = async () => "open-workspace"; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + emit, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/open", { method: "POST" }); + expect(res.status).toBe(200); + expect(emitCalls).toHaveLength(1); + expect(emitCalls[0]?.hook).toBe(conversationOpened); + expect(emitCalls[0]?.payload).toEqual({ + conversationId: "conv1", + workspaceId: "open-workspace", + }); + }); + + it("returns 500 when emit is absent", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/open", { method: "POST" }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("not available"); + }); }); describe("PUT /conversations/:id/title", () => { - it("returns 200 with title", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "My Conversation" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; title: string }; - expect(body.conversationId).toBe("conv1"); - expect(body.title).toBe("My Conversation"); - }); - - it("rejects empty title with 400", async () => { - let setTitleCalled = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setConversationTitle() { - setTitleCalled = true; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: " " }), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("title"); - expect(setTitleCalled).toBe(false); - }); - - it("returns 400 when title is missing", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("title"); - }); - - it("forwards the trimmed title to setConversationTitle", async () => { - const calls: { conversationId: string; title: string }[] = []; - const store: ConversationStore = { - ...createFakeConversationStore(), - async setConversationTitle(conversationId, title) { - calls.push({ conversationId, title }); - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: " trimmed title " }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { conversationId: string; title: string }; - expect(body.title).toBe("trimmed title"); - expect(calls).toHaveLength(1); - expect(calls[0]?.conversationId).toBe("conv1"); - expect(calls[0]?.title).toBe("trimmed title"); - }); - - it("returns 400 for invalid JSON body", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: "not json", - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("JSON"); - }); + it("returns 200 with title", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "My Conversation" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; title: string }; + expect(body.conversationId).toBe("conv1"); + expect(body.title).toBe("My Conversation"); + }); + + it("rejects empty title with 400", async () => { + let setTitleCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle() { + setTitleCalled = true; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: " " }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("title"); + expect(setTitleCalled).toBe(false); + }); + + it("returns 400 when title is missing", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("title"); + }); + + it("forwards the trimmed title to setConversationTitle", async () => { + const calls: { conversationId: string; title: string }[] = []; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle(conversationId, title) { + calls.push({ conversationId, title }); + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: " trimmed title " }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { conversationId: string; title: string }; + expect(body.title).toBe("trimmed title"); + expect(calls).toHaveLength(1); + expect(calls[0]?.conversationId).toBe("conv1"); + expect(calls[0]?.title).toBe("trimmed title"); + }); + + it("returns 400 for invalid JSON body", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("JSON"); + }); }); describe("extractLastAssistantText", () => { - it("returns last assistant text chunk", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, - { role: "user", chunks: [{ type: "text", text: "how are you?" }] }, - { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] }, - ]; - expect(extractLastAssistantText(messages)).toBe("I'm good!"); - }); - - it("returns empty string when no assistant message", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, - ]; - expect(extractLastAssistantText(messages)).toBe(""); - }); - - it("returns the LAST text chunk when an assistant message has multiple", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { - role: "assistant", - chunks: [ - { type: "text", text: "first" }, - { type: "thinking", text: "internal reasoning" }, - { type: "text", text: "second" }, - ], - }, - ]; - expect(extractLastAssistantText(messages)).toBe("second"); - }); - - it("returns empty string when the last assistant message has no text chunk", () => { - const messages: ChatMessage[] = [ - { role: "user", chunks: [{ type: "text", text: "hello" }] }, - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "tc1", - toolName: "read_file", - input: { path: "/tmp" }, - }, - ], - }, - ]; - expect(extractLastAssistantText(messages)).toBe(""); - }); - - it("returns empty string for an empty message list", () => { - expect(extractLastAssistantText([])).toBe(""); - }); + it("returns last assistant text chunk", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "assistant", chunks: [{ type: "text", text: "hi there" }] }, + { role: "user", chunks: [{ type: "text", text: "how are you?" }] }, + { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] }, + ]; + expect(extractLastAssistantText(messages)).toBe("I'm good!"); + }); + + it("returns empty string when no assistant message", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { role: "system", chunks: [{ type: "text", text: "system prompt" }] }, + ]; + expect(extractLastAssistantText(messages)).toBe(""); + }); + + it("returns the LAST text chunk when an assistant message has multiple", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + chunks: [ + { type: "text", text: "first" }, + { type: "thinking", text: "internal reasoning" }, + { type: "text", text: "second" }, + ], + }, + ]; + expect(extractLastAssistantText(messages)).toBe("second"); + }); + + it("returns empty string when the last assistant message has no text chunk", () => { + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "text", text: "hello" }] }, + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "tc1", + toolName: "read_file", + input: { path: "/tmp" }, + }, + ], + }, + ]; + expect(extractLastAssistantText(messages)).toBe(""); + }); + + it("returns empty string for an empty message list", () => { + expect(extractLastAssistantText([])).toBe(""); + }); }); describe("Workspaces", () => { - const sampleWorkspace: Workspace = { - id: "proj", - title: "proj", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 2000, - }; - - it("GET /workspaces returns list", async () => { - const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }]; - const store: ConversationStore = { - ...createFakeConversationStore(), - async listWorkspaces() { - return workspaceEntries; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces"); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceListResponse; - expect(body.workspaces).toEqual(workspaceEntries); - }); - - it("PUT /workspaces/:id creates on miss", async () => { - let ensured = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async ensureWorkspace(id, opts) { - ensured = true; - return { ...sampleWorkspace, id, ...opts }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }), - }); - expect(res.status).toBe(200); - expect(ensured).toBe(true); - const body = (await res.json()) as WorkspaceResponse; - expect(body.id).toBe("proj"); - expect(body.title).toBe("Project"); - expect(body.defaultCwd).toBe("/home/proj"); - }); - - it("PUT /workspaces/:id returns existing", async () => { - const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" }; - const store: ConversationStore = { - ...createFakeConversationStore(), - async ensureWorkspace() { - return existing; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "New Title" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.title).toBe("Existing"); - expect(body.defaultCwd).toBe("/old"); - }); - - it("PUT /workspaces/:id rejects invalid slug", async () => { - let ensured = false; - const store: ConversationStore = { - ...createFakeConversationStore(), - async ensureWorkspace() { - ensured = true; - return sampleWorkspace; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/Bad Slug!", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - expect(ensured).toBe(false); - }); - - it("GET /workspaces/:id returns 404 for missing", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async getWorkspace() { - return null; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/unknown"); - expect(res.status).toBe(404); - }); - - it("PUT /workspaces/:id/title renames", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceTitle(id, title) { - return { ...sampleWorkspace, id, title }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/title", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "Renamed" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.title).toBe("Renamed"); - }); - - it("PUT /workspaces/:id/default-cwd sets", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceDefaultCwd(id, defaultCwd) { - return { ...sampleWorkspace, id, defaultCwd }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/default-cwd", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultCwd: "/new/cwd" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.defaultCwd).toBe("/new/cwd"); - }); - - it("DELETE /workspaces/:id closes conversations", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async deleteWorkspace() { - return { closedCount: 3 }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj", { method: "DELETE" }); - expect(res.status).toBe(200); - const body = (await res.json()) as DeleteWorkspaceResponse; - expect(body.workspaceId).toBe("proj"); - expect(body.closedCount).toBe(3); - }); - - it("DELETE /workspaces/default returns 409", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/default", { method: "DELETE" }); - expect(res.status).toBe(409); - }); + const sampleWorkspace: Workspace = { + id: "proj", + title: "proj", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 2000, + }; + + it("GET /workspaces returns list", async () => { + const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }]; + const store: ConversationStore = { + ...createFakeConversationStore(), + async listWorkspaces() { + return workspaceEntries; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces"); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceListResponse; + expect(body.workspaces).toEqual(workspaceEntries); + }); + + it("PUT /workspaces/:id creates on miss", async () => { + let ensured = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async ensureWorkspace(id, opts) { + ensured = true; + return { ...sampleWorkspace, id, ...opts }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }), + }); + expect(res.status).toBe(200); + expect(ensured).toBe(true); + const body = (await res.json()) as WorkspaceResponse; + expect(body.id).toBe("proj"); + expect(body.title).toBe("Project"); + expect(body.defaultCwd).toBe("/home/proj"); + }); + + it("PUT /workspaces/:id returns existing", async () => { + const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" }; + const store: ConversationStore = { + ...createFakeConversationStore(), + async ensureWorkspace() { + return existing; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "New Title" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.title).toBe("Existing"); + expect(body.defaultCwd).toBe("/old"); + }); + + it("PUT /workspaces/:id rejects invalid slug", async () => { + let ensured = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async ensureWorkspace() { + ensured = true; + return sampleWorkspace; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/Bad Slug!", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(ensured).toBe(false); + }); + + it("GET /workspaces/:id returns 404 for missing", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async getWorkspace() { + return null; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/unknown"); + expect(res.status).toBe(404); + }); + + it("PUT /workspaces/:id/title renames", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceTitle(id, title) { + return { ...sampleWorkspace, id, title }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/title", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Renamed" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.title).toBe("Renamed"); + }); + + it("PUT /workspaces/:id/default-cwd sets", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultCwd(id, defaultCwd) { + return { ...sampleWorkspace, id, defaultCwd }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-cwd", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultCwd: "/new/cwd" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultCwd).toBe("/new/cwd"); + }); + + it("DELETE /workspaces/:id closes conversations", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async deleteWorkspace() { + return { closedCount: 3 }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { method: "DELETE" }); + expect(res.status).toBe(200); + const body = (await res.json()) as DeleteWorkspaceResponse; + expect(body.workspaceId).toBe("proj"); + expect(body.closedCount).toBe(3); + }); + + it("DELETE /workspaces/default returns 409", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/default", { method: "DELETE" }); + expect(res.status).toBe(409); + }); }); it("POST /chat threads workspaceId", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }), - }); - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.workspaceId).toBe("proj"); + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.workspaceId).toBe("proj"); }); it("GET /conversations?workspaceId= filters", async () => { - const calls: Parameters<ConversationStore["listConversations"]>[0][] = []; - const store: ConversationStore = { - ...createFakeConversationStore(), - async listConversations(filter) { - calls.push(filter); - return []; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations?workspaceId=proj"); - expect(res.status).toBe(200); - expect(calls).toHaveLength(1); - expect(calls[0]).toEqual({ workspaceId: "proj" }); + const calls: Parameters<ConversationStore["listConversations"]>[0][] = []; + const store: ConversationStore = { + ...createFakeConversationStore(), + async listConversations(filter) { + calls.push(filter); + return []; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations?workspaceId=proj"); + expect(res.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ workspaceId: "proj" }); }); it("GET /conversations/:id/lsp uses effective cwd", async () => { - let effectiveCwdCalled = false; - let getCwdCalled = false; - let lspCwd: string | null = null; - const store: ConversationStore = { - ...createFakeConversationStore(), - async getEffectiveCwd(_conversationId) { - effectiveCwdCalled = true; - return "/effective"; - }, - async getCwd(_conversationId) { - getCwdCalled = true; - return "/explicit"; - }, - }; - const lsp: LspService = { - async status(cwd) { - lspCwd = cwd; - return []; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - lspService: lsp, - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/lsp"); - expect(res.status).toBe(200); - expect(effectiveCwdCalled).toBe(true); - expect(getCwdCalled).toBe(true); // gated on persisted cwd first - expect(lspCwd).toBe("/effective"); - const body = (await res.json()) as { - conversationId: string; - cwd: string | null; - servers: readonly unknown[]; - }; - expect(body.cwd).toBe("/effective"); + let effectiveCwdCalled = false; + let getCwdCalled = false; + let lspCwd: string | null = null; + const store: ConversationStore = { + ...createFakeConversationStore(), + async getEffectiveCwd(_conversationId) { + effectiveCwdCalled = true; + return "/effective"; + }, + async getCwd(_conversationId) { + getCwdCalled = true; + return "/explicit"; + }, + }; + const lsp: LspService = { + async status(cwd) { + lspCwd = cwd; + return []; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + lspService: lsp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/lsp"); + expect(res.status).toBe(200); + expect(effectiveCwdCalled).toBe(true); + expect(getCwdCalled).toBe(true); // gated on persisted cwd first + expect(lspCwd).toBe("/effective"); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.cwd).toBe("/effective"); }); describe("GET /system-prompt", () => { - it("returns stored template", async () => { - const service = createFakeSystemPromptService("custom template"); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - systemPromptService: service, - logger: noopLogger, - }); - const res = await app.request("/system-prompt"); - expect(res.status).toBe(200); - const body = (await res.json()) as { template: string }; - expect(body.template).toBe("custom template"); - expect(service.getTemplateCalls).toBe(1); - }); - - it("returns default when service unavailable", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/system-prompt"); - expect(res.status).toBe(200); - const body = (await res.json()) as { template: string }; - expect(body.template).toBe(DEFAULT_TEMPLATE); - }); + it("returns stored template", async () => { + const service = createFakeSystemPromptService("custom template"); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + systemPromptService: service, + logger: noopLogger, + }); + const res = await app.request("/system-prompt"); + expect(res.status).toBe(200); + const body = (await res.json()) as { template: string }; + expect(body.template).toBe("custom template"); + expect(service.getTemplateCalls).toBe(1); + }); + + it("returns default when service unavailable", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/system-prompt"); + expect(res.status).toBe(200); + const body = (await res.json()) as { template: string }; + expect(body.template).toBe(DEFAULT_TEMPLATE); + }); }); describe("PUT /system-prompt", () => { - it("sets template", async () => { - const service = createFakeSystemPromptService(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - systemPromptService: service, - logger: noopLogger, - }); - const res = await app.request("/system-prompt", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template: "new" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as { template: string }; - expect(body.template).toBe("new"); - expect(service.setTemplateCalls).toEqual(["new"]); - }); - - it("missing template → 400", async () => { - const service = createFakeSystemPromptService(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - systemPromptService: service, - logger: noopLogger, - }); - const res = await app.request("/system-prompt", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - expect(service.setTemplateCalls).toEqual([]); - }); - - it("service unavailable → 503", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/system-prompt", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ template: "new" }), - }); - expect(res.status).toBe(503); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("System prompt service not available"); - }); + it("sets template", async () => { + const service = createFakeSystemPromptService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + systemPromptService: service, + logger: noopLogger, + }); + const res = await app.request("/system-prompt", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template: "new" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { template: string }; + expect(body.template).toBe("new"); + expect(service.setTemplateCalls).toEqual(["new"]); + }); + + it("missing template → 400", async () => { + const service = createFakeSystemPromptService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + systemPromptService: service, + logger: noopLogger, + }); + const res = await app.request("/system-prompt", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + expect(service.setTemplateCalls).toEqual([]); + }); + + it("service unavailable → 503", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/system-prompt", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template: "new" }), + }); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("System prompt service not available"); + }); }); describe("GET /system-prompt/variables", () => { - it("returns catalog", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/system-prompt/variables"); - expect(res.status).toBe(200); - const body = (await res.json()) as { variables: readonly SystemPromptVariable[] }; - expect(Array.isArray(body.variables)).toBe(true); - // Contains at least system:time, prompt:cwd, and a dynamic file:<path>. - const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time"); - const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd"); - const fileEntry = body.variables.find((v) => v.type === "file"); - expect(hasSystemTime).toBe(true); - expect(hasPromptCwd).toBe(true); - expect(fileEntry).toBeDefined(); - expect(fileEntry?.dynamic).toBe(true); - }); + it("returns catalog", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/system-prompt/variables"); + expect(res.status).toBe(200); + const body = (await res.json()) as { variables: readonly SystemPromptVariable[] }; + expect(Array.isArray(body.variables)).toBe(true); + // Contains at least system:time, prompt:cwd, and a dynamic file:<path>. + const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time"); + const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd"); + const fileEntry = body.variables.find((v) => v.type === "file"); + expect(hasSystemTime).toBe(true); + expect(hasPromptCwd).toBe(true); + expect(fileEntry).toBeDefined(); + expect(fileEntry?.dynamic).toBe(true); + }); }); // ─── Computers (mirrors the cwd / workspace routes) ───────────────────────── const sampleComputer: Computer = { - alias: "myserver", - hostName: "10.0.0.5", - port: 22, - user: "deploy", - identityFile: "/home/user/.ssh/id_ed25519", - knownHost: true, + alias: "myserver", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/user/.ssh/id_ed25519", + knownHost: true, }; describe("GET /computers", () => { - it("returns [] when no ComputerService is wired (graceful degrade)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers"); - expect(res.status).toBe(200); - const body = (await res.json()) as { computers: readonly ComputerEntry[] }; - expect(body.computers).toEqual([]); - }); - - it("delegates to the ComputerService when wired", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]), - logger: noopLogger, - }); - const res = await app.request("/computers"); - expect(res.status).toBe(200); - const body = (await res.json()) as { computers: readonly ComputerEntry[] }; - expect(body.computers).toHaveLength(1); - expect(body.computers[0]?.alias).toBe("myserver"); - expect(body.computers[0]?.usageCount).toBe(2); - }); + it("returns [] when no ComputerService is wired (graceful degrade)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toEqual([]); + }); + + it("delegates to the ComputerService when wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]), + logger: noopLogger, + }); + const res = await app.request("/computers"); + expect(res.status).toBe(200); + const body = (await res.json()) as { computers: readonly ComputerEntry[] }; + expect(body.computers).toHaveLength(1); + expect(body.computers[0]?.alias).toBe("myserver"); + expect(body.computers[0]?.usageCount).toBe(2); + }); }); describe("GET /computers/:alias", () => { - it("returns the computer when the alias is configured", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver"); - expect(res.status).toBe(200); - const body = (await res.json()) as Computer; - expect(body.alias).toBe("myserver"); - expect(body.hostName).toBe("10.0.0.5"); - }); - - it("returns 404 when the alias is not in the config", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - computerService: createFakeComputerService([]), - logger: noopLogger, - }); - const res = await app.request("/computers/unknown"); - expect(res.status).toBe(404); - }); - - it("returns 404 when no ComputerService is wired (no ssh)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver"); - expect(res.status).toBe(404); - }); + it("returns the computer when the alias is configured", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(200); + const body = (await res.json()) as Computer; + expect(body.alias).toBe("myserver"); + expect(body.hostName).toBe("10.0.0.5"); + }); + + it("returns 404 when the alias is not in the config", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + computerService: createFakeComputerService([]), + logger: noopLogger, + }); + const res = await app.request("/computers/unknown"); + expect(res.status).toBe(404); + }); + + it("returns 404 when no ComputerService is wired (no ssh)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver"); + expect(res.status).toBe(404); + }); }); describe("GET /computers/:alias/status", () => { - it("returns disconnected + knownHost:false when no ComputerService is wired", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver/status"); - expect(res.status).toBe(200); - const body = (await res.json()) as { - alias: string; - state: string; - knownHost: boolean; - }; - expect(body.alias).toBe("myserver"); - expect(body.state).toBe("disconnected"); - expect(body.knownHost).toBe(false); - }); + it("returns disconnected + knownHost:false when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/status"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + alias: string; + state: string; + knownHost: boolean; + }; + expect(body.alias).toBe("myserver"); + expect(body.state).toBe("disconnected"); + expect(body.knownHost).toBe(false); + }); }); describe("POST /computers/:alias/test", () => { - it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/computers/myserver/test", { method: "POST" }); - expect(res.status).toBe(200); - const body = (await res.json()) as { alias: string; ok: boolean; error?: string }; - expect(body.alias).toBe("myserver"); - expect(body.ok).toBe(false); - expect(body.error).toBe("SSH not configured"); - }); + it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/computers/myserver/test", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { alias: string; ok: boolean; error?: string }; + expect(body.alias).toBe("myserver"); + expect(body.ok).toBe(false); + expect(body.error).toBe("SSH not configured"); + }); }); describe("GET then PUT then GET /conversations/:id/computer", () => { - it("round-trips the value", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const get0 = await app.request("/conversations/conv1/computer"); - expect(get0.status).toBe(200); - const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null }; - expect(get0Body.conversationId).toBe("conv1"); - expect(get0Body.computerId).toBeNull(); - - const putRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(putRes.status).toBe(200); - const putBody = (await putRes.json()) as { conversationId: string; computerId: string }; - expect(putBody.conversationId).toBe("conv1"); - expect(putBody.computerId).toBe("myserver"); - - const getRes = await app.request("/conversations/conv1/computer"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null }; - expect(getBody.computerId).toBe("myserver"); - }); + it("round-trips the value", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const get0 = await app.request("/conversations/conv1/computer"); + expect(get0.status).toBe(200); + const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null }; + expect(get0Body.conversationId).toBe("conv1"); + expect(get0Body.computerId).toBeNull(); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + const putBody = (await putRes.json()) as { conversationId: string; computerId: string }; + expect(putBody.conversationId).toBe("conv1"); + expect(putBody.computerId).toBe("myserver"); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null }; + expect(getBody.computerId).toBe("myserver"); + }); }); describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => { - it("PUT null clears a previously-set computer", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(putRes.status).toBe(200); - - const clearRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: null }), - }); - expect(clearRes.status).toBe(200); - const clearBody = (await clearRes.json()) as { - conversationId: string; - computerId: string | null; - }; - expect(clearBody.computerId).toBeNull(); - - const getRes = await app.request("/conversations/conv1/computer"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { computerId: string | null }; - expect(getBody.computerId).toBeNull(); - }); + it("PUT null clears a previously-set computer", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const clearRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(clearRes.status).toBe(200); + const clearBody = (await clearRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(clearBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); }); describe("DELETE /conversations/:id/computer", () => { - it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => { - const store = createFakeConversationStore(); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const putRes = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(putRes.status).toBe(200); - - const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { - conversationId: string; - computerId: string | null; - }; - expect(deleteBody.conversationId).toBe("conv1"); - expect(deleteBody.computerId).toBeNull(); - - const getRes = await app.request("/conversations/conv1/computer"); - expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { computerId: string | null }; - expect(getBody.computerId).toBeNull(); - }); - - it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - const deleteBody = (await deleteRes.json()) as { - conversationId: string; - computerId: string | null; - }; - expect(deleteBody.computerId).toBeNull(); - }); - - it("does NOT affect other conversations' computers (isolation)", async () => { - const computerStore = new Map<string, string>([ - ["conv1", "myserver"], - ["conv2", "otherbox"], - ]); - const store = createFakeConversationStore( - new Map(), - new Map(), - new Map(), - new Map(), - new Map(), - computerStore, - ); - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - - const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); - expect(deleteRes.status).toBe(200); - - const get1 = await app.request("/conversations/conv1/computer"); - expect(get1.status).toBe(200); - expect((await get1.json()).computerId).toBeNull(); - - const get2 = await app.request("/conversations/conv2/computer"); - expect(get2.status).toBe(200); - expect((await get2.json()).computerId).toBe("otherbox"); - }); + it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => { + const store = createFakeConversationStore(); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const putRes = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(putRes.status).toBe(200); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.conversationId).toBe("conv1"); + expect(deleteBody.computerId).toBeNull(); + + const getRes = await app.request("/conversations/conv1/computer"); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { computerId: string | null }; + expect(getBody.computerId).toBeNull(); + }); + + it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + const deleteBody = (await deleteRes.json()) as { + conversationId: string; + computerId: string | null; + }; + expect(deleteBody.computerId).toBeNull(); + }); + + it("does NOT affect other conversations' computers (isolation)", async () => { + const computerStore = new Map<string, string>([ + ["conv1", "myserver"], + ["conv2", "otherbox"], + ]); + const store = createFakeConversationStore( + new Map(), + new Map(), + new Map(), + new Map(), + new Map(), + computerStore, + ); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" }); + expect(deleteRes.status).toBe(200); + + const get1 = await app.request("/conversations/conv1/computer"); + expect(get1.status).toBe(200); + expect((await get1.json()).computerId).toBeNull(); + + const get2 = await app.request("/conversations/conv2/computer"); + expect(get2.status).toBe(200); + expect((await get2.json()).computerId).toBe("otherbox"); + }); }); describe("PUT /conversations/:id/computer validation", () => { - it("with missing computerId returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("computerId"); - }); - - it("with empty-string computerId returns 400", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/conversations/conv1/computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "" }), - }); - expect(res.status).toBe(400); - }); + it("with missing computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("computerId"); + }); + + it("with empty-string computerId returns 400", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "" }), + }); + expect(res.status).toBe(400); + }); }); describe("PUT /workspaces/:id/default-computer", () => { - const wsSample: Workspace = { - id: "proj", - title: "proj", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1000, - lastActivityAt: 2000, - }; - - it("sets the default computer", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { ...wsSample, id, defaultComputerId }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/default-computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: "myserver" }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.defaultComputerId).toBe("myserver"); - }); - - it("clears the default computer with null", async () => { - const store: ConversationStore = { - ...createFakeConversationStore(), - async setWorkspaceDefaultComputerId(id, defaultComputerId) { - return { ...wsSample, id, defaultComputerId }; - }, - }; - const app = createApp({ - conversationStore: store, - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/proj/default-computer", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId: null }), - }); - expect(res.status).toBe(200); - const body = (await res.json()) as WorkspaceResponse; - expect(body.defaultComputerId).toBeNull(); - }); + const wsSample: Workspace = { + id: "proj", + title: "proj", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1000, + lastActivityAt: 2000, + }; + + it("sets the default computer", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: "myserver" }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBe("myserver"); + }); + + it("clears the default computer with null", async () => { + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceDefaultComputerId(id, defaultComputerId) { + return { ...wsSample, id, defaultComputerId }; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/default-computer", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId: null }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.defaultComputerId).toBeNull(); + }); }); describe("POST /chat threads computerId", () => { - it("forwards computerId into the orchestrator input when present", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "hi", - conversationId: "conv1", - computerId: "myserver", - }), - }); - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.conversationId).toBe("conv1"); - expect(cap.received?.computerId).toBe("myserver"); - }); - - it("omits computerId when not provided", async () => { - const cap = createCapturingOrchestrator(); - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: cap, - credentialStore: createFakeCredentialStore([]), - }); - const res = await app.request("/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hi", conversationId: "conv1" }), - }); - expect(res.status).toBe(200); - expect(cap.received).toBeDefined(); - expect(cap.received?.computerId).toBeUndefined(); - }); + it("forwards computerId into the orchestrator input when present", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "hi", + conversationId: "conv1", + computerId: "myserver", + }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.conversationId).toBe("conv1"); + expect(cap.received?.computerId).toBe("myserver"); + }); + + it("omits computerId when not provided", async () => { + const cap = createCapturingOrchestrator(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: cap, + credentialStore: createFakeCredentialStore([]), + }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.computerId).toBeUndefined(); + }); }); describe("GET /workspaces/:id/heartbeat/next-run", () => { - it("returns { nextRunAt: null } when no HeartbeatService is wired (graceful degrade)", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - logger: noopLogger, - }); - const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); - expect(res.status).toBe(200); - const body = (await res.json()) as { nextRunAt: string | null }; - expect(body.nextRunAt).toBeNull(); - }); - - it("delegates to the HeartbeatService and returns the next-run ISO timestamp", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - heartbeatService: createFakeHeartbeatService("2026-06-25T14:05:00Z"), - logger: noopLogger, - }); - const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); - expect(res.status).toBe(200); - const body = (await res.json()) as { nextRunAt: string | null }; - expect(body.nextRunAt).toBe("2026-06-25T14:05:00Z"); - }); - - it("returns { nextRunAt: null } when the service reports no scheduled run", async () => { - const app = createApp({ - conversationStore: createFakeConversationStore(), - orchestrator: createFakeOrchestrator([]), - credentialStore: createFakeCredentialStore([]), - heartbeatService: createFakeHeartbeatService(null), - logger: noopLogger, - }); - const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); - expect(res.status).toBe(200); - const body = (await res.json()) as { nextRunAt: string | null }; - expect(body.nextRunAt).toBeNull(); - }); + it("returns { nextRunAt: null } when no HeartbeatService is wired (graceful degrade)", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); + expect(res.status).toBe(200); + const body = (await res.json()) as { nextRunAt: string | null }; + expect(body.nextRunAt).toBeNull(); + }); + + it("delegates to the HeartbeatService and returns the next-run ISO timestamp", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: createFakeHeartbeatService("2026-06-25T14:05:00Z"), + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); + expect(res.status).toBe(200); + const body = (await res.json()) as { nextRunAt: string | null }; + expect(body.nextRunAt).toBe("2026-06-25T14:05:00Z"); + }); + + it("returns { nextRunAt: null } when the service reports no scheduled run", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: createFakeHeartbeatService(null), + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat/next-run"); + expect(res.status).toBe(200); + const body = (await res.json()) as { nextRunAt: string | null }; + expect(body.nextRunAt).toBeNull(); + }); }); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 27ae2bb..4fb295e 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -2,1624 +2,1624 @@ import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat"; import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt"; import type { - CloseConversationResponse, - CompactPercentResponse, - CompactResponse, - ComputerListResponse, - ComputerResponse, - ComputerStatusResponse, - ConversationComputerResponse, - ConversationHistoryResponse, - ConversationListResponse, - ConversationMetricsResponse, - ConversationStatusResponse, - CwdResponse, - DeleteWorkspaceResponse, - HeartbeatConfig, - HeartbeatRunsResponse, - LastMessageResponse, - LspServerInfo, - LspStatusResponse, - McpServerInfo, - McpStatusResponse, - ModelResponse, - ModelsResponse, - OpenConversationResponse, - QueueResponse, - ReasoningEffortResponse, - SetCompactPercentRequest, - SetConversationComputerRequest, - SetSystemPromptTemplateRequest, - SetWorkspaceDefaultComputerRequest, - StopHeartbeatRunResponse, - SystemPromptTemplateResponse, - SystemPromptVariablesResponse, - TestComputerResponse, - ThroughputResponse, - TitleResponse, - UpdateHeartbeatRequest, - WarmResponse, - WorkspaceListResponse, - WorkspaceResponse, + CloseConversationResponse, + CompactPercentResponse, + CompactResponse, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConversationComputerResponse, + ConversationHistoryResponse, + ConversationListResponse, + ConversationMetricsResponse, + ConversationStatusResponse, + CwdResponse, + DeleteWorkspaceResponse, + HeartbeatConfig, + HeartbeatRunsResponse, + LastMessageResponse, + LspServerInfo, + LspStatusResponse, + McpServerInfo, + McpStatusResponse, + ModelResponse, + ModelsResponse, + OpenConversationResponse, + QueueResponse, + ReasoningEffortResponse, + SetCompactPercentRequest, + SetConversationComputerRequest, + SetSystemPromptTemplateRequest, + SetWorkspaceDefaultComputerRequest, + StopHeartbeatRunResponse, + SystemPromptTemplateResponse, + SystemPromptVariablesResponse, + TestComputerResponse, + ThroughputResponse, + TitleResponse, + UpdateHeartbeatRequest, + WarmResponse, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { - computeCachePct, - computeExpectedCacheRate, - extractLastAssistantText, - isModelParseError, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - isValidReasoningEffort, - isWindowParamError, - parseChatBody, - parseModelBody, - parseQueueBody, - parseReasoningEffortBody, - parseSinceSeq, - parseStatusFilter, - parseWarmBody, - parseWindowParam, - serializeEventLine, + computeCachePct, + computeExpectedCacheRate, + extractLastAssistantText, + isModelParseError, + isParseError, + isReasoningEffortParseError, + isSinceSeqError, + isValidReasoningEffort, + isWindowParamError, + parseChatBody, + parseModelBody, + parseQueueBody, + parseReasoningEffortBody, + parseSinceSeq, + parseStatusFilter, + parseWarmBody, + parseWindowParam, + serializeEventLine, } from "./logic.js"; import { - type CompactionService, - type ComputerService, - type ConversationStore, - type CredentialStore, - conversationOpened, - type HeartbeatService, - isValidWorkspaceSlug, - type LspServerStatus, - type LspService, - type McpServerStatus, - type McpService, - type SessionOrchestrator, - type SystemPromptService, - ThroughputQueryError, - type ThroughputStore, - type WarmService, + type CompactionService, + type ComputerService, + type ConversationStore, + type CredentialStore, + conversationOpened, + type HeartbeatService, + isValidWorkspaceSlug, + type LspServerStatus, + type LspService, + type McpServerStatus, + type McpService, + type SessionOrchestrator, + type SystemPromptService, + ThroughputQueryError, + type ThroughputStore, + type WarmService, } from "./seam.js"; export interface CreateServerOptions { - readonly conversationStore: ConversationStore; - readonly orchestrator: SessionOrchestrator; - readonly credentialStore: CredentialStore; - readonly warmService?: WarmService; - readonly compactionService?: CompactionService; - readonly lspService?: LspService; - readonly mcpService?: McpService; - /** Optional — system prompt builder service (GET/PUT template). */ - readonly systemPromptService?: SystemPromptService; - /** - * Optional — per-workspace heartbeat loop service (provided by the - * `heartbeat` extension). When absent (heartbeat not loaded), the - * `/workspaces/:id/heartbeat*` routes degrade: GET returns defaults, - * PUT/POST return 503. - */ - readonly heartbeatService?: HeartbeatService; - /** - * Optional — computer discovery + live connection service (provided by the - * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes - * degrade: list returns `[]`, status returns "disconnected", test returns - * a not-configured result. The per-conversation / workspace-default computer - * endpoints work regardless (they only touch the conversation store). - */ - readonly computerService?: ComputerService; - /** Optional — defaults to a no-op store (recording disabled, empty reports). */ - readonly throughputStore?: ThroughputStore; - readonly logger?: Logger; - readonly generateId?: () => string; - /** Injectable clock for sample timestamps (default Date.now). */ - readonly now?: () => number; - /** - * Fire-and-forget event-bus emit (bound `host.emit`). Required by - * `POST /conversations/:id/open` to signal the frontend. When absent, - * that endpoint responds `500 { error: "not available" }`. - */ - readonly emit?: HostAPI["emit"]; - /** - * Directory containing built frontend static files. When set, unmatched GET - * requests fall through to static file serving (SPA fallback to index.html). - * When absent, no static serving (API-only — backward compatible). - */ - readonly webDir?: string; + readonly conversationStore: ConversationStore; + readonly orchestrator: SessionOrchestrator; + readonly credentialStore: CredentialStore; + readonly warmService?: WarmService; + readonly compactionService?: CompactionService; + readonly lspService?: LspService; + readonly mcpService?: McpService; + /** Optional — system prompt builder service (GET/PUT template). */ + readonly systemPromptService?: SystemPromptService; + /** + * Optional — per-workspace heartbeat loop service (provided by the + * `heartbeat` extension). When absent (heartbeat not loaded), the + * `/workspaces/:id/heartbeat*` routes degrade: GET returns defaults, + * PUT/POST return 503. + */ + readonly heartbeatService?: HeartbeatService; + /** + * Optional — computer discovery + live connection service (provided by the + * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes + * degrade: list returns `[]`, status returns "disconnected", test returns + * a not-configured result. The per-conversation / workspace-default computer + * endpoints work regardless (they only touch the conversation store). + */ + readonly computerService?: ComputerService; + /** Optional — defaults to a no-op store (recording disabled, empty reports). */ + readonly throughputStore?: ThroughputStore; + readonly logger?: Logger; + readonly generateId?: () => string; + /** Injectable clock for sample timestamps (default Date.now). */ + readonly now?: () => number; + /** + * Fire-and-forget event-bus emit (bound `host.emit`). Required by + * `POST /conversations/:id/open` to signal the frontend. When absent, + * that endpoint responds `500 { error: "not available" }`. + */ + readonly emit?: HostAPI["emit"]; + /** + * Directory containing built frontend static files. When set, unmatched GET + * requests fall through to static file serving (SPA fallback to index.html). + * When absent, no static serving (API-only — backward compatible). + */ + readonly webDir?: string; } const noopLogger: Logger = { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return noopLogger; - }, - span() { - return { - id: "noop-span", - log: noopLogger, - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return noopLogger; + }, + span() { + return { + id: "noop-span", + log: noopLogger, + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, }; const noopThroughputStore: ThroughputStore = { - record: async () => {}, - aggregate: async (q) => ({ period: q.period, date: q.date, start: 0, end: 0, models: [] }), + record: async () => {}, + aggregate: async (q) => ({ period: q.period, date: q.date, start: 0, end: 0, models: [] }), }; export function createApp(opts: CreateServerOptions): Hono { - const app = new Hono(); - const log = opts.logger ?? noopLogger; - const generateId = opts.generateId ?? (() => crypto.randomUUID()); - const now = opts.now ?? (() => Date.now()); - const throughputStore = opts.throughputStore ?? noopThroughputStore; - - async function recordThroughput( - turnEvents: readonly AgentEvent[], - model: string | undefined, - ): Promise<void> { - if (model === undefined) return; // no model selected → nothing to attribute - let genMs = 0; - let outputTokens = 0; - for (const e of turnEvents) { - if (e.type === "step-complete" && e.genTotalMs !== undefined) genMs += e.genTotalMs; - if (e.type === "done" && e.usage !== undefined) outputTokens = e.usage.outputTokens; - } - if (genMs <= 0) return; // no generation time → can't compute tok/s - try { - await throughputStore.record({ model, ts: now(), outputTokens, genMs }); - log.info("throughput: turn recorded", { - model, - outputTokens, - genMs, - tokensPerSecond: Math.round((outputTokens / (genMs / 1000)) * 100) / 100, - }); - } catch (err) { - log.warn("throughput: failed to record sample", { - error: err instanceof Error ? err.message : String(err), - }); - } - } - - app.use( - "*", - cors({ - origin: "*", - allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowHeaders: ["Content-Type"], - }), - ); - - app.get("/health", (c) => c.json({ ok: true })); - - app.get("/conversations/:id/metrics", async (c) => { - const conversationId = c.req.param("id"); - - try { - const turns = await opts.conversationStore.loadMetrics(conversationId); - log.info("conversations: metrics read", { - conversationId, - count: turns.length, - }); - const body: ConversationMetricsResponse = { turns }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: metrics store failure", { err }); - return c.json({ error: "Failed to load conversation metrics" }, 500); - } - }); - - app.get("/conversations/:id", async (c) => { - const conversationId = c.req.param("id"); - const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq")); - if (isSinceSeqError(sinceSeqResult)) { - log.warn("conversations: invalid sinceSeq", { - conversationId, - error: sinceSeqResult.error, - }); - return c.json({ error: sinceSeqResult.error }, 400); - } - - // `limit` / `beforeSeq` are optional positive-integer history-window - // params. The store is deliberately forgiving (a 0/negative bound is - // treated as ABSENT), so we MUST reject malformed values here and never - // forward an invalid window. - const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq"); - if (isWindowParamError(beforeSeqResult)) { - log.warn("conversations: invalid beforeSeq", { - conversationId, - error: beforeSeqResult.error, - }); - return c.json({ error: beforeSeqResult.error }, 400); - } - const limitResult = parseWindowParam(c.req.query("limit"), "limit"); - if (isWindowParamError(limitResult)) { - log.warn("conversations: invalid limit", { - conversationId, - error: limitResult.error, - }); - return c.json({ error: limitResult.error }, 400); - } - - // Include only the fields actually provided (exactOptionalPropertyTypes), - // and omit the window argument entirely when neither was given — keeping - // the pre-windowing call shape byte-identical for existing callers. - const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined = - beforeSeqResult !== undefined || limitResult !== undefined - ? { - ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}), - ...(limitResult !== undefined ? { limit: limitResult } : {}), - } - : undefined; - - try { - const chunks = - window !== undefined - ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window) - : await opts.conversationStore.loadSince(conversationId, sinceSeqResult); - const latestSeq = - chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult; - log.info("conversations: read", { - conversationId, - sinceSeq: sinceSeqResult, - count: chunks.length, - }); - const body: ConversationHistoryResponse = { chunks, latestSeq }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: store failure", { err }); - return c.json({ error: "Failed to load conversation" }, 500); - } - }); - - app.get("/conversations/:id/status", async (c) => { - const conversationId = c.req.param("id"); - const isActive = opts.orchestrator.isActive(conversationId); - const status = await opts.conversationStore.getConversationStatus(conversationId); - if (status === null) { - return c.json({ error: "Conversation not found" }, 404); - } - const body: ConversationStatusResponse = { conversationId, isActive, status }; - return c.json(body, 200); - }); - - app.get("/models", async (c) => { - try { - const models = await opts.credentialStore.listCatalog(); - const modelInfo: Record<string, { contextWindow?: number }> = {}; - for (const modelName of models) { - const info = await opts.credentialStore.getModelInfo(modelName); - if (info?.contextWindow !== undefined) { - modelInfo[modelName] = { contextWindow: info.contextWindow }; - } - } - const body: ModelsResponse = { - models, - ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}), - }; - return c.json(body, 200); - } catch (err) { - log.error("models: failed to retrieve catalog", { err }); - return c.json({ error: "Failed to retrieve model catalog" }, 502); - } - }); - - // ─── Computers (discovery + live state) ─────────────────────────────────── - // Read-only discovery + connection state is delegated to the ComputerService - // (provided by the `ssh` extension). When ssh is NOT loaded the routes - // degrade: list → empty, status → "disconnected", test → not-configured. - - app.get("/computers", async (c) => { - if (opts.computerService === undefined) { - // Graceful: no ssh configured → no computers discovered. - const body: ComputerListResponse = { computers: [] }; - return c.json(body, 200); - } - try { - const computers = await opts.computerService.listComputers(); - log.info("computers: list", { count: computers.length }); - const body: ComputerListResponse = { computers }; - return c.json(body, 200); - } catch (err) { - log.error("computers: list failure", { err }); - return c.json({ error: "Failed to list computers" }, 500); - } - }); - - app.get("/computers/:alias", async (c) => { - const alias = c.req.param("alias"); - if (opts.computerService === undefined) { - // No ssh configured → no computer resolves this alias. - return c.json({ error: "Computer not found" }, 404); - } - try { - const computer = await opts.computerService.getComputer(alias); - if (computer === null) { - return c.json({ error: "Computer not found" }, 404); - } - const body: ComputerResponse = computer; - return c.json(body, 200); - } catch (err) { - log.error("computers: get failure", { err, alias }); - return c.json({ error: "Failed to read computer" }, 500); - } - }); - - app.get("/computers/:alias/status", async (c) => { - const alias = c.req.param("alias"); - if (opts.computerService === undefined) { - const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false }; - return c.json(body, 200); - } - try { - const body = await opts.computerService.getStatus(alias); - return c.json(body, 200); - } catch (err) { - log.error("computers: status failure", { err, alias }); - return c.json({ error: "Failed to read computer status" }, 500); - } - }); - - app.post("/computers/:alias/test", async (c) => { - const alias = c.req.param("alias"); - if (opts.computerService === undefined) { - const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" }; - return c.json(body, 200); - } - try { - const body = await opts.computerService.test(alias); - return c.json(body, 200); - } catch (err) { - log.error("computers: test failure", { err, alias }); - return c.json({ error: "Failed to test computer" }, 500); - } - }); - - app.post("/chat", async (c) => { - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("chat: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const result = parseChatBody(body, generateId); - if (isParseError(result)) { - log.warn("chat: validation failed", { reason: result.error }); - return c.json({ error: result.error }, 400); - } - - const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } = - result; - log.info("chat: request accepted", { - conversationId, - hasModel: model !== undefined, - hasCwd: cwd !== undefined, - hasComputerId: computerId !== undefined, - hasReasoningEffort: reasoningEffort !== undefined, - hasWorkspaceId: workspaceId !== undefined, - }); - - const events: AgentEvent[] = []; - let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined; - let streamClosed = false; - - const stream = new ReadableStream<Uint8Array>({ - start(controller) { - controllerRef = controller; - }, - }); - - function safeEnqueue(data: Uint8Array): void { - if (streamClosed) return; - try { - controllerRef?.enqueue(data); - } catch (err) { - streamClosed = true; - log.warn("chat: stream enqueue failed", { - conversationId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - function safeClose(): void { - if (streamClosed) return; - streamClosed = true; - try { - controllerRef?.close(); - } catch (err) { - log.warn("chat: stream close failed", { - conversationId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - const orchestratorInput: Parameters<SessionOrchestrator["handleMessage"]>[0] = { - conversationId, - text: message, - onEvent: (event) => { - events.push(event); - safeEnqueue(new TextEncoder().encode(serializeEventLine(event))); - }, - ...(model !== undefined ? { modelName: model } : {}), - ...(cwd !== undefined ? { cwd } : {}), - ...(computerId !== undefined ? { computerId } : {}), - ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - }; - - opts.orchestrator - .handleMessage(orchestratorInput) - .then(async () => { - safeClose(); - await recordThroughput(events, model); - }) - .catch((err) => { - log.error("chat: turn failed", { err }); - const errorEvent: AgentEvent = { - type: "error", - conversationId, - turnId: "", - message: err instanceof Error ? err.message : String(err), - }; - safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent))); - safeClose(); - }); - - return new Response(stream, { - status: 200, - headers: { - "Content-Type": "application/x-ndjson", - "X-Conversation-Id": conversationId, - "Transfer-Encoding": "chunked", - }, - }); - }); - - app.post("/chat/warm", async (c) => { - if (opts.warmService === undefined) { - return c.json({ error: "Warm service not available" }, 503); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("chat/warm: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseWarmBody(body); - if ("error" in parsed) { - log.warn("chat/warm: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - const { conversationId, model, cwd } = parsed; - log.info("chat/warm: request accepted", { - conversationId, - hasModel: model !== undefined, - hasCwd: cwd !== undefined, - }); - - const warmOpts: { readonly cwd?: string; readonly modelName?: string } | undefined = - model !== undefined || cwd !== undefined - ? { - ...(cwd !== undefined ? { cwd } : {}), - ...(model !== undefined ? { modelName: model } : {}), - } - : undefined; - - const result = await opts.warmService.warm(conversationId, warmOpts); - - if ("error" in result) { - log.warn("chat/warm: service returned error", { conversationId, error: result.error }); - return c.json({ error: result.error }, 409); - } - - const response: WarmResponse = { - inputTokens: result.inputTokens, - outputTokens: result.outputTokens, - cacheReadTokens: result.cacheReadTokens, - cacheWriteTokens: result.cacheWriteTokens, - cachePct: computeCachePct(result.inputTokens, result.cacheReadTokens), - expectedCacheRate: computeExpectedCacheRate(result.cacheReadTokens, result.cacheWriteTokens), - }; - return c.json(response, 200); - }); - - app.get("/metrics/throughput", async (c) => { - const period = c.req.query("period"); - const date = c.req.query("date"); - if (period !== "day" && period !== "week" && period !== "month") { - return c.json({ error: "query param 'period' must be one of: day, week, month" }, 400); - } - if (date === undefined || date === "") { - return c.json({ error: "query param 'date' is required" }, 400); - } - try { - // Typed against the wire contract: if the store's report shape ever - // drifts from ThroughputResponse, this assignment fails to compile. - const body: ThroughputResponse = await throughputStore.aggregate({ period, date }); - return c.json(body); - } catch (err) { - if (err instanceof ThroughputQueryError) { - return c.json({ error: err.message }, 400); - } - log.error("throughput: aggregate failed", { err }); - return c.json({ error: "Failed to aggregate throughput" }, 502); - } - }); - - app.post("/conversations/:id/close", (c) => { - const conversationId = c.req.param("id"); - const { abortedTurn } = opts.orchestrator.closeConversation(conversationId); - log.info("conversations: closed", { conversationId, abortedTurn }); - const body: CloseConversationResponse = { conversationId, abortedTurn }; - return c.json(body, 200); - }); - - app.post("/conversations/:id/stop", (c) => { - const conversationId = c.req.param("id"); - const { abortedTurn } = opts.orchestrator.stopTurn(conversationId); - log.info("conversations: stop", { conversationId, abortedTurn }); - return c.json({ conversationId, abortedTurn }, 200); - }); - - app.post("/conversations/:id/queue", async (c) => { - const conversationId = c.req.param("id"); - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/queue: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseQueueBody(body); - if (isParseError(parsed)) { - log.warn("conversations/queue: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - // `enqueue` is synchronous and owns the idle→startTurn vs active→queue - // decision (no separate `isActive` race) — it does not throw for an - // unknown/idle conversation, which instead starts a turn. Mirrors the - // direct sync call used by `POST /conversations/:id/close`. - const { startedTurn, queue } = opts.orchestrator.enqueue({ - conversationId, - text: parsed.text, - ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}), - }); - log.info("conversations: enqueued", { - conversationId, - startedTurn, - queueLength: queue.length, - }); - const response: QueueResponse = { conversationId, startedTurn, queue }; - return c.json(response, 200); - }); - - app.get("/conversations/:id/cwd", async (c) => { - const conversationId = c.req.param("id"); - try { - const cwd = await opts.conversationStore.getCwd(conversationId); - log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null }); - const body: CwdResponse = { conversationId, cwd }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: cwd read failure", { err }); - return c.json({ error: "Failed to read conversation cwd" }, 500); - } - }); - - app.put("/conversations/:id/cwd", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/cwd: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record<string, unknown>; - if (typeof obj.cwd !== "string" || obj.cwd.length === 0) { - return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400); - } - - // When a workspaceId is provided, assign the conversation to that - // workspace BEFORE persisting the cwd — so a subsequent - // GET /conversations/:id/lsp resolves a relative cwd against the - // workspace's defaultCwd (not the server default). Omit for unchanged - // workspace assignment (backward compatible). - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { - return c.json({ error: "Invalid workspaceId" }, 400); - } - } - - try { - if (typeof obj.workspaceId === "string") { - await opts.conversationStore.ensureWorkspace(obj.workspaceId); - await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); - } - await opts.conversationStore.setCwd(conversationId, obj.cwd); - log.info("conversations: cwd set", { conversationId }); - const response: CwdResponse = { conversationId, cwd: obj.cwd }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: cwd set failure", { err }); - return c.json({ error: "Failed to set conversation cwd" }, 500); - } - }); - - app.delete("/conversations/:id/cwd", async (c) => { - const conversationId = c.req.param("id"); - try { - await opts.conversationStore.clearCwd(conversationId); - log.info("conversations: cwd cleared", { conversationId }); - const response: CwdResponse = { conversationId, cwd: null }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: cwd clear failure", { err }); - return c.json({ error: "Failed to clear conversation cwd" }, 500); - } - }); - - // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ────────── - - app.get("/conversations/:id/computer", async (c) => { - const conversationId = c.req.param("id"); - try { - const computerId = await opts.conversationStore.getComputerId(conversationId); - log.info("conversations: computer read", { - conversationId, - hasComputerId: computerId !== null, - }); - const body: ConversationComputerResponse = { conversationId, computerId }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: computer read failure", { err }); - return c.json({ error: "Failed to read conversation computer" }, 500); - } - }); - - app.put("/conversations/:id/computer", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/computer: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record<string, unknown>; - // `computerId` must be a string (the SSH alias) or null (clear → inherit - // the workspace defaultComputerId → local). An empty string is rejected - // (unlike cwd, an alias is never "empty"); null is the explicit clear. - if ( - obj.computerId !== null && - (typeof obj.computerId !== "string" || obj.computerId.length === 0) - ) { - return c.json( - { error: "Field 'computerId' is required and must be a non-empty string or null" }, - 400, - ); - } - const { computerId } = obj as unknown as SetConversationComputerRequest; - - // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided, - // assign the conversation to that workspace BEFORE persisting the - // computer, so a subsequent effective-computer resolution reads the - // workspace's defaultComputerId. Omit for unchanged workspace assignment. - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { - return c.json({ error: "Invalid workspaceId" }, 400); - } - } - - try { - if (typeof obj.workspaceId === "string") { - await opts.conversationStore.ensureWorkspace(obj.workspaceId); - await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); - } - // null → clear (inherit/local); string → persist the alias. - await opts.conversationStore.setComputerId(conversationId, computerId); - log.info("conversations: computer set", { conversationId }); - const response: ConversationComputerResponse = { conversationId, computerId }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: computer set failure", { err }); - return c.json({ error: "Failed to set conversation computer" }, 500); - } - }); - - app.delete("/conversations/:id/computer", async (c) => { - const conversationId = c.req.param("id"); - try { - await opts.conversationStore.clearComputerId(conversationId); - log.info("conversations: computer cleared", { conversationId }); - const response: ConversationComputerResponse = { conversationId, computerId: null }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: computer clear failure", { err }); - return c.json({ error: "Failed to clear conversation computer" }, 500); - } - }); - - app.get("/conversations/:id/reasoning-effort", async (c) => { - const conversationId = c.req.param("id"); - try { - const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId); - log.info("conversations: reasoning-effort read", { - conversationId, - hasEffort: reasoningEffort !== null, - }); - const body: ReasoningEffortResponse = { conversationId, reasoningEffort }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: reasoning-effort read failure", { err }); - return c.json({ error: "Failed to read conversation reasoning effort" }, 500); - } - }); - - app.put("/conversations/:id/reasoning-effort", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/reasoning-effort: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseReasoningEffortBody(body); - if (isReasoningEffortParseError(parsed)) { - log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - try { - await opts.conversationStore.setReasoningEffort(conversationId, parsed); - log.info("conversations: reasoning-effort set", { conversationId }); - const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: reasoning-effort set failure", { err }); - return c.json({ error: "Failed to set conversation reasoning effort" }, 500); - } - }); - - app.get("/conversations/:id/model", async (c) => { - const conversationId = c.req.param("id"); - try { - const model = await opts.conversationStore.getModel(conversationId); - log.info("conversations: model read", { - conversationId, - hasModel: model !== null, - }); - const body: ModelResponse = { conversationId, model }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: model read failure", { err }); - return c.json({ error: "Failed to read conversation model" }, 500); - } - }); - - app.put("/conversations/:id/model", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/model: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - const parsed = parseModelBody(body); - if (isModelParseError(parsed)) { - log.warn("conversations/model: validation failed", { reason: parsed.error }); - return c.json({ error: parsed.error }, 400); - } - - // A non-null non-empty model persists the selection; `null` or an empty - // string clears the key (the store treats an empty string as "delete"). - // The response carries the resulting value: the model name, or null when - // cleared (mirroring how `getModel` returns null after a clear). - const resultModel = parsed !== null && parsed.length > 0 ? parsed : null; - const persistedValue = resultModel !== null ? resultModel : ""; - - try { - await opts.conversationStore.setModel(conversationId, persistedValue); - log.debug("conversations: model set", { conversationId, model: resultModel }); - const response: ModelResponse = { conversationId, model: resultModel }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: model set failure", { err }); - return c.json({ error: "Failed to set conversation model" }, 500); - } - }); - - app.get("/conversations/:id/lsp", async (c) => { - const conversationId = c.req.param("id"); - try { - // Gate on the PERSISTED cwd first: when no cwd has been set for the - // conversation, the LSP does NOT connect (return null + empty servers) - // rather than falling through to the server default (process.cwd()). - const persistedCwd = await opts.conversationStore.getCwd(conversationId); - if (persistedCwd === null) { - log.info("conversations: lsp status read (no cwd)", { conversationId }); - const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd - // resolved against the workspace defaultCwd; absolute → as-is). - const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); - if (effectiveCwd === null) { - // Edge case: persisted cwd exists but resolution returned null. - log.info("conversations: lsp status read (no effective cwd)", { conversationId }); - const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - if (opts.lspService === undefined) { - log.warn("conversations: lsp service not available", { conversationId }); - return c.json({ error: "LSP service not available" }, 503); - } - - const statuses = await opts.lspService.status(effectiveCwd); - const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => { - const info: LspServerInfo = { - id: s.id, - name: s.name, - root: s.root, - extensions: s.extensions, - state: s.state, - ...(s.error !== undefined ? { error: s.error } : {}), - ...(s.configSource !== undefined ? { configSource: s.configSource } : {}), - }; - return info; - }); - log.info("conversations: lsp status read", { - conversationId, - cwd: effectiveCwd, - serverCount: servers.length, - }); - const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: lsp status failure", { err }); - return c.json({ error: "Failed to read LSP status" }, 500); - } - }); - - // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd, - // 503 when no MCP service, map McpServerStatus → McpServerInfo. - app.get("/conversations/:id/mcp", async (c) => { - const conversationId = c.req.param("id"); - try { - const persistedCwd = await opts.conversationStore.getCwd(conversationId); - if (persistedCwd === null) { - log.info("conversations: mcp status read (no cwd)", { conversationId }); - const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); - if (effectiveCwd === null) { - log.info("conversations: mcp status read (no effective cwd)", { conversationId }); - const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; - return c.json(body, 200); - } - - if (opts.mcpService === undefined) { - log.warn("conversations: mcp service not available", { conversationId }); - return c.json({ error: "MCP service not available" }, 503); - } - - const statuses = await opts.mcpService.status(effectiveCwd); - const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => { - const info: McpServerInfo = { - id: s.id, - state: s.state, - toolCount: s.toolCount, - ...(s.error !== undefined ? { error: s.error } : {}), - }; - return info; - }); - log.info("conversations: mcp status read", { - conversationId, - cwd: effectiveCwd, - serverCount: servers.length, - }); - const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: mcp status failure", { err }); - return c.json({ error: "Failed to read MCP status" }, 500); - } - }); - - app.get("/conversations", async (c) => { - try { - // Optional `?status=` comma-separated filter (e.g. "active,idle"). - // Default: all statuses. Invalid values are silently ignored. - const rawStatus = c.req.query("status"); - const statusFilter = parseStatusFilter(rawStatus); - // Optional `?workspaceId=` filter. A missing/empty/whitespace-only - // value is ignored → return all workspaces. Composable with `?status=` - // and `?q=`. - const rawWorkspaceId = c.req.query("workspaceId"); - const workspaceId = - rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0 - ? rawWorkspaceId.trim() - : undefined; - const filter: Parameters<ConversationStore["listConversations"]>[0] = - statusFilter !== undefined || workspaceId !== undefined - ? { - ...(statusFilter !== undefined ? { status: statusFilter } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - } - : undefined; - const all = await opts.conversationStore.listConversations(filter); - // Optional `?q=` filters by id prefix (short-id resolution). A - // missing/empty/whitespace-only `q` is ignored → return all. - const rawQ = c.req.query("q"); - const q = rawQ?.trim() ?? ""; - const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all; - log.info("conversations: list", { - count: conversations.length, - ...(q.length > 0 ? { q } : {}), - ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}), - ...(workspaceId !== undefined ? { workspaceId } : {}), - }); - const body: ConversationListResponse = { conversations }; - return c.json(body, 200); - } catch (err) { - log.error("conversations: list failure", { err }); - return c.json({ error: "Failed to list conversations" }, 500); - } - }); - - app.get("/conversations/:id/last", async (c) => { - const conversationId = c.req.param("id"); - - // Subscribe BEFORE checking isActive — closes the race where a seal - // fires between the check and the subscribe (we'd miss it). If idle, - // unsubscribe immediately; if active, wait for a `turn-sealed` event - // (or a 60s timeout, then proceed regardless of what's available). - let turnId: string | undefined; - let unsubscribe: (() => void) | undefined; - try { - await new Promise<void>((resolve) => { - let settled = false; - let timer: ReturnType<typeof setTimeout> | undefined; - const finish = (): void => { - if (settled) return; - settled = true; - if (timer !== undefined) clearTimeout(timer); - resolve(); - }; - unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => { - if (event.type === "turn-sealed") { - turnId = event.turnId; - finish(); - } - }); - if (!opts.orchestrator.isActive(conversationId)) { - finish(); - return; - } - // A seal may have fired synchronously during subscribe (the - // real orchestrator never does this, but a fake might) — don't - // arm a 60s timer for an already-settled promise. - if (settled) return; - timer = setTimeout(finish, 60_000); - }); - } finally { - unsubscribe?.(); - } - - let content = ""; - try { - const messages = await opts.conversationStore.load(conversationId); - content = extractLastAssistantText(messages); - } catch (err) { - log.error("conversations: last message load failure", { err }); - return c.json({ error: "Failed to load conversation" }, 500); - } - - log.info("conversations: last read", { - conversationId, - hasContent: content.length > 0, - }); - const body: LastMessageResponse = { - conversationId, - content, - ...(turnId !== undefined ? { turnId } : {}), - }; - return c.json(body, 200); - }); - - app.post("/conversations/:id/open", async (c) => { - const conversationId = c.req.param("id"); - if (opts.emit === undefined) { - log.warn("conversations: open requested but emit is not available", { - conversationId, - }); - return c.json({ error: "not available" }, 500); - } - // Resolve the conversation's persisted workspace id so the frontend can - // open/focus the tab in the correct workspace. The store falls back to - // `"default"` when no workspaceId is persisted (or the conversation is - // unknown), so this never throws for a missing conversation. - const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId); - opts.emit(conversationOpened, { conversationId, workspaceId }); - log.info("conversations: opened", { conversationId, workspaceId }); - const body: OpenConversationResponse = { conversationId }; - return c.json(body, 200); - }); - - app.put("/conversations/:id/title", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("conversations/title: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record<string, unknown>; - if (typeof obj.title !== "string" || obj.title.trim().length === 0) { - return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); - } - // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody` - // forward trimmed text), so a title never carries surrounding whitespace. - const title = obj.title.trim(); - - try { - await opts.conversationStore.setConversationTitle(conversationId, title); - log.info("conversations: title set", { conversationId }); - const response: TitleResponse = { conversationId, title }; - return c.json(response, 200); - } catch (err) { - log.error("conversations: title set failure", { err }); - return c.json({ error: "Failed to set conversation title" }, 500); - } - }); - - // ─── Compaction ────────────────────────────────────────────────────────── - - app.post("/conversations/:id/compact", async (c) => { - if (opts.compactionService === undefined) { - return c.json({ error: "Compaction service not available" }, 503); - } - const conversationId = c.req.param("id"); - let body: unknown = {}; - try { - body = await c.req.json(); - } catch { - // No body is fine — use defaults. - } - const obj = body as Record<string, unknown>; - const keepLastN = - typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0 - ? Math.floor(obj.keepLastN) - : undefined; - const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined; - - log.info("conversations: compact request", { conversationId }); - - const result = await opts.compactionService.compact(conversationId, { - ...(keepLastN !== undefined ? { keepLastN } : {}), - ...(modelName !== undefined ? { modelName } : {}), - }); - - if ("error" in result) { - log.warn("conversations: compact returned error", { - conversationId, - error: result.error, - }); - return c.json({ error: result.error }, 409); - } - - const response: CompactResponse = { - conversationId, - newConversationId: result.newConversationId, - messagesSummarized: result.messagesSummarized, - messagesKept: result.messagesKept, - }; - return c.json(response, 200); - }); - - app.get("/conversations/:id/compact-percent", async (c) => { - const conversationId = c.req.param("id"); - const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0; - const response: CompactPercentResponse = { conversationId, threshold }; - return c.json(response, 200); - }); - - app.put("/conversations/:id/compact-percent", async (c) => { - const conversationId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - return c.json({ error: "Invalid JSON body" }, 400); - } - const parsed = body as SetCompactPercentRequest; - if ( - typeof parsed.threshold !== "number" || - !Number.isFinite(parsed.threshold) || - parsed.threshold < 0 - ) { - return c.json({ error: "threshold must be a non-negative number" }, 400); - } - const threshold = Math.floor(parsed.threshold); - await opts.conversationStore.setCompactPercent(conversationId, threshold); - log.info("conversations: compact-percent set", { conversationId, threshold }); - const response: CompactPercentResponse = { conversationId, threshold }; - return c.json(response, 200); - }); - - // ─── Workspaces ────────────────────────────────────────────────────────── - - app.get("/workspaces", async (c) => { - try { - const workspaces = await opts.conversationStore.listWorkspaces(); - log.info("workspaces: list", { count: workspaces.length }); - const body: WorkspaceListResponse = { workspaces }; - return c.json(body, 200); - } catch (err) { - log.error("workspaces: list failure", { err }); - return c.json({ error: "Failed to list workspaces" }, 500); - } - }); - - app.put("/workspaces/:id", async (c) => { - const workspaceId = c.req.param("id"); - if (!isValidWorkspaceSlug(workspaceId)) { - return c.json( - { - error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)", - }, - 400, - ); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - body = {}; - } - const obj = body as Record<string, unknown>; - const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {}; - if (typeof obj.title === "string") { - (opts_ as { title?: string }).title = obj.title; - } - if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) { - (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd; - } - - try { - const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_); - log.info("workspaces: ensured", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: ensure failure", { err }); - return c.json({ error: "Failed to ensure workspace" }, 500); - } - }); - - app.get("/workspaces/:id", async (c) => { - const workspaceId = c.req.param("id"); - try { - const workspace = await opts.conversationStore.getWorkspace(workspaceId); - if (workspace === null) { - return c.json({ error: "Workspace not found" }, 404); - } - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: get failure", { err }); - return c.json({ error: "Failed to read workspace" }, 500); - } - }); - - app.put("/workspaces/:id/title", async (c) => { - const workspaceId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("workspaces/title: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record<string, unknown>; - if (typeof obj.title !== "string" || obj.title.trim().length === 0) { - return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); - } - const title = obj.title.trim(); - - try { - const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title); - log.info("workspaces: title set", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: title set failure", { err }); - return c.json({ error: "Failed to set workspace title" }, 500); - } - }); - - app.put("/workspaces/:id/default-cwd", async (c) => { - const workspaceId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - body = {}; - } - const obj = body as Record<string, unknown>; - const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null; - - try { - const workspace = await opts.conversationStore.setWorkspaceDefaultCwd( - workspaceId, - defaultCwd, - ); - log.info("workspaces: default-cwd set", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: default-cwd set failure", { err }); - return c.json({ error: "Failed to set workspace default cwd" }, 500); - } - }); - - // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog). - app.put("/workspaces/:id/default-computer", async (c) => { - const workspaceId = c.req.param("id"); - let body: unknown; - try { - body = await c.req.json(); - } catch { - body = {}; - } - const obj = body as Record<string, unknown>; - // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias; - // anything else (null/absent/non-string) → clear (local). - const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] = - typeof obj.computerId === "string" ? obj.computerId : null; - - try { - const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId( - workspaceId, - defaultComputerId, - ); - log.info("workspaces: default-computer set", { workspaceId }); - const response: WorkspaceResponse = workspace; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: default-computer set failure", { err }); - return c.json({ error: "Failed to set workspace default computer" }, 500); - } - }); - - app.delete("/workspaces/:id", async (c) => { - const workspaceId = c.req.param("id"); - if (workspaceId === "default") { - return c.json({ error: 'The "default" workspace cannot be deleted' }, 409); - } - - try { - const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId); - log.info("workspaces: deleted", { workspaceId, closedCount }); - const response: DeleteWorkspaceResponse = { workspaceId, closedCount }; - return c.json(response, 200); - } catch (err) { - log.error("workspaces: delete failure", { err }); - return c.json({ error: "Failed to delete workspace" }, 500); - } - }); - - // ─── Heartbeat (per-workspace AI loop) ───────────────────────────────────── - // The config + run history for a workspace's heartbeat loop. Delegated to - // the HeartbeatService (provided by the `heartbeat` extension). When - // heartbeat is NOT loaded the routes degrade: GET config → the defaults, - // GET runs → empty, PUT/POST → 503 (mirrors how /system-prompt returns the - // default template when its service is absent but 503s writes). - - app.get("/workspaces/:id/heartbeat", async (c) => { - const workspaceId = c.req.param("id"); - if (opts.heartbeatService === undefined) { - // Graceful: no heartbeat configured → return the defaults so the FE - // always gets a usable config shape (enabled: false, etc.). - const body: HeartbeatConfig = DEFAULT_HEARTBEAT_CONFIG; - return c.json(body, 200); - } - try { - const config = await opts.heartbeatService.getConfig(workspaceId); - log.info("heartbeat: config read", { workspaceId, enabled: config.enabled }); - const body: HeartbeatConfig = config; - return c.json(body, 200); - } catch (err) { - log.error("heartbeat: config read failure", { err, workspaceId }); - return c.json({ error: "Failed to read heartbeat config" }, 500); - } - }); - - app.put("/workspaces/:id/heartbeat", async (c) => { - const workspaceId = c.req.param("id"); - if (opts.heartbeatService === undefined) { - return c.json({ error: "Heartbeat service not available" }, 503); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("heartbeat: invalid JSON body", { workspaceId }); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record<string, unknown>; - - // Build a partial update, validating each present field. All fields are - // optional (a partial update); only provided fields are forwarded. - const update: Record<string, unknown> = {}; - - if (obj.enabled !== undefined) { - if (typeof obj.enabled !== "boolean") { - return c.json({ error: "Field 'enabled' must be a boolean" }, 400); - } - update.enabled = obj.enabled; - } - - if (obj.systemPrompt !== undefined) { - if (typeof obj.systemPrompt !== "string") { - return c.json({ error: "Field 'systemPrompt' must be a string" }, 400); - } - update.systemPrompt = obj.systemPrompt; - } - - if (obj.taskPrompt !== undefined) { - if (typeof obj.taskPrompt !== "string") { - return c.json({ error: "Field 'taskPrompt' must be a string" }, 400); - } - update.taskPrompt = obj.taskPrompt; - } - - if (obj.intervalMinutes !== undefined) { - if (typeof obj.intervalMinutes !== "number" || !Number.isFinite(obj.intervalMinutes)) { - return c.json({ error: "Field 'intervalMinutes' must be a number" }, 400); - } - update.intervalMinutes = obj.intervalMinutes; - } - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return c.json({ error: "Field 'model' must be a string" }, 400); - } - update.model = obj.model; - } - - // `reasoningEffort` accepts a valid level string OR null (clear the - // override → inherit the workspace default). Absent (undefined) leaves - // it unchanged. An unrecognized string → 400. - if (obj.reasoningEffort !== undefined) { - if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) { - return c.json( - { - error: "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null", - }, - 400, - ); - } - update.reasoningEffort = obj.reasoningEffort; - } - - try { - const config = await opts.heartbeatService.updateConfig( - workspaceId, - update as UpdateHeartbeatRequest, - ); - log.info("heartbeat: config updated", { - workspaceId, - enabled: config.enabled, - intervalMinutes: config.intervalMinutes, - }); - const response: HeartbeatConfig = config; - return c.json(response, 200); - } catch (err) { - log.error("heartbeat: config update failure", { err, workspaceId }); - return c.json({ error: "Failed to update heartbeat config" }, 500); - } - }); - - app.get("/workspaces/:id/heartbeat/runs", async (c) => { - const workspaceId = c.req.param("id"); - if (opts.heartbeatService === undefined) { - // Graceful: no heartbeat → no runs. - const body: HeartbeatRunsResponse = { runs: [] }; - return c.json(body, 200); - } - try { - const runs = await opts.heartbeatService.listRuns(workspaceId); - log.info("heartbeat: runs listed", { workspaceId, count: runs.length }); - const body: HeartbeatRunsResponse = { runs }; - return c.json(body, 200); - } catch (err) { - log.error("heartbeat: runs list failure", { err, workspaceId }); - return c.json({ error: "Failed to list heartbeat runs" }, 500); - } - }); - - // The server-authoritative next-fire time for a workspace's heartbeat. A - // lightweight read of the scheduler's pending fire time (polled by the FE - // alongside the runs list). `nextRunAt` is null when the heartbeat is - // disabled/disarmed, or when a run is in flight and the next hasn't been - // queued yet — the FE then shows no countdown, not a fabricated one. - app.get("/workspaces/:id/heartbeat/next-run", async (c) => { - const workspaceId = c.req.param("id"); - if (opts.heartbeatService === undefined) { - // Graceful: no heartbeat configured → no next run scheduled. - return c.json({ nextRunAt: null }, 200); - } - try { - const nextRunAt = await opts.heartbeatService.nextRunAt(workspaceId); - log.info("heartbeat: next-run read", { workspaceId, nextRunAt }); - return c.json({ nextRunAt }, 200); - } catch (err) { - log.error("heartbeat: next-run read failure", { err, workspaceId }); - return c.json({ error: "Failed to read heartbeat next-run" }, 500); - } - }); - - app.post("/workspaces/:id/heartbeat/runs/:runId/stop", async (c) => { - const workspaceId = c.req.param("id"); - const runId = c.req.param("runId"); - if (opts.heartbeatService === undefined) { - return c.json({ error: "Heartbeat service not available" }, 503); - } - try { - const result = await opts.heartbeatService.stopRun(workspaceId, runId); - log.info("heartbeat: run stopped", { workspaceId, runId }); - const body: StopHeartbeatRunResponse = result; - return c.json(body, 200); - } catch (err) { - // stopRun throws "Heartbeat run not found" for an unknown run id. - const message = err instanceof Error ? err.message : String(err); - if (message.includes("not found")) { - return c.json({ error: "Heartbeat run not found" }, 404); - } - log.error("heartbeat: run stop failure", { err, workspaceId, runId }); - return c.json({ error: "Failed to stop heartbeat run" }, 500); - } - }); - - // ─── System prompt template ─────────────────────────────────────────────── - - app.get("/system-prompt/variables", (c) => { - // Static catalog — no service call needed. Always available. - const variables = getVariableCatalog(); - const body: SystemPromptVariablesResponse = { variables }; - return c.json(body, 200); - }); - - app.get("/system-prompt", async (c) => { - if (opts.systemPromptService === undefined) { - // FE always gets something useful — the built-in default template. - const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE }; - return c.json(body, 200); - } - const template = await opts.systemPromptService.getTemplate(); - const body: SystemPromptTemplateResponse = { template }; - return c.json(body, 200); - }); - - app.put("/system-prompt", async (c) => { - if (opts.systemPromptService === undefined) { - return c.json({ error: "System prompt service not available" }, 503); - } - - let body: unknown; - try { - body = await c.req.json(); - } catch { - log.warn("system-prompt: invalid JSON body"); - return c.json({ error: "Invalid JSON body" }, 400); - } - - if (body === null || typeof body !== "object") { - return c.json({ error: "Request body must be a JSON object" }, 400); - } - const obj = body as Record<string, unknown>; - // `template` must be a string; empty string is valid ("no system prompt"). - if (typeof obj.template !== "string") { - return c.json({ error: "Field 'template' is required and must be a string" }, 400); - } - - const { template } = obj as unknown as SetSystemPromptTemplateRequest; - await opts.systemPromptService.setTemplate(template); - log.info("system-prompt: template set"); - const response: SystemPromptTemplateResponse = { template }; - return c.json(response, 200); - }); - - // ─── Static frontend serving (catch-all, API routes take precedence) ────── - if (opts.webDir !== undefined) { - const webDir = opts.webDir; - const MIME: Record<string, string> = { - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml", - ".png": "image/png", - ".jpg": "image/jpeg", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".txt": "text/plain; charset=utf-8", - ".wasm": "application/wasm", - }; - app.get("*", async (c) => { - const urlPath = new URL(c.req.url).pathname; - const filePath = `${webDir}${urlPath}`; - const file = Bun.file(filePath); - if (await file.exists()) { - const ext = filePath.slice(filePath.lastIndexOf(".")); - const contentType = MIME[ext] ?? "application/octet-stream"; - return new Response(file, { - headers: { "Content-Type": contentType }, - }); - } - // SPA fallback: serve index.html for client-side routing - const indexFile = Bun.file(`${webDir}/index.html`); - if (await indexFile.exists()) { - return new Response(indexFile, { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - return c.json({ error: "Not found" }, 404); - }); - } - - return app; + const app = new Hono(); + const log = opts.logger ?? noopLogger; + const generateId = opts.generateId ?? (() => crypto.randomUUID()); + const now = opts.now ?? (() => Date.now()); + const throughputStore = opts.throughputStore ?? noopThroughputStore; + + async function recordThroughput( + turnEvents: readonly AgentEvent[], + model: string | undefined, + ): Promise<void> { + if (model === undefined) return; // no model selected → nothing to attribute + let genMs = 0; + let outputTokens = 0; + for (const e of turnEvents) { + if (e.type === "step-complete" && e.genTotalMs !== undefined) genMs += e.genTotalMs; + if (e.type === "done" && e.usage !== undefined) outputTokens = e.usage.outputTokens; + } + if (genMs <= 0) return; // no generation time → can't compute tok/s + try { + await throughputStore.record({ model, ts: now(), outputTokens, genMs }); + log.info("throughput: turn recorded", { + model, + outputTokens, + genMs, + tokensPerSecond: Math.round((outputTokens / (genMs / 1000)) * 100) / 100, + }); + } catch (err) { + log.warn("throughput: failed to record sample", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + app.use( + "*", + cors({ + origin: "*", + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowHeaders: ["Content-Type"], + }), + ); + + app.get("/health", (c) => c.json({ ok: true })); + + app.get("/conversations/:id/metrics", async (c) => { + const conversationId = c.req.param("id"); + + try { + const turns = await opts.conversationStore.loadMetrics(conversationId); + log.info("conversations: metrics read", { + conversationId, + count: turns.length, + }); + const body: ConversationMetricsResponse = { turns }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: metrics store failure", { err }); + return c.json({ error: "Failed to load conversation metrics" }, 500); + } + }); + + app.get("/conversations/:id", async (c) => { + const conversationId = c.req.param("id"); + const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq")); + if (isSinceSeqError(sinceSeqResult)) { + log.warn("conversations: invalid sinceSeq", { + conversationId, + error: sinceSeqResult.error, + }); + return c.json({ error: sinceSeqResult.error }, 400); + } + + // `limit` / `beforeSeq` are optional positive-integer history-window + // params. The store is deliberately forgiving (a 0/negative bound is + // treated as ABSENT), so we MUST reject malformed values here and never + // forward an invalid window. + const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq"); + if (isWindowParamError(beforeSeqResult)) { + log.warn("conversations: invalid beforeSeq", { + conversationId, + error: beforeSeqResult.error, + }); + return c.json({ error: beforeSeqResult.error }, 400); + } + const limitResult = parseWindowParam(c.req.query("limit"), "limit"); + if (isWindowParamError(limitResult)) { + log.warn("conversations: invalid limit", { + conversationId, + error: limitResult.error, + }); + return c.json({ error: limitResult.error }, 400); + } + + // Include only the fields actually provided (exactOptionalPropertyTypes), + // and omit the window argument entirely when neither was given — keeping + // the pre-windowing call shape byte-identical for existing callers. + const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined = + beforeSeqResult !== undefined || limitResult !== undefined + ? { + ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}), + ...(limitResult !== undefined ? { limit: limitResult } : {}), + } + : undefined; + + try { + const chunks = + window !== undefined + ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window) + : await opts.conversationStore.loadSince(conversationId, sinceSeqResult); + const latestSeq = + chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult; + log.info("conversations: read", { + conversationId, + sinceSeq: sinceSeqResult, + count: chunks.length, + }); + const body: ConversationHistoryResponse = { chunks, latestSeq }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: store failure", { err }); + return c.json({ error: "Failed to load conversation" }, 500); + } + }); + + app.get("/conversations/:id/status", async (c) => { + const conversationId = c.req.param("id"); + const isActive = opts.orchestrator.isActive(conversationId); + const status = await opts.conversationStore.getConversationStatus(conversationId); + if (status === null) { + return c.json({ error: "Conversation not found" }, 404); + } + const body: ConversationStatusResponse = { conversationId, isActive, status }; + return c.json(body, 200); + }); + + app.get("/models", async (c) => { + try { + const models = await opts.credentialStore.listCatalog(); + const modelInfo: Record<string, { contextWindow?: number }> = {}; + for (const modelName of models) { + const info = await opts.credentialStore.getModelInfo(modelName); + if (info?.contextWindow !== undefined) { + modelInfo[modelName] = { contextWindow: info.contextWindow }; + } + } + const body: ModelsResponse = { + models, + ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}), + }; + return c.json(body, 200); + } catch (err) { + log.error("models: failed to retrieve catalog", { err }); + return c.json({ error: "Failed to retrieve model catalog" }, 502); + } + }); + + // ─── Computers (discovery + live state) ─────────────────────────────────── + // Read-only discovery + connection state is delegated to the ComputerService + // (provided by the `ssh` extension). When ssh is NOT loaded the routes + // degrade: list → empty, status → "disconnected", test → not-configured. + + app.get("/computers", async (c) => { + if (opts.computerService === undefined) { + // Graceful: no ssh configured → no computers discovered. + const body: ComputerListResponse = { computers: [] }; + return c.json(body, 200); + } + try { + const computers = await opts.computerService.listComputers(); + log.info("computers: list", { count: computers.length }); + const body: ComputerListResponse = { computers }; + return c.json(body, 200); + } catch (err) { + log.error("computers: list failure", { err }); + return c.json({ error: "Failed to list computers" }, 500); + } + }); + + app.get("/computers/:alias", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + // No ssh configured → no computer resolves this alias. + return c.json({ error: "Computer not found" }, 404); + } + try { + const computer = await opts.computerService.getComputer(alias); + if (computer === null) { + return c.json({ error: "Computer not found" }, 404); + } + const body: ComputerResponse = computer; + return c.json(body, 200); + } catch (err) { + log.error("computers: get failure", { err, alias }); + return c.json({ error: "Failed to read computer" }, 500); + } + }); + + app.get("/computers/:alias/status", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.getStatus(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: status failure", { err, alias }); + return c.json({ error: "Failed to read computer status" }, 500); + } + }); + + app.post("/computers/:alias/test", async (c) => { + const alias = c.req.param("alias"); + if (opts.computerService === undefined) { + const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" }; + return c.json(body, 200); + } + try { + const body = await opts.computerService.test(alias); + return c.json(body, 200); + } catch (err) { + log.error("computers: test failure", { err, alias }); + return c.json({ error: "Failed to test computer" }, 500); + } + }); + + app.post("/chat", async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("chat: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const result = parseChatBody(body, generateId); + if (isParseError(result)) { + log.warn("chat: validation failed", { reason: result.error }); + return c.json({ error: result.error }, 400); + } + + const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } = + result; + log.info("chat: request accepted", { + conversationId, + hasModel: model !== undefined, + hasCwd: cwd !== undefined, + hasComputerId: computerId !== undefined, + hasReasoningEffort: reasoningEffort !== undefined, + hasWorkspaceId: workspaceId !== undefined, + }); + + const events: AgentEvent[] = []; + let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined; + let streamClosed = false; + + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controllerRef = controller; + }, + }); + + function safeEnqueue(data: Uint8Array): void { + if (streamClosed) return; + try { + controllerRef?.enqueue(data); + } catch (err) { + streamClosed = true; + log.warn("chat: stream enqueue failed", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + function safeClose(): void { + if (streamClosed) return; + streamClosed = true; + try { + controllerRef?.close(); + } catch (err) { + log.warn("chat: stream close failed", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const orchestratorInput: Parameters<SessionOrchestrator["handleMessage"]>[0] = { + conversationId, + text: message, + onEvent: (event) => { + events.push(event); + safeEnqueue(new TextEncoder().encode(serializeEventLine(event))); + }, + ...(model !== undefined ? { modelName: model } : {}), + ...(cwd !== undefined ? { cwd } : {}), + ...(computerId !== undefined ? { computerId } : {}), + ...(reasoningEffort !== undefined ? { reasoningEffort } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + }; + + opts.orchestrator + .handleMessage(orchestratorInput) + .then(async () => { + safeClose(); + await recordThroughput(events, model); + }) + .catch((err) => { + log.error("chat: turn failed", { err }); + const errorEvent: AgentEvent = { + type: "error", + conversationId, + turnId: "", + message: err instanceof Error ? err.message : String(err), + }; + safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent))); + safeClose(); + }); + + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "application/x-ndjson", + "X-Conversation-Id": conversationId, + "Transfer-Encoding": "chunked", + }, + }); + }); + + app.post("/chat/warm", async (c) => { + if (opts.warmService === undefined) { + return c.json({ error: "Warm service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("chat/warm: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseWarmBody(body); + if ("error" in parsed) { + log.warn("chat/warm: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + const { conversationId, model, cwd } = parsed; + log.info("chat/warm: request accepted", { + conversationId, + hasModel: model !== undefined, + hasCwd: cwd !== undefined, + }); + + const warmOpts: { readonly cwd?: string; readonly modelName?: string } | undefined = + model !== undefined || cwd !== undefined + ? { + ...(cwd !== undefined ? { cwd } : {}), + ...(model !== undefined ? { modelName: model } : {}), + } + : undefined; + + const result = await opts.warmService.warm(conversationId, warmOpts); + + if ("error" in result) { + log.warn("chat/warm: service returned error", { conversationId, error: result.error }); + return c.json({ error: result.error }, 409); + } + + const response: WarmResponse = { + inputTokens: result.inputTokens, + outputTokens: result.outputTokens, + cacheReadTokens: result.cacheReadTokens, + cacheWriteTokens: result.cacheWriteTokens, + cachePct: computeCachePct(result.inputTokens, result.cacheReadTokens), + expectedCacheRate: computeExpectedCacheRate(result.cacheReadTokens, result.cacheWriteTokens), + }; + return c.json(response, 200); + }); + + app.get("/metrics/throughput", async (c) => { + const period = c.req.query("period"); + const date = c.req.query("date"); + if (period !== "day" && period !== "week" && period !== "month") { + return c.json({ error: "query param 'period' must be one of: day, week, month" }, 400); + } + if (date === undefined || date === "") { + return c.json({ error: "query param 'date' is required" }, 400); + } + try { + // Typed against the wire contract: if the store's report shape ever + // drifts from ThroughputResponse, this assignment fails to compile. + const body: ThroughputResponse = await throughputStore.aggregate({ period, date }); + return c.json(body); + } catch (err) { + if (err instanceof ThroughputQueryError) { + return c.json({ error: err.message }, 400); + } + log.error("throughput: aggregate failed", { err }); + return c.json({ error: "Failed to aggregate throughput" }, 502); + } + }); + + app.post("/conversations/:id/close", (c) => { + const conversationId = c.req.param("id"); + const { abortedTurn } = opts.orchestrator.closeConversation(conversationId); + log.info("conversations: closed", { conversationId, abortedTurn }); + const body: CloseConversationResponse = { conversationId, abortedTurn }; + return c.json(body, 200); + }); + + app.post("/conversations/:id/stop", (c) => { + const conversationId = c.req.param("id"); + const { abortedTurn } = opts.orchestrator.stopTurn(conversationId); + log.info("conversations: stop", { conversationId, abortedTurn }); + return c.json({ conversationId, abortedTurn }, 200); + }); + + app.post("/conversations/:id/queue", async (c) => { + const conversationId = c.req.param("id"); + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/queue: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseQueueBody(body); + if (isParseError(parsed)) { + log.warn("conversations/queue: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + // `enqueue` is synchronous and owns the idle→startTurn vs active→queue + // decision (no separate `isActive` race) — it does not throw for an + // unknown/idle conversation, which instead starts a turn. Mirrors the + // direct sync call used by `POST /conversations/:id/close`. + const { startedTurn, queue } = opts.orchestrator.enqueue({ + conversationId, + text: parsed.text, + ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}), + }); + log.info("conversations: enqueued", { + conversationId, + startedTurn, + queueLength: queue.length, + }); + const response: QueueResponse = { conversationId, startedTurn, queue }; + return c.json(response, 200); + }); + + app.get("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + try { + const cwd = await opts.conversationStore.getCwd(conversationId); + log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null }); + const body: CwdResponse = { conversationId, cwd }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: cwd read failure", { err }); + return c.json({ error: "Failed to read conversation cwd" }, 500); + } + }); + + app.put("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/cwd: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + if (typeof obj.cwd !== "string" || obj.cwd.length === 0) { + return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400); + } + + // When a workspaceId is provided, assign the conversation to that + // workspace BEFORE persisting the cwd — so a subsequent + // GET /conversations/:id/lsp resolves a relative cwd against the + // workspace's defaultCwd (not the server default). Omit for unchanged + // workspace assignment (backward compatible). + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } + await opts.conversationStore.setCwd(conversationId, obj.cwd); + log.info("conversations: cwd set", { conversationId }); + const response: CwdResponse = { conversationId, cwd: obj.cwd }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: cwd set failure", { err }); + return c.json({ error: "Failed to set conversation cwd" }, 500); + } + }); + + app.delete("/conversations/:id/cwd", async (c) => { + const conversationId = c.req.param("id"); + try { + await opts.conversationStore.clearCwd(conversationId); + log.info("conversations: cwd cleared", { conversationId }); + const response: CwdResponse = { conversationId, cwd: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: cwd clear failure", { err }); + return c.json({ error: "Failed to clear conversation cwd" }, 500); + } + }); + + // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ────────── + + app.get("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + const computerId = await opts.conversationStore.getComputerId(conversationId); + log.info("conversations: computer read", { + conversationId, + hasComputerId: computerId !== null, + }); + const body: ConversationComputerResponse = { conversationId, computerId }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: computer read failure", { err }); + return c.json({ error: "Failed to read conversation computer" }, 500); + } + }); + + app.put("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/computer: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + // `computerId` must be a string (the SSH alias) or null (clear → inherit + // the workspace defaultComputerId → local). An empty string is rejected + // (unlike cwd, an alias is never "empty"); null is the explicit clear. + if ( + obj.computerId !== null && + (typeof obj.computerId !== "string" || obj.computerId.length === 0) + ) { + return c.json( + { error: "Field 'computerId' is required and must be a non-empty string or null" }, + 400, + ); + } + const { computerId } = obj as unknown as SetConversationComputerRequest; + + // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided, + // assign the conversation to that workspace BEFORE persisting the + // computer, so a subsequent effective-computer resolution reads the + // workspace's defaultComputerId. Omit for unchanged workspace assignment. + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) { + return c.json({ error: "Invalid workspaceId" }, 400); + } + } + + try { + if (typeof obj.workspaceId === "string") { + await opts.conversationStore.ensureWorkspace(obj.workspaceId); + await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId); + } + // null → clear (inherit/local); string → persist the alias. + await opts.conversationStore.setComputerId(conversationId, computerId); + log.info("conversations: computer set", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer set failure", { err }); + return c.json({ error: "Failed to set conversation computer" }, 500); + } + }); + + app.delete("/conversations/:id/computer", async (c) => { + const conversationId = c.req.param("id"); + try { + await opts.conversationStore.clearComputerId(conversationId); + log.info("conversations: computer cleared", { conversationId }); + const response: ConversationComputerResponse = { conversationId, computerId: null }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: computer clear failure", { err }); + return c.json({ error: "Failed to clear conversation computer" }, 500); + } + }); + + app.get("/conversations/:id/reasoning-effort", async (c) => { + const conversationId = c.req.param("id"); + try { + const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId); + log.info("conversations: reasoning-effort read", { + conversationId, + hasEffort: reasoningEffort !== null, + }); + const body: ReasoningEffortResponse = { conversationId, reasoningEffort }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: reasoning-effort read failure", { err }); + return c.json({ error: "Failed to read conversation reasoning effort" }, 500); + } + }); + + app.put("/conversations/:id/reasoning-effort", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/reasoning-effort: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseReasoningEffortBody(body); + if (isReasoningEffortParseError(parsed)) { + log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + try { + await opts.conversationStore.setReasoningEffort(conversationId, parsed); + log.info("conversations: reasoning-effort set", { conversationId }); + const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: reasoning-effort set failure", { err }); + return c.json({ error: "Failed to set conversation reasoning effort" }, 500); + } + }); + + app.get("/conversations/:id/model", async (c) => { + const conversationId = c.req.param("id"); + try { + const model = await opts.conversationStore.getModel(conversationId); + log.info("conversations: model read", { + conversationId, + hasModel: model !== null, + }); + const body: ModelResponse = { conversationId, model }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: model read failure", { err }); + return c.json({ error: "Failed to read conversation model" }, 500); + } + }); + + app.put("/conversations/:id/model", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/model: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = parseModelBody(body); + if (isModelParseError(parsed)) { + log.warn("conversations/model: validation failed", { reason: parsed.error }); + return c.json({ error: parsed.error }, 400); + } + + // A non-null non-empty model persists the selection; `null` or an empty + // string clears the key (the store treats an empty string as "delete"). + // The response carries the resulting value: the model name, or null when + // cleared (mirroring how `getModel` returns null after a clear). + const resultModel = parsed !== null && parsed.length > 0 ? parsed : null; + const persistedValue = resultModel !== null ? resultModel : ""; + + try { + await opts.conversationStore.setModel(conversationId, persistedValue); + log.debug("conversations: model set", { conversationId, model: resultModel }); + const response: ModelResponse = { conversationId, model: resultModel }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: model set failure", { err }); + return c.json({ error: "Failed to set conversation model" }, 500); + } + }); + + app.get("/conversations/:id/lsp", async (c) => { + const conversationId = c.req.param("id"); + try { + // Gate on the PERSISTED cwd first: when no cwd has been set for the + // conversation, the LSP does NOT connect (return null + empty servers) + // rather than falling through to the server default (process.cwd()). + const persistedCwd = await opts.conversationStore.getCwd(conversationId); + if (persistedCwd === null) { + log.info("conversations: lsp status read (no cwd)", { conversationId }); + const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd + // resolved against the workspace defaultCwd; absolute → as-is). + const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); + if (effectiveCwd === null) { + // Edge case: persisted cwd exists but resolution returned null. + log.info("conversations: lsp status read (no effective cwd)", { conversationId }); + const body: LspStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + if (opts.lspService === undefined) { + log.warn("conversations: lsp service not available", { conversationId }); + return c.json({ error: "LSP service not available" }, 503); + } + + const statuses = await opts.lspService.status(effectiveCwd); + const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => { + const info: LspServerInfo = { + id: s.id, + name: s.name, + root: s.root, + extensions: s.extensions, + state: s.state, + ...(s.error !== undefined ? { error: s.error } : {}), + ...(s.configSource !== undefined ? { configSource: s.configSource } : {}), + }; + return info; + }); + log.info("conversations: lsp status read", { + conversationId, + cwd: effectiveCwd, + serverCount: servers.length, + }); + const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: lsp status failure", { err }); + return c.json({ error: "Failed to read LSP status" }, 500); + } + }); + + // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd, + // 503 when no MCP service, map McpServerStatus → McpServerInfo. + app.get("/conversations/:id/mcp", async (c) => { + const conversationId = c.req.param("id"); + try { + const persistedCwd = await opts.conversationStore.getCwd(conversationId); + if (persistedCwd === null) { + log.info("conversations: mcp status read (no cwd)", { conversationId }); + const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); + if (effectiveCwd === null) { + log.info("conversations: mcp status read (no effective cwd)", { conversationId }); + const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + if (opts.mcpService === undefined) { + log.warn("conversations: mcp service not available", { conversationId }); + return c.json({ error: "MCP service not available" }, 503); + } + + const statuses = await opts.mcpService.status(effectiveCwd); + const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => { + const info: McpServerInfo = { + id: s.id, + state: s.state, + toolCount: s.toolCount, + ...(s.error !== undefined ? { error: s.error } : {}), + }; + return info; + }); + log.info("conversations: mcp status read", { + conversationId, + cwd: effectiveCwd, + serverCount: servers.length, + }); + const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: mcp status failure", { err }); + return c.json({ error: "Failed to read MCP status" }, 500); + } + }); + + app.get("/conversations", async (c) => { + try { + // Optional `?status=` comma-separated filter (e.g. "active,idle"). + // Default: all statuses. Invalid values are silently ignored. + const rawStatus = c.req.query("status"); + const statusFilter = parseStatusFilter(rawStatus); + // Optional `?workspaceId=` filter. A missing/empty/whitespace-only + // value is ignored → return all workspaces. Composable with `?status=` + // and `?q=`. + const rawWorkspaceId = c.req.query("workspaceId"); + const workspaceId = + rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0 + ? rawWorkspaceId.trim() + : undefined; + const filter: Parameters<ConversationStore["listConversations"]>[0] = + statusFilter !== undefined || workspaceId !== undefined + ? { + ...(statusFilter !== undefined ? { status: statusFilter } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + } + : undefined; + const all = await opts.conversationStore.listConversations(filter); + // Optional `?q=` filters by id prefix (short-id resolution). A + // missing/empty/whitespace-only `q` is ignored → return all. + const rawQ = c.req.query("q"); + const q = rawQ?.trim() ?? ""; + const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all; + log.info("conversations: list", { + count: conversations.length, + ...(q.length > 0 ? { q } : {}), + ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}), + ...(workspaceId !== undefined ? { workspaceId } : {}), + }); + const body: ConversationListResponse = { conversations }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: list failure", { err }); + return c.json({ error: "Failed to list conversations" }, 500); + } + }); + + app.get("/conversations/:id/last", async (c) => { + const conversationId = c.req.param("id"); + + // Subscribe BEFORE checking isActive — closes the race where a seal + // fires between the check and the subscribe (we'd miss it). If idle, + // unsubscribe immediately; if active, wait for a `turn-sealed` event + // (or a 60s timeout, then proceed regardless of what's available). + let turnId: string | undefined; + let unsubscribe: (() => void) | undefined; + try { + await new Promise<void>((resolve) => { + let settled = false; + let timer: ReturnType<typeof setTimeout> | undefined; + const finish = (): void => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(); + }; + unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => { + if (event.type === "turn-sealed") { + turnId = event.turnId; + finish(); + } + }); + if (!opts.orchestrator.isActive(conversationId)) { + finish(); + return; + } + // A seal may have fired synchronously during subscribe (the + // real orchestrator never does this, but a fake might) — don't + // arm a 60s timer for an already-settled promise. + if (settled) return; + timer = setTimeout(finish, 60_000); + }); + } finally { + unsubscribe?.(); + } + + let content = ""; + try { + const messages = await opts.conversationStore.load(conversationId); + content = extractLastAssistantText(messages); + } catch (err) { + log.error("conversations: last message load failure", { err }); + return c.json({ error: "Failed to load conversation" }, 500); + } + + log.info("conversations: last read", { + conversationId, + hasContent: content.length > 0, + }); + const body: LastMessageResponse = { + conversationId, + content, + ...(turnId !== undefined ? { turnId } : {}), + }; + return c.json(body, 200); + }); + + app.post("/conversations/:id/open", async (c) => { + const conversationId = c.req.param("id"); + if (opts.emit === undefined) { + log.warn("conversations: open requested but emit is not available", { + conversationId, + }); + return c.json({ error: "not available" }, 500); + } + // Resolve the conversation's persisted workspace id so the frontend can + // open/focus the tab in the correct workspace. The store falls back to + // `"default"` when no workspaceId is persisted (or the conversation is + // unknown), so this never throws for a missing conversation. + const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId); + opts.emit(conversationOpened, { conversationId, workspaceId }); + log.info("conversations: opened", { conversationId, workspaceId }); + const body: OpenConversationResponse = { conversationId }; + return c.json(body, 200); + }); + + app.put("/conversations/:id/title", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("conversations/title: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + if (typeof obj.title !== "string" || obj.title.trim().length === 0) { + return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); + } + // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody` + // forward trimmed text), so a title never carries surrounding whitespace. + const title = obj.title.trim(); + + try { + await opts.conversationStore.setConversationTitle(conversationId, title); + log.info("conversations: title set", { conversationId }); + const response: TitleResponse = { conversationId, title }; + return c.json(response, 200); + } catch (err) { + log.error("conversations: title set failure", { err }); + return c.json({ error: "Failed to set conversation title" }, 500); + } + }); + + // ─── Compaction ────────────────────────────────────────────────────────── + + app.post("/conversations/:id/compact", async (c) => { + if (opts.compactionService === undefined) { + return c.json({ error: "Compaction service not available" }, 503); + } + const conversationId = c.req.param("id"); + let body: unknown = {}; + try { + body = await c.req.json(); + } catch { + // No body is fine — use defaults. + } + const obj = body as Record<string, unknown>; + const keepLastN = + typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0 + ? Math.floor(obj.keepLastN) + : undefined; + const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined; + + log.info("conversations: compact request", { conversationId }); + + const result = await opts.compactionService.compact(conversationId, { + ...(keepLastN !== undefined ? { keepLastN } : {}), + ...(modelName !== undefined ? { modelName } : {}), + }); + + if ("error" in result) { + log.warn("conversations: compact returned error", { + conversationId, + error: result.error, + }); + return c.json({ error: result.error }, 409); + } + + const response: CompactResponse = { + conversationId, + newConversationId: result.newConversationId, + messagesSummarized: result.messagesSummarized, + messagesKept: result.messagesKept, + }; + return c.json(response, 200); + }); + + app.get("/conversations/:id/compact-percent", async (c) => { + const conversationId = c.req.param("id"); + const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0; + const response: CompactPercentResponse = { conversationId, threshold }; + return c.json(response, 200); + }); + + app.put("/conversations/:id/compact-percent", async (c) => { + const conversationId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + const parsed = body as SetCompactPercentRequest; + if ( + typeof parsed.threshold !== "number" || + !Number.isFinite(parsed.threshold) || + parsed.threshold < 0 + ) { + return c.json({ error: "threshold must be a non-negative number" }, 400); + } + const threshold = Math.floor(parsed.threshold); + await opts.conversationStore.setCompactPercent(conversationId, threshold); + log.info("conversations: compact-percent set", { conversationId, threshold }); + const response: CompactPercentResponse = { conversationId, threshold }; + return c.json(response, 200); + }); + + // ─── Workspaces ────────────────────────────────────────────────────────── + + app.get("/workspaces", async (c) => { + try { + const workspaces = await opts.conversationStore.listWorkspaces(); + log.info("workspaces: list", { count: workspaces.length }); + const body: WorkspaceListResponse = { workspaces }; + return c.json(body, 200); + } catch (err) { + log.error("workspaces: list failure", { err }); + return c.json({ error: "Failed to list workspaces" }, 500); + } + }); + + app.put("/workspaces/:id", async (c) => { + const workspaceId = c.req.param("id"); + if (!isValidWorkspaceSlug(workspaceId)) { + return c.json( + { + error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)", + }, + 400, + ); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record<string, unknown>; + const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {}; + if (typeof obj.title === "string") { + (opts_ as { title?: string }).title = obj.title; + } + if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) { + (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd; + } + + try { + const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_); + log.info("workspaces: ensured", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: ensure failure", { err }); + return c.json({ error: "Failed to ensure workspace" }, 500); + } + }); + + app.get("/workspaces/:id", async (c) => { + const workspaceId = c.req.param("id"); + try { + const workspace = await opts.conversationStore.getWorkspace(workspaceId); + if (workspace === null) { + return c.json({ error: "Workspace not found" }, 404); + } + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: get failure", { err }); + return c.json({ error: "Failed to read workspace" }, 500); + } + }); + + app.put("/workspaces/:id/title", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("workspaces/title: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + if (typeof obj.title !== "string" || obj.title.trim().length === 0) { + return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400); + } + const title = obj.title.trim(); + + try { + const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title); + log.info("workspaces: title set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: title set failure", { err }); + return c.json({ error: "Failed to set workspace title" }, 500); + } + }); + + app.put("/workspaces/:id/default-cwd", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record<string, unknown>; + const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null; + + try { + const workspace = await opts.conversationStore.setWorkspaceDefaultCwd( + workspaceId, + defaultCwd, + ); + log.info("workspaces: default-cwd set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: default-cwd set failure", { err }); + return c.json({ error: "Failed to set workspace default cwd" }, 500); + } + }); + + // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog). + app.put("/workspaces/:id/default-computer", async (c) => { + const workspaceId = c.req.param("id"); + let body: unknown; + try { + body = await c.req.json(); + } catch { + body = {}; + } + const obj = body as Record<string, unknown>; + // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias; + // anything else (null/absent/non-string) → clear (local). + const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] = + typeof obj.computerId === "string" ? obj.computerId : null; + + try { + const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId( + workspaceId, + defaultComputerId, + ); + log.info("workspaces: default-computer set", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: default-computer set failure", { err }); + return c.json({ error: "Failed to set workspace default computer" }, 500); + } + }); + + app.delete("/workspaces/:id", async (c) => { + const workspaceId = c.req.param("id"); + if (workspaceId === "default") { + return c.json({ error: 'The "default" workspace cannot be deleted' }, 409); + } + + try { + const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId); + log.info("workspaces: deleted", { workspaceId, closedCount }); + const response: DeleteWorkspaceResponse = { workspaceId, closedCount }; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: delete failure", { err }); + return c.json({ error: "Failed to delete workspace" }, 500); + } + }); + + // ─── Heartbeat (per-workspace AI loop) ───────────────────────────────────── + // The config + run history for a workspace's heartbeat loop. Delegated to + // the HeartbeatService (provided by the `heartbeat` extension). When + // heartbeat is NOT loaded the routes degrade: GET config → the defaults, + // GET runs → empty, PUT/POST → 503 (mirrors how /system-prompt returns the + // default template when its service is absent but 503s writes). + + app.get("/workspaces/:id/heartbeat", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + // Graceful: no heartbeat configured → return the defaults so the FE + // always gets a usable config shape (enabled: false, etc.). + const body: HeartbeatConfig = DEFAULT_HEARTBEAT_CONFIG; + return c.json(body, 200); + } + try { + const config = await opts.heartbeatService.getConfig(workspaceId); + log.info("heartbeat: config read", { workspaceId, enabled: config.enabled }); + const body: HeartbeatConfig = config; + return c.json(body, 200); + } catch (err) { + log.error("heartbeat: config read failure", { err, workspaceId }); + return c.json({ error: "Failed to read heartbeat config" }, 500); + } + }); + + app.put("/workspaces/:id/heartbeat", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + return c.json({ error: "Heartbeat service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("heartbeat: invalid JSON body", { workspaceId }); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + + // Build a partial update, validating each present field. All fields are + // optional (a partial update); only provided fields are forwarded. + const update: Record<string, unknown> = {}; + + if (obj.enabled !== undefined) { + if (typeof obj.enabled !== "boolean") { + return c.json({ error: "Field 'enabled' must be a boolean" }, 400); + } + update.enabled = obj.enabled; + } + + if (obj.systemPrompt !== undefined) { + if (typeof obj.systemPrompt !== "string") { + return c.json({ error: "Field 'systemPrompt' must be a string" }, 400); + } + update.systemPrompt = obj.systemPrompt; + } + + if (obj.taskPrompt !== undefined) { + if (typeof obj.taskPrompt !== "string") { + return c.json({ error: "Field 'taskPrompt' must be a string" }, 400); + } + update.taskPrompt = obj.taskPrompt; + } + + if (obj.intervalMinutes !== undefined) { + if (typeof obj.intervalMinutes !== "number" || !Number.isFinite(obj.intervalMinutes)) { + return c.json({ error: "Field 'intervalMinutes' must be a number" }, 400); + } + update.intervalMinutes = obj.intervalMinutes; + } + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return c.json({ error: "Field 'model' must be a string" }, 400); + } + update.model = obj.model; + } + + // `reasoningEffort` accepts a valid level string OR null (clear the + // override → inherit the workspace default). Absent (undefined) leaves + // it unchanged. An unrecognized string → 400. + if (obj.reasoningEffort !== undefined) { + if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) { + return c.json( + { + error: "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null", + }, + 400, + ); + } + update.reasoningEffort = obj.reasoningEffort; + } + + try { + const config = await opts.heartbeatService.updateConfig( + workspaceId, + update as UpdateHeartbeatRequest, + ); + log.info("heartbeat: config updated", { + workspaceId, + enabled: config.enabled, + intervalMinutes: config.intervalMinutes, + }); + const response: HeartbeatConfig = config; + return c.json(response, 200); + } catch (err) { + log.error("heartbeat: config update failure", { err, workspaceId }); + return c.json({ error: "Failed to update heartbeat config" }, 500); + } + }); + + app.get("/workspaces/:id/heartbeat/runs", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + // Graceful: no heartbeat → no runs. + const body: HeartbeatRunsResponse = { runs: [] }; + return c.json(body, 200); + } + try { + const runs = await opts.heartbeatService.listRuns(workspaceId); + log.info("heartbeat: runs listed", { workspaceId, count: runs.length }); + const body: HeartbeatRunsResponse = { runs }; + return c.json(body, 200); + } catch (err) { + log.error("heartbeat: runs list failure", { err, workspaceId }); + return c.json({ error: "Failed to list heartbeat runs" }, 500); + } + }); + + // The server-authoritative next-fire time for a workspace's heartbeat. A + // lightweight read of the scheduler's pending fire time (polled by the FE + // alongside the runs list). `nextRunAt` is null when the heartbeat is + // disabled/disarmed, or when a run is in flight and the next hasn't been + // queued yet — the FE then shows no countdown, not a fabricated one. + app.get("/workspaces/:id/heartbeat/next-run", async (c) => { + const workspaceId = c.req.param("id"); + if (opts.heartbeatService === undefined) { + // Graceful: no heartbeat configured → no next run scheduled. + return c.json({ nextRunAt: null }, 200); + } + try { + const nextRunAt = await opts.heartbeatService.nextRunAt(workspaceId); + log.info("heartbeat: next-run read", { workspaceId, nextRunAt }); + return c.json({ nextRunAt }, 200); + } catch (err) { + log.error("heartbeat: next-run read failure", { err, workspaceId }); + return c.json({ error: "Failed to read heartbeat next-run" }, 500); + } + }); + + app.post("/workspaces/:id/heartbeat/runs/:runId/stop", async (c) => { + const workspaceId = c.req.param("id"); + const runId = c.req.param("runId"); + if (opts.heartbeatService === undefined) { + return c.json({ error: "Heartbeat service not available" }, 503); + } + try { + const result = await opts.heartbeatService.stopRun(workspaceId, runId); + log.info("heartbeat: run stopped", { workspaceId, runId }); + const body: StopHeartbeatRunResponse = result; + return c.json(body, 200); + } catch (err) { + // stopRun throws "Heartbeat run not found" for an unknown run id. + const message = err instanceof Error ? err.message : String(err); + if (message.includes("not found")) { + return c.json({ error: "Heartbeat run not found" }, 404); + } + log.error("heartbeat: run stop failure", { err, workspaceId, runId }); + return c.json({ error: "Failed to stop heartbeat run" }, 500); + } + }); + + // ─── System prompt template ─────────────────────────────────────────────── + + app.get("/system-prompt/variables", (c) => { + // Static catalog — no service call needed. Always available. + const variables = getVariableCatalog(); + const body: SystemPromptVariablesResponse = { variables }; + return c.json(body, 200); + }); + + app.get("/system-prompt", async (c) => { + if (opts.systemPromptService === undefined) { + // FE always gets something useful — the built-in default template. + const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE }; + return c.json(body, 200); + } + const template = await opts.systemPromptService.getTemplate(); + const body: SystemPromptTemplateResponse = { template }; + return c.json(body, 200); + }); + + app.put("/system-prompt", async (c) => { + if (opts.systemPromptService === undefined) { + return c.json({ error: "System prompt service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("system-prompt: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (body === null || typeof body !== "object") { + return c.json({ error: "Request body must be a JSON object" }, 400); + } + const obj = body as Record<string, unknown>; + // `template` must be a string; empty string is valid ("no system prompt"). + if (typeof obj.template !== "string") { + return c.json({ error: "Field 'template' is required and must be a string" }, 400); + } + + const { template } = obj as unknown as SetSystemPromptTemplateRequest; + await opts.systemPromptService.setTemplate(template); + log.info("system-prompt: template set"); + const response: SystemPromptTemplateResponse = { template }; + return c.json(response, 200); + }); + + // ─── Static frontend serving (catch-all, API routes take precedence) ────── + if (opts.webDir !== undefined) { + const webDir = opts.webDir; + const MIME: Record<string, string> = { + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", + }; + app.get("*", async (c) => { + const urlPath = new URL(c.req.url).pathname; + const filePath = `${webDir}${urlPath}`; + const file = Bun.file(filePath); + if (await file.exists()) { + const ext = filePath.slice(filePath.lastIndexOf(".")); + const contentType = MIME[ext] ?? "application/octet-stream"; + return new Response(file, { + headers: { "Content-Type": contentType }, + }); + } + // SPA fallback: serve index.html for client-side routing + const indexFile = Bun.file(`${webDir}/index.html`); + if (await indexFile.exists()) { + return new Response(indexFile, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + return c.json({ error: "Not found" }, 404); + }); + } + + return app; } diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index eb14c55..76d58b6 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,147 +1,147 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { - type ComputerService, - cacheWarmHandle, - compactionHandle, - computerServiceHandle, - conversationStoreHandle, - credentialStoreHandle, - heartbeatServiceHandle, - lspServiceHandle, - mcpServiceHandle, - sessionOrchestratorHandle, - systemPromptHandle, - throughputStoreHandle, + type ComputerService, + cacheWarmHandle, + compactionHandle, + computerServiceHandle, + conversationStoreHandle, + credentialStoreHandle, + heartbeatServiceHandle, + lspServiceHandle, + mcpServiceHandle, + sessionOrchestratorHandle, + systemPromptHandle, + throughputStoreHandle, } from "./seam.js"; export const manifest: Manifest = { - id: "transport-http", - name: "Transport HTTP", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - dependsOn: [ - "conversation-store", - "credential-store", - "heartbeat", - "lsp", - "mcp", - "session-orchestrator", - "throughput-store", - ], - capabilities: { network: true }, - contributes: { - routes: [ - "/chat", - "/chat/warm", - "/computers", - "/computers/:alias", - "/computers/:alias/status", - "/computers/:alias/test", - "/conversations", - "/conversations/:id", - "/conversations/:id/close", - "/conversations/:id/compact", - "/conversations/:id/compact-percent", - "/conversations/:id/computer", - "/conversations/:id/cwd", - "/conversations/:id/last", - "/conversations/:id/lsp", - "/conversations/:id/mcp", - "/conversations/:id/open", - "/conversations/:id/queue", - "/conversations/:id/reasoning-effort", - "/conversations/:id/status", - "/conversations/:id/stop", - "/conversations/:id/title", - "/health", - "/models", - "/metrics/throughput", - "/system-prompt", - "/system-prompt/variables", - "/workspaces", - "/workspaces/:id", - "/workspaces/:id/default-cwd", - "/workspaces/:id/default-computer", - "/workspaces/:id/heartbeat", - "/workspaces/:id/heartbeat/runs", - "/workspaces/:id/heartbeat/runs/:runId/stop", - "/workspaces/:id/title", - ], - }, - activation: "eager", + id: "transport-http", + name: "Transport HTTP", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: [ + "conversation-store", + "credential-store", + "heartbeat", + "lsp", + "mcp", + "session-orchestrator", + "throughput-store", + ], + capabilities: { network: true }, + contributes: { + routes: [ + "/chat", + "/chat/warm", + "/computers", + "/computers/:alias", + "/computers/:alias/status", + "/computers/:alias/test", + "/conversations", + "/conversations/:id", + "/conversations/:id/close", + "/conversations/:id/compact", + "/conversations/:id/compact-percent", + "/conversations/:id/computer", + "/conversations/:id/cwd", + "/conversations/:id/last", + "/conversations/:id/lsp", + "/conversations/:id/mcp", + "/conversations/:id/open", + "/conversations/:id/queue", + "/conversations/:id/reasoning-effort", + "/conversations/:id/status", + "/conversations/:id/stop", + "/conversations/:id/title", + "/health", + "/models", + "/metrics/throughput", + "/system-prompt", + "/system-prompt/variables", + "/workspaces", + "/workspaces/:id", + "/workspaces/:id/default-cwd", + "/workspaces/:id/default-computer", + "/workspaces/:id/heartbeat", + "/workspaces/:id/heartbeat/runs", + "/workspaces/:id/heartbeat/runs/:runId/stop", + "/workspaces/:id/title", + ], + }, + activation: "eager", }; export function createTransportHttpExtension(): Extension & { - readonly _testServer: ReturnType<typeof Bun.serve> | undefined; + readonly _testServer: ReturnType<typeof Bun.serve> | undefined; } { - let server: ReturnType<typeof Bun.serve> | undefined; + let server: ReturnType<typeof Bun.serve> | undefined; - return { - get _testServer() { - return server; - }, - manifest, - async activate(host: HostAPI) { - const conversationStore = host.getService(conversationStoreHandle); - const orchestrator = host.getService(sessionOrchestratorHandle); - const credentialStore = host.getService(credentialStoreHandle); - const throughputStore = host.getService(throughputStoreHandle); - const warmService = host.getService(cacheWarmHandle); - const compactionService = host.getService(compactionHandle); - const lspService = host.getService(lspServiceHandle); - const mcpService = host.getService(mcpServiceHandle); - const systemPromptService = host.getService(systemPromptHandle); - const heartbeatService = host.getService(heartbeatServiceHandle); - // Optional: the `ssh` extension provides ComputerService. It is NOT in - // dependsOn (ssh may be absent), so resolve defensively — when no - // provider registered the handle, the computer routes degrade to - // empty/disconnected (see app.ts). Wrapped because getService throws - // for an unregistered handle. - let computerService: ComputerService | undefined; - try { - computerService = host.getService(computerServiceHandle); - } catch { - computerService = undefined; - } - const logger = host.logger; + return { + get _testServer() { + return server; + }, + manifest, + async activate(host: HostAPI) { + const conversationStore = host.getService(conversationStoreHandle); + const orchestrator = host.getService(sessionOrchestratorHandle); + const credentialStore = host.getService(credentialStoreHandle); + const throughputStore = host.getService(throughputStoreHandle); + const warmService = host.getService(cacheWarmHandle); + const compactionService = host.getService(compactionHandle); + const lspService = host.getService(lspServiceHandle); + const mcpService = host.getService(mcpServiceHandle); + const systemPromptService = host.getService(systemPromptHandle); + const heartbeatService = host.getService(heartbeatServiceHandle); + // Optional: the `ssh` extension provides ComputerService. It is NOT in + // dependsOn (ssh may be absent), so resolve defensively — when no + // provider registered the handle, the computer routes degrade to + // empty/disconnected (see app.ts). Wrapped because getService throws + // for an unregistered handle. + let computerService: ComputerService | undefined; + try { + computerService = host.getService(computerServiceHandle); + } catch { + computerService = undefined; + } + const logger = host.logger; - const app = createApp({ - conversationStore, - orchestrator, - credentialStore, - throughputStore, - warmService, - compactionService, - lspService, - mcpService, - systemPromptService, - heartbeatService, - ...(computerService !== undefined ? { computerService } : {}), - logger, - emit: host.emit.bind(host), - ...(process.env.DISPATCH_WEB_DIR !== undefined - ? { webDir: process.env.DISPATCH_WEB_DIR } - : {}), - }); + const app = createApp({ + conversationStore, + orchestrator, + credentialStore, + throughputStore, + warmService, + compactionService, + lspService, + mcpService, + systemPromptService, + heartbeatService, + ...(computerService !== undefined ? { computerService } : {}), + logger, + emit: host.emit.bind(host), + ...(process.env.DISPATCH_WEB_DIR !== undefined + ? { webDir: process.env.DISPATCH_WEB_DIR } + : {}), + }); - const port = host.config.get<number>("httpPort") ?? 24203; + const port = host.config.get<number>("httpPort") ?? 24203; - server = Bun.serve({ - port, - fetch: app.fetch, - idleTimeout: 0, - }); + server = Bun.serve({ + port, + fetch: app.fetch, + idleTimeout: 0, + }); - logger.info("transport-http: listening", { port }); - }, + logger.info("transport-http: listening", { port }); + }, - deactivate() { - if (server) { - server.stop(); - server = undefined; - } - }, - }; + deactivate() { + if (server) { + server.stop(); + server = undefined; + } + }, + }; } diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json index 001a373..4a76434 100644 --- a/packages/transport-http/tsconfig.json +++ b/packages/transport-http/tsconfig.json @@ -1,16 +1,16 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, - "include": ["src/**/*.ts"], - "references": [ - { "path": "../conversation-store" }, - { "path": "../credential-store" }, - { "path": "../heartbeat" }, - { "path": "../kernel" }, - { "path": "../lsp" }, - { "path": "../session-orchestrator" }, - { "path": "../system-prompt" }, - { "path": "../throughput-store" }, - { "path": "../transport-contract" } - ] + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../conversation-store" }, + { "path": "../credential-store" }, + { "path": "../heartbeat" }, + { "path": "../kernel" }, + { "path": "../lsp" }, + { "path": "../session-orchestrator" }, + { "path": "../system-prompt" }, + { "path": "../throughput-store" }, + { "path": "../transport-contract" } + ] } diff --git a/tsconfig.json b/tsconfig.json index cde3850..e4e833d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,122 +1,122 @@ { - "files": [], - "references": [ - { - "path": "./packages/wire" - }, - { - "path": "./packages/kernel" - }, - { - "path": "./packages/transport-contract" - }, - { - "path": "./packages/ui-contract" - }, - { - "path": "./packages/surface-registry" - }, - { - "path": "./packages/transport-ws" - }, - { - "path": "./packages/surface-loaded-extensions" - }, - { - "path": "./packages/storage-sqlite" - }, - { - "path": "./packages/auth-apikey" - }, - { - "path": "./packages/provider-openai-compat" - }, - { - "path": "./packages/openai-stream" - }, - { - "path": "./packages/provider-umans" - }, - { - "path": "./packages/credential-store" - }, - { - "path": "./packages/exec-backend" - }, - { - "path": "./packages/ssh" - }, - { - "path": "./packages/conversation-store" - }, - { - "path": "./packages/throughput-store" - }, - { - "path": "./packages/todo" - }, - { - "path": "./packages/session-orchestrator" - }, - { - "path": "./packages/transport-http" - }, - { - "path": "./packages/tool-read-file" - }, - { - "path": "./packages/tool-shell" - }, - { - "path": "./packages/tool-edit-file" - }, - { - "path": "./packages/tool-write-file" - }, - { - "path": "./packages/tool-web-search" - }, - { - "path": "./packages/tool-youtube-transcript" - }, - { - "path": "./packages/skills" - }, - { - "path": "./packages/cache-warming" - }, - { - "path": "./packages/message-queue" - }, - { - "path": "./packages/mcp" - }, - { - "path": "./packages/lsp" - }, - { - "path": "./packages/heartbeat" - }, - { - "path": "./packages/system-prompt" - }, - { - "path": "./packages/cli" - }, - { - "path": "./packages/journal-sink" - }, - { - "path": "./packages/trace-store" - }, - { - "path": "./packages/observability-collector" - }, - { - "path": "./packages/trace-replay" - }, - { - "path": "./packages/host-bin" - } - ] + "files": [], + "references": [ + { + "path": "./packages/wire" + }, + { + "path": "./packages/kernel" + }, + { + "path": "./packages/transport-contract" + }, + { + "path": "./packages/ui-contract" + }, + { + "path": "./packages/surface-registry" + }, + { + "path": "./packages/transport-ws" + }, + { + "path": "./packages/surface-loaded-extensions" + }, + { + "path": "./packages/storage-sqlite" + }, + { + "path": "./packages/auth-apikey" + }, + { + "path": "./packages/provider-openai-compat" + }, + { + "path": "./packages/openai-stream" + }, + { + "path": "./packages/provider-umans" + }, + { + "path": "./packages/credential-store" + }, + { + "path": "./packages/exec-backend" + }, + { + "path": "./packages/ssh" + }, + { + "path": "./packages/conversation-store" + }, + { + "path": "./packages/throughput-store" + }, + { + "path": "./packages/todo" + }, + { + "path": "./packages/session-orchestrator" + }, + { + "path": "./packages/transport-http" + }, + { + "path": "./packages/tool-read-file" + }, + { + "path": "./packages/tool-shell" + }, + { + "path": "./packages/tool-edit-file" + }, + { + "path": "./packages/tool-write-file" + }, + { + "path": "./packages/tool-web-search" + }, + { + "path": "./packages/tool-youtube-transcript" + }, + { + "path": "./packages/skills" + }, + { + "path": "./packages/cache-warming" + }, + { + "path": "./packages/message-queue" + }, + { + "path": "./packages/mcp" + }, + { + "path": "./packages/lsp" + }, + { + "path": "./packages/heartbeat" + }, + { + "path": "./packages/system-prompt" + }, + { + "path": "./packages/cli" + }, + { + "path": "./packages/journal-sink" + }, + { + "path": "./packages/trace-store" + }, + { + "path": "./packages/observability-collector" + }, + { + "path": "./packages/trace-replay" + }, + { + "path": "./packages/host-bin" + } + ] } |
