diff options
Diffstat (limited to 'packages/transport-http/src/app.test.ts')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 8787 |
1 files changed, 4676 insertions, 4111 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(); + }); }); |
