import type { 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, } 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, } from "./seam.js"; import { conversationOpened } from "./seam.js"; function createMemStorage(): StorageNamespace { const map = new Map(); 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; } 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() {}, }; }, }; } function createFakeConversationStore( store: Map = new Map(), metricsStore: Map = new Map(), cwdStore: Map = new Map(), reasoningEffortStore: Map = new Map(), modelStore: Map = new Map(), computerStore: Map = 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; }, }; } /** * Wraps a ConversationStore to record the ORDER of mutating calls * (ensureWorkspace, setWorkspaceId, setCwd) as a labeled list — so tests can * assert that workspace assignment happens BEFORE setCwd. */ function createCallTrackingStore( 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); }, }; } 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); } }, }; } function createCapturingOrchestrator(): SessionOrchestrator & { received: Parameters[0] | undefined; } { const state: { received: Parameters[0] | undefined; } = { received: undefined }; return { get received() { return state.received; }, startTurn() { return { started: true, turnId: "fake-turn" }; }, subscribe() { return () => {}; }, isActive() { return false; }, enqueue() { return { startedTurn: false, queue: [] }; }, closeConversation() { return { abortedTurn: false }; }, stopTurn() { return { abortedTurn: false }; }, async handleMessage(input) { state.received = input; }, }; } function createThrowingOrchestrator(error: Error): SessionOrchestrator { return { startTurn() { return { started: true, turnId: "fake-turn" }; }, subscribe() { return () => {}; }, isActive() { return false; }, enqueue() { return { startedTurn: false, queue: [] }; }, closeConversation() { return { abortedTurn: false }; }, stopTurn() { return { abortedTurn: false }; }, async handleMessage() { throw error; }, }; } function createFakeCredentialStore(models: string[]): CredentialStore { 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; }, }; } function createFakeWarmService( result: | { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; } | { error: string }, ): WarmService { 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; }[] = [], ): LspService { 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; }[] = [], ): LspService & { readonly statusCalls: readonly string[] } { 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; }[] = [], ): McpService { return { async status() { return statuses; }, }; } function createCapturingMcpService( 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; }, }; } function createFakeSystemPromptService( template: string = "custom template", ): SystemPromptService & { 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; }, }; } function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService { const byAlias = new Map(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 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 }); }); }); 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"); }); }); 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(); }); }); 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"); }); }); 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([["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([["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([["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([["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) { 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([["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([["conv1", sampleChunks]]); const metricsStore = new Map([["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); }); }); 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([["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"); }); }); 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, events: AgentEvent[], ) { return createApp({ conversationStore: createFakeConversationStore(), orchestrator: createFakeOrchestrator(events), credentialStore: createFakeCredentialStore([]), throughputStore, now: () => ts, }); } async function postChat(app: ReturnType, body: Record) { 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 }); }); }); 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"); }); }); 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(); }); }); 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"); }); }); 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([ ["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); }); }); 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([["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(); // 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([["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([["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[]; }; 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(); // 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([["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([["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([["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); }); }); 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(); }); }); 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); }); }); 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(); }); }); 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([["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([["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"); }); }); describe("GET /conversations/:id/last", () => { function appWithMessages(messagesByConv: Map) { 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"); }); }); 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"); }); }); 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(""); }); }); 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); }); }); 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"); }); it("GET /conversations?workspaceId= filters", async () => { const calls: Parameters[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"); }); 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); }); }); 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"); }); }); 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:. 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, }; 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); }); }); 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); }); }); 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); }); }); 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"); }); }); 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"); }); }); 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(); }); }); 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([ ["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); }); }); 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(); }); }); 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(); }); });