diff options
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 8787 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 3349 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 277 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 70 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 913 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 466 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 36 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 504 |
8 files changed, 7849 insertions, 6553 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 4f64ece..557fb44 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -1,227 +1,234 @@ +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, + QueueCancelResponse, + 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, - 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, + starred: false, + 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 setWorkspaceStarred(id, starred) { + return { ...sampleWorkspace, id, starred }; + }, + 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; + }, + }; } /** @@ -230,4103 +237,4661 @@ 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: [] }; + }, + cancelQueuedMessage() { + return { cancelled: 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: [] }; + }, + cancelQueuedMessage() { + return { cancelled: 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: [] }; + }, + cancelQueuedMessage() { + return { cancelled: 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" }; + }, + }; +} + +/** + * A minimal HeartbeatService fake for the next-run route: only `nextRunAt` is + * exercised by the route; the rest return inert defaults so the object + * 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, + }; +} + +/** + * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId + + * partial update) and returns a config echoing the captured update on top of + * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards + * validated fields to the service. + */ +function createCapturingHeartbeatService(): HeartbeatService & { + readonly captured: { workspaceId: string; update: Record<string, unknown> }[]; +} { + const captured: { workspaceId: string; update: Record<string, unknown> }[] = []; + const svc: HeartbeatService = { + getConfig: async () => DEFAULT_HEARTBEAT_CONFIG, + async updateConfig(workspaceId, update) { + captured.push({ workspaceId, update: update as Record<string, unknown> }); + return { ...DEFAULT_HEARTBEAT_CONFIG, ...update }; + }, + listRuns: async () => [], + stopRun: async () => ({ ok: true }), + startAll: async () => {}, + stopAll: () => {}, + nextRunAt: async () => null, + }; + return Object.assign(svc, { + get captured() { + return captured; + }, + }); } 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(); + }); + + it("forwards the title to the 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", title: "My Task" }), + }); + + expect(res.status).toBe(200); + expect(cap.received).toBeDefined(); + expect(cap.received?.title).toBe("My Task"); + }); + + it("forwards a trimmed title to the 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", title: " spaced " }), + }); + + expect(res.status).toBe(200); + expect(cap.received?.title).toBe("spaced"); + }); + + it("does not forward a title when omitted", 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?.title).toBeUndefined(); + }); + + it("does not forward a title for a whitespace-only title", 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", title: " " }), + }); + + expect(res.status).toBe(200); + expect(cap.received?.title).toBeUndefined(); + }); + + it("returns 400 when title is not a string", 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", title: 42 }), + }); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("title"); + }); + + it("does not call setConversationTitle itself (the orchestrator owns it)", async () => { + let setTitleCalled = false; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setConversationTitle() { + setTitleCalled = true; + }, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }), + }); + + expect(res.status).toBe(200); + // The route must NOT pre-create the meta — that would bypass the + // orchestrator's new-conversation workspace/system-prompt init. The + // orchestrator sets the title after workspace setup instead. + expect(setTitleCalled).toBe(false); + }); }); 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("DELETE /conversations/:id/queue/:messageId", () => { + it("when a message is cancelled → 200 + QueueCancelResponse (cancelled:true + post-cancel queue)", async () => { + const remaining: readonly QueuedMessage[] = [ + { id: "q1", text: "kept", queuedAt: 1700000000000 }, + ]; + let received: { conversationId: string; messageId: string } | undefined; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + cancelQueuedMessage(input) { + received = input; + return { cancelled: true, queue: remaining }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue/q2", { + method: "DELETE", + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueCancelResponse; + expect(body.conversationId).toBe("conv1"); + expect(body.cancelled).toBe(true); + expect(body.queue).toEqual(remaining); + // forwards the path conversationId + messageId + expect(received?.conversationId).toBe("conv1"); + expect(received?.messageId).toBe("q2"); + }); + + it("when the message is not in the queue → 200 cancelled:false (idempotent, not an error)", async () => { + const queue: readonly QueuedMessage[] = [ + { id: "q1", text: "still-queued", queuedAt: 1700000000000 }, + ]; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + cancelQueuedMessage() { + return { cancelled: false, queue }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue/missing", { + method: "DELETE", + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueCancelResponse; + expect(body.cancelled).toBe(false); + expect(body.queue).toEqual(queue); + }); + + it("when the queue ext is not loaded → 200 cancelled:false, empty queue (degraded)", async () => { + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + cancelQueuedMessage() { + return { cancelled: false, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + const res = await app.request("/conversations/conv1/queue/whatever", { + method: "DELETE", + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as QueueCancelResponse; + expect(body.cancelled).toBe(false); + expect(body.queue).toEqual([]); + }); + + it("delegates the cancel to the orchestrator (never reads the body)", async () => { + let calls = 0; + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + cancelQueuedMessage() { + calls += 1; + return { cancelled: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + + // No Content-Type / body — the endpoint takes the messageId from the path. + const res = await app.request("/conversations/conv-x/queue/m1", { + method: "DELETE", + }); + + expect(res.status).toBe(200); + expect(calls).toBe(1); + }); + + it("logs an info line on success and never logs the message text", async () => { + const logger = createFakeLogger(); + const orchestrator: SessionOrchestrator = { + ...createFakeOrchestrator([]), + cancelQueuedMessage() { + return { cancelled: true, queue: [] }; + }, + }; + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator, + credentialStore: createFakeCredentialStore([]), + logger, + }); + + await app.request("/conversations/conv1/queue/q-secret", { method: "DELETE" }); + + const infoLogs = logger.records.filter((r) => r.level === "info"); + expect(infoLogs).toHaveLength(1); + expect(infoLogs[0]?.msg).toBe("conversations: cancelled queued message"); + expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1"); + expect(infoLogs[0]?.attrs?.messageId).toBe("q-secret"); + expect(infoLogs[0]?.attrs?.cancelled).toBe(true); + }); }); 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, + starred: false, + 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); + }); + + // ─── Star/unstar workspace (concurrency priority) ──────────────────────── + + it("PUT /workspaces/:id/star persists + notifies the concurrency service", async () => { + let starredCalled: { id: string; starred: boolean } | null = null; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceStarred(id, starred) { + return { ...sampleWorkspace, id, starred }; + }, + }; + const concurrencyService = { + acquire: async () => () => {}, + reportRateLimit() {}, + setLimit() {}, + getLimit: () => undefined, + getLimits: () => [], + getStatus: () => undefined, + getStatusAll: () => [], + notifyWorkspaceStarred(id: string, starred: boolean) { + starredCalled = { id, starred }; + }, + destroy() {}, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + ...(concurrencyService !== undefined ? { concurrencyService } : {}), + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/star", { method: "PUT" }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.starred).toBe(true); + expect(starredCalled).toEqual({ id: "proj", starred: true }); + }); + + it("DELETE /workspaces/:id/star persists + notifies the concurrency service", async () => { + let starredCalled: { id: string; starred: boolean } | null = null; + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceStarred(id, starred) { + return { ...sampleWorkspace, id, starred }; + }, + }; + const concurrencyService = { + acquire: async () => () => {}, + reportRateLimit() {}, + setLimit() {}, + getLimit: () => undefined, + getLimits: () => [], + getStatus: () => undefined, + getStatusAll: () => [], + notifyWorkspaceStarred(id: string, starred: boolean) { + starredCalled = { id, starred }; + }, + destroy() {}, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + concurrencyService, + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj/star", { method: "DELETE" }); + expect(res.status).toBe(200); + const body = (await res.json()) as WorkspaceResponse; + expect(body.starred).toBe(false); + expect(starredCalled).toEqual({ id: "proj", starred: false }); + }); + + it("PUT /workspaces/:id/star rejects invalid slug", async () => { + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger: noopLogger, + }); + const res = await app.request("/workspaces/Bad Slug!/star", { method: "PUT" }); + expect(res.status).toBe(400); + }); + + it("DELETE /workspaces/:id cleans up the in-memory starred cache (bug fix)", async () => { + // Bug 1 fix: deleting a workspace must notify the concurrency service to + // remove the workspace ID from the starred cache, preventing stale IDs. + let starredCalled: { id: string; starred: boolean } | null = null; + const store: ConversationStore = { + ...createFakeConversationStore(), + async deleteWorkspace() { + return { closedCount: 2 }; + }, + }; + const concurrencyService = { + acquire: async () => () => {}, + reportRateLimit() {}, + setLimit() {}, + getLimit: () => undefined, + getLimits: () => [], + getStatus: () => undefined, + getStatusAll: () => [], + notifyWorkspaceStarred(id: string, starred: boolean) { + starredCalled = { id, starred }; + }, + destroy() {}, + }; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + concurrencyService, + logger: noopLogger, + }); + const res = await app.request("/workspaces/proj", { method: "DELETE" }); + expect(res.status).toBe(200); + // The concurrency service must be notified to clear the starred cache. + expect(starredCalled).toEqual({ id: "proj", starred: false }); + }); + + it("PUT /workspaces/:id/star logs warning when concurrency service is absent (bug fix)", async () => { + // Bug 2 fix: when the concurrency service is not loaded, the star toggle + // persists but the priority cache is not updated. A warning log makes this + // degraded behavior visible. + const logger = createFakeLogger(); + const store: ConversationStore = { + ...createFakeConversationStore(), + async setWorkspaceStarred(id, starred) { + return { ...sampleWorkspace, id, starred }; + }, + }; + // NOTE: no concurrencyService provided — simulates the extension being absent. + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + logger, + }); + const res = await app.request("/workspaces/proj/star", { method: "PUT" }); + expect(res.status).toBe(200); + // The star persisted, but a warning was logged about the missing service. + const warnings = logger.records.filter((r) => r.level === "warn"); + expect(warnings.some((r) => r.msg.includes("concurrency service is not loaded"))).toBe(true); + }); }); 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(); + }); +}); + +describe("PUT /workspaces/:id/heartbeat", () => { + it("forwards inactiveOnly to the service and echoes it in the response", async () => { + const hb = createCapturingHeartbeatService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: hb, + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inactiveOnly: false }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { inactiveOnly: boolean }; + expect(body.inactiveOnly).toBe(false); + expect(hb.captured).toHaveLength(1); + expect(hb.captured[0]?.workspaceId).toBe("ws-1"); + expect(hb.captured[0]?.update.inactiveOnly).toBe(false); + }); + + it("rejects a non-boolean inactiveOnly with 400", async () => { + const hb = createCapturingHeartbeatService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: hb, + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ inactiveOnly: "yes" }), + }); + expect(res.status).toBe(400); + // The service was NOT called (validation happened first). + expect(hb.captured).toHaveLength(0); + }); + + it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => { + const hb = createCapturingHeartbeatService(); + const app = createApp({ + conversationStore: createFakeConversationStore(), + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + heartbeatService: hb, + logger: noopLogger, + }); + const res = await app.request("/workspaces/ws-1/heartbeat", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(res.status).toBe(200); + expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined(); + }); }); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 2e81c46..32a92f1 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,1433 +1,1960 @@ +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, - LastMessageResponse, - LspServerInfo, - LspStatusResponse, - McpServerInfo, - McpStatusResponse, - ModelResponse, - ModelsResponse, - OpenConversationResponse, - QueueResponse, - ReasoningEffortResponse, - SetCompactPercentRequest, - SetConversationComputerRequest, - SetSystemPromptTemplateRequest, - SetWorkspaceDefaultComputerRequest, - SystemPromptTemplateResponse, - SystemPromptVariablesResponse, - TestComputerResponse, - ThroughputResponse, - TitleResponse, - WarmResponse, - WorkspaceListResponse, - WorkspaceResponse, + CloseConversationResponse, + CompactPercentResponse, + CompactResponse, + ComputerListResponse, + ComputerResponse, + ComputerStatusResponse, + ConcurrencyCooldownResponse, + ConcurrencyLimitResponse, + ConcurrencyLimitsResponse, + ConcurrencyStatusResponse, + ConversationComputerResponse, + ConversationHistoryResponse, + ConversationListResponse, + ConversationMetricsResponse, + ConversationStatusResponse, + CwdResponse, + DeleteWorkspaceResponse, + HeartbeatConfig, + HeartbeatRunsResponse, + LastMessageResponse, + LspServerInfo, + LspStatusResponse, + McpServerInfo, + McpStatusResponse, + ModelResponse, + ModelsResponse, + OpenConversationResponse, + QueueCancelResponse, + QueueResponse, + ReasoningEffortResponse, + SetCompactPercentRequest, + SetConcurrencyCooldownRequest, + SetConcurrencyLimitRequest, + SetConversationComputerRequest, + SetSystemPromptTemplateRequest, + SetWorkspaceDefaultComputerRequest, + StopHeartbeatRunResponse, + SystemPromptTemplateResponse, + SystemPromptVariablesResponse, + TestComputerResponse, + ThroughputResponse, + TitleResponse, + UpdateHeartbeatRequest, + VisionSettingsResponse, + WarmResponse, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { - computeCachePct, - computeExpectedCacheRate, - extractLastAssistantText, - isModelParseError, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - 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, - isValidWorkspaceSlug, - type LspServerStatus, - type LspService, - type McpServerStatus, - type McpService, - type SessionOrchestrator, - type SystemPromptService, - ThroughputQueryError, - type ThroughputStore, - type WarmService, + type CompactionService, + type ComputerService, + type ConcurrencyService, + 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 — 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; + /** + * Optional — provider concurrency limiter service (provided by the + * `provider-concurrency` extension). When absent (extension not loaded), + * the `/concurrency/*` routes degrade: limits returns empty, status returns + * empty, PUT returns 503. + */ + readonly concurrencyService?: ConcurrencyService; + 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); - } - }); - - // ─── 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 })); + + // ── Tmp image serving (vision handoff) ────────────────────────────────────── + app.get("/images/:conversationId/:imageId", async (c) => { + const conversationId = c.req.param("conversationId"); + const imageId = c.req.param("imageId"); + if (imageId.includes("/") || imageId.includes("..")) { + return c.json({ error: "Invalid image ID" }, 400); + } + const imageDir = process.env.DISPATCH_IMAGE_DIR ?? "/tmp/dispatch/images"; + const { join } = await import("node:path"); + const { readFile: fsReadFile } = await import("node:fs/promises"); + const filePath = join(imageDir, conversationId, imageId); + try { + const buf = await fsReadFile(filePath); + const ext = imageId.toLowerCase(); + const mime = ext.endsWith(".png") + ? "image/png" + : ext.endsWith(".jpg") || ext.endsWith(".jpeg") + ? "image/jpeg" + : ext.endsWith(".webp") + ? "image/webp" + : ext.endsWith(".gif") + ? "image/gif" + : ext.endsWith(".bmp") + ? "image/bmp" + : "application/octet-stream"; + return new Response(buf, { headers: { "Content-Type": mime, "Cache-Control": "no-cache" } }); + } catch { + return c.json({ error: "Image not found" }, 404); + } + }); + + 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; vision?: boolean }> = {}; + for (const modelName of models) { + const info = await opts.credentialStore.getModelInfo(modelName); + if (info?.contextWindow !== undefined || info?.vision === true) { + const entry: { contextWindow?: number; vision?: boolean } = {}; + if (info?.contextWindow !== undefined) entry.contextWindow = info.contextWindow; + if (info?.vision === true) entry.vision = true; + modelInfo[modelName] = entry; + } + } + 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, + images, + title, + } = result; + log.info("chat: request accepted", { + conversationId, + hasModel: model !== undefined, + hasCwd: cwd !== undefined, + hasComputerId: computerId !== undefined, + hasReasoningEffort: reasoningEffort !== undefined, + hasWorkspaceId: workspaceId !== undefined, + imageCount: images?.length ?? 0, + }); + + 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 } : {}), + ...(images !== undefined ? { images } : {}), + ...(title !== undefined ? { title } : {}), + }; + + 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); + } + }); + + // ─── Provider concurrency limits ──────────────────────────────────────────── + + app.get("/concurrency/limits", (c) => { + if (opts.concurrencyService === undefined) { + const body: ConcurrencyLimitsResponse = { limits: [] }; + return c.json(body, 200); + } + const limits = opts.concurrencyService.getLimits(); + const body: ConcurrencyLimitsResponse = { limits }; + return c.json(body, 200); + }); + + app.get("/concurrency/limits/:providerId", (c) => { + const providerId = c.req.param("providerId"); + if (opts.concurrencyService === undefined) { + return c.json({ error: "Concurrency service not available" }, 503); + } + const limit = opts.concurrencyService.getLimit(providerId); + if (limit === undefined) { + return c.json({ error: "No concurrency limit configured for this provider" }, 404); + } + const body: ConcurrencyLimitResponse = { providerId, limit }; + return c.json(body, 200); + }); + + app.put("/concurrency/limits/:providerId", async (c) => { + const providerId = c.req.param("providerId"); + if (opts.concurrencyService === undefined) { + return c.json({ error: "Concurrency service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("concurrency: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = body as SetConcurrencyLimitRequest; + if ( + parsed === null || + typeof parsed !== "object" || + typeof parsed.limit !== "number" || + !Number.isInteger(parsed.limit) || + parsed.limit <= 0 + ) { + return c.json({ error: "Body must be { limit: <positive integer> }" }, 400); + } + + opts.concurrencyService.setLimit(providerId, parsed.limit); + const responseBody: ConcurrencyLimitResponse = { providerId, limit: parsed.limit }; + return c.json(responseBody, 200); + }); + + app.delete("/concurrency/limits/:providerId", (c) => { + const providerId = c.req.param("providerId"); + if (opts.concurrencyService === undefined) { + return c.json({ error: "Concurrency service not available" }, 503); + } + const existing = opts.concurrencyService.getLimit(providerId); + if (existing === undefined) { + return c.json({ error: "No concurrency limit configured for this provider" }, 404); + } + opts.concurrencyService.removeLimit(providerId); + return c.json({ ok: true, providerId }, 200); + }); + + app.get("/concurrency/cooldown/:providerId", (c) => { + const providerId = c.req.param("providerId"); + if (opts.concurrencyService === undefined) { + return c.json({ error: "Concurrency service not available" }, 503); + } + // A cooldown may be the default (when a limit is configured but no explicit + // cooldown was set) or explicitly set. getCooldown returns undefined only + // when the provider has NO state at all (no limit, no cooldown) — treat that + // as "not configured". + const cooldownMs = opts.concurrencyService.getCooldown(providerId); + if (cooldownMs === undefined) { + return c.json({ error: "No concurrency configuration for this provider" }, 404); + } + const body: ConcurrencyCooldownResponse = { providerId, cooldownMs }; + return c.json(body, 200); + }); + + app.put("/concurrency/cooldown/:providerId", async (c) => { + const providerId = c.req.param("providerId"); + if (opts.concurrencyService === undefined) { + return c.json({ error: "Concurrency service not available" }, 503); + } + + let body: unknown; + try { + body = await c.req.json(); + } catch { + log.warn("concurrency: invalid JSON body"); + return c.json({ error: "Invalid JSON body" }, 400); + } + + const parsed = body as SetConcurrencyCooldownRequest; + if ( + parsed === null || + typeof parsed !== "object" || + typeof parsed.cooldownMs !== "number" || + !Number.isInteger(parsed.cooldownMs) || + parsed.cooldownMs < 0 + ) { + return c.json({ error: "Body must be { cooldownMs: <non-negative integer> }" }, 400); + } + + opts.concurrencyService.setCooldown(providerId, parsed.cooldownMs); + const responseBody: ConcurrencyCooldownResponse = { providerId, cooldownMs: parsed.cooldownMs }; + return c.json(responseBody, 200); + }); + + app.get("/concurrency/status", (c) => { + if (opts.concurrencyService === undefined) { + const body: ConcurrencyStatusResponse = { providers: [] }; + return c.json(body, 200); + } + const statuses = opts.concurrencyService.getStatusAll(); + const body: ConcurrencyStatusResponse = { providers: statuses }; + return c.json(body, 200); + }); + + 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.delete("/conversations/:id/queue/:messageId", (c) => { + const conversationId = c.req.param("id"); + const messageId = c.req.param("messageId"); + + // `cancelQueuedMessage` is synchronous and owns the lookup + removal (no + // separate race — the pure `cancel` is idempotent). It does not throw for an + // unknown/idle conversation, which instead returns cancelled:false. Mirrors + // the direct sync call used by `POST /conversations/:id/queue`. + const { cancelled, queue } = opts.orchestrator.cancelQueuedMessage({ + conversationId, + messageId, + }); + log.info("conversations: cancelled queued message", { + conversationId, + messageId, + cancelled, + queueLength: queue.length, + }); + const response: QueueCancelResponse = { conversationId, cancelled, 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); + // Clean up the in-memory starred cache so a deleted workspace's ID + // doesn't linger (and so a future workspace re-created with the same + // slug doesn't inherit the stale starred state). + opts.concurrencyService?.notifyWorkspaceStarred(workspaceId, false); + 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); + } + }); + + // ─── Star/unstar workspace (concurrency priority) ─────────────────────────── + // Starred workspaces receive PRIORITY in the concurrency limiter queue — + // their agents jump ahead of agents from non-starred workspaces. The + // starred state is persisted in the conversation store AND the in-memory + // cache in the concurrency service is notified so already-queued agents + // are re-prioritized immediately. + + app.put("/workspaces/:id/star", 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, + ); + } + try { + const workspace = await opts.conversationStore.setWorkspaceStarred(workspaceId, true); + // Notify the concurrency service's in-memory cache so queued agents + // from this workspace jump ahead immediately. When the concurrency + // service is absent (extension not loaded), the starred state is + // persisted but the in-memory priority cache is NOT updated — log a + // warning so the degraded behavior is visible (queued agents keep + // their old priority until restart or the extension is loaded). + if (opts.concurrencyService !== undefined) { + opts.concurrencyService.notifyWorkspaceStarred(workspaceId, true); + } else { + log.warn( + "workspaces: starred but concurrency service is not loaded — priority cache not updated", + { + workspaceId, + }, + ); + } + log.info("workspaces: starred", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: star failure", { err, workspaceId }); + return c.json({ error: "Failed to star workspace" }, 500); + } + }); + + app.delete("/workspaces/:id/star", 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, + ); + } + try { + const workspace = await opts.conversationStore.setWorkspaceStarred(workspaceId, false); + if (opts.concurrencyService !== undefined) { + opts.concurrencyService.notifyWorkspaceStarred(workspaceId, false); + } else { + log.warn( + "workspaces: unstarred but concurrency service is not loaded — priority cache not updated", + { + workspaceId, + }, + ); + } + log.info("workspaces: unstarred", { workspaceId }); + const response: WorkspaceResponse = workspace; + return c.json(response, 200); + } catch (err) { + log.error("workspaces: unstar failure", { err, workspaceId }); + return c.json({ error: "Failed to unstar 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; + } + + // inactiveOnly: when true (the default), the heartbeat skips a fire while + // the configured workspace has active agents. A boolean; absent leaves it + // unchanged. + if (obj.inactiveOnly !== undefined) { + if (typeof obj.inactiveOnly !== "boolean") { + return c.json({ error: "Field 'inactiveOnly' must be a boolean" }, 400); + } + update.inactiveOnly = obj.inactiveOnly; + } + + 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); + }); + + app.get("/settings/vision", async (c) => { + const settings = await opts.conversationStore.getVisionSettings(); + const body: VisionSettingsResponse = settings; + return c.json(body, 200); + }); + + app.put("/settings/vision", async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + const obj = body as { imageLimit?: unknown; compactionModel?: unknown }; + if (obj.imageLimit !== undefined) { + if ( + typeof obj.imageLimit !== "number" || + !Number.isInteger(obj.imageLimit) || + obj.imageLimit < 0 + ) { + return c.json({ error: "imageLimit must be a non-negative integer" }, 400); + } + await opts.conversationStore.setVisionImageLimit(obj.imageLimit); + log.info("vision: image limit set", { imageLimit: obj.imageLimit }); + } + if (obj.compactionModel !== undefined) { + if (obj.compactionModel !== null && typeof obj.compactionModel !== "string") { + return c.json({ error: "compactionModel must be a string or null" }, 400); + } + await opts.conversationStore.setVisionCompactionModel(obj.compactionModel); + log.info("vision: compaction model set", { compactionModel: obj.compactionModel }); + } + const settings = await opts.conversationStore.getVisionSettings(); + const response: VisionSettingsResponse = settings; + 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 4ab43ce..effbadd 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -1,140 +1,171 @@ import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { - type ComputerService, - cacheWarmHandle, - compactionHandle, - computerServiceHandle, - conversationStoreHandle, - credentialStoreHandle, - lspServiceHandle, - mcpServiceHandle, - sessionOrchestratorHandle, - systemPromptHandle, - throughputStoreHandle, + type ComputerService, + type ConcurrencyService, + cacheWarmHandle, + compactionHandle, + computerServiceHandle, + concurrencyServiceHandle, + conversationStoreHandle, + credentialStoreHandle, + heartbeatServiceHandle, + type LspService, + 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", - "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/title", - "/workspaces/:id/default-cwd", - "/workspaces/:id/default-computer", - ], - }, - activation: "eager", + id: "transport-http", + name: "Transport HTTP", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: [ + "conversation-store", + "credential-store", + "heartbeat", + "mcp", + "session-orchestrator", + "throughput-store", + ], + capabilities: { network: true }, + contributes: { + routes: [ + "/chat", + "/chat/warm", + "/computers", + "/computers/:alias", + "/computers/:alias/status", + "/computers/:alias/test", + "/concurrency/limits", + "/concurrency/limits/:providerId", + "/concurrency/status", + "/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/queue/:messageId", + "/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/star", + "/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); - // 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); + // Optional: the `lsp` extension may be disabled (hot-fix). Wrapped because + // getService throws for an unregistered handle — degrades to no diagnostics. + let lspService: LspService | undefined; + try { + lspService = host.getService(lspServiceHandle); + } catch { + lspService = undefined; + } + 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; + } + // Optional: the `provider-concurrency` extension provides the + // concurrency limiter service. NOT in dependsOn (may be absent), so + // resolve defensively — when absent the /concurrency/* routes degrade. + let concurrencyService: ConcurrencyService | undefined; + try { + concurrencyService = host.getService(concurrencyServiceHandle); + } catch { + concurrencyService = undefined; + } + const logger = host.logger; - const app = createApp({ - conversationStore, - orchestrator, - credentialStore, - throughputStore, - warmService, - compactionService, - lspService, - mcpService, - systemPromptService, - ...(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 !== undefined ? { lspService } : {}), + mcpService, + systemPromptService, + heartbeatService, + ...(computerService !== undefined ? { computerService } : {}), + ...(concurrencyService !== undefined ? { concurrencyService } : {}), + 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/src/index.ts b/packages/transport-http/src/index.ts index 192b00c..47c06bd 100644 --- a/packages/transport-http/src/index.ts +++ b/packages/transport-http/src/index.ts @@ -2,45 +2,45 @@ export type { CreateServerOptions } from "./app.js"; export { createApp } from "./app.js"; export { createTransportHttpExtension, manifest } from "./extension.js"; export type { - ChatCommand, - ParseError, - ParseResult, - QueueBodyParsed, - SinceSeqResult, - WarmBodyParsed, - WindowParamResult, + ChatCommand, + ParseError, + ParseResult, + QueueBodyParsed, + SinceSeqResult, + WarmBodyParsed, + WindowParamResult, } from "./logic.js"; export { - computeCachePct, - extractLastAssistantText, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - isValidReasoningEffort, - isWindowParamError, - parseChatBody, - parseQueueBody, - parseReasoningEffortBody, - parseSinceSeq, - parseWindowParam, - serializeEventLine, + computeCachePct, + extractLastAssistantText, + isParseError, + isReasoningEffortParseError, + isSinceSeqError, + isValidReasoningEffort, + isWindowParamError, + parseChatBody, + parseQueueBody, + parseReasoningEffortBody, + parseSinceSeq, + parseWindowParam, + serializeEventLine, } from "./logic.js"; export type { - ComputerService, - ConversationStore, - CredentialStore, - LspService, - SessionOrchestrator, - SystemPromptService, - WarmService, + ComputerService, + ConversationStore, + CredentialStore, + LspService, + SessionOrchestrator, + SystemPromptService, + WarmService, } from "./seam.js"; export { - cacheWarmHandle, - computerServiceHandle, - conversationStoreHandle, - credentialStoreHandle, - isValidWorkspaceSlug, - lspServiceHandle, - sessionOrchestratorHandle, - systemPromptHandle, + cacheWarmHandle, + computerServiceHandle, + conversationStoreHandle, + credentialStoreHandle, + isValidWorkspaceSlug, + lspServiceHandle, + sessionOrchestratorHandle, + systemPromptHandle, } from "./seam.js"; diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts index 40a82fd..271ee96 100644 --- a/packages/transport-http/src/logic.test.ts +++ b/packages/transport-http/src/logic.test.ts @@ -1,428 +1,541 @@ import type { AgentEvent } from "@dispatch/kernel"; import { describe, expect, it } from "vitest"; import { - computeExpectedCacheRate, - isParseError, - isReasoningEffortParseError, - isSinceSeqError, - isValidReasoningEffort, - isWindowParamError, - parseChatBody, - parseQueueBody, - parseReasoningEffortBody, - parseSinceSeq, - parseWindowParam, - serializeEventLine, + computeExpectedCacheRate, + isParseError, + isReasoningEffortParseError, + isSinceSeqError, + isValidReasoningEffort, + isWindowParamError, + parseChatBody, + parseQueueBody, + parseReasoningEffortBody, + parseSinceSeq, + parseWindowParam, + serializeEventLine, } from "./logic.js"; describe("parseChatBody", () => { - const fakeId = () => "test-uuid"; - - it("returns error for null body", () => { - const result = parseChatBody(null, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("JSON object"); - } - }); - - it("returns error for non-object body", () => { - const result = parseChatBody("hello", fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when message is missing", () => { - const result = parseChatBody({ conversationId: "c1" }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("message"); - } - }); - - it("returns error when message is empty string", () => { - const result = parseChatBody({ message: "" }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when message is whitespace only", () => { - const result = parseChatBody({ message: " " }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when message is not a string", () => { - const result = parseChatBody({ message: 42 }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("generates conversationId when absent", () => { - const result = parseChatBody({ message: "hello" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.conversationId).toBe("test-uuid"); - expect(result.message).toBe("hello"); - } - }); - - it("generates conversationId when empty string", () => { - const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.conversationId).toBe("test-uuid"); - } - }); - - it("uses provided conversationId", () => { - const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.conversationId).toBe("my-conv"); - } - }); - - it("trims message whitespace", () => { - const result = parseChatBody({ message: " hello world " }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.message).toBe("hello world"); - } - }); - - it("extracts model when present", () => { - const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.model).toBe("opencode/m1"); - } - }); - - it("extracts cwd when present", () => { - const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.cwd).toBe("/tmp"); - } - }); - - it("extracts both model and cwd", () => { - const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.model).toBe("openai/gpt-4"); - expect(result.cwd).toBe("/home"); - } - }); - - it("omits model when absent", () => { - const result = parseChatBody({ message: "hi" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.model).toBeUndefined(); - } - }); - - it("omits cwd when absent", () => { - const result = parseChatBody({ message: "hi" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.cwd).toBeUndefined(); - } - }); - - it("returns error when model is not a string", () => { - const result = parseChatBody({ message: "hi", model: 42 }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("model"); - } - }); - - it("returns error when cwd is not a string", () => { - const result = parseChatBody({ message: "hi", cwd: true }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("cwd"); - } - }); - - it("extracts reasoningEffort when present and valid", () => { - const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.reasoningEffort).toBe("low"); - } - }); - - it("accepts all valid reasoningEffort levels", () => { - for (const level of ["low", "medium", "high", "xhigh", "max"]) { - const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.reasoningEffort).toBe(level); - } - } - }); - - it("returns error for invalid reasoningEffort", () => { - const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("reasoningEffort"); - } - }); - - it("returns error for non-string reasoningEffort", () => { - const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId); - expect(isParseError(result)).toBe(true); - }); - - it("omits reasoningEffort when absent", () => { - const result = parseChatBody({ message: "hi" }, fakeId); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.reasoningEffort).toBeUndefined(); - } - }); + const fakeId = () => "test-uuid"; + + it("returns error for null body", () => { + const result = parseChatBody(null, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("JSON object"); + } + }); + + it("returns error for non-object body", () => { + const result = parseChatBody("hello", fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is missing", () => { + const result = parseChatBody({ conversationId: "c1" }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("message"); + } + }); + + it("returns error when message is empty string", () => { + const result = parseChatBody({ message: "" }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is whitespace only", () => { + const result = parseChatBody({ message: " " }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is not a string", () => { + const result = parseChatBody({ message: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("generates conversationId when absent", () => { + const result = parseChatBody({ message: "hello" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("test-uuid"); + expect(result.message).toBe("hello"); + } + }); + + it("generates conversationId when empty string", () => { + const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("test-uuid"); + } + }); + + it("uses provided conversationId", () => { + const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("my-conv"); + } + }); + + it("trims message whitespace", () => { + const result = parseChatBody({ message: " hello world " }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.message).toBe("hello world"); + } + }); + + it("extracts model when present", () => { + const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBe("opencode/m1"); + } + }); + + it("extracts cwd when present", () => { + const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.cwd).toBe("/tmp"); + } + }); + + it("extracts both model and cwd", () => { + const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBe("openai/gpt-4"); + expect(result.cwd).toBe("/home"); + } + }); + + it("omits model when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.model).toBeUndefined(); + } + }); + + it("omits cwd when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.cwd).toBeUndefined(); + } + }); + + it("returns error when model is not a string", () => { + const result = parseChatBody({ message: "hi", model: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("model"); + } + }); + + it("returns error when cwd is not a string", () => { + const result = parseChatBody({ message: "hi", cwd: true }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("cwd"); + } + }); + + it("extracts reasoningEffort when present and valid", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBe("low"); + } + }); + + it("accepts all valid reasoningEffort levels", () => { + for (const level of ["low", "medium", "high", "xhigh", "max"]) { + const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBe(level); + } + } + }); + + it("returns error for invalid reasoningEffort", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns error for non-string reasoningEffort", () => { + const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("omits reasoningEffort when absent", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.reasoningEffort).toBeUndefined(); + } + }); + + // ── title ──────────────────────────────────────────────────────────────── + + it("extracts title when present", () => { + const result = parseChatBody({ message: "hi", title: "My Task" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.title).toBe("My Task"); + } + }); + + it("trims title whitespace", () => { + const result = parseChatBody({ message: "hi", title: " spaced title " }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.title).toBe("spaced title"); + } + }); + + it("omits title when absent (backward compatible)", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.title).toBeUndefined(); + } + }); + + it("omits title when whitespace-only (treated as absent)", () => { + const result = parseChatBody({ message: "hi", title: " " }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.title).toBeUndefined(); + } + }); + + it("omits title when empty string (treated as absent)", () => { + const result = parseChatBody({ message: "hi", title: "" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.title).toBeUndefined(); + } + }); + + it("returns error when title is not a string", () => { + const result = parseChatBody({ message: "hi", title: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("title"); + } + }); + + // ── images ────────────────────────────────────────────────────────────── + + it("parses images array with data URLs", () => { + const result = parseChatBody( + { + message: "what is this?", + images: [ + { url: "data:image/png;base64,aaa" }, + { url: "data:image/jpeg;base64,bbb", mimeType: "image/jpeg" }, + ], + }, + fakeId, + ); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.images).toHaveLength(2); + expect(result.images?.[0]?.url).toBe("data:image/png;base64,aaa"); + expect(result.images?.[1]?.mimeType).toBe("image/jpeg"); + } + }); + + it("parses images with http URLs", () => { + const result = parseChatBody( + { message: "hi", images: [{ url: "https://example.com/x.png" }] }, + fakeId, + ); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.images?.[0]?.url).toBe("https://example.com/x.png"); + } + }); + + it("returns error when images is not an array", () => { + const result = parseChatBody({ message: "hi", images: "not-an-array" }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when an image lacks a url", () => { + const result = parseChatBody({ message: "hi", images: [{ mimeType: "image/png" }] }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when an image url is empty", () => { + const result = parseChatBody({ message: "hi", images: [{ url: "" }] }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("omits images when absent (backward compatible)", () => { + const result = parseChatBody({ message: "hi" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.images).toBeUndefined(); + } + }); + + it("omits images when the array is empty", () => { + const result = parseChatBody({ message: "hi", images: [] }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.images).toBeUndefined(); + } + }); }); describe("parseSinceSeq", () => { - it("returns 0 when undefined", () => { - expect(parseSinceSeq(undefined)).toBe(0); - }); - - it("returns 0 when empty string", () => { - expect(parseSinceSeq("")).toBe(0); - }); - - it("parses valid non-negative integer", () => { - expect(parseSinceSeq("0")).toBe(0); - expect(parseSinceSeq("5")).toBe(5); - expect(parseSinceSeq("42")).toBe(42); - }); - - it("returns ParseError for non-integer string", () => { - const result = parseSinceSeq("abc"); - expect(isSinceSeqError(result)).toBe(true); - if (isSinceSeqError(result)) { - expect(result.error).toContain("sinceSeq"); - } - }); - - it("returns ParseError for float", () => { - const result = parseSinceSeq("3.14"); - expect(isSinceSeqError(result)).toBe(true); - }); - - it("returns ParseError for negative integer", () => { - const result = parseSinceSeq("-1"); - expect(isSinceSeqError(result)).toBe(true); - }); + it("returns 0 when undefined", () => { + expect(parseSinceSeq(undefined)).toBe(0); + }); + + it("returns 0 when empty string", () => { + expect(parseSinceSeq("")).toBe(0); + }); + + it("parses valid non-negative integer", () => { + expect(parseSinceSeq("0")).toBe(0); + expect(parseSinceSeq("5")).toBe(5); + expect(parseSinceSeq("42")).toBe(42); + }); + + it("returns ParseError for non-integer string", () => { + const result = parseSinceSeq("abc"); + expect(isSinceSeqError(result)).toBe(true); + if (isSinceSeqError(result)) { + expect(result.error).toContain("sinceSeq"); + } + }); + + it("returns ParseError for float", () => { + const result = parseSinceSeq("3.14"); + expect(isSinceSeqError(result)).toBe(true); + }); + + it("returns ParseError for negative integer", () => { + const result = parseSinceSeq("-1"); + expect(isSinceSeqError(result)).toBe(true); + }); }); describe("parseWindowParam", () => { - it("returns undefined (absent) when undefined", () => { - expect(parseWindowParam(undefined, "limit")).toBeUndefined(); - }); - - it("returns undefined (absent) when empty string", () => { - expect(parseWindowParam("", "limit")).toBeUndefined(); - }); - - it("parses a valid positive integer", () => { - expect(parseWindowParam("1", "limit")).toBe(1); - expect(parseWindowParam("42", "beforeSeq")).toBe(42); - }); - - it("returns ParseError for zero (store would treat it as absent)", () => { - const result = parseWindowParam("0", "limit"); - expect(isWindowParamError(result)).toBe(true); - if (isWindowParamError(result)) { - expect(result.error).toContain("limit"); - expect(result.error).toContain("positive integer"); - } - }); - - it("returns ParseError for a negative integer", () => { - expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true); - }); - - it("returns ParseError for a non-integer", () => { - expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true); - }); - - it("returns ParseError for a non-numeric string", () => { - const result = parseWindowParam("abc", "beforeSeq"); - expect(isWindowParamError(result)).toBe(true); - if (isWindowParamError(result)) { - expect(result.error).toContain("beforeSeq"); - } - }); - - it("names the param in the error message", () => { - const limit = parseWindowParam("0", "limit"); - const before = parseWindowParam("0", "beforeSeq"); - if (isWindowParamError(limit)) expect(limit.error).toContain("limit"); - if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq"); - }); - - it("isWindowParamError is false for absent and for a valid number", () => { - expect(isWindowParamError(undefined)).toBe(false); - expect(isWindowParamError(5)).toBe(false); - }); + it("returns undefined (absent) when undefined", () => { + expect(parseWindowParam(undefined, "limit")).toBeUndefined(); + }); + + it("returns undefined (absent) when empty string", () => { + expect(parseWindowParam("", "limit")).toBeUndefined(); + }); + + it("parses a valid positive integer", () => { + expect(parseWindowParam("1", "limit")).toBe(1); + expect(parseWindowParam("42", "beforeSeq")).toBe(42); + }); + + it("returns ParseError for zero (store would treat it as absent)", () => { + const result = parseWindowParam("0", "limit"); + expect(isWindowParamError(result)).toBe(true); + if (isWindowParamError(result)) { + expect(result.error).toContain("limit"); + expect(result.error).toContain("positive integer"); + } + }); + + it("returns ParseError for a negative integer", () => { + expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true); + }); + + it("returns ParseError for a non-integer", () => { + expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true); + }); + + it("returns ParseError for a non-numeric string", () => { + const result = parseWindowParam("abc", "beforeSeq"); + expect(isWindowParamError(result)).toBe(true); + if (isWindowParamError(result)) { + expect(result.error).toContain("beforeSeq"); + } + }); + + it("names the param in the error message", () => { + const limit = parseWindowParam("0", "limit"); + const before = parseWindowParam("0", "beforeSeq"); + if (isWindowParamError(limit)) expect(limit.error).toContain("limit"); + if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq"); + }); + + it("isWindowParamError is false for absent and for a valid number", () => { + expect(isWindowParamError(undefined)).toBe(false); + expect(isWindowParamError(5)).toBe(false); + }); }); describe("serializeEventLine", () => { - it("serializes an event as JSON followed by newline", () => { - const event: AgentEvent = { - type: "text-delta", - conversationId: "tab1", - turnId: "turn1", - delta: "hello", - }; - const line = serializeEventLine(event); - expect(line).toBe(`${JSON.stringify(event)}\n`); - }); - - it("serializes a done event", () => { - const event: AgentEvent = { - type: "done", - conversationId: "tab1", - turnId: "turn1", - reason: "stop", - }; - const line = serializeEventLine(event); - const parsed = JSON.parse(line.trim()); - expect(parsed.type).toBe("done"); - expect(parsed.reason).toBe("stop"); - }); + it("serializes an event as JSON followed by newline", () => { + const event: AgentEvent = { + type: "text-delta", + conversationId: "tab1", + turnId: "turn1", + delta: "hello", + }; + const line = serializeEventLine(event); + expect(line).toBe(`${JSON.stringify(event)}\n`); + }); + + it("serializes a done event", () => { + const event: AgentEvent = { + type: "done", + conversationId: "tab1", + turnId: "turn1", + reason: "stop", + }; + const line = serializeEventLine(event); + const parsed = JSON.parse(line.trim()); + expect(parsed.type).toBe("done"); + expect(parsed.reason).toBe("stop"); + }); }); describe("computeExpectedCacheRate", () => { - it("returns round(cacheRead/(cacheRead+cacheWrite)*100)", () => { - expect(computeExpectedCacheRate(800, 200)).toBe(80); - }); - - it("returns 0 when cacheRead+cacheWrite is 0", () => { - expect(computeExpectedCacheRate(0, 0)).toBe(0); - }); - - it("returns 100 when all tokens are cacheRead", () => { - expect(computeExpectedCacheRate(500, 0)).toBe(100); - }); - - it("returns 0 when all tokens are cacheWrite", () => { - expect(computeExpectedCacheRate(0, 500)).toBe(0); - }); - - it("rounds to nearest integer", () => { - expect(computeExpectedCacheRate(1, 2)).toBe(33); - expect(computeExpectedCacheRate(2, 1)).toBe(67); - }); + it("returns round(cacheRead/(cacheRead+cacheWrite)*100)", () => { + expect(computeExpectedCacheRate(800, 200)).toBe(80); + }); + + it("returns 0 when cacheRead+cacheWrite is 0", () => { + expect(computeExpectedCacheRate(0, 0)).toBe(0); + }); + + it("returns 100 when all tokens are cacheRead", () => { + expect(computeExpectedCacheRate(500, 0)).toBe(100); + }); + + it("returns 0 when all tokens are cacheWrite", () => { + expect(computeExpectedCacheRate(0, 500)).toBe(0); + }); + + it("rounds to nearest integer", () => { + expect(computeExpectedCacheRate(1, 2)).toBe(33); + expect(computeExpectedCacheRate(2, 1)).toBe(67); + }); }); describe("isValidReasoningEffort", () => { - it("returns true for all valid levels", () => { - expect(isValidReasoningEffort("low")).toBe(true); - expect(isValidReasoningEffort("medium")).toBe(true); - expect(isValidReasoningEffort("high")).toBe(true); - expect(isValidReasoningEffort("xhigh")).toBe(true); - expect(isValidReasoningEffort("max")).toBe(true); - }); - - it("returns false for invalid strings", () => { - expect(isValidReasoningEffort("banana")).toBe(false); - expect(isValidReasoningEffort("")).toBe(false); - expect(isValidReasoningEffort("LOW")).toBe(false); - }); - - it("returns false for non-strings", () => { - expect(isValidReasoningEffort(42)).toBe(false); - expect(isValidReasoningEffort(null)).toBe(false); - expect(isValidReasoningEffort(undefined)).toBe(false); - expect(isValidReasoningEffort(true)).toBe(false); - }); + it("returns true for all valid levels", () => { + expect(isValidReasoningEffort("low")).toBe(true); + expect(isValidReasoningEffort("medium")).toBe(true); + expect(isValidReasoningEffort("high")).toBe(true); + expect(isValidReasoningEffort("xhigh")).toBe(true); + expect(isValidReasoningEffort("max")).toBe(true); + }); + + it("returns false for invalid strings", () => { + expect(isValidReasoningEffort("banana")).toBe(false); + expect(isValidReasoningEffort("")).toBe(false); + expect(isValidReasoningEffort("LOW")).toBe(false); + }); + + it("returns false for non-strings", () => { + expect(isValidReasoningEffort(42)).toBe(false); + expect(isValidReasoningEffort(null)).toBe(false); + expect(isValidReasoningEffort(undefined)).toBe(false); + expect(isValidReasoningEffort(true)).toBe(false); + }); }); describe("parseReasoningEffortBody", () => { - it("returns the level for a valid body", () => { - expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low"); - expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max"); - }); - - it("returns ParseError for missing reasoningEffort", () => { - const result = parseReasoningEffortBody({}); - expect(isReasoningEffortParseError(result)).toBe(true); - if (isReasoningEffortParseError(result)) { - expect(result.error).toContain("reasoningEffort"); - } - }); - - it("returns ParseError for invalid level", () => { - const result = parseReasoningEffortBody({ reasoningEffort: "banana" }); - expect(isReasoningEffortParseError(result)).toBe(true); - if (isReasoningEffortParseError(result)) { - expect(result.error).toContain("reasoningEffort"); - } - }); - - it("returns ParseError for non-object body", () => { - expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true); - expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true); - }); + it("returns the level for a valid body", () => { + expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low"); + expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max"); + }); + + it("returns ParseError for missing reasoningEffort", () => { + const result = parseReasoningEffortBody({}); + expect(isReasoningEffortParseError(result)).toBe(true); + if (isReasoningEffortParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns ParseError for invalid level", () => { + const result = parseReasoningEffortBody({ reasoningEffort: "banana" }); + expect(isReasoningEffortParseError(result)).toBe(true); + if (isReasoningEffortParseError(result)) { + expect(result.error).toContain("reasoningEffort"); + } + }); + + it("returns ParseError for non-object body", () => { + expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true); + expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true); + }); }); describe("parseQueueBody", () => { - it("returns error for null body", () => { - const result = parseQueueBody(null); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("JSON object"); - } - }); - - it("returns error for non-object body", () => { - const result = parseQueueBody("hello"); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when text is missing", () => { - const result = parseQueueBody({}); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("text"); - } - }); - - it("returns error when text is empty string", () => { - const result = parseQueueBody({ text: "" }); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when text is whitespace only", () => { - const result = parseQueueBody({ text: " " }); - expect(isParseError(result)).toBe(true); - }); - - it("returns error when text is not a string", () => { - const result = parseQueueBody({ text: 42 }); - expect(isParseError(result)).toBe(true); - if (isParseError(result)) { - expect(result.error).toContain("text"); - } - }); - - it("returns the trimmed text for a valid body", () => { - const result = parseQueueBody({ text: "hello" }); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.text).toBe("hello"); - } - }); - - it("trims text whitespace", () => { - const result = parseQueueBody({ text: " hello world " }); - expect(isParseError(result)).toBe(false); - if (!isParseError(result)) { - expect(result.text).toBe("hello world"); - } - }); + it("returns error for null body", () => { + const result = parseQueueBody(null); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("JSON object"); + } + }); + + it("returns error for non-object body", () => { + const result = parseQueueBody("hello"); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is missing", () => { + const result = parseQueueBody({}); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("text"); + } + }); + + it("returns error when text is empty string", () => { + const result = parseQueueBody({ text: "" }); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is whitespace only", () => { + const result = parseQueueBody({ text: " " }); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when text is not a string", () => { + const result = parseQueueBody({ text: 42 }); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("text"); + } + }); + + it("returns the trimmed text for a valid body", () => { + const result = parseQueueBody({ text: "hello" }); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.text).toBe("hello"); + } + }); + + it("trims text whitespace", () => { + const result = parseQueueBody({ text: " hello world " }); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.text).toBe("hello world"); + } + }); }); diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts index 4e099c4..c703049 100644 --- a/packages/transport-http/src/logic.ts +++ b/packages/transport-http/src/logic.ts @@ -1,19 +1,19 @@ import type { - AgentEvent, - ChatMessage, - ConversationStatus, - ReasoningEffort, + AgentEvent, + ChatMessage, + ConversationStatus, + ReasoningEffort, } from "@dispatch/kernel"; const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; -const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed"]; +const VALID_STATUSES: readonly ConversationStatus[] = ["active", "queued", "idle", "closed"]; /** * Pure: parse a `?status=` query value into a list of valid ConversationStatus @@ -22,43 +22,60 @@ const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed * `undefined` (no filter — shows all). */ export function parseStatusFilter( - raw: string | undefined, + raw: string | undefined, ): readonly ConversationStatus[] | undefined { - if (raw === undefined) return undefined; - const trimmed = raw.trim(); - if (trimmed.length === 0) return undefined; - const parts = trimmed - .split(",") - .map((s) => s.trim()) - .filter((s) => s.length > 0); - const valid = parts.filter((p): p is ConversationStatus => - VALID_STATUSES.includes(p as ConversationStatus), - ); - return valid.length > 0 ? valid : undefined; + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const parts = trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const valid = parts.filter((p): p is ConversationStatus => + VALID_STATUSES.includes(p as ConversationStatus), + ); + return valid.length > 0 ? valid : undefined; } export function isValidReasoningEffort(value: unknown): value is ReasoningEffort { - return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); + return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort); } export interface ChatCommand { - readonly conversationId: string; - readonly message: string; - readonly model?: string; - readonly cwd?: string; - /** - * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded - * to the orchestrator verbatim and never part of the model prompt. When - * absent, the orchestrator resolves the per-conversation → workspace - * default → local chain. - */ - readonly computerId?: string; - readonly reasoningEffort?: ReasoningEffort; - readonly workspaceId?: string; + readonly conversationId: string; + readonly message: string; + readonly model?: string; + readonly cwd?: string; + /** + * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded + * to the orchestrator verbatim and never part of the model prompt. When + * absent, the orchestrator resolves the per-conversation → workspace + * default → local chain. + */ + readonly computerId?: string; + readonly reasoningEffort?: ReasoningEffort; + readonly workspaceId?: string; + /** + * A human-readable title for the conversation tab, set at creation time. + * Parsed from the `ChatRequest.title` field; trimmed server-side. A + * whitespace-only value is treated as absent (omitted) so the auto-derived + * title applies. Forwarded to the orchestrator, which persists it via the + * conversation store's `setConversationTitle` AFTER the new-conversation + * workspace setup (so workspace assignment / first-turn system-prompt + * construction are not skipped) and before the first message append. + */ + readonly title?: string; + /** + * Images attached to this turn (data URLs or http URLs). Parsed from the + * `ChatRequest.images` field; forwarded to the orchestrator which converts + * them to `image` chunks on the user message. Each entry must have a non-empty + * string `url`; `mimeType` is optional. + */ + readonly images?: readonly { readonly url: string; readonly mimeType?: string }[]; } export interface ParseError { - readonly error: string; + readonly error: string; } export type ParseResult = ChatCommand | ParseError; @@ -66,83 +83,122 @@ export type ParseResult = ChatCommand | ParseError; export type SinceSeqResult = number | ParseError; export function parseChatBody(body: unknown, generateId: () => string): ParseResult { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record<string, unknown>; - - const message = obj.message; - if (typeof message !== "string" || message.trim().length === 0) { - return { error: "Field 'message' is required and must be a non-empty string" }; - } - - const conversationId = - typeof obj.conversationId === "string" && obj.conversationId.length > 0 - ? obj.conversationId - : generateId(); - - const result: ChatCommand = { conversationId, message: message.trim() }; - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return { error: "Field 'model' must be a string" }; - } - (result as { model?: string }).model = obj.model; - } - - if (obj.cwd !== undefined) { - if (typeof obj.cwd !== "string") { - return { error: "Field 'cwd' must be a string" }; - } - (result as { cwd?: string }).cwd = obj.cwd; - } - - if (obj.computerId !== undefined) { - if (typeof obj.computerId !== "string") { - return { error: "Field 'computerId' must be a string" }; - } - (result as { computerId?: string }).computerId = obj.computerId; - } - - if (obj.reasoningEffort !== undefined) { - if (!isValidReasoningEffort(obj.reasoningEffort)) { - return { - error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, - }; - } - (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; - } - - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string") { - return { error: "Field 'workspaceId' must be a string" }; - } - (result as { workspaceId?: string }).workspaceId = obj.workspaceId; - } - - return result; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record<string, unknown>; + + const message = obj.message; + if (typeof message !== "string" || message.trim().length === 0) { + return { error: "Field 'message' is required and must be a non-empty string" }; + } + + const conversationId = + typeof obj.conversationId === "string" && obj.conversationId.length > 0 + ? obj.conversationId + : generateId(); + + const result: ChatCommand = { conversationId, message: message.trim() }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + (result as { model?: string }).model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + (result as { cwd?: string }).cwd = obj.cwd; + } + + if (obj.computerId !== undefined) { + if (typeof obj.computerId !== "string") { + return { error: "Field 'computerId' must be a string" }; + } + (result as { computerId?: string }).computerId = obj.computerId; + } + + if (obj.reasoningEffort !== undefined) { + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort; + } + + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string") { + return { error: "Field 'workspaceId' must be a string" }; + } + (result as { workspaceId?: string }).workspaceId = obj.workspaceId; + } + + if (obj.title !== undefined) { + if (typeof obj.title !== "string") { + return { error: "Field 'title' must be a string" }; + } + const title = obj.title.trim(); + // A whitespace-only title is treated as absent so the auto-derived title + // applies (mirrors omitting the field) — never persist an empty title. + if (title.length > 0) { + (result as { title?: string }).title = title; + } + } + + if (obj.images !== undefined) { + if (!Array.isArray(obj.images)) { + return { error: "Field 'images' must be an array" }; + } + const images: { url: string; mimeType?: string }[] = []; + for (const entry of obj.images) { + if (entry === null || typeof entry !== "object") { + return { error: "Each image must be an object with a 'url' string" }; + } + const img = entry as { url?: unknown; mimeType?: unknown }; + if (typeof img.url !== "string" || img.url.length === 0) { + return { error: "Each image must have a non-empty string 'url'" }; + } + const parsed: { url: string; mimeType?: string } = { url: img.url }; + if (img.mimeType !== undefined) { + if (typeof img.mimeType !== "string") { + return { error: "Field 'mimeType' on an image must be a string" }; + } + parsed.mimeType = img.mimeType; + } + images.push(parsed); + } + if (images.length > 0) { + (result as { images?: readonly { url: string; mimeType?: string }[] }).images = images; + } + } + + return result; } export function isParseError<T>(result: T | ParseError): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } export function serializeEventLine(event: AgentEvent): string { - return `${JSON.stringify(event)}\n`; + return `${JSON.stringify(event)}\n`; } export function parseSinceSeq(raw: string | undefined): SinceSeqResult { - if (raw === undefined || raw === "") return 0; - const n = Number(raw); - if (!Number.isInteger(n) || n < 0) { - return { error: "sinceSeq must be a non-negative integer" }; - } - return n; + if (raw === undefined || raw === "") return 0; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + return { error: "sinceSeq must be a non-negative integer" }; + } + return n; } export function isSinceSeqError(result: SinceSeqResult): result is ParseError { - return typeof result === "object"; + return typeof result === "object"; } /** @@ -160,67 +216,67 @@ export type WindowParamResult = number | undefined | ParseError; * Absent (`undefined` / empty) is the valid "no window" case → `undefined`. */ export function parseWindowParam(raw: string | undefined, name: string): WindowParamResult { - if (raw === undefined || raw === "") return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n <= 0) { - return { error: `${name} must be a positive integer` }; - } - return n; + if (raw === undefined || raw === "") return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + return { error: `${name} must be a positive integer` }; + } + return n; } export function isWindowParamError(result: WindowParamResult): result is ParseError { - return typeof result === "object" && result !== null; + return typeof result === "object" && result !== null; } export interface WarmBodyParsed { - readonly conversationId: string; - readonly model?: string; - readonly cwd?: string; + readonly conversationId: string; + readonly model?: string; + readonly cwd?: string; } export function parseWarmBody(body: unknown): WarmBodyParsed | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - - const obj = body as Record<string, unknown>; - - const conversationId = obj.conversationId; - if (typeof conversationId !== "string" || conversationId.length === 0) { - return { error: "Field 'conversationId' is required and must be a non-empty string" }; - } - - const result: Record<string, unknown> = { conversationId }; - - if (obj.model !== undefined) { - if (typeof obj.model !== "string") { - return { error: "Field 'model' must be a string" }; - } - result.model = obj.model; - } - - if (obj.cwd !== undefined) { - if (typeof obj.cwd !== "string") { - return { error: "Field 'cwd' must be a string" }; - } - result.cwd = obj.cwd; - } - - return result as unknown as WarmBodyParsed; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record<string, unknown>; + + const conversationId = obj.conversationId; + if (typeof conversationId !== "string" || conversationId.length === 0) { + return { error: "Field 'conversationId' is required and must be a non-empty string" }; + } + + const result: Record<string, unknown> = { conversationId }; + + if (obj.model !== undefined) { + if (typeof obj.model !== "string") { + return { error: "Field 'model' must be a string" }; + } + result.model = obj.model; + } + + if (obj.cwd !== undefined) { + if (typeof obj.cwd !== "string") { + return { error: "Field 'cwd' must be a string" }; + } + result.cwd = obj.cwd; + } + + return result as unknown as WarmBodyParsed; } export function computeCachePct(inputTokens: number, cacheReadTokens: number): number { - if (inputTokens <= 0) return 0; - return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); + if (inputTokens <= 0) return 0; + return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100); } export function computeExpectedCacheRate( - cacheReadTokens: number, - cacheWriteTokens: number, + cacheReadTokens: number, + cacheWriteTokens: number, ): number { - const denom = cacheReadTokens + cacheWriteTokens; - if (denom <= 0) return 0; - return Math.round((cacheReadTokens / denom) * 100); + const denom = cacheReadTokens + cacheWriteTokens; + if (denom <= 0) return 0; + return Math.round((cacheReadTokens / denom) * 100); } /** @@ -229,8 +285,8 @@ export function computeExpectedCacheRate( * is deliberately NOT part of this parse result. */ export interface QueueBodyParsed { - readonly text: string; - readonly workspaceId?: string; + readonly text: string; + readonly workspaceId?: string; } /** @@ -241,46 +297,46 @@ export interface QueueBodyParsed { * `message`. */ export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } - const obj = body as Record<string, unknown>; + const obj = body as Record<string, unknown>; - const text = obj.text; - if (typeof text !== "string" || text.trim().length === 0) { - return { error: "Field 'text' is required and must be a non-empty string" }; - } + const text = obj.text; + if (typeof text !== "string" || text.trim().length === 0) { + return { error: "Field 'text' is required and must be a non-empty string" }; + } - const result: QueueBodyParsed = { text: text.trim() }; + const result: QueueBodyParsed = { text: text.trim() }; - if (obj.workspaceId !== undefined) { - if (typeof obj.workspaceId !== "string") { - return { error: "Field 'workspaceId' must be a string" }; - } - return { text: text.trim(), workspaceId: obj.workspaceId }; - } + if (obj.workspaceId !== undefined) { + if (typeof obj.workspaceId !== "string") { + return { error: "Field 'workspaceId' must be a string" }; + } + return { text: text.trim(), workspaceId: obj.workspaceId }; + } - return result; + return result; } export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - const obj = body as Record<string, unknown>; - if (!isValidReasoningEffort(obj.reasoningEffort)) { - return { - error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, - }; - } - return obj.reasoningEffort; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record<string, unknown>; + if (!isValidReasoningEffort(obj.reasoningEffort)) { + return { + error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`, + }; + } + return obj.reasoningEffort; } export function isReasoningEffortParseError( - result: ReasoningEffort | ParseError, + result: ReasoningEffort | ParseError, ): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } /** @@ -293,21 +349,21 @@ export function isReasoningEffortParseError( * Returns the validated `model` value (`string | null`) on success. */ export function parseModelBody(body: unknown): string | null | ParseError { - if (body === null || typeof body !== "object") { - return { error: "Request body must be a JSON object" }; - } - const obj = body as Record<string, unknown>; - if (obj.model === undefined) { - return { error: "Field 'model' is required and must be a string or null" }; - } - if (obj.model !== null && typeof obj.model !== "string") { - return { error: "Field 'model' must be a string or null" }; - } - return obj.model as string | null; + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + const obj = body as Record<string, unknown>; + if (obj.model === undefined) { + return { error: "Field 'model' is required and must be a string or null" }; + } + if (obj.model !== null && typeof obj.model !== "string") { + return { error: "Field 'model' must be a string or null" }; + } + return obj.model as string | null; } export function isModelParseError(result: string | null | ParseError): result is ParseError { - return typeof result === "object" && result !== null && "error" in result; + return typeof result === "object" && result !== null && "error" in result; } /** @@ -322,20 +378,20 @@ export function isModelParseError(result: string | null | ParseError): result is * Pure (input → output); zero I/O, so it tests directly without mocks. */ export function extractLastAssistantText(messages: readonly ChatMessage[]): string { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg === undefined || msg.role !== "assistant") continue; - // Found the last assistant message — scan its chunks from the end for - // the last `text` chunk. Stop here (do not keep scanning earlier - // assistant messages): the contract is "the last assistant message's - // last text chunk", not "the most recent text chunk anywhere". - for (let j = msg.chunks.length - 1; j >= 0; j--) { - const chunk = msg.chunks[j]; - if (chunk !== undefined && chunk.type === "text") { - return chunk.text; - } - } - return ""; - } - return ""; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg === undefined || msg.role !== "assistant") continue; + // Found the last assistant message — scan its chunks from the end for + // the last `text` chunk. Stop here (do not keep scanning earlier + // assistant messages): the contract is "the last assistant message's + // last text chunk", not "the most recent text chunk anywhere". + for (let j = msg.chunks.length - 1; j >= 0; j--) { + const chunk = msg.chunks[j]; + if (chunk !== undefined && chunk.type === "text") { + return chunk.text; + } + } + return ""; + } + return ""; } diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index ef28a09..dcb3f80 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -6,20 +6,24 @@ export type { ConversationStore } from "@dispatch/conversation-store"; export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store"; export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; +export type { HeartbeatService } from "@dispatch/heartbeat"; +export { heartbeatServiceHandle } from "@dispatch/heartbeat"; export type { LspServerStatus, LspService } from "@dispatch/lsp"; export { lspServiceHandle } from "@dispatch/lsp"; export type { McpServerStatus, McpService } from "@dispatch/mcp"; export { mcpServiceHandle } from "@dispatch/mcp"; +export type { ConcurrencyService } from "@dispatch/provider-concurrency"; +export { concurrencyServiceHandle } from "@dispatch/provider-concurrency"; export type { - CompactionService, - SessionOrchestrator, - WarmService, + CompactionService, + SessionOrchestrator, + WarmService, } from "@dispatch/session-orchestrator"; export { - cacheWarmHandle, - compactionHandle, - conversationOpened, - sessionOrchestratorHandle, + cacheWarmHandle, + compactionHandle, + conversationOpened, + sessionOrchestratorHandle, } from "@dispatch/session-orchestrator"; export type { SystemPromptService } from "@dispatch/system-prompt"; export { systemPromptHandle } from "@dispatch/system-prompt"; @@ -45,14 +49,14 @@ export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughpu * — an ABSENT service (ssh extension not loaded) is the graceful-degrade path. */ export interface ComputerService { - /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */ - readonly listComputers: () => Promise<readonly ComputerEntry[]>; - /** One computer by alias, or `null` when the alias isn't in the config. */ - readonly getComputer: (alias: string) => Promise<Computer | null>; - /** Live connection state for a computer alias. */ - readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>; - /** One-shot connectivity probe (open, run a trivial command, close). */ - readonly test: (alias: string) => Promise<TestComputerResponse>; + /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */ + readonly listComputers: () => Promise<readonly ComputerEntry[]>; + /** One computer by alias, or `null` when the alias isn't in the config. */ + readonly getComputer: (alias: string) => Promise<Computer | null>; + /** Live connection state for a computer alias. */ + readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>; + /** One-shot connectivity probe (open, run a trivial command, close). */ + readonly test: (alias: string) => Promise<TestComputerResponse>; } /** @@ -60,4 +64,4 @@ export interface ComputerService { * consume. Mirrors `lspServiceHandle` / `mcpServiceHandle`. */ export const computerServiceHandle: ServiceHandle<ComputerService> = - defineService<ComputerService>("ssh"); + defineService<ComputerService>("ssh"); diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index f552507..f93e259 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -3,305 +3,305 @@ import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel"; import { createApp } from "./app.js"; import { createTransportHttpExtension } from "./index.js"; import type { - ConversationStore, - CredentialStore, - LspService, - SessionOrchestrator, + ConversationStore, + CredentialStore, + LspService, + SessionOrchestrator, } from "./seam.js"; function fakeLogger(): Logger { - return { - debug() {}, - info() {}, - warn() {}, - error() {}, - child() { - return fakeLogger(); - }, - span() { - return { - id: "fake-span", - log: fakeLogger(), - setAttributes() {}, - addLink() {}, - child() { - return this; - }, - end() {}, - }; - }, - }; + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return fakeLogger(); + }, + span() { + return { + id: "fake-span", + log: fakeLogger(), + setAttributes() {}, + addLink() {}, + child() { + return this; + }, + end() {}, + }; + }, + }; } function fakeConversationStore(): ConversationStore { - return { - async append() {}, - async load() { - return []; - }, - async loadSince() { - return []; - }, - async appendMetrics() {}, - async loadMetrics() { - return []; - }, - async getCwd() { - return null; - }, - async setCwd() {}, - 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, createdAt: 0, lastActivityAt: 0 }; - }, - async setWorkspaceTitle() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; - }, - async setWorkspaceDefaultCwd() { - return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; - }, - async deleteWorkspace() { - return { closedCount: 0 }; - }, - async listWorkspaces() { - return []; - }, - async getWorkspaceId() { - return "default"; - }, - async setWorkspaceId() {}, - async getEffectiveCwd() { - return null; - }, - }; + return { + async append() {}, + async load() { + return []; + }, + async loadSince() { + return []; + }, + async appendMetrics() {}, + async loadMetrics() { + return []; + }, + async getCwd() { + return null; + }, + async setCwd() {}, + 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, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceTitle() { + return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async setWorkspaceDefaultCwd() { + return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 }; + }, + async deleteWorkspace() { + return { closedCount: 0 }; + }, + async listWorkspaces() { + return []; + }, + async getWorkspaceId() { + return "default"; + }, + async setWorkspaceId() {}, + async getEffectiveCwd() { + return null; + }, + }; } function fakeOrchestrator(): 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() {}, - }; + 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() {}, + }; } function fakeCredentialStore(): CredentialStore { - return { - resolve() { - return undefined; - }, - async getModelInfo() { - return undefined; - }, - async listCatalog() { - return []; - }, - }; + return { + resolve() { + return undefined; + }, + async getModelInfo() { + return undefined; + }, + async listCatalog() { + return []; + }, + }; } function fakeLspService(): LspService { - return { - async status() { - return []; - }, - }; + return { + async status() { + return []; + }, + }; } function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess { - return { - get<T>(key: string): T | undefined { - return overrides[key] as T | undefined; - }, - getAll() { - return overrides; - }, - }; + return { + get<T>(key: string): T | undefined { + return overrides[key] as T | undefined; + }, + getAll() { + return overrides; + }, + }; } const SERVICES = new Map<string, unknown>([ - ["conversation-store/store", fakeConversationStore()], - ["session-orchestrator/orchestrator", fakeOrchestrator()], - ["credential-store/registry", fakeCredentialStore()], - ["lsp", fakeLspService()], + ["conversation-store/store", fakeConversationStore()], + ["session-orchestrator/orchestrator", fakeOrchestrator()], + ["credential-store/registry", fakeCredentialStore()], + ["lsp", fakeLspService()], ]); function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI { - return { - defineTool() {}, - defineProvider() {}, - defineAuth() {}, - on() { - return () => {}; - }, - emit() {}, - addFilter() { - return () => {}; - }, - async applyFilters(_hook, value) { - return value; - }, - provideService() {}, - getService(handle) { - return SERVICES.get(handle.id) as never; - }, - storage() { - return { - get: async () => null, - set: async () => {}, - delete: async () => {}, - has: async () => false, - keys: async () => [], - }; - }, - config: fakeConfig(configOverrides), - secrets: { get: async () => null, set: async () => {}, delete: async () => {} }, - permissions: { check: async () => ({ allowed: true }) }, - events: { emit() {} }, - logger: fakeLogger(), - getProviders() { - return new Map(); - }, - getTools() { - return new Map(); - }, - getAuthProviders() { - return new Map(); - }, - getAuthProvider() { - return undefined; - }, - getExtensions() { - return []; - }, - scheduler: { register() {} }, - }; + return { + defineTool() {}, + defineProvider() {}, + defineAuth() {}, + on() { + return () => {}; + }, + emit() {}, + addFilter() { + return () => {}; + }, + async applyFilters(_hook, value) { + return value; + }, + provideService() {}, + getService(handle) { + return SERVICES.get(handle.id) as never; + }, + storage() { + return { + get: async () => null, + set: async () => {}, + delete: async () => {}, + has: async () => false, + keys: async () => [], + }; + }, + config: fakeConfig(configOverrides), + secrets: { get: async () => null, set: async () => {}, delete: async () => {} }, + permissions: { check: async () => ({ allowed: true }) }, + events: { emit() {} }, + logger: fakeLogger(), + getProviders() { + return new Map(); + }, + getTools() { + return new Map(); + }, + getAuthProviders() { + return new Map(); + }, + getAuthProvider() { + return undefined; + }, + getExtensions() { + return []; + }, + scheduler: { register() {} }, + }; } // ── Server helper (mirrors extension.ts logic for direct testing) ──────── function startServer(port = 0) { - const app = createApp({ - conversationStore: fakeConversationStore(), - orchestrator: fakeOrchestrator(), - credentialStore: fakeCredentialStore(), - }); - return Bun.serve({ port, fetch: app.fetch }); + const app = createApp({ + conversationStore: fakeConversationStore(), + orchestrator: fakeOrchestrator(), + credentialStore: fakeCredentialStore(), + }); + return Bun.serve({ port, fetch: app.fetch }); } // ── Tests ──────────────────────────────────────────────────────────────── describe("serves HTTP on the configured port", () => { - let server: ReturnType<typeof Bun.serve>; - let port: number; + let server: ReturnType<typeof Bun.serve>; + let port: number; - afterEach(() => { - server.stop(); - }); + afterEach(() => { + server.stop(); + }); - test("GET /health returns 200", async () => { - server = startServer(); - port = server.port as number; + test("GET /health returns 200", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/health`); - expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); - }); + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean }; + expect(body.ok).toBe(true); + }); - test("POST /chat returns NDJSON", async () => { - server = startServer(); - port = server.port as number; + test("POST /chat returns NDJSON", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/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"); - }); + const res = await fetch(`http://localhost:${port}/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"); + }); - test("GET /models returns 200", async () => { - server = startServer(); - port = server.port as number; + test("GET /models returns 200", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/models`); - expect(res.status).toBe(200); - const body = (await res.json()) as { models: readonly string[] }; - expect(body.models).toEqual([]); - }); + const res = await fetch(`http://localhost:${port}/models`); + expect(res.status).toBe(200); + const body = (await res.json()) as { models: readonly string[] }; + expect(body.models).toEqual([]); + }); - test("GET /conversations/:id returns 200", async () => { - server = startServer(); - port = server.port as number; + test("GET /conversations/:id returns 200", async () => { + server = startServer(); + port = server.port as number; - const res = await fetch(`http://localhost:${port}/conversations/conv1`); - expect(res.status).toBe(200); - const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number }; - expect(body.chunks).toEqual([]); - expect(body.latestSeq).toBe(0); - }); + const res = await fetch(`http://localhost:${port}/conversations/conv1`); + expect(res.status).toBe(200); + const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number }; + expect(body.chunks).toEqual([]); + expect(body.latestSeq).toBe(0); + }); }); describe("extension lifecycle", () => { - test("activate starts server on config port and deactivate stops it", async () => { - const ext = createTransportHttpExtension(); - const host = createFakeHostAPI({ httpPort: 0 }); - await ext.activate(host); - const server = ext._testServer; - expect(server).toBeDefined(); - const port = server?.port as number; - expect(port).toBeGreaterThan(0); + test("activate starts server on config port and deactivate stops it", async () => { + const ext = createTransportHttpExtension(); + const host = createFakeHostAPI({ httpPort: 0 }); + await ext.activate(host); + const server = ext._testServer; + expect(server).toBeDefined(); + const port = server?.port as number; + expect(port).toBeGreaterThan(0); - const res = await fetch(`http://localhost:${port}/health`); - expect(res.status).toBe(200); + const res = await fetch(`http://localhost:${port}/health`); + expect(res.status).toBe(200); - ext.deactivate?.(); + ext.deactivate?.(); - try { - await fetch(`http://localhost:${port}/health`); - expect(true).toBe(false); - } catch { - // expected — server stopped - } - }); + try { + await fetch(`http://localhost:${port}/health`); + expect(true).toBe(false); + } catch { + // expected — server stopped + } + }); }); |
