summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src/app.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/transport-http/src/app.test.ts')
-rw-r--r--packages/transport-http/src/app.test.ts8787
1 files changed, 4676 insertions, 4111 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 4f64ece..557fb44 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -1,227 +1,234 @@
+import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat";
import type {
- AgentEvent,
- ChatMessage,
- ConversationMeta,
- HostAPI,
- Logger,
- ReasoningEffort,
- StepId,
- StorageNamespace,
- StoredChunk,
- TurnMetrics,
+ AgentEvent,
+ ChatMessage,
+ ConversationMeta,
+ HostAPI,
+ Logger,
+ ReasoningEffort,
+ StepId,
+ StorageNamespace,
+ StoredChunk,
+ TurnMetrics,
} from "@dispatch/kernel";
import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
- DeleteWorkspaceResponse,
- QueuedMessage,
- QueueResponse,
- SystemPromptVariable,
- ThroughputResponse,
- WorkspaceListResponse,
- WorkspaceResponse,
+ DeleteWorkspaceResponse,
+ QueueCancelResponse,
+ QueuedMessage,
+ QueueResponse,
+ SystemPromptVariable,
+ ThroughputResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { extractLastAssistantText } from "./logic.js";
import type {
- ComputerService,
- ConversationStore,
- CredentialStore,
- LspService,
- McpService,
- SessionOrchestrator,
- SystemPromptService,
- WarmService,
+ ComputerService,
+ ConversationStore,
+ CredentialStore,
+ HeartbeatService,
+ LspService,
+ McpService,
+ SessionOrchestrator,
+ SystemPromptService,
+ WarmService,
} from "./seam.js";
import { conversationOpened } from "./seam.js";
function createMemStorage(): StorageNamespace {
- const map = new Map<string, string>();
- return {
- get: async (k) => map.get(k) ?? null,
- set: async (k, v) => {
- map.set(k, v);
- },
- delete: async (k) => {
- map.delete(k);
- },
- has: async (k) => map.has(k),
- keys: async (prefix) =>
- [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
- };
+ const map = new Map<string, string>();
+ return {
+ get: async (k) => map.get(k) ?? null,
+ set: async (k, v) => {
+ map.set(k, v);
+ },
+ delete: async (k) => {
+ map.delete(k);
+ },
+ has: async (k) => map.has(k),
+ keys: async (prefix) =>
+ [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
+ };
}
interface CapturedLog {
- readonly level: "debug" | "info" | "warn" | "error";
- readonly msg: string;
- readonly attrs?: Record<string, unknown>;
+ readonly level: "debug" | "info" | "warn" | "error";
+ readonly msg: string;
+ readonly attrs?: Record<string, unknown>;
}
function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[] } {
- const records: CapturedLog[] = [];
- return {
- get records() {
- return records;
- },
- debug(msg, attrs) {
- records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) });
- },
- info(msg, attrs) {
- records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) });
- },
- warn(msg, attrs) {
- records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) });
- },
- error(msg, attrs) {
- records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) });
- },
- child() {
- return createFakeLogger();
- },
- span() {
- return {
- id: "fake-span",
- log: createFakeLogger(),
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
- };
+ const records: CapturedLog[] = [];
+ return {
+ get records() {
+ return records;
+ },
+ debug(msg, attrs) {
+ records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) });
+ },
+ info(msg, attrs) {
+ records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) });
+ },
+ warn(msg, attrs) {
+ records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) });
+ },
+ error(msg, attrs) {
+ records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) });
+ },
+ child() {
+ return createFakeLogger();
+ },
+ span() {
+ return {
+ id: "fake-span",
+ log: createFakeLogger(),
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
+ };
}
function createFakeConversationStore(
- store: Map<string, StoredChunk[]> = new Map(),
- metricsStore: Map<string, TurnMetrics[]> = new Map(),
- cwdStore: Map<string, string> = new Map(),
- reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
- modelStore: Map<string, string> = new Map(),
- computerStore: Map<string, string> = new Map(),
+ store: Map<string, StoredChunk[]> = new Map(),
+ metricsStore: Map<string, TurnMetrics[]> = new Map(),
+ cwdStore: Map<string, string> = new Map(),
+ reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
+ modelStore: Map<string, string> = new Map(),
+ computerStore: Map<string, string> = new Map(),
): ConversationStore {
- const sampleWorkspace = {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- return {
- async append() {},
- async load() {
- return [];
- },
- async loadSince(conversationId, sinceSeq, window) {
- const chunks = store.get(conversationId) ?? [];
- const minSeq = sinceSeq ?? 0;
- const beforeSeq = window?.beforeSeq;
- const limit = window?.limit;
- const selected = chunks.filter(
- (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq),
- );
- // Window: keep only the NEWEST `limit`, still ascending by seq.
- if (limit !== undefined && selected.length > limit) {
- return selected.slice(selected.length - limit);
- }
- return selected;
- },
- async appendMetrics() {},
- async loadMetrics(conversationId) {
- return metricsStore.get(conversationId) ?? [];
- },
- async getCwd(conversationId) {
- return cwdStore.get(conversationId) ?? null;
- },
- async setCwd(conversationId, cwd) {
- cwdStore.set(conversationId, cwd);
- },
- async clearCwd(conversationId) {
- cwdStore.delete(conversationId);
- },
- async getComputerId(conversationId) {
- return computerStore.get(conversationId) ?? null;
- },
- async setComputerId(conversationId, alias) {
- if (alias === null) {
- computerStore.delete(conversationId);
- } else {
- computerStore.set(conversationId, alias);
- }
- },
- async clearComputerId(conversationId) {
- computerStore.delete(conversationId);
- },
- async getReasoningEffort(conversationId) {
- return reasoningEffortStore.get(conversationId) ?? null;
- },
- async setReasoningEffort(conversationId, effort) {
- reasoningEffortStore.set(conversationId, effort);
- },
- async getModel(conversationId) {
- return modelStore.get(conversationId) ?? null;
- },
- async setModel(conversationId, model) {
- if (model === "") {
- modelStore.delete(conversationId);
- } else {
- modelStore.set(conversationId, model);
- }
- },
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return sampleWorkspace;
- },
- async setWorkspaceTitle() {
- return sampleWorkspace;
- },
- async setWorkspaceDefaultCwd() {
- return sampleWorkspace;
- },
- async setWorkspaceDefaultComputerId(id, defaultComputerId) {
- return { ...sampleWorkspace, id, defaultComputerId };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd(conversationId) {
- return cwdStore.get(conversationId) ?? null;
- },
- async getEffectiveComputer(conversationId) {
- return computerStore.get(conversationId) ?? null;
- },
- };
+ const sampleWorkspace = {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ return {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince(conversationId, sinceSeq, window) {
+ const chunks = store.get(conversationId) ?? [];
+ const minSeq = sinceSeq ?? 0;
+ const beforeSeq = window?.beforeSeq;
+ const limit = window?.limit;
+ const selected = chunks.filter(
+ (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq),
+ );
+ // Window: keep only the NEWEST `limit`, still ascending by seq.
+ if (limit !== undefined && selected.length > limit) {
+ return selected.slice(selected.length - limit);
+ }
+ return selected;
+ },
+ async appendMetrics() {},
+ async loadMetrics(conversationId) {
+ return metricsStore.get(conversationId) ?? [];
+ },
+ async getCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
+ async setCwd(conversationId, cwd) {
+ cwdStore.set(conversationId, cwd);
+ },
+ async clearCwd(conversationId) {
+ cwdStore.delete(conversationId);
+ },
+ async getComputerId(conversationId) {
+ return computerStore.get(conversationId) ?? null;
+ },
+ async setComputerId(conversationId, alias) {
+ if (alias === null) {
+ computerStore.delete(conversationId);
+ } else {
+ computerStore.set(conversationId, alias);
+ }
+ },
+ async clearComputerId(conversationId) {
+ computerStore.delete(conversationId);
+ },
+ async getReasoningEffort(conversationId) {
+ return reasoningEffortStore.get(conversationId) ?? null;
+ },
+ async setReasoningEffort(conversationId, effort) {
+ reasoningEffortStore.set(conversationId, effort);
+ },
+ async getModel(conversationId) {
+ return modelStore.get(conversationId) ?? null;
+ },
+ async setModel(conversationId, model) {
+ if (model === "") {
+ modelStore.delete(conversationId);
+ } else {
+ modelStore.set(conversationId, model);
+ }
+ },
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return sampleWorkspace;
+ },
+ async setWorkspaceTitle() {
+ return sampleWorkspace;
+ },
+ async setWorkspaceDefaultCwd() {
+ return sampleWorkspace;
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...sampleWorkspace, id, defaultComputerId };
+ },
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
+ async getEffectiveComputer(conversationId) {
+ return computerStore.get(conversationId) ?? null;
+ },
+ };
}
/**
@@ -230,4103 +237,4661 @@ function createFakeConversationStore(
* assert that workspace assignment happens BEFORE setCwd.
*/
function createCallTrackingStore(
- base: ConversationStore,
+ base: ConversationStore,
): ConversationStore & { readonly calls: readonly string[] } {
- const calls: string[] = [];
- return {
- ...base,
- get calls() {
- return calls;
- },
- async ensureWorkspace(id, opts) {
- calls.push(`ensureWorkspace:${id}`);
- return base.ensureWorkspace(id, opts);
- },
- async setWorkspaceId(conversationId, workspaceId) {
- calls.push(`setWorkspaceId:${workspaceId}`);
- await base.setWorkspaceId(conversationId, workspaceId);
- },
- async setCwd(conversationId, cwd) {
- calls.push(`setCwd:${cwd}`);
- await base.setCwd(conversationId, cwd);
- },
- };
+ const calls: string[] = [];
+ return {
+ ...base,
+ get calls() {
+ return calls;
+ },
+ async ensureWorkspace(id, opts) {
+ calls.push(`ensureWorkspace:${id}`);
+ return base.ensureWorkspace(id, opts);
+ },
+ async setWorkspaceId(conversationId, workspaceId) {
+ calls.push(`setWorkspaceId:${workspaceId}`);
+ await base.setWorkspaceId(conversationId, workspaceId);
+ },
+ async setCwd(conversationId, cwd) {
+ calls.push(`setCwd:${cwd}`);
+ await base.setCwd(conversationId, cwd);
+ },
+ };
}
function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
- return {
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage(input) {
- for (const event of events) {
- input.onEvent(event);
- }
- },
- };
+ return {
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage(input) {
+ for (const event of events) {
+ input.onEvent(event);
+ }
+ },
+ };
}
function createCapturingOrchestrator(): SessionOrchestrator & {
- received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
+ received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
} {
- const state: {
- received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
- } = { received: undefined };
- return {
- get received() {
- return state.received;
- },
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage(input) {
- state.received = input;
- },
- };
+ const state: {
+ received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
+ } = { received: undefined };
+ return {
+ get received() {
+ return state.received;
+ },
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage(input) {
+ state.received = input;
+ },
+ };
}
function createThrowingOrchestrator(error: Error): SessionOrchestrator {
- return {
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage() {
- throw error;
- },
- };
+ return {
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage() {
+ throw error;
+ },
+ };
}
function createFakeCredentialStore(models: string[]): CredentialStore {
- return {
- resolve() {
- return undefined;
- },
- async getModelInfo() {
- return undefined;
- },
- async listCatalog() {
- return models;
- },
- };
+ return {
+ resolve() {
+ return undefined;
+ },
+ async getModelInfo() {
+ return undefined;
+ },
+ async listCatalog() {
+ return models;
+ },
+ };
}
function createThrowingCredentialStore(error: Error): CredentialStore {
- return {
- resolve() {
- return undefined;
- },
- async getModelInfo() {
- return undefined;
- },
- async listCatalog() {
- throw error;
- },
- };
+ return {
+ resolve() {
+ return undefined;
+ },
+ async getModelInfo() {
+ return undefined;
+ },
+ async listCatalog() {
+ throw error;
+ },
+ };
}
function createFakeWarmService(
- result:
- | {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- }
- | { error: string },
+ result:
+ | {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ }
+ | { error: string },
): WarmService {
- return {
- async warm() {
- return result;
- },
- };
+ return {
+ async warm() {
+ return result;
+ },
+ };
}
function createFakeLspService(
- statuses: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: "connected" | "starting" | "error" | "not-started";
- readonly error?: string;
- readonly configSource?: string;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: "connected" | "starting" | "error" | "not-started";
+ readonly error?: string;
+ readonly configSource?: string;
+ }[] = [],
): LspService {
- return {
- async status() {
- return statuses;
- },
- };
+ return {
+ async status() {
+ return statuses;
+ },
+ };
}
function createCapturingLspService(
- statuses: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: "connected" | "starting" | "error" | "not-started";
- readonly error?: string;
- readonly configSource?: string;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: "connected" | "starting" | "error" | "not-started";
+ readonly error?: string;
+ readonly configSource?: string;
+ }[] = [],
): LspService & { readonly statusCalls: readonly string[] } {
- const calls: string[] = [];
- return {
- get statusCalls() {
- return calls;
- },
- async status(cwd) {
- calls.push(cwd);
- return statuses;
- },
- };
+ const calls: string[] = [];
+ return {
+ get statusCalls() {
+ return calls;
+ },
+ async status(cwd) {
+ calls.push(cwd);
+ return statuses;
+ },
+ };
}
function createFakeMcpService(
- statuses: readonly {
- readonly id: string;
- readonly state: "connecting" | "connected" | "error" | "disconnected";
- readonly error?: string;
- readonly toolCount: number;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly state: "connecting" | "connected" | "error" | "disconnected";
+ readonly error?: string;
+ readonly toolCount: number;
+ }[] = [],
): McpService {
- return {
- async status() {
- return statuses;
- },
- };
+ return {
+ async status() {
+ return statuses;
+ },
+ };
}
function createCapturingMcpService(
- statuses: readonly {
- readonly id: string;
- readonly state: "connecting" | "connected" | "error" | "disconnected";
- readonly error?: string;
- readonly toolCount: number;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly state: "connecting" | "connected" | "error" | "disconnected";
+ readonly error?: string;
+ readonly toolCount: number;
+ }[] = [],
): McpService & { readonly statusCalls: readonly string[] } {
- const calls: string[] = [];
- return {
- get statusCalls() {
- return calls;
- },
- async status(cwd) {
- calls.push(cwd);
- return statuses;
- },
- };
+ const calls: string[] = [];
+ return {
+ get statusCalls() {
+ return calls;
+ },
+ async status(cwd) {
+ calls.push(cwd);
+ return statuses;
+ },
+ };
}
function createFakeSystemPromptService(
- template: string = "custom template",
+ template: string = "custom template",
): SystemPromptService & {
- readonly setTemplateCalls: readonly string[];
- readonly getTemplateCalls: number;
+ readonly setTemplateCalls: readonly string[];
+ readonly getTemplateCalls: number;
} {
- const setCalls: string[] = [];
- let getTemplateCount = 0;
- let currentTemplate = template;
- return {
- get setTemplateCalls() {
- return setCalls;
- },
- get getTemplateCalls() {
- return getTemplateCount;
- },
- async construct() {
- return currentTemplate;
- },
- async get() {
- return currentTemplate;
- },
- async getTemplate() {
- getTemplateCount++;
- return currentTemplate;
- },
- async setTemplate(t) {
- setCalls.push(t);
- currentTemplate = t;
- },
- };
+ const setCalls: string[] = [];
+ let getTemplateCount = 0;
+ let currentTemplate = template;
+ return {
+ get setTemplateCalls() {
+ return setCalls;
+ },
+ get getTemplateCalls() {
+ return getTemplateCount;
+ },
+ async construct() {
+ return currentTemplate;
+ },
+ async get() {
+ return currentTemplate;
+ },
+ async getTemplate() {
+ getTemplateCount++;
+ return currentTemplate;
+ },
+ async setTemplate(t) {
+ setCalls.push(t);
+ currentTemplate = t;
+ },
+ };
}
function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService {
- const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c]));
- return {
- async listComputers() {
- return computers;
- },
- async getComputer(alias) {
- return byAlias.get(alias) ?? null;
- },
- async getStatus(alias) {
- const known = byAlias.has(alias);
- return { alias, state: "disconnected", knownHost: known };
- },
- async test(alias) {
- return byAlias.has(alias)
- ? { alias, ok: true }
- : { alias, ok: false, error: "Computer not found" };
- },
- };
+ const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c]));
+ return {
+ async listComputers() {
+ return computers;
+ },
+ async getComputer(alias) {
+ return byAlias.get(alias) ?? null;
+ },
+ async getStatus(alias) {
+ const known = byAlias.has(alias);
+ return { alias, state: "disconnected", knownHost: known };
+ },
+ async test(alias) {
+ return byAlias.has(alias)
+ ? { alias, ok: true }
+ : { alias, ok: false, error: "Computer not found" };
+ },
+ };
+}
+
+/**
+ * A minimal HeartbeatService fake for the next-run route: only `nextRunAt` is
+ * exercised by the route; the rest return inert defaults so the object
+ * satisfies the interface without dragging in real stores/scheduler.
+ */
+function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService {
+ return {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => nextRunAt,
+ };
+}
+
+/**
+ * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId +
+ * partial update) and returns a config echoing the captured update on top of
+ * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards
+ * validated fields to the service.
+ */
+function createCapturingHeartbeatService(): HeartbeatService & {
+ readonly captured: { workspaceId: string; update: Record<string, unknown> }[];
+} {
+ const captured: { workspaceId: string; update: Record<string, unknown> }[] = [];
+ const svc: HeartbeatService = {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ async updateConfig(workspaceId, update) {
+ captured.push({ workspaceId, update: update as Record<string, unknown> });
+ return { ...DEFAULT_HEARTBEAT_CONFIG, ...update };
+ },
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => null,
+ };
+ return Object.assign(svc, {
+ get captured() {
+ return captured;
+ },
+ });
}
const noopLogger = createFakeLogger();
describe("GET /health", () => {
- it("returns ok", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/health");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ ok: true });
- });
+ it("returns ok", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/health");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ ok: true });
+ });
});
describe("GET /models", () => {
- it("returns model catalog", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]),
- logger: noopLogger,
- });
- const res = await app.request("/models");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { models: readonly string[] };
- expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]);
- });
-
- it("returns empty array when no models", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/models");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { models: readonly string[] };
- expect(body.models).toEqual([]);
- });
-
- it("returns 502 when listCatalog throws", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createThrowingCredentialStore(new Error("db down")),
- logger: noopLogger,
- });
- const res = await app.request("/models");
- expect(res.status).toBe(502);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("Failed to retrieve model catalog");
- });
+ it("returns model catalog", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/models");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { models: readonly string[] };
+ expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]);
+ });
+
+ it("returns empty array when no models", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/models");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { models: readonly string[] };
+ expect(body.models).toEqual([]);
+ });
+
+ it("returns 502 when listCatalog throws", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createThrowingCredentialStore(new Error("db down")),
+ logger: noopLogger,
+ });
+ const res = await app.request("/models");
+ expect(res.status).toBe(502);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Failed to retrieve model catalog");
+ });
});
describe("POST /chat", () => {
- it("returns 400 for invalid JSON", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 for missing message", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "c1" }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("message");
- });
-
- it("returns 400 for empty message", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("streams events as NDJSON", async () => {
- const events: AgentEvent[] = [
- { type: "turn-start", conversationId: "tab1", turnId: "turn1" },
- { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" },
- { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" },
- { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
- ];
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator(events),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
- expect(res.headers.get("X-Conversation-Id")).toBe("conv1");
-
- const text = await res.text();
- const lines = text.trim().split("\n");
- expect(lines).toHaveLength(4);
-
- const parsed = lines.map((line) => JSON.parse(line) as AgentEvent);
- expect(parsed[0]?.type).toBe("turn-start");
- expect(parsed[1]?.type).toBe("text-delta");
- expect((parsed[1] as { delta: string }).delta).toBe("Hello");
- expect(parsed[2]?.type).toBe("text-delta");
- expect(parsed[3]?.type).toBe("done");
- });
-
- it("generates conversationId when not provided", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([
- { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
- ]),
- credentialStore: createFakeCredentialStore([]),
- generateId: () => "generated-uuid",
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi" }),
- });
-
- expect(res.status).toBe(200);
- expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid");
- });
-
- it("emits error event when orchestrator throws", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createThrowingOrchestrator(new Error("provider unavailable")),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const text = await res.text();
- const lines = text.trim().split("\n");
- expect(lines.length).toBeGreaterThanOrEqual(1);
-
- const lastLine = lines[lines.length - 1];
- if (!lastLine) throw new Error("expected at least one line");
- const lastEvent = JSON.parse(lastLine) as AgentEvent;
- expect(lastEvent.type).toBe("error");
- if (lastEvent.type === "error") {
- expect(lastEvent.message).toContain("provider unavailable");
- }
- });
-
- it("handles empty event list", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi" }),
- });
-
- expect(res.status).toBe(200);
- const text = await res.text();
- expect(text).toBe("");
- });
-
- it("forwards modelName and cwd to orchestrator", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- model: "opencode/m1",
- cwd: "/tmp",
- }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.conversationId).toBe("conv1");
- expect(cap.received?.text).toBe("hi");
- expect(cap.received?.modelName).toBe("opencode/m1");
- expect(cap.received?.cwd).toBe("/tmp");
- });
-
- it("omits modelName and cwd when not provided", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.modelName).toBeUndefined();
- expect(cap.received?.cwd).toBeUndefined();
- });
+ it("returns 400 for invalid JSON", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing message", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "c1" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("message");
+ });
+
+ it("returns 400 for empty message", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("streams events as NDJSON", async () => {
+ const events: AgentEvent[] = [
+ { type: "turn-start", conversationId: "tab1", turnId: "turn1" },
+ { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" },
+ { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" },
+ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
+ ];
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator(events),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
+ expect(res.headers.get("X-Conversation-Id")).toBe("conv1");
+
+ const text = await res.text();
+ const lines = text.trim().split("\n");
+ expect(lines).toHaveLength(4);
+
+ const parsed = lines.map((line) => JSON.parse(line) as AgentEvent);
+ expect(parsed[0]?.type).toBe("turn-start");
+ expect(parsed[1]?.type).toBe("text-delta");
+ expect((parsed[1] as { delta: string }).delta).toBe("Hello");
+ expect(parsed[2]?.type).toBe("text-delta");
+ expect(parsed[3]?.type).toBe("done");
+ });
+
+ it("generates conversationId when not provided", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([
+ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
+ ]),
+ credentialStore: createFakeCredentialStore([]),
+ generateId: () => "generated-uuid",
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid");
+ });
+
+ it("emits error event when orchestrator throws", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createThrowingOrchestrator(new Error("provider unavailable")),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ const lines = text.trim().split("\n");
+ expect(lines.length).toBeGreaterThanOrEqual(1);
+
+ const lastLine = lines[lines.length - 1];
+ if (!lastLine) throw new Error("expected at least one line");
+ const lastEvent = JSON.parse(lastLine) as AgentEvent;
+ expect(lastEvent.type).toBe("error");
+ if (lastEvent.type === "error") {
+ expect(lastEvent.message).toContain("provider unavailable");
+ }
+ });
+
+ it("handles empty event list", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi" }),
+ });
+
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ expect(text).toBe("");
+ });
+
+ it("forwards modelName and cwd to orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ model: "opencode/m1",
+ cwd: "/tmp",
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.conversationId).toBe("conv1");
+ expect(cap.received?.text).toBe("hi");
+ expect(cap.received?.modelName).toBe("opencode/m1");
+ expect(cap.received?.cwd).toBe("/tmp");
+ });
+
+ it("omits modelName and cwd when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.modelName).toBeUndefined();
+ expect(cap.received?.cwd).toBeUndefined();
+ });
+
+ it("forwards the title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.title).toBe("My Task");
+ });
+
+ it("forwards a trimmed title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " spaced " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBe("spaced");
+ });
+
+ it("does not forward a title when omitted", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("does not forward a title for a whitespace-only title", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("returns 400 when title is not a string", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: 42 }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ });
+
+ it("does not call setConversationTitle itself (the orchestrator owns it)", async () => {
+ let setTitleCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle() {
+ setTitleCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ // The route must NOT pre-create the meta — that would bypass the
+ // orchestrator's new-conversation workspace/system-prompt init. The
+ // orchestrator sets the title after workspace setup instead.
+ expect(setTitleCalled).toBe(false);
+ });
});
describe("POST /chat/warm", () => {
- it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 1000,
- outputTokens: 200,
- cacheReadTokens: 800,
- cacheWriteTokens: 100,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- cachePct: number;
- expectedCacheRate: number;
- };
- expect(body.inputTokens).toBe(1000);
- expect(body.outputTokens).toBe(200);
- expect(body.cacheReadTokens).toBe(800);
- expect(body.cacheWriteTokens).toBe(100);
- expect(body.cachePct).toBe(80);
- expect(body.expectedCacheRate).toBe(89);
- });
-
- it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 500,
- outputTokens: 100,
- cacheReadTokens: 400,
- cacheWriteTokens: 100,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as { expectedCacheRate: number };
- expect(body.expectedCacheRate).toBe(80);
- });
-
- it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 100,
- outputTokens: 50,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as { expectedCacheRate: number };
- expect(body.expectedCacheRate).toBe(0);
- });
-
- it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({ error: "conversation is generating" }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(409);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("conversation is generating");
- });
-
- it("POST /chat/warm returns 400 when conversationId is missing", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 0,
- outputTokens: 0,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("conversationId");
- });
+ it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 1000,
+ outputTokens: 200,
+ cacheReadTokens: 800,
+ cacheWriteTokens: 100,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ cachePct: number;
+ expectedCacheRate: number;
+ };
+ expect(body.inputTokens).toBe(1000);
+ expect(body.outputTokens).toBe(200);
+ expect(body.cacheReadTokens).toBe(800);
+ expect(body.cacheWriteTokens).toBe(100);
+ expect(body.cachePct).toBe(80);
+ expect(body.expectedCacheRate).toBe(89);
+ });
+
+ it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 500,
+ outputTokens: 100,
+ cacheReadTokens: 400,
+ cacheWriteTokens: 100,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { expectedCacheRate: number };
+ expect(body.expectedCacheRate).toBe(80);
+ });
+
+ it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 100,
+ outputTokens: 50,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { expectedCacheRate: number };
+ expect(body.expectedCacheRate).toBe(0);
+ });
+
+ it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({ error: "conversation is generating" }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("conversation is generating");
+ });
+
+ it("POST /chat/warm returns 400 when conversationId is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("conversationId");
+ });
});
describe("GET /conversations/:id", () => {
- const sampleChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
- { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } },
- { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } },
- ];
-
- it("returns the full seq-ordered StoredChunk history", async () => {
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(4);
- expect(body.chunks[0]?.seq).toBe(1);
- expect(body.chunks[3]?.seq).toBe(4);
- expect(body.latestSeq).toBe(4);
- });
-
- it("returns only chunks with seq > N and latestSeq = last seq", async () => {
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=2");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(2);
- expect(body.chunks[0]?.seq).toBe(3);
- expect(body.chunks[1]?.seq).toBe(4);
- expect(body.latestSeq).toBe(4);
- });
-
- it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => {
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=4");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(0);
- expect(body.latestSeq).toBe(4);
- });
-
- it("returns empty chunks and latestSeq 0 for unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/unknown");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(0);
- expect(body.latestSeq).toBe(0);
- });
-
- it("returns 400 for invalid sinceSeq", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=abc");
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("sinceSeq");
- });
-
- it("returns 400 for negative sinceSeq", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=-1");
- expect(res.status).toBe(400);
- });
-
- const sixChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "one" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } },
- { seq: 3, role: "user", chunk: { type: "text", text: "three" } },
- { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } },
- { seq: 5, role: "user", chunk: { type: "text", text: "five" } },
- { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } },
- ];
-
- function appWithChunks(chunks: StoredChunk[]) {
- const store = new Map<string, StoredChunk[]>([["conv1", chunks]]);
- return createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- }
-
- it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?limit=2");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]);
- expect(body.latestSeq).toBe(6);
- });
-
- it("?limit=N with N >= conversation size returns the full log", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?limit=10");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]);
- expect(body.latestSeq).toBe(6);
- });
-
- it("?beforeSeq=S returns only chunks with seq < S", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?beforeSeq=3");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]);
- expect(body.latestSeq).toBe(2);
- });
-
- it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- // selection = seq 1..4; newest 2 = [3, 4]
- expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
- expect(body.latestSeq).toBe(4);
- });
-
- it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
- expect(body.latestSeq).toBe(4);
- });
-
- describe("window param validation → 400 and store not called with an invalid window", () => {
- function appCapturingWindow() {
- const calls: {
- readonly sinceSeq: number | undefined;
- readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined;
- }[] = [];
- const store: ConversationStore = {
- async append() {},
- async load() {
- return [];
- },
- async loadSince(_conversationId, sinceSeq, window) {
- calls.push({ sinceSeq, window });
- return [];
- },
- async appendMetrics() {},
- async loadMetrics() {
- return [];
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async clearCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- return { app, calls };
- }
-
- const cases: readonly { readonly name: string; readonly query: string }[] = [
- { name: "limit=0", query: "limit=0" },
- { name: "limit=-1", query: "limit=-1" },
- { name: "limit=abc", query: "limit=abc" },
- { name: "beforeSeq=0", query: "beforeSeq=0" },
- { name: "beforeSeq=1.5", query: "beforeSeq=1.5" },
- ];
-
- for (const { name, query } of cases) {
- it(`${name} → 400 { error } and loadSince is never called`, async () => {
- const { app, calls } = appCapturingWindow();
- const res = await app.request(`/conversations/conv1?${query}`);
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(typeof body.error).toBe("string");
- expect(body.error.length).toBeGreaterThan(0);
- expect(calls).toHaveLength(0);
- });
- }
- });
-
- it("no params → byte-identical to the no-window read (regression guard)", async () => {
- // Rest-param spy: record how many args the route actually passes, so we
- // can prove the third (window) arg is OMITTED entirely — not merely
- // forwarded as undefined — preserving the existing two-arg call shape.
- const argCounts: number[] = [];
- const store: ConversationStore = {
- async append() {},
- async load() {
- return [];
- },
- loadSince(...args: Parameters<ConversationStore["loadSince"]>) {
- argCounts.push(args.length);
- const sinceSeq = args[1] ?? 0;
- return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq));
- },
- async appendMetrics() {},
- async loadMetrics() {
- return [];
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async clearCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]);
- expect(body.latestSeq).toBe(4);
- // Called once, with exactly two arguments (no window arg).
- expect(argCounts).toEqual([2]);
- });
+ const sampleChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
+ { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } },
+ { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } },
+ ];
+
+ it("returns the full seq-ordered StoredChunk history", async () => {
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(4);
+ expect(body.chunks[0]?.seq).toBe(1);
+ expect(body.chunks[3]?.seq).toBe(4);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("returns only chunks with seq > N and latestSeq = last seq", async () => {
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(2);
+ expect(body.chunks[0]?.seq).toBe(3);
+ expect(body.chunks[1]?.seq).toBe(4);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => {
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=4");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(0);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("returns empty chunks and latestSeq 0 for unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/unknown");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(0);
+ expect(body.latestSeq).toBe(0);
+ });
+
+ it("returns 400 for invalid sinceSeq", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=abc");
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("sinceSeq");
+ });
+
+ it("returns 400 for negative sinceSeq", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=-1");
+ expect(res.status).toBe(400);
+ });
+
+ const sixChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "one" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } },
+ { seq: 3, role: "user", chunk: { type: "text", text: "three" } },
+ { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } },
+ { seq: 5, role: "user", chunk: { type: "text", text: "five" } },
+ { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } },
+ ];
+
+ function appWithChunks(chunks: StoredChunk[]) {
+ const store = new Map<string, StoredChunk[]>([["conv1", chunks]]);
+ return createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ }
+
+ it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?limit=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]);
+ expect(body.latestSeq).toBe(6);
+ });
+
+ it("?limit=N with N >= conversation size returns the full log", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?limit=10");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(body.latestSeq).toBe(6);
+ });
+
+ it("?beforeSeq=S returns only chunks with seq < S", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?beforeSeq=3");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]);
+ expect(body.latestSeq).toBe(2);
+ });
+
+ it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ // selection = seq 1..4; newest 2 = [3, 4]
+ expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ describe("window param validation → 400 and store not called with an invalid window", () => {
+ function appCapturingWindow() {
+ const calls: {
+ readonly sinceSeq: number | undefined;
+ readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined;
+ }[] = [];
+ const store: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince(_conversationId, sinceSeq, window) {
+ calls.push({ sinceSeq, window });
+ return [];
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async clearCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ return { app, calls };
+ }
+
+ const cases: readonly { readonly name: string; readonly query: string }[] = [
+ { name: "limit=0", query: "limit=0" },
+ { name: "limit=-1", query: "limit=-1" },
+ { name: "limit=abc", query: "limit=abc" },
+ { name: "beforeSeq=0", query: "beforeSeq=0" },
+ { name: "beforeSeq=1.5", query: "beforeSeq=1.5" },
+ ];
+
+ for (const { name, query } of cases) {
+ it(`${name} → 400 { error } and loadSince is never called`, async () => {
+ const { app, calls } = appCapturingWindow();
+ const res = await app.request(`/conversations/conv1?${query}`);
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(typeof body.error).toBe("string");
+ expect(body.error.length).toBeGreaterThan(0);
+ expect(calls).toHaveLength(0);
+ });
+ }
+ });
+
+ it("no params → byte-identical to the no-window read (regression guard)", async () => {
+ // Rest-param spy: record how many args the route actually passes, so we
+ // can prove the third (window) arg is OMITTED entirely — not merely
+ // forwarded as undefined — preserving the existing two-arg call shape.
+ const argCounts: number[] = [];
+ const store: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ loadSince(...args: Parameters<ConversationStore["loadSince"]>) {
+ argCounts.push(args.length);
+ const sinceSeq = args[1] ?? 0;
+ return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq));
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async clearCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]);
+ expect(body.latestSeq).toBe(4);
+ // Called once, with exactly two arguments (no window arg).
+ expect(argCounts).toEqual([2]);
+ });
});
describe("GET /conversations/:id/metrics", () => {
- const sampleMetrics: TurnMetrics[] = [
- {
- turnId: "turn1",
- usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
- durationMs: 1000,
- steps: [
- {
- stepId: "step1" as StepId,
- usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
- ttftMs: 200,
- decodeMs: 300,
- genTotalMs: 500,
- },
- ],
- },
- {
- turnId: "turn2",
- usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
- durationMs: 1500,
- steps: [
- {
- stepId: "step2" as StepId,
- usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
- ttftMs: 300,
- decodeMs: 500,
- genTotalMs: 800,
- },
- ],
- },
- ];
-
- it("returns persisted turn metrics as { turns }", async () => {
- const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(new Map(), metricsStore),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1/metrics");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { turns: readonly TurnMetrics[] };
- expect(body.turns).toHaveLength(2);
- expect(body.turns[0]?.turnId).toBe("turn1");
- expect(body.turns[1]?.turnId).toBe("turn2");
- });
-
- it("returns { turns: [] } for an unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/unknown/metrics");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { turns: readonly TurnMetrics[] };
- expect(body.turns).toHaveLength(0);
- });
-
- it("the metrics route does not collide with GET /conversations/:id history route", async () => {
- const sampleChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
- ];
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store, metricsStore),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const metricsRes = await app.request("/conversations/conv1/metrics");
- expect(metricsRes.status).toBe(200);
- const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] };
- expect(metricsBody.turns).toHaveLength(2);
-
- const historyRes = await app.request("/conversations/conv1");
- expect(historyRes.status).toBe(200);
- const historyBody = (await historyRes.json()) as {
- chunks: readonly StoredChunk[];
- latestSeq: number;
- };
- expect(historyBody.chunks).toHaveLength(1);
- });
-
- it("a store failure on the metrics read returns an error status + logs an error", async () => {
- const logger = createFakeLogger();
- const brokenStore: ConversationStore = {
- async append() {},
- async load() {
- return [];
- },
- async loadSince() {
- return [];
- },
- async appendMetrics() {},
- async loadMetrics() {
- throw new Error("storage exploded");
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async clearCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: brokenStore,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- const res = await app.request("/conversations/conv1/metrics");
- expect(res.status).toBe(500);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("Failed to load conversation metrics");
-
- const errorLogs = logger.records.filter((r) => r.level === "error");
- expect(errorLogs).toHaveLength(1);
- expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure");
- expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
- });
+ const sampleMetrics: TurnMetrics[] = [
+ {
+ turnId: "turn1",
+ usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ durationMs: 1000,
+ steps: [
+ {
+ stepId: "step1" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ ttftMs: 200,
+ decodeMs: 300,
+ genTotalMs: 500,
+ },
+ ],
+ },
+ {
+ turnId: "turn2",
+ usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
+ durationMs: 1500,
+ steps: [
+ {
+ stepId: "step2" as StepId,
+ usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
+ ttftMs: 300,
+ decodeMs: 500,
+ genTotalMs: 800,
+ },
+ ],
+ },
+ ];
+
+ it("returns persisted turn metrics as { turns }", async () => {
+ const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(new Map(), metricsStore),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1/metrics");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { turns: readonly TurnMetrics[] };
+ expect(body.turns).toHaveLength(2);
+ expect(body.turns[0]?.turnId).toBe("turn1");
+ expect(body.turns[1]?.turnId).toBe("turn2");
+ });
+
+ it("returns { turns: [] } for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/unknown/metrics");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { turns: readonly TurnMetrics[] };
+ expect(body.turns).toHaveLength(0);
+ });
+
+ it("the metrics route does not collide with GET /conversations/:id history route", async () => {
+ const sampleChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
+ ];
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store, metricsStore),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const metricsRes = await app.request("/conversations/conv1/metrics");
+ expect(metricsRes.status).toBe(200);
+ const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] };
+ expect(metricsBody.turns).toHaveLength(2);
+
+ const historyRes = await app.request("/conversations/conv1");
+ expect(historyRes.status).toBe(200);
+ const historyBody = (await historyRes.json()) as {
+ chunks: readonly StoredChunk[];
+ latestSeq: number;
+ };
+ expect(historyBody.chunks).toHaveLength(1);
+ });
+
+ it("a store failure on the metrics read returns an error status + logs an error", async () => {
+ const logger = createFakeLogger();
+ const brokenStore: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince() {
+ return [];
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ throw new Error("storage exploded");
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async clearCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: brokenStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ const res = await app.request("/conversations/conv1/metrics");
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Failed to load conversation metrics");
+
+ const errorLogs = logger.records.filter((r) => r.level === "error");
+ expect(errorLogs).toHaveLength(1);
+ expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure");
+ expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
+ });
});
describe("POST /chat logging", () => {
- it("POST /chat logs an info line when a request is accepted", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([
- { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
- ]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- model: "opencode/m1",
- cwd: "/tmp",
- }),
- });
-
- const infoLogs = logger.records.filter((r) => r.level === "info");
- expect(infoLogs).toHaveLength(1);
- expect(infoLogs[0]?.msg).toBe("chat: request accepted");
- expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
- expect(infoLogs[0]?.attrs?.hasModel).toBe(true);
- expect(infoLogs[0]?.attrs?.hasCwd).toBe(true);
- });
-
- it("POST /chat logs a warn on a malformed body (400)", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
-
- const warnLogs = logger.records.filter((r) => r.level === "warn");
- expect(warnLogs.length).toBeGreaterThanOrEqual(1);
- expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body");
- });
-
- it("POST /chat logs an error when the turn fails", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createThrowingOrchestrator(new Error("boom")),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- const errorLogs = logger.records.filter((r) => r.level === "error");
- expect(errorLogs).toHaveLength(1);
- expect(errorLogs[0]?.msg).toBe("chat: turn failed");
- expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
- });
+ it("POST /chat logs an info line when a request is accepted", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([
+ { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
+ ]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ model: "opencode/m1",
+ cwd: "/tmp",
+ }),
+ });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("chat: request accepted");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.hasModel).toBe(true);
+ expect(infoLogs[0]?.attrs?.hasCwd).toBe(true);
+ });
+
+ it("POST /chat logs a warn on a malformed body (400)", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+
+ const warnLogs = logger.records.filter((r) => r.level === "warn");
+ expect(warnLogs.length).toBeGreaterThanOrEqual(1);
+ expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body");
+ });
+
+ it("POST /chat logs an error when the turn fails", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createThrowingOrchestrator(new Error("boom")),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ const errorLogs = logger.records.filter((r) => r.level === "error");
+ expect(errorLogs).toHaveLength(1);
+ expect(errorLogs[0]?.msg).toBe("chat: turn failed");
+ expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
+ });
});
describe("GET /conversations/:id logging", () => {
- it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => {
- const logger = createFakeLogger();
- const sampleChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
- ];
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/conversations/conv1?sinceSeq=0");
-
- const infoLogs = logger.records.filter((r) => r.level === "info");
- expect(infoLogs).toHaveLength(1);
- expect(infoLogs[0]?.msg).toBe("conversations: read");
- expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
- expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0);
- expect(infoLogs[0]?.attrs?.count).toBe(2);
- });
+ it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => {
+ const logger = createFakeLogger();
+ const sampleChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
+ ];
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1?sinceSeq=0");
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: read");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0);
+ expect(infoLogs[0]?.attrs?.count).toBe(2);
+ });
});
describe("CORS", () => {
- function createTestApp() {
- return createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([
- { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
- ]),
- credentialStore: createFakeCredentialStore(["opencode/m1"]),
- });
- }
-
- it("POST /chat response carries Access-Control-Allow-Origin: *", async () => {
- const app = createTestApp();
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
- expect(res.status).toBe(200);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- });
-
- it("GET /models response carries the CORS headers", async () => {
- const app = createTestApp();
- const res = await app.request("/models");
- expect(res.status).toBe(200);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
- });
-
- it("GET /conversations/:id response carries the CORS headers", async () => {
- const app = createTestApp();
- const res = await app.request("/conversations/conv1");
- expect(res.status).toBe(200);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
- });
-
- it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => {
- const app = createTestApp();
- const res = await app.request("/chat", { method: "OPTIONS" });
- expect(res.status).toBe(204);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET");
- expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST");
- expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS");
- expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type");
- });
+ function createTestApp() {
+ return createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([
+ { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
+ ]),
+ credentialStore: createFakeCredentialStore(["opencode/m1"]),
+ });
+ }
+
+ it("POST /chat response carries Access-Control-Allow-Origin: *", async () => {
+ const app = createTestApp();
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ });
+
+ it("GET /models response carries the CORS headers", async () => {
+ const app = createTestApp();
+ const res = await app.request("/models");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
+ });
+
+ it("GET /conversations/:id response carries the CORS headers", async () => {
+ const app = createTestApp();
+ const res = await app.request("/conversations/conv1");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
+ });
+
+ it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => {
+ const app = createTestApp();
+ const res = await app.request("/chat", { method: "OPTIONS" });
+ expect(res.status).toBe(204);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET");
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST");
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS");
+ expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type");
+ });
});
describe("throughput recording + GET /metrics/throughput", () => {
- const ts = new Date(2026, 5, 10, 12, 0, 0).getTime();
- const day = dayKeyOf(ts);
-
- function appWith(
- throughputStore: ReturnType<typeof createThroughputStore>,
- events: AgentEvent[],
- ) {
- return createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator(events),
- credentialStore: createFakeCredentialStore([]),
- throughputStore,
- now: () => ts,
- });
- }
-
- async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) {
- return app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- }
-
- it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => {
- const store = createThroughputStore({ storage: createMemStorage() });
- const events: AgentEvent[] = [
- {
- type: "step-complete",
- conversationId: "c1",
- turnId: "t1",
- stepId: "t1#0" as StepId,
- genTotalMs: 2000,
- },
- {
- type: "done",
- conversationId: "c1",
- turnId: "t1",
- reason: "stop",
- usage: { inputTokens: 10, outputTokens: 400 },
- },
- ];
- const app = appWith(store, events);
-
- const chat = await postChat(app, {
- conversationId: "c1",
- message: "hi",
- model: "claude/haiku",
- });
- expect(chat.status).toBe(200);
-
- const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
- expect(res.status).toBe(200);
- const report = (await res.json()) as ThroughputResponse;
- expect(report.period).toBe("day");
- expect(report.models).toHaveLength(1);
- expect(report.models[0]).toMatchObject({
- model: "claude/haiku",
- totalOutputTokens: 400,
- totalGenMs: 2000,
- tokensPerSecond: 200, // 400 tokens / 2s
- turns: 1,
- });
- });
-
- it("does not record a sample when no model is selected", async () => {
- const store = createThroughputStore({ storage: createMemStorage() });
- const events: AgentEvent[] = [
- {
- type: "step-complete",
- conversationId: "c1",
- turnId: "t1",
- stepId: "t1#0" as StepId,
- genTotalMs: 2000,
- },
- {
- type: "done",
- conversationId: "c1",
- turnId: "t1",
- reason: "stop",
- usage: { inputTokens: 1, outputTokens: 5 },
- },
- ];
- const app = appWith(store, events);
-
- await postChat(app, { conversationId: "c1", message: "hi" }); // no model
- const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
- const report = (await res.json()) as { models: unknown[] };
- expect(report.models).toEqual([]);
- });
-
- it("returns 400 for an invalid period", async () => {
- const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
- const res = await app.request("/metrics/throughput?period=year&date=2026");
- expect(res.status).toBe(400);
- });
-
- it("returns 400 for a malformed date", async () => {
- const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
- const res = await app.request("/metrics/throughput?period=day&date=nope");
- expect(res.status).toBe(400);
- });
-
- it("returns 400 when date is missing", async () => {
- const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
- const res = await app.request("/metrics/throughput?period=day");
- expect(res.status).toBe(400);
- });
+ const ts = new Date(2026, 5, 10, 12, 0, 0).getTime();
+ const day = dayKeyOf(ts);
+
+ function appWith(
+ throughputStore: ReturnType<typeof createThroughputStore>,
+ events: AgentEvent[],
+ ) {
+ return createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator(events),
+ credentialStore: createFakeCredentialStore([]),
+ throughputStore,
+ now: () => ts,
+ });
+ }
+
+ async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) {
+ return app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+
+ it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => {
+ const store = createThroughputStore({ storage: createMemStorage() });
+ const events: AgentEvent[] = [
+ {
+ type: "step-complete",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ genTotalMs: 2000,
+ },
+ {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "stop",
+ usage: { inputTokens: 10, outputTokens: 400 },
+ },
+ ];
+ const app = appWith(store, events);
+
+ const chat = await postChat(app, {
+ conversationId: "c1",
+ message: "hi",
+ model: "claude/haiku",
+ });
+ expect(chat.status).toBe(200);
+
+ const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
+ expect(res.status).toBe(200);
+ const report = (await res.json()) as ThroughputResponse;
+ expect(report.period).toBe("day");
+ expect(report.models).toHaveLength(1);
+ expect(report.models[0]).toMatchObject({
+ model: "claude/haiku",
+ totalOutputTokens: 400,
+ totalGenMs: 2000,
+ tokensPerSecond: 200, // 400 tokens / 2s
+ turns: 1,
+ });
+ });
+
+ it("does not record a sample when no model is selected", async () => {
+ const store = createThroughputStore({ storage: createMemStorage() });
+ const events: AgentEvent[] = [
+ {
+ type: "step-complete",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ genTotalMs: 2000,
+ },
+ {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "stop",
+ usage: { inputTokens: 1, outputTokens: 5 },
+ },
+ ];
+ const app = appWith(store, events);
+
+ await postChat(app, { conversationId: "c1", message: "hi" }); // no model
+ const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
+ const report = (await res.json()) as { models: unknown[] };
+ expect(report.models).toEqual([]);
+ });
+
+ it("returns 400 for an invalid period", async () => {
+ const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
+ const res = await app.request("/metrics/throughput?period=year&date=2026");
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for a malformed date", async () => {
+ const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
+ const res = await app.request("/metrics/throughput?period=day&date=nope");
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when date is missing", async () => {
+ const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
+ const res = await app.request("/metrics/throughput?period=day");
+ expect(res.status).toBe(400);
+ });
});
describe("POST /conversations/:id/close", () => {
- it("closes via the orchestrator and returns CloseConversationResponse", async () => {
- const closeCalls: string[] = [];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- closeConversation(conversationId) {
- closeCalls.push(conversationId);
- return { abortedTurn: true };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-9/close", { method: "POST" });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true });
- expect(closeCalls).toEqual(["conv-9"]);
- });
-
- it("reports abortedTurn false for an idle conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-idle/close", { method: "POST" });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false });
- });
+ it("closes via the orchestrator and returns CloseConversationResponse", async () => {
+ const closeCalls: string[] = [];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ closeConversation(conversationId) {
+ closeCalls.push(conversationId);
+ return { abortedTurn: true };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-9/close", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true });
+ expect(closeCalls).toEqual(["conv-9"]);
+ });
+
+ it("reports abortedTurn false for an idle conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-idle/close", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false });
+ });
});
describe("POST /conversations/:id/queue", () => {
- it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => {
- const queue: readonly QueuedMessage[] = [
- { id: "q1", text: "queued-msg", queuedAt: 1700000000000 },
- ];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: false, queue };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "hello" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as QueueResponse;
- expect(body.conversationId).toBe("conv1");
- expect(body.startedTurn).toBe(false);
- expect(body.queue).toEqual(queue);
- });
-
- it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => {
- let enqueueCalled = false;
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- enqueueCalled = true;
- return { startedTurn: false, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: " " }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("text");
- expect(enqueueCalled).toBe(false);
- });
-
- it("with missing text field → 400 { error } and enqueue is never called", async () => {
- let enqueueCalled = false;
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- enqueueCalled = true;
- return { startedTurn: false, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("text");
- expect(enqueueCalled).toBe(false);
- });
-
- it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => {
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: true, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-idle/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "go" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as QueueResponse;
- expect(body.conversationId).toBe("conv-idle");
- expect(body.startedTurn).toBe(true);
- expect(body.queue).toEqual([]);
- });
-
- it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => {
- const queue: readonly QueuedMessage[] = [
- { id: "q1", text: "second", queuedAt: 1700000000000 },
- { id: "q2", text: "third", queuedAt: 1700000001000 },
- ];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: false, queue };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-active/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "steer" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as QueueResponse;
- expect(body.conversationId).toBe("conv-active");
- expect(body.startedTurn).toBe(false);
- expect(body.queue).toEqual(queue);
- });
-
- it("forwards the path conversationId and trimmed text to enqueue", async () => {
- const calls: { conversationId: string; text: string }[] = [];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue(input) {
- calls.push(input);
- return { startedTurn: false, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: " hello world " }),
- });
-
- expect(res.status).toBe(200);
- expect(calls).toHaveLength(1);
- expect(calls[0]?.conversationId).toBe("conv-1");
- expect(calls[0]?.text).toBe("hello world");
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("JSON");
- });
-
- it("returns 400 for a non-string text", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: 42 }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("text");
- });
-
- it("logs an info line on success and never logs the enqueued text", async () => {
- const logger = createFakeLogger();
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: true, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "secret-ish user message" }),
- });
-
- const infoLogs = logger.records.filter((r) => r.level === "info");
- expect(infoLogs).toHaveLength(1);
- expect(infoLogs[0]?.msg).toBe("conversations: enqueued");
- expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
- expect(infoLogs[0]?.attrs?.startedTurn).toBe(true);
- expect(infoLogs[0]?.attrs?.queueLength).toBe(0);
- // Restraint: the user's message text is never logged (mirrors POST /chat).
- expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message");
- });
-
- it("logs a warn on a malformed body (400)", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "" }),
- });
-
- const warnLogs = logger.records.filter((r) => r.level === "warn");
- expect(warnLogs.length).toBeGreaterThanOrEqual(1);
- expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed");
- });
+ it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "queued-msg", queuedAt: 1700000000000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "hello" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueResponse;
+ expect(body.conversationId).toBe("conv1");
+ expect(body.startedTurn).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => {
+ let enqueueCalled = false;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ enqueueCalled = true;
+ return { startedTurn: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: " " }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("text");
+ expect(enqueueCalled).toBe(false);
+ });
+
+ it("with missing text field → 400 { error } and enqueue is never called", async () => {
+ let enqueueCalled = false;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ enqueueCalled = true;
+ return { startedTurn: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("text");
+ expect(enqueueCalled).toBe(false);
+ });
+
+ it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => {
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-idle/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "go" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueResponse;
+ expect(body.conversationId).toBe("conv-idle");
+ expect(body.startedTurn).toBe(true);
+ expect(body.queue).toEqual([]);
+ });
+
+ it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "second", queuedAt: 1700000000000 },
+ { id: "q2", text: "third", queuedAt: 1700000001000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-active/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "steer" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueResponse;
+ expect(body.conversationId).toBe("conv-active");
+ expect(body.startedTurn).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("forwards the path conversationId and trimmed text to enqueue", async () => {
+ const calls: { conversationId: string; text: string }[] = [];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue(input) {
+ calls.push(input);
+ return { startedTurn: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: " hello world " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.conversationId).toBe("conv-1");
+ expect(calls[0]?.text).toBe("hello world");
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("JSON");
+ });
+
+ it("returns 400 for a non-string text", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: 42 }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("text");
+ });
+
+ it("logs an info line on success and never logs the enqueued text", async () => {
+ const logger = createFakeLogger();
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "secret-ish user message" }),
+ });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: enqueued");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.startedTurn).toBe(true);
+ expect(infoLogs[0]?.attrs?.queueLength).toBe(0);
+ // Restraint: the user's message text is never logged (mirrors POST /chat).
+ expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message");
+ });
+
+ it("logs a warn on a malformed body (400)", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "" }),
+ });
+
+ const warnLogs = logger.records.filter((r) => r.level === "warn");
+ expect(warnLogs.length).toBeGreaterThanOrEqual(1);
+ expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed");
+ });
+});
+
+describe("DELETE /conversations/:id/queue/:messageId", () => {
+ it("when a message is cancelled → 200 + QueueCancelResponse (cancelled:true + post-cancel queue)", async () => {
+ const remaining: readonly QueuedMessage[] = [
+ { id: "q1", text: "kept", queuedAt: 1700000000000 },
+ ];
+ let received: { conversationId: string; messageId: string } | undefined;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage(input) {
+ received = input;
+ return { cancelled: true, queue: remaining };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/q2", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cancelled).toBe(true);
+ expect(body.queue).toEqual(remaining);
+ // forwards the path conversationId + messageId
+ expect(received?.conversationId).toBe("conv1");
+ expect(received?.messageId).toBe("q2");
+ });
+
+ it("when the message is not in the queue → 200 cancelled:false (idempotent, not an error)", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "still-queued", queuedAt: 1700000000000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/missing", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("when the queue ext is not loaded → 200 cancelled:false, empty queue (degraded)", async () => {
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/whatever", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual([]);
+ });
+
+ it("delegates the cancel to the orchestrator (never reads the body)", async () => {
+ let calls = 0;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ calls += 1;
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // No Content-Type / body — the endpoint takes the messageId from the path.
+ const res = await app.request("/conversations/conv-x/queue/m1", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toBe(1);
+ });
+
+ it("logs an info line on success and never logs the message text", async () => {
+ const logger = createFakeLogger();
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue/q-secret", { method: "DELETE" });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: cancelled queued message");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.messageId).toBe("q-secret");
+ expect(infoLogs[0]?.attrs?.cancelled).toBe(true);
+ });
});
describe("GET /conversations/:id/cwd", () => {
- it("returns null when unset", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; cwd: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- });
+ it("returns null when unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ });
});
describe("PUT then GET /conversations/:id/cwd", () => {
- it("round-trips the value", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project" }),
- });
- expect(putRes.status).toBe(200);
- const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
- expect(putBody.conversationId).toBe("conv1");
- expect(putBody.cwd).toBe("/home/user/project");
-
- const getRes = await app.request("/conversations/conv1/cwd");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
- expect(getBody.cwd).toBe("/home/user/project");
- });
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.cwd).toBe("/home/user/project");
+
+ const getRes = await app.request("/conversations/conv1/cwd");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
+ expect(getBody.cwd).toBe("/home/user/project");
+ });
});
describe("DELETE /conversations/:id/cwd", () => {
- it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project" }),
- });
- expect(putRes.status).toBe(200);
-
- const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
- expect(deleteBody.conversationId).toBe("conv1");
- expect(deleteBody.cwd).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/cwd");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
- expect(getBody.cwd).toBeNull();
- });
-
- it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
- expect(deleteBody.conversationId).toBe("conv1");
- expect(deleteBody.cwd).toBeNull();
- });
-
- it("does NOT affect other conversations' cwds (isolation)", async () => {
- const cwdStore = new Map<string, string>([
- ["conv1", "/home/user/project"],
- ["conv2", "/other/path"],
- ]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
-
- const get1Res = await app.request("/conversations/conv1/cwd");
- expect(get1Res.status).toBe(200);
- const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null };
- expect(get1Body.cwd).toBeNull();
-
- const get2Res = await app.request("/conversations/conv2/cwd");
- expect(get2Res.status).toBe(200);
- const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null };
- expect(get2Body.cwd).toBe("/other/path");
- });
+ it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.cwd).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/cwd");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
+ expect(getBody.cwd).toBeNull();
+ });
+
+ it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.cwd).toBeNull();
+ });
+
+ it("does NOT affect other conversations' cwds (isolation)", async () => {
+ const cwdStore = new Map<string, string>([
+ ["conv1", "/home/user/project"],
+ ["conv2", "/other/path"],
+ ]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+
+ const get1Res = await app.request("/conversations/conv1/cwd");
+ expect(get1Res.status).toBe(200);
+ const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null };
+ expect(get1Body.cwd).toBeNull();
+
+ const get2Res = await app.request("/conversations/conv2/cwd");
+ expect(get2Res.status).toBe(200);
+ const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null };
+ expect(get2Body.cwd).toBe("/other/path");
+ });
});
describe("PUT /conversations/:id/cwd", () => {
- it("with missing cwd returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("cwd");
- });
-
- it("with empty cwd returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => {
- const base = createFakeConversationStore();
- const store = createCallTrackingStore(base);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; cwd: string };
- expect(body.cwd).toBe("/home/user/project");
- // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd.
- expect(store.calls).toEqual([
- "ensureWorkspace:my-team",
- "setWorkspaceId:my-team",
- "setCwd:/home/user/project",
- ]);
- });
-
- it("PUT cwd without workspaceId: only setCwd", async () => {
- const base = createFakeConversationStore();
- const store = createCallTrackingStore(base);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project" }),
- });
- expect(res.status).toBe(200);
- // ensureWorkspace and setWorkspaceId NOT called.
- expect(store.calls).toEqual(["setCwd:/home/user/project"]);
- });
-
- it("PUT cwd with invalid workspaceId: returns 400", async () => {
- const base = createFakeConversationStore();
- const store = createCallTrackingStore(base);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- // Uppercase is not a valid workspace slug.
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("Invalid workspaceId");
- // No mutating calls should have been made.
- expect(store.calls).toEqual([]);
-
- // Empty string is also invalid.
- const res2 = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }),
- });
- expect(res2.status).toBe(400);
- });
+ it("with missing cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("cwd");
+ });
+
+ it("with empty cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string };
+ expect(body.cwd).toBe("/home/user/project");
+ // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd.
+ expect(store.calls).toEqual([
+ "ensureWorkspace:my-team",
+ "setWorkspaceId:my-team",
+ "setCwd:/home/user/project",
+ ]);
+ });
+
+ it("PUT cwd without workspaceId: only setCwd", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(res.status).toBe(200);
+ // ensureWorkspace and setWorkspaceId NOT called.
+ expect(store.calls).toEqual(["setCwd:/home/user/project"]);
+ });
+
+ it("PUT cwd with invalid workspaceId: returns 400", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ // Uppercase is not a valid workspace slug.
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("Invalid workspaceId");
+ // No mutating calls should have been made.
+ expect(store.calls).toEqual([]);
+
+ // Empty string is also invalid.
+ const res2 = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }),
+ });
+ expect(res2.status).toBe(400);
+ });
});
describe("GET /conversations/:id/lsp", () => {
- it("returns empty servers when cwd is unset", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: createFakeLspService(),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- expect(body.servers).toEqual([]);
- });
-
- it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const lspStatuses = [
- {
- id: "typescript",
- name: "TypeScript",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- },
- {
- id: "lua-lsp",
- name: "Lua LSP",
- root: "/home/user/project",
- extensions: [".luau"],
- state: "error" as const,
- error: "spawn failed",
- },
- ];
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: createFakeLspService(lspStatuses),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: string;
- readonly error?: string;
- }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/home/user/project");
- expect(body.servers).toHaveLength(2);
- expect(body.servers[0]?.id).toBe("typescript");
- expect(body.servers[0]?.state).toBe("connected");
- expect(body.servers[0]?.error).toBeUndefined();
- expect(body.servers[1]?.id).toBe("lua-lsp");
- expect(body.servers[1]?.state).toBe("error");
- expect(body.servers[1]?.error).toBe("spawn failed");
- });
-
- it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => {
- const cwdStore = new Map<string, string>(); // no persisted cwd
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const lsp = createCapturingLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/irrelevant",
- extensions: [".ts"],
- state: "connected" as const,
- },
- ]);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lsp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- expect(body.servers).toEqual([]);
- expect(lsp.statusCalls).toEqual([]); // status NOT called
- });
-
- it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
- // Persisted (relative) cwd differs from the resolved effective cwd.
- const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- // Override getEffectiveCwd to return the resolved (absolute) value.
- const resolvedStore: ConversationStore = {
- ...store,
- async getEffectiveCwd() {
- return "/workspace/subdir";
- },
- };
- const lsp = createCapturingLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/workspace/subdir",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- },
- ]);
- const app = createApp({
- conversationStore: resolvedStore,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lsp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly { readonly id: string }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
- expect(lsp.statusCalls).toEqual(["/workspace/subdir"]);
- expect(body.servers).toHaveLength(1);
- expect(body.servers[0]?.id).toBe("typescript");
- });
-
- it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- // Case 1: configSource is defined → reaches the wire verbatim.
- const lspWithSource = createFakeLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- configSource: ".dispatch/lsp.json",
- },
- ]);
- const appWithSource = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lspWithSource,
- logger: noopLogger,
- });
- const resWithSource = await appWithSource.request("/conversations/conv1/lsp");
- expect(resWithSource.status).toBe(200);
- const bodyWithSource = (await resWithSource.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly {
- readonly id: string;
- readonly configSource?: string;
- }[];
- };
- expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json");
-
- // Case 2: configSource is undefined → the field is OMITTED from the
- // response (proves exactOptionalPropertyTypes is respected — never
- // stamping `undefined` onto the wire object).
- const lspWithoutSource = createFakeLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- },
- ]);
- const appWithoutSource = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lspWithoutSource,
- logger: noopLogger,
- });
- const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp");
- expect(resWithoutSource.status).toBe(200);
- const bodyWithoutSource = (await resWithoutSource.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly Record<string, unknown>[];
- };
- expect(bodyWithoutSource.servers).toHaveLength(1);
- expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource");
- });
+ it("returns empty servers when cwd is unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ });
+
+ it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lspStatuses = [
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ {
+ id: "lua-lsp",
+ name: "Lua LSP",
+ root: "/home/user/project",
+ extensions: [".luau"],
+ state: "error" as const,
+ error: "spawn failed",
+ },
+ ];
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(lspStatuses),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: string;
+ readonly error?: string;
+ }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/home/user/project");
+ expect(body.servers).toHaveLength(2);
+ expect(body.servers[0]?.id).toBe("typescript");
+ expect(body.servers[0]?.state).toBe("connected");
+ expect(body.servers[0]?.error).toBeUndefined();
+ expect(body.servers[1]?.id).toBe("lua-lsp");
+ expect(body.servers[1]?.state).toBe("error");
+ expect(body.servers[1]?.error).toBe("spawn failed");
+ });
+
+ it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => {
+ const cwdStore = new Map<string, string>(); // no persisted cwd
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lsp = createCapturingLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/irrelevant",
+ extensions: [".ts"],
+ state: "connected" as const,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ expect(lsp.statusCalls).toEqual([]); // status NOT called
+ });
+
+ it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
+ // Persisted (relative) cwd differs from the resolved effective cwd.
+ const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ // Override getEffectiveCwd to return the resolved (absolute) value.
+ const resolvedStore: ConversationStore = {
+ ...store,
+ async getEffectiveCwd() {
+ return "/workspace/subdir";
+ },
+ };
+ const lsp = createCapturingLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/workspace/subdir",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: resolvedStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly { readonly id: string }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
+ expect(lsp.statusCalls).toEqual(["/workspace/subdir"]);
+ expect(body.servers).toHaveLength(1);
+ expect(body.servers[0]?.id).toBe("typescript");
+ });
+
+ it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ // Case 1: configSource is defined → reaches the wire verbatim.
+ const lspWithSource = createFakeLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ configSource: ".dispatch/lsp.json",
+ },
+ ]);
+ const appWithSource = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lspWithSource,
+ logger: noopLogger,
+ });
+ const resWithSource = await appWithSource.request("/conversations/conv1/lsp");
+ expect(resWithSource.status).toBe(200);
+ const bodyWithSource = (await resWithSource.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly configSource?: string;
+ }[];
+ };
+ expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json");
+
+ // Case 2: configSource is undefined → the field is OMITTED from the
+ // response (proves exactOptionalPropertyTypes is respected — never
+ // stamping `undefined` onto the wire object).
+ const lspWithoutSource = createFakeLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ ]);
+ const appWithoutSource = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lspWithoutSource,
+ logger: noopLogger,
+ });
+ const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp");
+ expect(resWithoutSource.status).toBe(200);
+ const bodyWithoutSource = (await resWithoutSource.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly Record<string, unknown>[];
+ };
+ expect(bodyWithoutSource.servers).toHaveLength(1);
+ expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource");
+ });
});
describe("GET /conversations/:id/mcp", () => {
- it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => {
- const cwdStore = new Map<string, string>(); // no persisted cwd
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const mcp = createCapturingMcpService([
- {
- id: "freecad",
- state: "connected" as const,
- toolCount: 3,
- },
- ]);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- mcpService: mcp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- expect(body.servers).toEqual([]);
- expect(mcp.statusCalls).toEqual([]); // status NOT called
- });
-
- it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const mcpStatuses = [
- {
- id: "freecad",
- state: "connected" as const,
- toolCount: 5,
- },
- {
- id: "broken",
- state: "error" as const,
- toolCount: 0,
- error: "spawn failed",
- },
- ];
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- mcpService: createFakeMcpService(mcpStatuses),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly {
- readonly id: string;
- readonly state: string;
- readonly toolCount: number;
- readonly error?: string;
- }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/home/user/project");
- expect(body.servers).toHaveLength(2);
- expect(body.servers[0]?.id).toBe("freecad");
- expect(body.servers[0]?.state).toBe("connected");
- expect(body.servers[0]?.toolCount).toBe(5);
- expect(body.servers[0]?.error).toBeUndefined();
- expect(body.servers[1]?.id).toBe("broken");
- expect(body.servers[1]?.state).toBe("error");
- expect(body.servers[1]?.toolCount).toBe(0);
- expect(body.servers[1]?.error).toBe("spawn failed");
- });
-
- it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
- const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const resolvedStore: ConversationStore = {
- ...store,
- async getEffectiveCwd() {
- return "/workspace/subdir";
- },
- };
- const mcp = createCapturingMcpService([
- {
- id: "freecad",
- state: "connected" as const,
- toolCount: 2,
- },
- ]);
- const app = createApp({
- conversationStore: resolvedStore,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- mcpService: mcp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly { readonly id: string }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
- expect(mcp.statusCalls).toEqual(["/workspace/subdir"]);
- expect(body.servers).toHaveLength(1);
- expect(body.servers[0]?.id).toBe("freecad");
- });
-
- it("MCP: returns 503 when mcpService is undefined", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- // mcpService intentionally omitted
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(503);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("MCP service not available");
- });
+ it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => {
+ const cwdStore = new Map<string, string>(); // no persisted cwd
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const mcp = createCapturingMcpService([
+ {
+ id: "freecad",
+ state: "connected" as const,
+ toolCount: 3,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ mcpService: mcp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ expect(mcp.statusCalls).toEqual([]); // status NOT called
+ });
+
+ it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const mcpStatuses = [
+ {
+ id: "freecad",
+ state: "connected" as const,
+ toolCount: 5,
+ },
+ {
+ id: "broken",
+ state: "error" as const,
+ toolCount: 0,
+ error: "spawn failed",
+ },
+ ];
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ mcpService: createFakeMcpService(mcpStatuses),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly state: string;
+ readonly toolCount: number;
+ readonly error?: string;
+ }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/home/user/project");
+ expect(body.servers).toHaveLength(2);
+ expect(body.servers[0]?.id).toBe("freecad");
+ expect(body.servers[0]?.state).toBe("connected");
+ expect(body.servers[0]?.toolCount).toBe(5);
+ expect(body.servers[0]?.error).toBeUndefined();
+ expect(body.servers[1]?.id).toBe("broken");
+ expect(body.servers[1]?.state).toBe("error");
+ expect(body.servers[1]?.toolCount).toBe(0);
+ expect(body.servers[1]?.error).toBe("spawn failed");
+ });
+
+ it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const resolvedStore: ConversationStore = {
+ ...store,
+ async getEffectiveCwd() {
+ return "/workspace/subdir";
+ },
+ };
+ const mcp = createCapturingMcpService([
+ {
+ id: "freecad",
+ state: "connected" as const,
+ toolCount: 2,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: resolvedStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ mcpService: mcp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly { readonly id: string }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
+ expect(mcp.statusCalls).toEqual(["/workspace/subdir"]);
+ expect(body.servers).toHaveLength(1);
+ expect(body.servers[0]?.id).toBe("freecad");
+ });
+
+ it("MCP: returns 503 when mcpService is undefined", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ // mcpService intentionally omitted
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(503);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("MCP service not available");
+ });
});
describe("POST /chat reasoningEffort", () => {
- const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
-
- for (const level of allLevels) {
- it(`forwards reasoningEffort="${level}" to orchestrator`, async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- reasoningEffort: level,
- }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.reasoningEffort).toBe(level);
- });
- }
-
- it("omits reasoningEffort from orchestrator input when not provided", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.reasoningEffort).toBeUndefined();
- });
-
- it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => {
- let handleMessageCalled = false;
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- async handleMessage(input) {
- handleMessageCalled = true;
- return createFakeOrchestrator([]).handleMessage(input);
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- reasoningEffort: "banana",
- }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("reasoningEffort");
- expect(handleMessageCalled).toBe(false);
- });
-
- it("returns 400 for non-string reasoningEffort", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- reasoningEffort: 42,
- }),
- });
-
- expect(res.status).toBe(400);
- });
+ const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
+
+ for (const level of allLevels) {
+ it(`forwards reasoningEffort="${level}" to orchestrator`, async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ reasoningEffort: level,
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.reasoningEffort).toBe(level);
+ });
+ }
+
+ it("omits reasoningEffort from orchestrator input when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.reasoningEffort).toBeUndefined();
+ });
+
+ it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => {
+ let handleMessageCalled = false;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ async handleMessage(input) {
+ handleMessageCalled = true;
+ return createFakeOrchestrator([]).handleMessage(input);
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ reasoningEffort: "banana",
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("reasoningEffort");
+ expect(handleMessageCalled).toBe(false);
+ });
+
+ it("returns 400 for non-string reasoningEffort", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ reasoningEffort: 42,
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
});
describe("GET /conversations/:id/reasoning-effort", () => {
- it("returns null when never set", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/reasoning-effort");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.reasoningEffort).toBeNull();
- });
-
- it("returns the level after PUT", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "xhigh" }),
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
- expect(body.reasoningEffort).toBe("xhigh");
- });
-
- it("returns null for an unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/unknown/reasoning-effort");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
- expect(body.conversationId).toBe("unknown");
- expect(body.reasoningEffort).toBeNull();
- });
+ it("returns null when never set", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/reasoning-effort");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.reasoningEffort).toBeNull();
+ });
+
+ it("returns the level after PUT", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "xhigh" }),
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
+ expect(body.reasoningEffort).toBe("xhigh");
+ });
+
+ it("returns null for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/unknown/reasoning-effort");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.reasoningEffort).toBeNull();
+ });
});
describe("PUT /conversations/:id/reasoning-effort", () => {
- it("persists a valid level and returns it", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "low" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.reasoningEffort).toBe("low");
- });
-
- it("returns 400 for an invalid level", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "banana" }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("reasoningEffort");
- });
-
- it("returns 400 when reasoningEffort is missing from body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- });
-
- it("does not call store on validation failure", async () => {
- let storeCalled = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setReasoningEffort() {
- storeCalled = true;
- },
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "invalid" }),
- });
- expect(res.status).toBe(400);
- expect(storeCalled).toBe(false);
- });
+ it("persists a valid level and returns it", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "low" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.reasoningEffort).toBe("low");
+ });
+
+ it("returns 400 for an invalid level", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "banana" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("reasoningEffort");
+ });
+
+ it("returns 400 when reasoningEffort is missing from body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("does not call store on validation failure", async () => {
+ let storeCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setReasoningEffort() {
+ storeCalled = true;
+ },
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "invalid" }),
+ });
+ expect(res.status).toBe(400);
+ expect(storeCalled).toBe(false);
+ });
});
describe("GET /conversations/:id/model", () => {
- it("returns null when never set", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/model");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.model).toBeNull();
- });
-
- it("returns the model after PUT", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
- });
-
- const res = await app.request("/conversations/conv1/model");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.model).toBe("umans/umans-glm-5.2");
- });
-
- it("returns null for an unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/unknown/model");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.conversationId).toBe("unknown");
- expect(body.model).toBeNull();
- });
+ it("returns null when never set", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBeNull();
+ });
+
+ it("returns the model after PUT", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("returns null for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/unknown/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.model).toBeNull();
+ });
});
describe("PUT /conversations/:id/model", () => {
- it("persists a non-empty model and returns it", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.model).toBe("umans/umans-glm-5.2");
-
- // A subsequent GET reflects the persisted value.
- const getRes = await app.request("/conversations/conv1/model");
- const getBody = (await getRes.json()) as { model: string | null };
- expect(getBody.model).toBe("umans/umans-glm-5.2");
- });
-
- it("clears the model when model is null and GET returns null", async () => {
- const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
- const store = createFakeConversationStore(
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- modelStore,
- );
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- // Preconditions: a model is set.
- const before = await app.request("/conversations/conv1/model");
- expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: null }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.model).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/model");
- const getBody = (await getRes.json()) as { model: string | null };
- expect(getBody.model).toBeNull();
- });
-
- it("clears the model when model is an empty string and GET returns null", async () => {
- const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
- const store = createFakeConversationStore(
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- modelStore,
- );
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: "" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.model).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/model");
- const getBody = (await getRes.json()) as { model: string | null };
- expect(getBody.model).toBeNull();
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 when model field is missing", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("model");
- });
-
- it("returns 400 when model is a non-string non-null type", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: 42 }),
- });
- expect(res.status).toBe(400);
- });
-
- it("does not call store on validation failure", async () => {
- let storeCalled = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setModel() {
- storeCalled = true;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- expect(storeCalled).toBe(false);
- });
+ it("persists a non-empty model and returns it", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBe("umans/umans-glm-5.2");
+
+ // A subsequent GET reflects the persisted value.
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("clears the model when model is null and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // Preconditions: a model is set.
+ const before = await app.request("/conversations/conv1/model");
+ expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("clears the model when model is an empty string and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when model field is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("model");
+ });
+
+ it("returns 400 when model is a non-string non-null type", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: 42 }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("does not call store on validation failure", async () => {
+ let storeCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setModel() {
+ storeCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(storeCalled).toBe(false);
+ });
});
describe("GET /conversations", () => {
- const sampleConvos: ConversationMeta[] = [
- {
- id: "conv-1",
- createdAt: 1000,
- lastActivityAt: 2000,
- title: "First",
- status: "idle",
- workspaceId: "default",
- },
- {
- id: "conv-2",
- createdAt: 1500,
- lastActivityAt: 2500,
- title: "Second",
- status: "idle",
- workspaceId: "default",
- },
- {
- id: "other-1",
- createdAt: 3000,
- lastActivityAt: 4000,
- title: "Other",
- status: "idle",
- workspaceId: "default",
- },
- ];
-
- function appWithList(list: ConversationMeta[]) {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listConversations() {
- return list;
- },
- };
- return createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- }
-
- it("returns 200 with list", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(3);
- expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]);
- });
-
- it("?q= filters by id prefix", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations?q=conv-");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(2);
- expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]);
- });
-
- it("?q= returns all when q is empty", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations?q=");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(3);
- });
-
- it("?q= with whitespace-only returns all (trimmed to empty)", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations?q=%20%20%20");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(3);
- });
-
- it("returns 500 when listConversations throws", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listConversations() {
- throw new Error("db down");
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations");
- expect(res.status).toBe(500);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("Failed to list conversations");
- });
+ const sampleConvos: ConversationMeta[] = [
+ {
+ id: "conv-1",
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ title: "First",
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "conv-2",
+ createdAt: 1500,
+ lastActivityAt: 2500,
+ title: "Second",
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "other-1",
+ createdAt: 3000,
+ lastActivityAt: 4000,
+ title: "Other",
+ status: "idle",
+ workspaceId: "default",
+ },
+ ];
+
+ function appWithList(list: ConversationMeta[]) {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations() {
+ return list;
+ },
+ };
+ return createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ }
+
+ it("returns 200 with list", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(3);
+ expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]);
+ });
+
+ it("?q= filters by id prefix", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations?q=conv-");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(2);
+ expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]);
+ });
+
+ it("?q= returns all when q is empty", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations?q=");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(3);
+ });
+
+ it("?q= with whitespace-only returns all (trimmed to empty)", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations?q=%20%20%20");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(3);
+ });
+
+ it("returns 500 when listConversations throws", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations() {
+ throw new Error("db down");
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations");
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Failed to list conversations");
+ });
});
describe("GET /conversations/:id/last", () => {
- function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async load(conversationId) {
- return messagesByConv.get(conversationId) ?? [];
- },
- };
- return createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- }
-
- it("returns last assistant text", async () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
- { role: "user", chunks: [{ type: "text", text: "more" }] },
- { role: "assistant", chunks: [{ type: "text", text: "final reply" }] },
- ];
- const app = appWithMessages(new Map([["conv1", messages]]));
- const res = await app.request("/conversations/conv1/last");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.content).toBe("final reply");
- expect(body.turnId).toBeUndefined();
- });
-
- it("returns empty content for unknown conversation", async () => {
- const app = appWithMessages(new Map());
- const res = await app.request("/conversations/unknown/last");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
- expect(body.conversationId).toBe("unknown");
- expect(body.content).toBe("");
- expect(body.turnId).toBeUndefined();
- });
-
- it("blocks until turn settles", async () => {
- const turnId = "sealed-turn";
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- subscribe(conversationId, listener) {
- const event = { type: "turn-sealed" as const, conversationId, turnId };
- setTimeout(() => listener(event), 0);
- return () => {};
- },
- isActive() {
- return true;
- },
- };
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "after seal" }] },
- ];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async load() {
- return messages;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/last");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.content).toBe("after seal");
- expect(body.turnId).toBe(turnId);
- });
+ function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async load(conversationId) {
+ return messagesByConv.get(conversationId) ?? [];
+ },
+ };
+ return createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ }
+
+ it("returns last assistant text", async () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
+ { role: "user", chunks: [{ type: "text", text: "more" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "final reply" }] },
+ ];
+ const app = appWithMessages(new Map([["conv1", messages]]));
+ const res = await app.request("/conversations/conv1/last");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.content).toBe("final reply");
+ expect(body.turnId).toBeUndefined();
+ });
+
+ it("returns empty content for unknown conversation", async () => {
+ const app = appWithMessages(new Map());
+ const res = await app.request("/conversations/unknown/last");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.content).toBe("");
+ expect(body.turnId).toBeUndefined();
+ });
+
+ it("blocks until turn settles", async () => {
+ const turnId = "sealed-turn";
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ subscribe(conversationId, listener) {
+ const event = { type: "turn-sealed" as const, conversationId, turnId };
+ setTimeout(() => listener(event), 0);
+ return () => {};
+ },
+ isActive() {
+ return true;
+ },
+ };
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "after seal" }] },
+ ];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async load() {
+ return messages;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/last");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.content).toBe("after seal");
+ expect(body.turnId).toBe(turnId);
+ });
});
describe("POST /conversations/:id/open", () => {
- it("returns 200", async () => {
- const emit: HostAPI["emit"] = () => {};
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- emit,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/open", { method: "POST" });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string };
- expect(body.conversationId).toBe("conv1");
- });
-
- it("calls emit with conversationOpened", async () => {
- const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = [];
- const emit: HostAPI["emit"] = (hook, payload) => {
- emitCalls.push({ hook, payload });
- };
- // A store whose getWorkspaceId returns a non-default id, so the test
- // proves the handler resolves and forwards the PERSISTED workspace id
- // (not a hard-coded "default").
- const store = createFakeConversationStore();
- store.getWorkspaceId = async () => "open-workspace";
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- emit,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/open", { method: "POST" });
- expect(res.status).toBe(200);
- expect(emitCalls).toHaveLength(1);
- expect(emitCalls[0]?.hook).toBe(conversationOpened);
- expect(emitCalls[0]?.payload).toEqual({
- conversationId: "conv1",
- workspaceId: "open-workspace",
- });
- });
-
- it("returns 500 when emit is absent", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/open", { method: "POST" });
- expect(res.status).toBe(500);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("not available");
- });
+ it("returns 200", async () => {
+ const emit: HostAPI["emit"] = () => {};
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ emit,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/open", { method: "POST" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string };
+ expect(body.conversationId).toBe("conv1");
+ });
+
+ it("calls emit with conversationOpened", async () => {
+ const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = [];
+ const emit: HostAPI["emit"] = (hook, payload) => {
+ emitCalls.push({ hook, payload });
+ };
+ // A store whose getWorkspaceId returns a non-default id, so the test
+ // proves the handler resolves and forwards the PERSISTED workspace id
+ // (not a hard-coded "default").
+ const store = createFakeConversationStore();
+ store.getWorkspaceId = async () => "open-workspace";
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ emit,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/open", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(emitCalls).toHaveLength(1);
+ expect(emitCalls[0]?.hook).toBe(conversationOpened);
+ expect(emitCalls[0]?.payload).toEqual({
+ conversationId: "conv1",
+ workspaceId: "open-workspace",
+ });
+ });
+
+ it("returns 500 when emit is absent", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/open", { method: "POST" });
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("not available");
+ });
});
describe("PUT /conversations/:id/title", () => {
- it("returns 200 with title", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "My Conversation" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; title: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.title).toBe("My Conversation");
- });
-
- it("rejects empty title with 400", async () => {
- let setTitleCalled = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setConversationTitle() {
- setTitleCalled = true;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: " " }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("title");
- expect(setTitleCalled).toBe(false);
- });
-
- it("returns 400 when title is missing", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("title");
- });
-
- it("forwards the trimmed title to setConversationTitle", async () => {
- const calls: { conversationId: string; title: string }[] = [];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setConversationTitle(conversationId, title) {
- calls.push({ conversationId, title });
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: " trimmed title " }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; title: string };
- expect(body.title).toBe("trimmed title");
- expect(calls).toHaveLength(1);
- expect(calls[0]?.conversationId).toBe("conv1");
- expect(calls[0]?.title).toBe("trimmed title");
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("JSON");
- });
+ it("returns 200 with title", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "My Conversation" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; title: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.title).toBe("My Conversation");
+ });
+
+ it("rejects empty title with 400", async () => {
+ let setTitleCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle() {
+ setTitleCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: " " }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ expect(setTitleCalled).toBe(false);
+ });
+
+ it("returns 400 when title is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ });
+
+ it("forwards the trimmed title to setConversationTitle", async () => {
+ const calls: { conversationId: string; title: string }[] = [];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle(conversationId, title) {
+ calls.push({ conversationId, title });
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: " trimmed title " }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; title: string };
+ expect(body.title).toBe("trimmed title");
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.conversationId).toBe("conv1");
+ expect(calls[0]?.title).toBe("trimmed title");
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("JSON");
+ });
});
describe("extractLastAssistantText", () => {
- it("returns last assistant text chunk", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
- { role: "user", chunks: [{ type: "text", text: "how are you?" }] },
- { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] },
- ];
- expect(extractLastAssistantText(messages)).toBe("I'm good!");
- });
-
- it("returns empty string when no assistant message", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "system", chunks: [{ type: "text", text: "system prompt" }] },
- ];
- expect(extractLastAssistantText(messages)).toBe("");
- });
-
- it("returns the LAST text chunk when an assistant message has multiple", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- {
- role: "assistant",
- chunks: [
- { type: "text", text: "first" },
- { type: "thinking", text: "internal reasoning" },
- { type: "text", text: "second" },
- ],
- },
- ];
- expect(extractLastAssistantText(messages)).toBe("second");
- });
-
- it("returns empty string when the last assistant message has no text chunk", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp" },
- },
- ],
- },
- ];
- expect(extractLastAssistantText(messages)).toBe("");
- });
-
- it("returns empty string for an empty message list", () => {
- expect(extractLastAssistantText([])).toBe("");
- });
+ it("returns last assistant text chunk", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
+ { role: "user", chunks: [{ type: "text", text: "how are you?" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("I'm good!");
+ });
+
+ it("returns empty string when no assistant message", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "system", chunks: [{ type: "text", text: "system prompt" }] },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("");
+ });
+
+ it("returns the LAST text chunk when an assistant message has multiple", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ {
+ role: "assistant",
+ chunks: [
+ { type: "text", text: "first" },
+ { type: "thinking", text: "internal reasoning" },
+ { type: "text", text: "second" },
+ ],
+ },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("second");
+ });
+
+ it("returns empty string when the last assistant message has no text chunk", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp" },
+ },
+ ],
+ },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("");
+ });
+
+ it("returns empty string for an empty message list", () => {
+ expect(extractLastAssistantText([])).toBe("");
+ });
});
describe("Workspaces", () => {
- const sampleWorkspace: Workspace = {
- id: "proj",
- title: "proj",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 1000,
- lastActivityAt: 2000,
- };
-
- it("GET /workspaces returns list", async () => {
- const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listWorkspaces() {
- return workspaceEntries;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces");
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceListResponse;
- expect(body.workspaces).toEqual(workspaceEntries);
- });
-
- it("PUT /workspaces/:id creates on miss", async () => {
- let ensured = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async ensureWorkspace(id, opts) {
- ensured = true;
- return { ...sampleWorkspace, id, ...opts };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
- });
- expect(res.status).toBe(200);
- expect(ensured).toBe(true);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.id).toBe("proj");
- expect(body.title).toBe("Project");
- expect(body.defaultCwd).toBe("/home/proj");
- });
-
- it("PUT /workspaces/:id returns existing", async () => {
- const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async ensureWorkspace() {
- return existing;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "New Title" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.title).toBe("Existing");
- expect(body.defaultCwd).toBe("/old");
- });
-
- it("PUT /workspaces/:id rejects invalid slug", async () => {
- let ensured = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async ensureWorkspace() {
- ensured = true;
- return sampleWorkspace;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/Bad Slug!", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- expect(ensured).toBe(false);
- });
-
- it("GET /workspaces/:id returns 404 for missing", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async getWorkspace() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/unknown");
- expect(res.status).toBe(404);
- });
-
- it("PUT /workspaces/:id/title renames", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceTitle(id, title) {
- return { ...sampleWorkspace, id, title };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "Renamed" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.title).toBe("Renamed");
- });
-
- it("PUT /workspaces/:id/default-cwd sets", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceDefaultCwd(id, defaultCwd) {
- return { ...sampleWorkspace, id, defaultCwd };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/default-cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ defaultCwd: "/new/cwd" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.defaultCwd).toBe("/new/cwd");
- });
-
- it("DELETE /workspaces/:id closes conversations", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async deleteWorkspace() {
- return { closedCount: 3 };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj", { method: "DELETE" });
- expect(res.status).toBe(200);
- const body = (await res.json()) as DeleteWorkspaceResponse;
- expect(body.workspaceId).toBe("proj");
- expect(body.closedCount).toBe(3);
- });
-
- it("DELETE /workspaces/default returns 409", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/default", { method: "DELETE" });
- expect(res.status).toBe(409);
- });
+ const sampleWorkspace: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("GET /workspaces returns list", async () => {
+ const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listWorkspaces() {
+ return workspaceEntries;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceListResponse;
+ expect(body.workspaces).toEqual(workspaceEntries);
+ });
+
+ it("PUT /workspaces/:id creates on miss", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace(id, opts) {
+ ensured = true;
+ return { ...sampleWorkspace, id, ...opts };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(ensured).toBe(true);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.id).toBe("proj");
+ expect(body.title).toBe("Project");
+ expect(body.defaultCwd).toBe("/home/proj");
+ });
+
+ it("PUT /workspaces/:id returns existing", async () => {
+ const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ return existing;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "New Title" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Existing");
+ expect(body.defaultCwd).toBe("/old");
+ });
+
+ it("PUT /workspaces/:id rejects invalid slug", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ ensured = true;
+ return sampleWorkspace;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(ensured).toBe(false);
+ });
+
+ it("GET /workspaces/:id returns 404 for missing", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getWorkspace() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("PUT /workspaces/:id/title renames", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceTitle(id, title) {
+ return { ...sampleWorkspace, id, title };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Renamed" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Renamed");
+ });
+
+ it("PUT /workspaces/:id/default-cwd sets", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { ...sampleWorkspace, id, defaultCwd };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd: "/new/cwd" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultCwd).toBe("/new/cwd");
+ });
+
+ it("DELETE /workspaces/:id closes conversations", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 3 };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeleteWorkspaceResponse;
+ expect(body.workspaceId).toBe("proj");
+ expect(body.closedCount).toBe(3);
+ });
+
+ it("DELETE /workspaces/default returns 409", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/default", { method: "DELETE" });
+ expect(res.status).toBe(409);
+ });
+
+ // ─── Star/unstar workspace (concurrency priority) ────────────────────────
+
+ it("PUT /workspaces/:id/star persists + notifies the concurrency service", async () => {
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ ...(concurrencyService !== undefined ? { concurrencyService } : {}),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "PUT" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.starred).toBe(true);
+ expect(starredCalled).toEqual({ id: "proj", starred: true });
+ });
+
+ it("DELETE /workspaces/:id/star persists + notifies the concurrency service", async () => {
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ concurrencyService,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.starred).toBe(false);
+ expect(starredCalled).toEqual({ id: "proj", starred: false });
+ });
+
+ it("PUT /workspaces/:id/star rejects invalid slug", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!/star", { method: "PUT" });
+ expect(res.status).toBe(400);
+ });
+
+ it("DELETE /workspaces/:id cleans up the in-memory starred cache (bug fix)", async () => {
+ // Bug 1 fix: deleting a workspace must notify the concurrency service to
+ // remove the workspace ID from the starred cache, preventing stale IDs.
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 2 };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ concurrencyService,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ // The concurrency service must be notified to clear the starred cache.
+ expect(starredCalled).toEqual({ id: "proj", starred: false });
+ });
+
+ it("PUT /workspaces/:id/star logs warning when concurrency service is absent (bug fix)", async () => {
+ // Bug 2 fix: when the concurrency service is not loaded, the star toggle
+ // persists but the priority cache is not updated. A warning log makes this
+ // degraded behavior visible.
+ const logger = createFakeLogger();
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ // NOTE: no concurrencyService provided — simulates the extension being absent.
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "PUT" });
+ expect(res.status).toBe(200);
+ // The star persisted, but a warning was logged about the missing service.
+ const warnings = logger.records.filter((r) => r.level === "warn");
+ expect(warnings.some((r) => r.msg.includes("concurrency service is not loaded"))).toBe(true);
+ });
});
it("POST /chat threads workspaceId", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.workspaceId).toBe("proj");
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.workspaceId).toBe("proj");
});
it("GET /conversations?workspaceId= filters", async () => {
- const calls: Parameters<ConversationStore["listConversations"]>[0][] = [];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listConversations(filter) {
- calls.push(filter);
- return [];
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations?workspaceId=proj");
- expect(res.status).toBe(200);
- expect(calls).toHaveLength(1);
- expect(calls[0]).toEqual({ workspaceId: "proj" });
+ const calls: Parameters<ConversationStore["listConversations"]>[0][] = [];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations(filter) {
+ calls.push(filter);
+ return [];
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations?workspaceId=proj");
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toEqual({ workspaceId: "proj" });
});
it("GET /conversations/:id/lsp uses effective cwd", async () => {
- let effectiveCwdCalled = false;
- let getCwdCalled = false;
- let lspCwd: string | null = null;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async getEffectiveCwd(_conversationId) {
- effectiveCwdCalled = true;
- return "/effective";
- },
- async getCwd(_conversationId) {
- getCwdCalled = true;
- return "/explicit";
- },
- };
- const lsp: LspService = {
- async status(cwd) {
- lspCwd = cwd;
- return [];
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lsp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- expect(effectiveCwdCalled).toBe(true);
- expect(getCwdCalled).toBe(true); // gated on persisted cwd first
- expect(lspCwd).toBe("/effective");
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.cwd).toBe("/effective");
+ let effectiveCwdCalled = false;
+ let getCwdCalled = false;
+ let lspCwd: string | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getEffectiveCwd(_conversationId) {
+ effectiveCwdCalled = true;
+ return "/effective";
+ },
+ async getCwd(_conversationId) {
+ getCwdCalled = true;
+ return "/explicit";
+ },
+ };
+ const lsp: LspService = {
+ async status(cwd) {
+ lspCwd = cwd;
+ return [];
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ expect(effectiveCwdCalled).toBe(true);
+ expect(getCwdCalled).toBe(true); // gated on persisted cwd first
+ expect(lspCwd).toBe("/effective");
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.cwd).toBe("/effective");
});
describe("GET /system-prompt", () => {
- it("returns stored template", async () => {
- const service = createFakeSystemPromptService("custom template");
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- systemPromptService: service,
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { template: string };
- expect(body.template).toBe("custom template");
- expect(service.getTemplateCalls).toBe(1);
- });
-
- it("returns default when service unavailable", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { template: string };
- expect(body.template).toBe(DEFAULT_TEMPLATE);
- });
+ it("returns stored template", async () => {
+ const service = createFakeSystemPromptService("custom template");
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe("custom template");
+ expect(service.getTemplateCalls).toBe(1);
+ });
+
+ it("returns default when service unavailable", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe(DEFAULT_TEMPLATE);
+ });
});
describe("PUT /system-prompt", () => {
- it("sets template", async () => {
- const service = createFakeSystemPromptService();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- systemPromptService: service,
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ template: "new" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { template: string };
- expect(body.template).toBe("new");
- expect(service.setTemplateCalls).toEqual(["new"]);
- });
-
- it("missing template → 400", async () => {
- const service = createFakeSystemPromptService();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- systemPromptService: service,
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- expect(service.setTemplateCalls).toEqual([]);
- });
-
- it("service unavailable → 503", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ template: "new" }),
- });
- expect(res.status).toBe(503);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("System prompt service not available");
- });
+ it("sets template", async () => {
+ const service = createFakeSystemPromptService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template: "new" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe("new");
+ expect(service.setTemplateCalls).toEqual(["new"]);
+ });
+
+ it("missing template → 400", async () => {
+ const service = createFakeSystemPromptService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(service.setTemplateCalls).toEqual([]);
+ });
+
+ it("service unavailable → 503", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template: "new" }),
+ });
+ expect(res.status).toBe(503);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("System prompt service not available");
+ });
});
describe("GET /system-prompt/variables", () => {
- it("returns catalog", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt/variables");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { variables: readonly SystemPromptVariable[] };
- expect(Array.isArray(body.variables)).toBe(true);
- // Contains at least system:time, prompt:cwd, and a dynamic file:<path>.
- const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time");
- const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd");
- const fileEntry = body.variables.find((v) => v.type === "file");
- expect(hasSystemTime).toBe(true);
- expect(hasPromptCwd).toBe(true);
- expect(fileEntry).toBeDefined();
- expect(fileEntry?.dynamic).toBe(true);
- });
+ it("returns catalog", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt/variables");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { variables: readonly SystemPromptVariable[] };
+ expect(Array.isArray(body.variables)).toBe(true);
+ // Contains at least system:time, prompt:cwd, and a dynamic file:<path>.
+ const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time");
+ const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd");
+ const fileEntry = body.variables.find((v) => v.type === "file");
+ expect(hasSystemTime).toBe(true);
+ expect(hasPromptCwd).toBe(true);
+ expect(fileEntry).toBeDefined();
+ expect(fileEntry?.dynamic).toBe(true);
+ });
});
// ─── Computers (mirrors the cwd / workspace routes) ─────────────────────────
const sampleComputer: Computer = {
- alias: "myserver",
- hostName: "10.0.0.5",
- port: 22,
- user: "deploy",
- identityFile: "/home/user/.ssh/id_ed25519",
- knownHost: true,
+ alias: "myserver",
+ hostName: "10.0.0.5",
+ port: 22,
+ user: "deploy",
+ identityFile: "/home/user/.ssh/id_ed25519",
+ knownHost: true,
};
describe("GET /computers", () => {
- it("returns [] when no ComputerService is wired (graceful degrade)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { computers: readonly ComputerEntry[] };
- expect(body.computers).toEqual([]);
- });
-
- it("delegates to the ComputerService when wired", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]),
- logger: noopLogger,
- });
- const res = await app.request("/computers");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { computers: readonly ComputerEntry[] };
- expect(body.computers).toHaveLength(1);
- expect(body.computers[0]?.alias).toBe("myserver");
- expect(body.computers[0]?.usageCount).toBe(2);
- });
+ it("returns [] when no ComputerService is wired (graceful degrade)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { computers: readonly ComputerEntry[] };
+ expect(body.computers).toEqual([]);
+ });
+
+ it("delegates to the ComputerService when wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { computers: readonly ComputerEntry[] };
+ expect(body.computers).toHaveLength(1);
+ expect(body.computers[0]?.alias).toBe("myserver");
+ expect(body.computers[0]?.usageCount).toBe(2);
+ });
});
describe("GET /computers/:alias", () => {
- it("returns the computer when the alias is configured", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver");
- expect(res.status).toBe(200);
- const body = (await res.json()) as Computer;
- expect(body.alias).toBe("myserver");
- expect(body.hostName).toBe("10.0.0.5");
- });
-
- it("returns 404 when the alias is not in the config", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- computerService: createFakeComputerService([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/unknown");
- expect(res.status).toBe(404);
- });
-
- it("returns 404 when no ComputerService is wired (no ssh)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver");
- expect(res.status).toBe(404);
- });
+ it("returns the computer when the alias is configured", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Computer;
+ expect(body.alias).toBe("myserver");
+ expect(body.hostName).toBe("10.0.0.5");
+ });
+
+ it("returns 404 when the alias is not in the config", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when no ComputerService is wired (no ssh)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver");
+ expect(res.status).toBe(404);
+ });
});
describe("GET /computers/:alias/status", () => {
- it("returns disconnected + knownHost:false when no ComputerService is wired", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver/status");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- alias: string;
- state: string;
- knownHost: boolean;
- };
- expect(body.alias).toBe("myserver");
- expect(body.state).toBe("disconnected");
- expect(body.knownHost).toBe(false);
- });
+ it("returns disconnected + knownHost:false when no ComputerService is wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver/status");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ alias: string;
+ state: string;
+ knownHost: boolean;
+ };
+ expect(body.alias).toBe("myserver");
+ expect(body.state).toBe("disconnected");
+ expect(body.knownHost).toBe(false);
+ });
});
describe("POST /computers/:alias/test", () => {
- it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver/test", { method: "POST" });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { alias: string; ok: boolean; error?: string };
- expect(body.alias).toBe("myserver");
- expect(body.ok).toBe(false);
- expect(body.error).toBe("SSH not configured");
- });
+ it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver/test", { method: "POST" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { alias: string; ok: boolean; error?: string };
+ expect(body.alias).toBe("myserver");
+ expect(body.ok).toBe(false);
+ expect(body.error).toBe("SSH not configured");
+ });
});
describe("GET then PUT then GET /conversations/:id/computer", () => {
- it("round-trips the value", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const get0 = await app.request("/conversations/conv1/computer");
- expect(get0.status).toBe(200);
- const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null };
- expect(get0Body.conversationId).toBe("conv1");
- expect(get0Body.computerId).toBeNull();
-
- const putRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(putRes.status).toBe(200);
- const putBody = (await putRes.json()) as { conversationId: string; computerId: string };
- expect(putBody.conversationId).toBe("conv1");
- expect(putBody.computerId).toBe("myserver");
-
- const getRes = await app.request("/conversations/conv1/computer");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null };
- expect(getBody.computerId).toBe("myserver");
- });
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const get0 = await app.request("/conversations/conv1/computer");
+ expect(get0.status).toBe(200);
+ const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null };
+ expect(get0Body.conversationId).toBe("conv1");
+ expect(get0Body.computerId).toBeNull();
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; computerId: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.computerId).toBe("myserver");
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null };
+ expect(getBody.computerId).toBe("myserver");
+ });
});
describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => {
- it("PUT null clears a previously-set computer", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(putRes.status).toBe(200);
-
- const clearRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: null }),
- });
- expect(clearRes.status).toBe(200);
- const clearBody = (await clearRes.json()) as {
- conversationId: string;
- computerId: string | null;
- };
- expect(clearBody.computerId).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/computer");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { computerId: string | null };
- expect(getBody.computerId).toBeNull();
- });
+ it("PUT null clears a previously-set computer", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const clearRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: null }),
+ });
+ expect(clearRes.status).toBe(200);
+ const clearBody = (await clearRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(clearBody.computerId).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { computerId: string | null };
+ expect(getBody.computerId).toBeNull();
+ });
});
describe("DELETE /conversations/:id/computer", () => {
- it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(putRes.status).toBe(200);
-
- const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as {
- conversationId: string;
- computerId: string | null;
- };
- expect(deleteBody.conversationId).toBe("conv1");
- expect(deleteBody.computerId).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/computer");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { computerId: string | null };
- expect(getBody.computerId).toBeNull();
- });
-
- it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as {
- conversationId: string;
- computerId: string | null;
- };
- expect(deleteBody.computerId).toBeNull();
- });
-
- it("does NOT affect other conversations' computers (isolation)", async () => {
- const computerStore = new Map<string, string>([
- ["conv1", "myserver"],
- ["conv2", "otherbox"],
- ]);
- const store = createFakeConversationStore(
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- computerStore,
- );
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
-
- const get1 = await app.request("/conversations/conv1/computer");
- expect(get1.status).toBe(200);
- expect((await get1.json()).computerId).toBeNull();
-
- const get2 = await app.request("/conversations/conv2/computer");
- expect(get2.status).toBe(200);
- expect((await get2.json()).computerId).toBe("otherbox");
- });
+ it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.computerId).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { computerId: string | null };
+ expect(getBody.computerId).toBeNull();
+ });
+
+ it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(deleteBody.computerId).toBeNull();
+ });
+
+ it("does NOT affect other conversations' computers (isolation)", async () => {
+ const computerStore = new Map<string, string>([
+ ["conv1", "myserver"],
+ ["conv2", "otherbox"],
+ ]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ computerStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+
+ const get1 = await app.request("/conversations/conv1/computer");
+ expect(get1.status).toBe(200);
+ expect((await get1.json()).computerId).toBeNull();
+
+ const get2 = await app.request("/conversations/conv2/computer");
+ expect(get2.status).toBe(200);
+ expect((await get2.json()).computerId).toBe("otherbox");
+ });
});
describe("PUT /conversations/:id/computer validation", () => {
- it("with missing computerId returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("computerId");
- });
-
- it("with empty-string computerId returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "" }),
- });
- expect(res.status).toBe(400);
- });
+ it("with missing computerId returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("computerId");
+ });
+
+ it("with empty-string computerId returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
});
describe("PUT /workspaces/:id/default-computer", () => {
- const wsSample: Workspace = {
- id: "proj",
- title: "proj",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 1000,
- lastActivityAt: 2000,
- };
-
- it("sets the default computer", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceDefaultComputerId(id, defaultComputerId) {
- return { ...wsSample, id, defaultComputerId };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/default-computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.defaultComputerId).toBe("myserver");
- });
-
- it("clears the default computer with null", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceDefaultComputerId(id, defaultComputerId) {
- return { ...wsSample, id, defaultComputerId };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/default-computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: null }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.defaultComputerId).toBeNull();
- });
+ const wsSample: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("sets the default computer", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...wsSample, id, defaultComputerId };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultComputerId).toBe("myserver");
+ });
+
+ it("clears the default computer with null", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...wsSample, id, defaultComputerId };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultComputerId).toBeNull();
+ });
});
describe("POST /chat threads computerId", () => {
- it("forwards computerId into the orchestrator input when present", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- computerId: "myserver",
- }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.conversationId).toBe("conv1");
- expect(cap.received?.computerId).toBe("myserver");
- });
-
- it("omits computerId when not provided", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.computerId).toBeUndefined();
- });
+ it("forwards computerId into the orchestrator input when present", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ computerId: "myserver",
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.conversationId).toBe("conv1");
+ expect(cap.received?.computerId).toBe("myserver");
+ });
+
+ it("omits computerId when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.computerId).toBeUndefined();
+ });
+});
+
+describe("GET /workspaces/:id/heartbeat/next-run", () => {
+ it("returns { nextRunAt: null } when no HeartbeatService is wired (graceful degrade)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { nextRunAt: string | null };
+ expect(body.nextRunAt).toBeNull();
+ });
+
+ it("delegates to the HeartbeatService and returns the next-run ISO timestamp", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: createFakeHeartbeatService("2026-06-25T14:05:00Z"),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { nextRunAt: string | null };
+ expect(body.nextRunAt).toBe("2026-06-25T14:05:00Z");
+ });
+
+ it("returns { nextRunAt: null } when the service reports no scheduled run", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: createFakeHeartbeatService(null),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { nextRunAt: string | null };
+ expect(body.nextRunAt).toBeNull();
+ });
+});
+
+describe("PUT /workspaces/:id/heartbeat", () => {
+ it("forwards inactiveOnly to the service and echoes it in the response", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: false }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { inactiveOnly: boolean };
+ expect(body.inactiveOnly).toBe(false);
+ expect(hb.captured).toHaveLength(1);
+ expect(hb.captured[0]?.workspaceId).toBe("ws-1");
+ expect(hb.captured[0]?.update.inactiveOnly).toBe(false);
+ });
+
+ it("rejects a non-boolean inactiveOnly with 400", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: "yes" }),
+ });
+ expect(res.status).toBe(400);
+ // The service was NOT called (validation happened first).
+ expect(hb.captured).toHaveLength(0);
+ });
+
+ it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined();
+ });
});