summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http
diff options
context:
space:
mode:
Diffstat (limited to 'packages/transport-http')
-rw-r--r--packages/transport-http/package.json40
-rw-r--r--packages/transport-http/src/app.test.ts8787
-rw-r--r--packages/transport-http/src/app.ts3349
-rw-r--r--packages/transport-http/src/extension.ts277
-rw-r--r--packages/transport-http/src/index.ts70
-rw-r--r--packages/transport-http/src/logic.test.ts913
-rw-r--r--packages/transport-http/src/logic.ts466
-rw-r--r--packages/transport-http/src/seam.ts36
-rw-r--r--packages/transport-http/src/server.bun.test.ts504
-rw-r--r--packages/transport-http/tsconfig.json29
10 files changed, 7886 insertions, 6585 deletions
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index e7eb85c..71da855 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -1,21 +1,23 @@
{
- "name": "@dispatch/transport-http",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "dependencies": {
- "@dispatch/conversation-store": "workspace:*",
- "@dispatch/credential-store": "workspace:*",
- "@dispatch/kernel": "workspace:*",
- "@dispatch/lsp": "workspace:*",
- "@dispatch/mcp": "workspace:*",
- "@dispatch/session-orchestrator": "workspace:*",
- "@dispatch/throughput-store": "workspace:*",
- "@dispatch/transport-contract": "workspace:*",
- "@dispatch/wire": "workspace:*",
- "hono": "^4.0.0",
- "@dispatch/system-prompt": "workspace:*"
- }
+ "name": "@dispatch/transport-http",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/credential-store": "workspace:*",
+ "@dispatch/heartbeat": "workspace:*",
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/lsp": "workspace:*",
+ "@dispatch/mcp": "workspace:*",
+ "@dispatch/provider-concurrency": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/throughput-store": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
+ "@dispatch/wire": "workspace:*",
+ "hono": "^4.0.0",
+ "@dispatch/system-prompt": "workspace:*"
+ }
}
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 4f64ece..557fb44 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -1,227 +1,234 @@
+import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat";
import type {
- AgentEvent,
- ChatMessage,
- ConversationMeta,
- HostAPI,
- Logger,
- ReasoningEffort,
- StepId,
- StorageNamespace,
- StoredChunk,
- TurnMetrics,
+ AgentEvent,
+ ChatMessage,
+ ConversationMeta,
+ HostAPI,
+ Logger,
+ ReasoningEffort,
+ StepId,
+ StorageNamespace,
+ StoredChunk,
+ TurnMetrics,
} from "@dispatch/kernel";
import { DEFAULT_TEMPLATE } from "@dispatch/system-prompt";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
- DeleteWorkspaceResponse,
- QueuedMessage,
- QueueResponse,
- SystemPromptVariable,
- ThroughputResponse,
- WorkspaceListResponse,
- WorkspaceResponse,
+ DeleteWorkspaceResponse,
+ QueueCancelResponse,
+ QueuedMessage,
+ QueueResponse,
+ SystemPromptVariable,
+ ThroughputResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { extractLastAssistantText } from "./logic.js";
import type {
- ComputerService,
- ConversationStore,
- CredentialStore,
- LspService,
- McpService,
- SessionOrchestrator,
- SystemPromptService,
- WarmService,
+ ComputerService,
+ ConversationStore,
+ CredentialStore,
+ HeartbeatService,
+ LspService,
+ McpService,
+ SessionOrchestrator,
+ SystemPromptService,
+ WarmService,
} from "./seam.js";
import { conversationOpened } from "./seam.js";
function createMemStorage(): StorageNamespace {
- const map = new Map<string, string>();
- return {
- get: async (k) => map.get(k) ?? null,
- set: async (k, v) => {
- map.set(k, v);
- },
- delete: async (k) => {
- map.delete(k);
- },
- has: async (k) => map.has(k),
- keys: async (prefix) =>
- [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
- };
+ const map = new Map<string, string>();
+ return {
+ get: async (k) => map.get(k) ?? null,
+ set: async (k, v) => {
+ map.set(k, v);
+ },
+ delete: async (k) => {
+ map.delete(k);
+ },
+ has: async (k) => map.has(k),
+ keys: async (prefix) =>
+ [...map.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
+ };
}
interface CapturedLog {
- readonly level: "debug" | "info" | "warn" | "error";
- readonly msg: string;
- readonly attrs?: Record<string, unknown>;
+ readonly level: "debug" | "info" | "warn" | "error";
+ readonly msg: string;
+ readonly attrs?: Record<string, unknown>;
}
function createFakeLogger(): Logger & { readonly records: readonly CapturedLog[] } {
- const records: CapturedLog[] = [];
- return {
- get records() {
- return records;
- },
- debug(msg, attrs) {
- records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) });
- },
- info(msg, attrs) {
- records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) });
- },
- warn(msg, attrs) {
- records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) });
- },
- error(msg, attrs) {
- records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) });
- },
- child() {
- return createFakeLogger();
- },
- span() {
- return {
- id: "fake-span",
- log: createFakeLogger(),
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
- };
+ const records: CapturedLog[] = [];
+ return {
+ get records() {
+ return records;
+ },
+ debug(msg, attrs) {
+ records.push({ level: "debug", msg, ...(attrs ? { attrs } : {}) });
+ },
+ info(msg, attrs) {
+ records.push({ level: "info", msg, ...(attrs ? { attrs } : {}) });
+ },
+ warn(msg, attrs) {
+ records.push({ level: "warn", msg, ...(attrs ? { attrs } : {}) });
+ },
+ error(msg, attrs) {
+ records.push({ level: "error", msg, ...(attrs ? { attrs } : {}) });
+ },
+ child() {
+ return createFakeLogger();
+ },
+ span() {
+ return {
+ id: "fake-span",
+ log: createFakeLogger(),
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
+ };
}
function createFakeConversationStore(
- store: Map<string, StoredChunk[]> = new Map(),
- metricsStore: Map<string, TurnMetrics[]> = new Map(),
- cwdStore: Map<string, string> = new Map(),
- reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
- modelStore: Map<string, string> = new Map(),
- computerStore: Map<string, string> = new Map(),
+ store: Map<string, StoredChunk[]> = new Map(),
+ metricsStore: Map<string, TurnMetrics[]> = new Map(),
+ cwdStore: Map<string, string> = new Map(),
+ reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
+ modelStore: Map<string, string> = new Map(),
+ computerStore: Map<string, string> = new Map(),
): ConversationStore {
- const sampleWorkspace = {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- return {
- async append() {},
- async load() {
- return [];
- },
- async loadSince(conversationId, sinceSeq, window) {
- const chunks = store.get(conversationId) ?? [];
- const minSeq = sinceSeq ?? 0;
- const beforeSeq = window?.beforeSeq;
- const limit = window?.limit;
- const selected = chunks.filter(
- (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq),
- );
- // Window: keep only the NEWEST `limit`, still ascending by seq.
- if (limit !== undefined && selected.length > limit) {
- return selected.slice(selected.length - limit);
- }
- return selected;
- },
- async appendMetrics() {},
- async loadMetrics(conversationId) {
- return metricsStore.get(conversationId) ?? [];
- },
- async getCwd(conversationId) {
- return cwdStore.get(conversationId) ?? null;
- },
- async setCwd(conversationId, cwd) {
- cwdStore.set(conversationId, cwd);
- },
- async clearCwd(conversationId) {
- cwdStore.delete(conversationId);
- },
- async getComputerId(conversationId) {
- return computerStore.get(conversationId) ?? null;
- },
- async setComputerId(conversationId, alias) {
- if (alias === null) {
- computerStore.delete(conversationId);
- } else {
- computerStore.set(conversationId, alias);
- }
- },
- async clearComputerId(conversationId) {
- computerStore.delete(conversationId);
- },
- async getReasoningEffort(conversationId) {
- return reasoningEffortStore.get(conversationId) ?? null;
- },
- async setReasoningEffort(conversationId, effort) {
- reasoningEffortStore.set(conversationId, effort);
- },
- async getModel(conversationId) {
- return modelStore.get(conversationId) ?? null;
- },
- async setModel(conversationId, model) {
- if (model === "") {
- modelStore.delete(conversationId);
- } else {
- modelStore.set(conversationId, model);
- }
- },
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return sampleWorkspace;
- },
- async setWorkspaceTitle() {
- return sampleWorkspace;
- },
- async setWorkspaceDefaultCwd() {
- return sampleWorkspace;
- },
- async setWorkspaceDefaultComputerId(id, defaultComputerId) {
- return { ...sampleWorkspace, id, defaultComputerId };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd(conversationId) {
- return cwdStore.get(conversationId) ?? null;
- },
- async getEffectiveComputer(conversationId) {
- return computerStore.get(conversationId) ?? null;
- },
- };
+ const sampleWorkspace = {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ return {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince(conversationId, sinceSeq, window) {
+ const chunks = store.get(conversationId) ?? [];
+ const minSeq = sinceSeq ?? 0;
+ const beforeSeq = window?.beforeSeq;
+ const limit = window?.limit;
+ const selected = chunks.filter(
+ (c) => c.seq > minSeq && (beforeSeq === undefined || c.seq < beforeSeq),
+ );
+ // Window: keep only the NEWEST `limit`, still ascending by seq.
+ if (limit !== undefined && selected.length > limit) {
+ return selected.slice(selected.length - limit);
+ }
+ return selected;
+ },
+ async appendMetrics() {},
+ async loadMetrics(conversationId) {
+ return metricsStore.get(conversationId) ?? [];
+ },
+ async getCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
+ async setCwd(conversationId, cwd) {
+ cwdStore.set(conversationId, cwd);
+ },
+ async clearCwd(conversationId) {
+ cwdStore.delete(conversationId);
+ },
+ async getComputerId(conversationId) {
+ return computerStore.get(conversationId) ?? null;
+ },
+ async setComputerId(conversationId, alias) {
+ if (alias === null) {
+ computerStore.delete(conversationId);
+ } else {
+ computerStore.set(conversationId, alias);
+ }
+ },
+ async clearComputerId(conversationId) {
+ computerStore.delete(conversationId);
+ },
+ async getReasoningEffort(conversationId) {
+ return reasoningEffortStore.get(conversationId) ?? null;
+ },
+ async setReasoningEffort(conversationId, effort) {
+ reasoningEffortStore.set(conversationId, effort);
+ },
+ async getModel(conversationId) {
+ return modelStore.get(conversationId) ?? null;
+ },
+ async setModel(conversationId, model) {
+ if (model === "") {
+ modelStore.delete(conversationId);
+ } else {
+ modelStore.set(conversationId, model);
+ }
+ },
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return sampleWorkspace;
+ },
+ async setWorkspaceTitle() {
+ return sampleWorkspace;
+ },
+ async setWorkspaceDefaultCwd() {
+ return sampleWorkspace;
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...sampleWorkspace, id, defaultComputerId };
+ },
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
+ async getEffectiveComputer(conversationId) {
+ return computerStore.get(conversationId) ?? null;
+ },
+ };
}
/**
@@ -230,4103 +237,4661 @@ function createFakeConversationStore(
* assert that workspace assignment happens BEFORE setCwd.
*/
function createCallTrackingStore(
- base: ConversationStore,
+ base: ConversationStore,
): ConversationStore & { readonly calls: readonly string[] } {
- const calls: string[] = [];
- return {
- ...base,
- get calls() {
- return calls;
- },
- async ensureWorkspace(id, opts) {
- calls.push(`ensureWorkspace:${id}`);
- return base.ensureWorkspace(id, opts);
- },
- async setWorkspaceId(conversationId, workspaceId) {
- calls.push(`setWorkspaceId:${workspaceId}`);
- await base.setWorkspaceId(conversationId, workspaceId);
- },
- async setCwd(conversationId, cwd) {
- calls.push(`setCwd:${cwd}`);
- await base.setCwd(conversationId, cwd);
- },
- };
+ const calls: string[] = [];
+ return {
+ ...base,
+ get calls() {
+ return calls;
+ },
+ async ensureWorkspace(id, opts) {
+ calls.push(`ensureWorkspace:${id}`);
+ return base.ensureWorkspace(id, opts);
+ },
+ async setWorkspaceId(conversationId, workspaceId) {
+ calls.push(`setWorkspaceId:${workspaceId}`);
+ await base.setWorkspaceId(conversationId, workspaceId);
+ },
+ async setCwd(conversationId, cwd) {
+ calls.push(`setCwd:${cwd}`);
+ await base.setCwd(conversationId, cwd);
+ },
+ };
}
function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator {
- return {
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage(input) {
- for (const event of events) {
- input.onEvent(event);
- }
- },
- };
+ return {
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage(input) {
+ for (const event of events) {
+ input.onEvent(event);
+ }
+ },
+ };
}
function createCapturingOrchestrator(): SessionOrchestrator & {
- received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
+ received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
} {
- const state: {
- received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
- } = { received: undefined };
- return {
- get received() {
- return state.received;
- },
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage(input) {
- state.received = input;
- },
- };
+ const state: {
+ received: Parameters<SessionOrchestrator["handleMessage"]>[0] | undefined;
+ } = { received: undefined };
+ return {
+ get received() {
+ return state.received;
+ },
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage(input) {
+ state.received = input;
+ },
+ };
}
function createThrowingOrchestrator(error: Error): SessionOrchestrator {
- return {
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage() {
- throw error;
- },
- };
+ return {
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage() {
+ throw error;
+ },
+ };
}
function createFakeCredentialStore(models: string[]): CredentialStore {
- return {
- resolve() {
- return undefined;
- },
- async getModelInfo() {
- return undefined;
- },
- async listCatalog() {
- return models;
- },
- };
+ return {
+ resolve() {
+ return undefined;
+ },
+ async getModelInfo() {
+ return undefined;
+ },
+ async listCatalog() {
+ return models;
+ },
+ };
}
function createThrowingCredentialStore(error: Error): CredentialStore {
- return {
- resolve() {
- return undefined;
- },
- async getModelInfo() {
- return undefined;
- },
- async listCatalog() {
- throw error;
- },
- };
+ return {
+ resolve() {
+ return undefined;
+ },
+ async getModelInfo() {
+ return undefined;
+ },
+ async listCatalog() {
+ throw error;
+ },
+ };
}
function createFakeWarmService(
- result:
- | {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- }
- | { error: string },
+ result:
+ | {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ }
+ | { error: string },
): WarmService {
- return {
- async warm() {
- return result;
- },
- };
+ return {
+ async warm() {
+ return result;
+ },
+ };
}
function createFakeLspService(
- statuses: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: "connected" | "starting" | "error" | "not-started";
- readonly error?: string;
- readonly configSource?: string;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: "connected" | "starting" | "error" | "not-started";
+ readonly error?: string;
+ readonly configSource?: string;
+ }[] = [],
): LspService {
- return {
- async status() {
- return statuses;
- },
- };
+ return {
+ async status() {
+ return statuses;
+ },
+ };
}
function createCapturingLspService(
- statuses: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: "connected" | "starting" | "error" | "not-started";
- readonly error?: string;
- readonly configSource?: string;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: "connected" | "starting" | "error" | "not-started";
+ readonly error?: string;
+ readonly configSource?: string;
+ }[] = [],
): LspService & { readonly statusCalls: readonly string[] } {
- const calls: string[] = [];
- return {
- get statusCalls() {
- return calls;
- },
- async status(cwd) {
- calls.push(cwd);
- return statuses;
- },
- };
+ const calls: string[] = [];
+ return {
+ get statusCalls() {
+ return calls;
+ },
+ async status(cwd) {
+ calls.push(cwd);
+ return statuses;
+ },
+ };
}
function createFakeMcpService(
- statuses: readonly {
- readonly id: string;
- readonly state: "connecting" | "connected" | "error" | "disconnected";
- readonly error?: string;
- readonly toolCount: number;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly state: "connecting" | "connected" | "error" | "disconnected";
+ readonly error?: string;
+ readonly toolCount: number;
+ }[] = [],
): McpService {
- return {
- async status() {
- return statuses;
- },
- };
+ return {
+ async status() {
+ return statuses;
+ },
+ };
}
function createCapturingMcpService(
- statuses: readonly {
- readonly id: string;
- readonly state: "connecting" | "connected" | "error" | "disconnected";
- readonly error?: string;
- readonly toolCount: number;
- }[] = [],
+ statuses: readonly {
+ readonly id: string;
+ readonly state: "connecting" | "connected" | "error" | "disconnected";
+ readonly error?: string;
+ readonly toolCount: number;
+ }[] = [],
): McpService & { readonly statusCalls: readonly string[] } {
- const calls: string[] = [];
- return {
- get statusCalls() {
- return calls;
- },
- async status(cwd) {
- calls.push(cwd);
- return statuses;
- },
- };
+ const calls: string[] = [];
+ return {
+ get statusCalls() {
+ return calls;
+ },
+ async status(cwd) {
+ calls.push(cwd);
+ return statuses;
+ },
+ };
}
function createFakeSystemPromptService(
- template: string = "custom template",
+ template: string = "custom template",
): SystemPromptService & {
- readonly setTemplateCalls: readonly string[];
- readonly getTemplateCalls: number;
+ readonly setTemplateCalls: readonly string[];
+ readonly getTemplateCalls: number;
} {
- const setCalls: string[] = [];
- let getTemplateCount = 0;
- let currentTemplate = template;
- return {
- get setTemplateCalls() {
- return setCalls;
- },
- get getTemplateCalls() {
- return getTemplateCount;
- },
- async construct() {
- return currentTemplate;
- },
- async get() {
- return currentTemplate;
- },
- async getTemplate() {
- getTemplateCount++;
- return currentTemplate;
- },
- async setTemplate(t) {
- setCalls.push(t);
- currentTemplate = t;
- },
- };
+ const setCalls: string[] = [];
+ let getTemplateCount = 0;
+ let currentTemplate = template;
+ return {
+ get setTemplateCalls() {
+ return setCalls;
+ },
+ get getTemplateCalls() {
+ return getTemplateCount;
+ },
+ async construct() {
+ return currentTemplate;
+ },
+ async get() {
+ return currentTemplate;
+ },
+ async getTemplate() {
+ getTemplateCount++;
+ return currentTemplate;
+ },
+ async setTemplate(t) {
+ setCalls.push(t);
+ currentTemplate = t;
+ },
+ };
}
function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService {
- const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c]));
- return {
- async listComputers() {
- return computers;
- },
- async getComputer(alias) {
- return byAlias.get(alias) ?? null;
- },
- async getStatus(alias) {
- const known = byAlias.has(alias);
- return { alias, state: "disconnected", knownHost: known };
- },
- async test(alias) {
- return byAlias.has(alias)
- ? { alias, ok: true }
- : { alias, ok: false, error: "Computer not found" };
- },
- };
+ const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c]));
+ return {
+ async listComputers() {
+ return computers;
+ },
+ async getComputer(alias) {
+ return byAlias.get(alias) ?? null;
+ },
+ async getStatus(alias) {
+ const known = byAlias.has(alias);
+ return { alias, state: "disconnected", knownHost: known };
+ },
+ async test(alias) {
+ return byAlias.has(alias)
+ ? { alias, ok: true }
+ : { alias, ok: false, error: "Computer not found" };
+ },
+ };
+}
+
+/**
+ * A minimal HeartbeatService fake for the next-run route: only `nextRunAt` is
+ * exercised by the route; the rest return inert defaults so the object
+ * satisfies the interface without dragging in real stores/scheduler.
+ */
+function createFakeHeartbeatService(nextRunAt: string | null): HeartbeatService {
+ return {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => nextRunAt,
+ };
+}
+
+/**
+ * A HeartbeatService fake that CAPTURES the updateConfig call (workspaceId +
+ * partial update) and returns a config echoing the captured update on top of
+ * the defaults — for asserting the PUT /workspaces/:id/heartbeat route forwards
+ * validated fields to the service.
+ */
+function createCapturingHeartbeatService(): HeartbeatService & {
+ readonly captured: { workspaceId: string; update: Record<string, unknown> }[];
+} {
+ const captured: { workspaceId: string; update: Record<string, unknown> }[] = [];
+ const svc: HeartbeatService = {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ async updateConfig(workspaceId, update) {
+ captured.push({ workspaceId, update: update as Record<string, unknown> });
+ return { ...DEFAULT_HEARTBEAT_CONFIG, ...update };
+ },
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => null,
+ };
+ return Object.assign(svc, {
+ get captured() {
+ return captured;
+ },
+ });
}
const noopLogger = createFakeLogger();
describe("GET /health", () => {
- it("returns ok", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/health");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ ok: true });
- });
+ it("returns ok", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/health");
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body).toEqual({ ok: true });
+ });
});
describe("GET /models", () => {
- it("returns model catalog", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]),
- logger: noopLogger,
- });
- const res = await app.request("/models");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { models: readonly string[] };
- expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]);
- });
-
- it("returns empty array when no models", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/models");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { models: readonly string[] };
- expect(body.models).toEqual([]);
- });
-
- it("returns 502 when listCatalog throws", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createThrowingCredentialStore(new Error("db down")),
- logger: noopLogger,
- });
- const res = await app.request("/models");
- expect(res.status).toBe(502);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("Failed to retrieve model catalog");
- });
+ it("returns model catalog", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore(["opencode/m1", "openai/gpt-4"]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/models");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { models: readonly string[] };
+ expect(body.models).toEqual(["opencode/m1", "openai/gpt-4"]);
+ });
+
+ it("returns empty array when no models", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/models");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { models: readonly string[] };
+ expect(body.models).toEqual([]);
+ });
+
+ it("returns 502 when listCatalog throws", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createThrowingCredentialStore(new Error("db down")),
+ logger: noopLogger,
+ });
+ const res = await app.request("/models");
+ expect(res.status).toBe(502);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Failed to retrieve model catalog");
+ });
});
describe("POST /chat", () => {
- it("returns 400 for invalid JSON", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 for missing message", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "c1" }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("message");
- });
-
- it("returns 400 for empty message", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("streams events as NDJSON", async () => {
- const events: AgentEvent[] = [
- { type: "turn-start", conversationId: "tab1", turnId: "turn1" },
- { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" },
- { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" },
- { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
- ];
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator(events),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
- expect(res.headers.get("X-Conversation-Id")).toBe("conv1");
-
- const text = await res.text();
- const lines = text.trim().split("\n");
- expect(lines).toHaveLength(4);
-
- const parsed = lines.map((line) => JSON.parse(line) as AgentEvent);
- expect(parsed[0]?.type).toBe("turn-start");
- expect(parsed[1]?.type).toBe("text-delta");
- expect((parsed[1] as { delta: string }).delta).toBe("Hello");
- expect(parsed[2]?.type).toBe("text-delta");
- expect(parsed[3]?.type).toBe("done");
- });
-
- it("generates conversationId when not provided", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([
- { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
- ]),
- credentialStore: createFakeCredentialStore([]),
- generateId: () => "generated-uuid",
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi" }),
- });
-
- expect(res.status).toBe(200);
- expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid");
- });
-
- it("emits error event when orchestrator throws", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createThrowingOrchestrator(new Error("provider unavailable")),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const text = await res.text();
- const lines = text.trim().split("\n");
- expect(lines.length).toBeGreaterThanOrEqual(1);
-
- const lastLine = lines[lines.length - 1];
- if (!lastLine) throw new Error("expected at least one line");
- const lastEvent = JSON.parse(lastLine) as AgentEvent;
- expect(lastEvent.type).toBe("error");
- if (lastEvent.type === "error") {
- expect(lastEvent.message).toContain("provider unavailable");
- }
- });
-
- it("handles empty event list", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi" }),
- });
-
- expect(res.status).toBe(200);
- const text = await res.text();
- expect(text).toBe("");
- });
-
- it("forwards modelName and cwd to orchestrator", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- model: "opencode/m1",
- cwd: "/tmp",
- }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.conversationId).toBe("conv1");
- expect(cap.received?.text).toBe("hi");
- expect(cap.received?.modelName).toBe("opencode/m1");
- expect(cap.received?.cwd).toBe("/tmp");
- });
-
- it("omits modelName and cwd when not provided", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.modelName).toBeUndefined();
- expect(cap.received?.cwd).toBeUndefined();
- });
+ it("returns 400 for invalid JSON", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing message", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "c1" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("message");
+ });
+
+ it("returns 400 for empty message", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("streams events as NDJSON", async () => {
+ const events: AgentEvent[] = [
+ { type: "turn-start", conversationId: "tab1", turnId: "turn1" },
+ { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: "Hello" },
+ { type: "text-delta", conversationId: "tab1", turnId: "turn1", delta: " world" },
+ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
+ ];
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator(events),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
+ expect(res.headers.get("X-Conversation-Id")).toBe("conv1");
+
+ const text = await res.text();
+ const lines = text.trim().split("\n");
+ expect(lines).toHaveLength(4);
+
+ const parsed = lines.map((line) => JSON.parse(line) as AgentEvent);
+ expect(parsed[0]?.type).toBe("turn-start");
+ expect(parsed[1]?.type).toBe("text-delta");
+ expect((parsed[1] as { delta: string }).delta).toBe("Hello");
+ expect(parsed[2]?.type).toBe("text-delta");
+ expect(parsed[3]?.type).toBe("done");
+ });
+
+ it("generates conversationId when not provided", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([
+ { type: "done", conversationId: "tab1", turnId: "turn1", reason: "stop" },
+ ]),
+ credentialStore: createFakeCredentialStore([]),
+ generateId: () => "generated-uuid",
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid");
+ });
+
+ it("emits error event when orchestrator throws", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createThrowingOrchestrator(new Error("provider unavailable")),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ const lines = text.trim().split("\n");
+ expect(lines.length).toBeGreaterThanOrEqual(1);
+
+ const lastLine = lines[lines.length - 1];
+ if (!lastLine) throw new Error("expected at least one line");
+ const lastEvent = JSON.parse(lastLine) as AgentEvent;
+ expect(lastEvent.type).toBe("error");
+ if (lastEvent.type === "error") {
+ expect(lastEvent.message).toContain("provider unavailable");
+ }
+ });
+
+ it("handles empty event list", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi" }),
+ });
+
+ expect(res.status).toBe(200);
+ const text = await res.text();
+ expect(text).toBe("");
+ });
+
+ it("forwards modelName and cwd to orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ model: "opencode/m1",
+ cwd: "/tmp",
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.conversationId).toBe("conv1");
+ expect(cap.received?.text).toBe("hi");
+ expect(cap.received?.modelName).toBe("opencode/m1");
+ expect(cap.received?.cwd).toBe("/tmp");
+ });
+
+ it("omits modelName and cwd when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.modelName).toBeUndefined();
+ expect(cap.received?.cwd).toBeUndefined();
+ });
+
+ it("forwards the title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.title).toBe("My Task");
+ });
+
+ it("forwards a trimmed title to the orchestrator", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " spaced " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBe("spaced");
+ });
+
+ it("does not forward a title when omitted", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("does not forward a title for a whitespace-only title", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: " " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received?.title).toBeUndefined();
+ });
+
+ it("returns 400 when title is not a string", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: 42 }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ });
+
+ it("does not call setConversationTitle itself (the orchestrator owns it)", async () => {
+ let setTitleCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle() {
+ setTitleCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", title: "My Task" }),
+ });
+
+ expect(res.status).toBe(200);
+ // The route must NOT pre-create the meta — that would bypass the
+ // orchestrator's new-conversation workspace/system-prompt init. The
+ // orchestrator sets the title after workspace setup instead.
+ expect(setTitleCalled).toBe(false);
+ });
});
describe("POST /chat/warm", () => {
- it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 1000,
- outputTokens: 200,
- cacheReadTokens: 800,
- cacheWriteTokens: 100,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- cachePct: number;
- expectedCacheRate: number;
- };
- expect(body.inputTokens).toBe(1000);
- expect(body.outputTokens).toBe(200);
- expect(body.cacheReadTokens).toBe(800);
- expect(body.cacheWriteTokens).toBe(100);
- expect(body.cachePct).toBe(80);
- expect(body.expectedCacheRate).toBe(89);
- });
-
- it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 500,
- outputTokens: 100,
- cacheReadTokens: 400,
- cacheWriteTokens: 100,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as { expectedCacheRate: number };
- expect(body.expectedCacheRate).toBe(80);
- });
-
- it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 100,
- outputTokens: 50,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as { expectedCacheRate: number };
- expect(body.expectedCacheRate).toBe(0);
- });
-
- it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({ error: "conversation is generating" }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(409);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("conversation is generating");
- });
-
- it("POST /chat/warm returns 400 when conversationId is missing", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- warmService: createFakeWarmService({
- inputTokens: 0,
- outputTokens: 0,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- logger: noopLogger,
- });
-
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("conversationId");
- });
+ it("POST /chat/warm returns 200 with cachePct from the warm usage", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 1000,
+ outputTokens: 200,
+ cacheReadTokens: 800,
+ cacheWriteTokens: 100,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheWriteTokens: number;
+ cachePct: number;
+ expectedCacheRate: number;
+ };
+ expect(body.inputTokens).toBe(1000);
+ expect(body.outputTokens).toBe(200);
+ expect(body.cacheReadTokens).toBe(800);
+ expect(body.cacheWriteTokens).toBe(100);
+ expect(body.cachePct).toBe(80);
+ expect(body.expectedCacheRate).toBe(89);
+ });
+
+ it("POST /chat/warm returns expectedCacheRate = round(cacheRead/(cacheRead+cacheWrite)*100)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 500,
+ outputTokens: 100,
+ cacheReadTokens: 400,
+ cacheWriteTokens: 100,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { expectedCacheRate: number };
+ expect(body.expectedCacheRate).toBe(80);
+ });
+
+ it("POST /chat/warm returns expectedCacheRate = 0 when cacheRead+cacheWrite is 0", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 100,
+ outputTokens: 50,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { expectedCacheRate: number };
+ expect(body.expectedCacheRate).toBe(0);
+ });
+
+ it("POST /chat/warm returns 409 when the warm service reports the conversation is generating", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({ error: "conversation is generating" }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(409);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("conversation is generating");
+ });
+
+ it("POST /chat/warm returns 400 when conversationId is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ warmService: createFakeWarmService({
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ }),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/chat/warm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("conversationId");
+ });
});
describe("GET /conversations/:id", () => {
- const sampleChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
- { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } },
- { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } },
- ];
-
- it("returns the full seq-ordered StoredChunk history", async () => {
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(4);
- expect(body.chunks[0]?.seq).toBe(1);
- expect(body.chunks[3]?.seq).toBe(4);
- expect(body.latestSeq).toBe(4);
- });
-
- it("returns only chunks with seq > N and latestSeq = last seq", async () => {
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=2");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(2);
- expect(body.chunks[0]?.seq).toBe(3);
- expect(body.chunks[1]?.seq).toBe(4);
- expect(body.latestSeq).toBe(4);
- });
-
- it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => {
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=4");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(0);
- expect(body.latestSeq).toBe(4);
- });
-
- it("returns empty chunks and latestSeq 0 for unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/unknown");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks).toHaveLength(0);
- expect(body.latestSeq).toBe(0);
- });
-
- it("returns 400 for invalid sinceSeq", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=abc");
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("sinceSeq");
- });
-
- it("returns 400 for negative sinceSeq", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1?sinceSeq=-1");
- expect(res.status).toBe(400);
- });
-
- const sixChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "one" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } },
- { seq: 3, role: "user", chunk: { type: "text", text: "three" } },
- { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } },
- { seq: 5, role: "user", chunk: { type: "text", text: "five" } },
- { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } },
- ];
-
- function appWithChunks(chunks: StoredChunk[]) {
- const store = new Map<string, StoredChunk[]>([["conv1", chunks]]);
- return createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- }
-
- it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?limit=2");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]);
- expect(body.latestSeq).toBe(6);
- });
-
- it("?limit=N with N >= conversation size returns the full log", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?limit=10");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]);
- expect(body.latestSeq).toBe(6);
- });
-
- it("?beforeSeq=S returns only chunks with seq < S", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?beforeSeq=3");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]);
- expect(body.latestSeq).toBe(2);
- });
-
- it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- // selection = seq 1..4; newest 2 = [3, 4]
- expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
- expect(body.latestSeq).toBe(4);
- });
-
- it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => {
- const app = appWithChunks(sixChunks);
- const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
- expect(body.latestSeq).toBe(4);
- });
-
- describe("window param validation → 400 and store not called with an invalid window", () => {
- function appCapturingWindow() {
- const calls: {
- readonly sinceSeq: number | undefined;
- readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined;
- }[] = [];
- const store: ConversationStore = {
- async append() {},
- async load() {
- return [];
- },
- async loadSince(_conversationId, sinceSeq, window) {
- calls.push({ sinceSeq, window });
- return [];
- },
- async appendMetrics() {},
- async loadMetrics() {
- return [];
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async clearCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
- return { app, calls };
- }
-
- const cases: readonly { readonly name: string; readonly query: string }[] = [
- { name: "limit=0", query: "limit=0" },
- { name: "limit=-1", query: "limit=-1" },
- { name: "limit=abc", query: "limit=abc" },
- { name: "beforeSeq=0", query: "beforeSeq=0" },
- { name: "beforeSeq=1.5", query: "beforeSeq=1.5" },
- ];
-
- for (const { name, query } of cases) {
- it(`${name} → 400 { error } and loadSince is never called`, async () => {
- const { app, calls } = appCapturingWindow();
- const res = await app.request(`/conversations/conv1?${query}`);
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(typeof body.error).toBe("string");
- expect(body.error.length).toBeGreaterThan(0);
- expect(calls).toHaveLength(0);
- });
- }
- });
-
- it("no params → byte-identical to the no-window read (regression guard)", async () => {
- // Rest-param spy: record how many args the route actually passes, so we
- // can prove the third (window) arg is OMITTED entirely — not merely
- // forwarded as undefined — preserving the existing two-arg call shape.
- const argCounts: number[] = [];
- const store: ConversationStore = {
- async append() {},
- async load() {
- return [];
- },
- loadSince(...args: Parameters<ConversationStore["loadSince"]>) {
- argCounts.push(args.length);
- const sinceSeq = args[1] ?? 0;
- return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq));
- },
- async appendMetrics() {},
- async loadMetrics() {
- return [];
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async clearCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
- expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]);
- expect(body.latestSeq).toBe(4);
- // Called once, with exactly two arguments (no window arg).
- expect(argCounts).toEqual([2]);
- });
+ const sampleChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
+ { seq: 3, role: "user", chunk: { type: "text", text: "how are you?" } },
+ { seq: 4, role: "assistant", chunk: { type: "text", text: "I'm good!" } },
+ ];
+
+ it("returns the full seq-ordered StoredChunk history", async () => {
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(4);
+ expect(body.chunks[0]?.seq).toBe(1);
+ expect(body.chunks[3]?.seq).toBe(4);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("returns only chunks with seq > N and latestSeq = last seq", async () => {
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(2);
+ expect(body.chunks[0]?.seq).toBe(3);
+ expect(body.chunks[1]?.seq).toBe(4);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("returns empty chunks and latestSeq === sinceSeq when caught up", async () => {
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=4");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(0);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("returns empty chunks and latestSeq 0 for unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/unknown");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks).toHaveLength(0);
+ expect(body.latestSeq).toBe(0);
+ });
+
+ it("returns 400 for invalid sinceSeq", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=abc");
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("sinceSeq");
+ });
+
+ it("returns 400 for negative sinceSeq", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1?sinceSeq=-1");
+ expect(res.status).toBe(400);
+ });
+
+ const sixChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "one" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "two" } },
+ { seq: 3, role: "user", chunk: { type: "text", text: "three" } },
+ { seq: 4, role: "assistant", chunk: { type: "text", text: "four" } },
+ { seq: 5, role: "user", chunk: { type: "text", text: "five" } },
+ { seq: 6, role: "assistant", chunk: { type: "text", text: "six" } },
+ ];
+
+ function appWithChunks(chunks: StoredChunk[]) {
+ const store = new Map<string, StoredChunk[]>([["conv1", chunks]]);
+ return createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ }
+
+ it("?limit=N returns only the newest N chunks, ascending, latestSeq = last seq", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?limit=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([5, 6]);
+ expect(body.latestSeq).toBe(6);
+ });
+
+ it("?limit=N with N >= conversation size returns the full log", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?limit=10");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4, 5, 6]);
+ expect(body.latestSeq).toBe(6);
+ });
+
+ it("?beforeSeq=S returns only chunks with seq < S", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?beforeSeq=3");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2]);
+ expect(body.latestSeq).toBe(2);
+ });
+
+ it("?beforeSeq=S&limit=N returns the newest N below S, ascending", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?beforeSeq=5&limit=2");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ // selection = seq 1..4; newest 2 = [3, 4]
+ expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ it("?sinceSeq=A&beforeSeq=B returns A < seq < B", async () => {
+ const app = appWithChunks(sixChunks);
+ const res = await app.request("/conversations/conv1?sinceSeq=2&beforeSeq=5");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([3, 4]);
+ expect(body.latestSeq).toBe(4);
+ });
+
+ describe("window param validation → 400 and store not called with an invalid window", () => {
+ function appCapturingWindow() {
+ const calls: {
+ readonly sinceSeq: number | undefined;
+ readonly window: { readonly beforeSeq?: number; readonly limit?: number } | undefined;
+ }[] = [];
+ const store: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince(_conversationId, sinceSeq, window) {
+ calls.push({ sinceSeq, window });
+ return [];
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async clearCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+ return { app, calls };
+ }
+
+ const cases: readonly { readonly name: string; readonly query: string }[] = [
+ { name: "limit=0", query: "limit=0" },
+ { name: "limit=-1", query: "limit=-1" },
+ { name: "limit=abc", query: "limit=abc" },
+ { name: "beforeSeq=0", query: "beforeSeq=0" },
+ { name: "beforeSeq=1.5", query: "beforeSeq=1.5" },
+ ];
+
+ for (const { name, query } of cases) {
+ it(`${name} → 400 { error } and loadSince is never called`, async () => {
+ const { app, calls } = appCapturingWindow();
+ const res = await app.request(`/conversations/conv1?${query}`);
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(typeof body.error).toBe("string");
+ expect(body.error.length).toBeGreaterThan(0);
+ expect(calls).toHaveLength(0);
+ });
+ }
+ });
+
+ it("no params → byte-identical to the no-window read (regression guard)", async () => {
+ // Rest-param spy: record how many args the route actually passes, so we
+ // can prove the third (window) arg is OMITTED entirely — not merely
+ // forwarded as undefined — preserving the existing two-arg call shape.
+ const argCounts: number[] = [];
+ const store: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ loadSince(...args: Parameters<ConversationStore["loadSince"]>) {
+ argCounts.push(args.length);
+ const sinceSeq = args[1] ?? 0;
+ return Promise.resolve(sampleChunks.filter((c) => c.seq > sinceSeq));
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async clearCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly StoredChunk[]; latestSeq: number };
+ expect(body.chunks.map((c) => c.seq)).toEqual([1, 2, 3, 4]);
+ expect(body.latestSeq).toBe(4);
+ // Called once, with exactly two arguments (no window arg).
+ expect(argCounts).toEqual([2]);
+ });
});
describe("GET /conversations/:id/metrics", () => {
- const sampleMetrics: TurnMetrics[] = [
- {
- turnId: "turn1",
- usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
- durationMs: 1000,
- steps: [
- {
- stepId: "step1" as StepId,
- usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
- ttftMs: 200,
- decodeMs: 300,
- genTotalMs: 500,
- },
- ],
- },
- {
- turnId: "turn2",
- usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
- durationMs: 1500,
- steps: [
- {
- stepId: "step2" as StepId,
- usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
- ttftMs: 300,
- decodeMs: 500,
- genTotalMs: 800,
- },
- ],
- },
- ];
-
- it("returns persisted turn metrics as { turns }", async () => {
- const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(new Map(), metricsStore),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/conv1/metrics");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { turns: readonly TurnMetrics[] };
- expect(body.turns).toHaveLength(2);
- expect(body.turns[0]?.turnId).toBe("turn1");
- expect(body.turns[1]?.turnId).toBe("turn2");
- });
-
- it("returns { turns: [] } for an unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/conversations/unknown/metrics");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { turns: readonly TurnMetrics[] };
- expect(body.turns).toHaveLength(0);
- });
-
- it("the metrics route does not collide with GET /conversations/:id history route", async () => {
- const sampleChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
- ];
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store, metricsStore),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const metricsRes = await app.request("/conversations/conv1/metrics");
- expect(metricsRes.status).toBe(200);
- const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] };
- expect(metricsBody.turns).toHaveLength(2);
-
- const historyRes = await app.request("/conversations/conv1");
- expect(historyRes.status).toBe(200);
- const historyBody = (await historyRes.json()) as {
- chunks: readonly StoredChunk[];
- latestSeq: number;
- };
- expect(historyBody.chunks).toHaveLength(1);
- });
-
- it("a store failure on the metrics read returns an error status + logs an error", async () => {
- const logger = createFakeLogger();
- const brokenStore: ConversationStore = {
- async append() {},
- async load() {
- return [];
- },
- async loadSince() {
- return [];
- },
- async appendMetrics() {},
- async loadMetrics() {
- throw new Error("storage exploded");
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async clearCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: brokenStore,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- const res = await app.request("/conversations/conv1/metrics");
- expect(res.status).toBe(500);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("Failed to load conversation metrics");
-
- const errorLogs = logger.records.filter((r) => r.level === "error");
- expect(errorLogs).toHaveLength(1);
- expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure");
- expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
- });
+ const sampleMetrics: TurnMetrics[] = [
+ {
+ turnId: "turn1",
+ usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ durationMs: 1000,
+ steps: [
+ {
+ stepId: "step1" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ ttftMs: 200,
+ decodeMs: 300,
+ genTotalMs: 500,
+ },
+ ],
+ },
+ {
+ turnId: "turn2",
+ usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
+ durationMs: 1500,
+ steps: [
+ {
+ stepId: "step2" as StepId,
+ usage: { inputTokens: 200, outputTokens: 80, cacheReadTokens: 10, cacheWriteTokens: 5 },
+ ttftMs: 300,
+ decodeMs: 500,
+ genTotalMs: 800,
+ },
+ ],
+ },
+ ];
+
+ it("returns persisted turn metrics as { turns }", async () => {
+ const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(new Map(), metricsStore),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/conv1/metrics");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { turns: readonly TurnMetrics[] };
+ expect(body.turns).toHaveLength(2);
+ expect(body.turns[0]?.turnId).toBe("turn1");
+ expect(body.turns[1]?.turnId).toBe("turn2");
+ });
+
+ it("returns { turns: [] } for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/conversations/unknown/metrics");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { turns: readonly TurnMetrics[] };
+ expect(body.turns).toHaveLength(0);
+ });
+
+ it("the metrics route does not collide with GET /conversations/:id history route", async () => {
+ const sampleChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
+ ];
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const metricsStore = new Map<string, TurnMetrics[]>([["conv1", sampleMetrics]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store, metricsStore),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const metricsRes = await app.request("/conversations/conv1/metrics");
+ expect(metricsRes.status).toBe(200);
+ const metricsBody = (await metricsRes.json()) as { turns: readonly TurnMetrics[] };
+ expect(metricsBody.turns).toHaveLength(2);
+
+ const historyRes = await app.request("/conversations/conv1");
+ expect(historyRes.status).toBe(200);
+ const historyBody = (await historyRes.json()) as {
+ chunks: readonly StoredChunk[];
+ latestSeq: number;
+ };
+ expect(historyBody.chunks).toHaveLength(1);
+ });
+
+ it("a store failure on the metrics read returns an error status + logs an error", async () => {
+ const logger = createFakeLogger();
+ const brokenStore: ConversationStore = {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince() {
+ return [];
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ throw new Error("storage exploded");
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async clearCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: brokenStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ const res = await app.request("/conversations/conv1/metrics");
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Failed to load conversation metrics");
+
+ const errorLogs = logger.records.filter((r) => r.level === "error");
+ expect(errorLogs).toHaveLength(1);
+ expect(errorLogs[0]?.msg).toBe("conversations: metrics store failure");
+ expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
+ });
});
describe("POST /chat logging", () => {
- it("POST /chat logs an info line when a request is accepted", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([
- { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
- ]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- model: "opencode/m1",
- cwd: "/tmp",
- }),
- });
-
- const infoLogs = logger.records.filter((r) => r.level === "info");
- expect(infoLogs).toHaveLength(1);
- expect(infoLogs[0]?.msg).toBe("chat: request accepted");
- expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
- expect(infoLogs[0]?.attrs?.hasModel).toBe(true);
- expect(infoLogs[0]?.attrs?.hasCwd).toBe(true);
- });
-
- it("POST /chat logs a warn on a malformed body (400)", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
-
- const warnLogs = logger.records.filter((r) => r.level === "warn");
- expect(warnLogs.length).toBeGreaterThanOrEqual(1);
- expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body");
- });
-
- it("POST /chat logs an error when the turn fails", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createThrowingOrchestrator(new Error("boom")),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- const errorLogs = logger.records.filter((r) => r.level === "error");
- expect(errorLogs).toHaveLength(1);
- expect(errorLogs[0]?.msg).toBe("chat: turn failed");
- expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
- });
+ it("POST /chat logs an info line when a request is accepted", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([
+ { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
+ ]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ model: "opencode/m1",
+ cwd: "/tmp",
+ }),
+ });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("chat: request accepted");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.hasModel).toBe(true);
+ expect(infoLogs[0]?.attrs?.hasCwd).toBe(true);
+ });
+
+ it("POST /chat logs a warn on a malformed body (400)", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+
+ const warnLogs = logger.records.filter((r) => r.level === "warn");
+ expect(warnLogs.length).toBeGreaterThanOrEqual(1);
+ expect(warnLogs[0]?.msg).toBe("chat: invalid JSON body");
+ });
+
+ it("POST /chat logs an error when the turn fails", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createThrowingOrchestrator(new Error("boom")),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ const errorLogs = logger.records.filter((r) => r.level === "error");
+ expect(errorLogs).toHaveLength(1);
+ expect(errorLogs[0]?.msg).toBe("chat: turn failed");
+ expect(errorLogs[0]?.attrs?.err).toBeInstanceOf(Error);
+ });
});
describe("GET /conversations/:id logging", () => {
- it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => {
- const logger = createFakeLogger();
- const sampleChunks: StoredChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
- ];
- const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
- const app = createApp({
- conversationStore: createFakeConversationStore(store),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/conversations/conv1?sinceSeq=0");
-
- const infoLogs = logger.records.filter((r) => r.level === "info");
- expect(infoLogs).toHaveLength(1);
- expect(infoLogs[0]?.msg).toBe("conversations: read");
- expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
- expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0);
- expect(infoLogs[0]?.attrs?.count).toBe(2);
- });
+ it("GET /conversations/:id logs the read (conversationId + sinceSeq + count)", async () => {
+ const logger = createFakeLogger();
+ const sampleChunks: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hi there" } },
+ ];
+ const store = new Map<string, StoredChunk[]>([["conv1", sampleChunks]]);
+ const app = createApp({
+ conversationStore: createFakeConversationStore(store),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1?sinceSeq=0");
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: read");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.sinceSeq).toBe(0);
+ expect(infoLogs[0]?.attrs?.count).toBe(2);
+ });
});
describe("CORS", () => {
- function createTestApp() {
- return createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([
- { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
- ]),
- credentialStore: createFakeCredentialStore(["opencode/m1"]),
- });
- }
-
- it("POST /chat response carries Access-Control-Allow-Origin: *", async () => {
- const app = createTestApp();
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
- expect(res.status).toBe(200);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- });
-
- it("GET /models response carries the CORS headers", async () => {
- const app = createTestApp();
- const res = await app.request("/models");
- expect(res.status).toBe(200);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
- });
-
- it("GET /conversations/:id response carries the CORS headers", async () => {
- const app = createTestApp();
- const res = await app.request("/conversations/conv1");
- expect(res.status).toBe(200);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
- });
-
- it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => {
- const app = createTestApp();
- const res = await app.request("/chat", { method: "OPTIONS" });
- expect(res.status).toBe(204);
- expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
- expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET");
- expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST");
- expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS");
- expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type");
- });
+ function createTestApp() {
+ return createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([
+ { type: "done", conversationId: "conv1", turnId: "turn1", reason: "stop" },
+ ]),
+ credentialStore: createFakeCredentialStore(["opencode/m1"]),
+ });
+ }
+
+ it("POST /chat response carries Access-Control-Allow-Origin: *", async () => {
+ const app = createTestApp();
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ });
+
+ it("GET /models response carries the CORS headers", async () => {
+ const app = createTestApp();
+ const res = await app.request("/models");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
+ });
+
+ it("GET /conversations/:id response carries the CORS headers", async () => {
+ const app = createTestApp();
+ const res = await app.request("/conversations/conv1");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(res.headers.get("Access-Control-Expose-Headers")).toBeDefined();
+ });
+
+ it("OPTIONS preflight for /chat returns 204 with Allow-Methods + Allow-Headers", async () => {
+ const app = createTestApp();
+ const res = await app.request("/chat", { method: "OPTIONS" });
+ expect(res.status).toBe(204);
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("GET");
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("POST");
+ expect(res.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS");
+ expect(res.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type");
+ });
});
describe("throughput recording + GET /metrics/throughput", () => {
- const ts = new Date(2026, 5, 10, 12, 0, 0).getTime();
- const day = dayKeyOf(ts);
-
- function appWith(
- throughputStore: ReturnType<typeof createThroughputStore>,
- events: AgentEvent[],
- ) {
- return createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator(events),
- credentialStore: createFakeCredentialStore([]),
- throughputStore,
- now: () => ts,
- });
- }
-
- async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) {
- return app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- }
-
- it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => {
- const store = createThroughputStore({ storage: createMemStorage() });
- const events: AgentEvent[] = [
- {
- type: "step-complete",
- conversationId: "c1",
- turnId: "t1",
- stepId: "t1#0" as StepId,
- genTotalMs: 2000,
- },
- {
- type: "done",
- conversationId: "c1",
- turnId: "t1",
- reason: "stop",
- usage: { inputTokens: 10, outputTokens: 400 },
- },
- ];
- const app = appWith(store, events);
-
- const chat = await postChat(app, {
- conversationId: "c1",
- message: "hi",
- model: "claude/haiku",
- });
- expect(chat.status).toBe(200);
-
- const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
- expect(res.status).toBe(200);
- const report = (await res.json()) as ThroughputResponse;
- expect(report.period).toBe("day");
- expect(report.models).toHaveLength(1);
- expect(report.models[0]).toMatchObject({
- model: "claude/haiku",
- totalOutputTokens: 400,
- totalGenMs: 2000,
- tokensPerSecond: 200, // 400 tokens / 2s
- turns: 1,
- });
- });
-
- it("does not record a sample when no model is selected", async () => {
- const store = createThroughputStore({ storage: createMemStorage() });
- const events: AgentEvent[] = [
- {
- type: "step-complete",
- conversationId: "c1",
- turnId: "t1",
- stepId: "t1#0" as StepId,
- genTotalMs: 2000,
- },
- {
- type: "done",
- conversationId: "c1",
- turnId: "t1",
- reason: "stop",
- usage: { inputTokens: 1, outputTokens: 5 },
- },
- ];
- const app = appWith(store, events);
-
- await postChat(app, { conversationId: "c1", message: "hi" }); // no model
- const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
- const report = (await res.json()) as { models: unknown[] };
- expect(report.models).toEqual([]);
- });
-
- it("returns 400 for an invalid period", async () => {
- const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
- const res = await app.request("/metrics/throughput?period=year&date=2026");
- expect(res.status).toBe(400);
- });
-
- it("returns 400 for a malformed date", async () => {
- const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
- const res = await app.request("/metrics/throughput?period=day&date=nope");
- expect(res.status).toBe(400);
- });
-
- it("returns 400 when date is missing", async () => {
- const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
- const res = await app.request("/metrics/throughput?period=day");
- expect(res.status).toBe(400);
- });
+ const ts = new Date(2026, 5, 10, 12, 0, 0).getTime();
+ const day = dayKeyOf(ts);
+
+ function appWith(
+ throughputStore: ReturnType<typeof createThroughputStore>,
+ events: AgentEvent[],
+ ) {
+ return createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator(events),
+ credentialStore: createFakeCredentialStore([]),
+ throughputStore,
+ now: () => ts,
+ });
+ }
+
+ async function postChat(app: ReturnType<typeof createApp>, body: Record<string, unknown>) {
+ return app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+
+ it("records a per-model sample from a turn and aggregates it (token-weighted tok/s)", async () => {
+ const store = createThroughputStore({ storage: createMemStorage() });
+ const events: AgentEvent[] = [
+ {
+ type: "step-complete",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ genTotalMs: 2000,
+ },
+ {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "stop",
+ usage: { inputTokens: 10, outputTokens: 400 },
+ },
+ ];
+ const app = appWith(store, events);
+
+ const chat = await postChat(app, {
+ conversationId: "c1",
+ message: "hi",
+ model: "claude/haiku",
+ });
+ expect(chat.status).toBe(200);
+
+ const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
+ expect(res.status).toBe(200);
+ const report = (await res.json()) as ThroughputResponse;
+ expect(report.period).toBe("day");
+ expect(report.models).toHaveLength(1);
+ expect(report.models[0]).toMatchObject({
+ model: "claude/haiku",
+ totalOutputTokens: 400,
+ totalGenMs: 2000,
+ tokensPerSecond: 200, // 400 tokens / 2s
+ turns: 1,
+ });
+ });
+
+ it("does not record a sample when no model is selected", async () => {
+ const store = createThroughputStore({ storage: createMemStorage() });
+ const events: AgentEvent[] = [
+ {
+ type: "step-complete",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ genTotalMs: 2000,
+ },
+ {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "stop",
+ usage: { inputTokens: 1, outputTokens: 5 },
+ },
+ ];
+ const app = appWith(store, events);
+
+ await postChat(app, { conversationId: "c1", message: "hi" }); // no model
+ const res = await app.request(`/metrics/throughput?period=day&date=${day}`);
+ const report = (await res.json()) as { models: unknown[] };
+ expect(report.models).toEqual([]);
+ });
+
+ it("returns 400 for an invalid period", async () => {
+ const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
+ const res = await app.request("/metrics/throughput?period=year&date=2026");
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for a malformed date", async () => {
+ const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
+ const res = await app.request("/metrics/throughput?period=day&date=nope");
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when date is missing", async () => {
+ const app = appWith(createThroughputStore({ storage: createMemStorage() }), []);
+ const res = await app.request("/metrics/throughput?period=day");
+ expect(res.status).toBe(400);
+ });
});
describe("POST /conversations/:id/close", () => {
- it("closes via the orchestrator and returns CloseConversationResponse", async () => {
- const closeCalls: string[] = [];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- closeConversation(conversationId) {
- closeCalls.push(conversationId);
- return { abortedTurn: true };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-9/close", { method: "POST" });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true });
- expect(closeCalls).toEqual(["conv-9"]);
- });
-
- it("reports abortedTurn false for an idle conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-idle/close", { method: "POST" });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false });
- });
+ it("closes via the orchestrator and returns CloseConversationResponse", async () => {
+ const closeCalls: string[] = [];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ closeConversation(conversationId) {
+ closeCalls.push(conversationId);
+ return { abortedTurn: true };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-9/close", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ conversationId: "conv-9", abortedTurn: true });
+ expect(closeCalls).toEqual(["conv-9"]);
+ });
+
+ it("reports abortedTurn false for an idle conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-idle/close", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ conversationId: "conv-idle", abortedTurn: false });
+ });
});
describe("POST /conversations/:id/queue", () => {
- it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => {
- const queue: readonly QueuedMessage[] = [
- { id: "q1", text: "queued-msg", queuedAt: 1700000000000 },
- ];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: false, queue };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "hello" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as QueueResponse;
- expect(body.conversationId).toBe("conv1");
- expect(body.startedTurn).toBe(false);
- expect(body.queue).toEqual(queue);
- });
-
- it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => {
- let enqueueCalled = false;
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- enqueueCalled = true;
- return { startedTurn: false, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: " " }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("text");
- expect(enqueueCalled).toBe(false);
- });
-
- it("with missing text field → 400 { error } and enqueue is never called", async () => {
- let enqueueCalled = false;
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- enqueueCalled = true;
- return { startedTurn: false, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("text");
- expect(enqueueCalled).toBe(false);
- });
-
- it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => {
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: true, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-idle/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "go" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as QueueResponse;
- expect(body.conversationId).toBe("conv-idle");
- expect(body.startedTurn).toBe(true);
- expect(body.queue).toEqual([]);
- });
-
- it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => {
- const queue: readonly QueuedMessage[] = [
- { id: "q1", text: "second", queuedAt: 1700000000000 },
- { id: "q2", text: "third", queuedAt: 1700000001000 },
- ];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: false, queue };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-active/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "steer" }),
- });
-
- expect(res.status).toBe(200);
- const body = (await res.json()) as QueueResponse;
- expect(body.conversationId).toBe("conv-active");
- expect(body.startedTurn).toBe(false);
- expect(body.queue).toEqual(queue);
- });
-
- it("forwards the path conversationId and trimmed text to enqueue", async () => {
- const calls: { conversationId: string; text: string }[] = [];
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue(input) {
- calls.push(input);
- return { startedTurn: false, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv-1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: " hello world " }),
- });
-
- expect(res.status).toBe(200);
- expect(calls).toHaveLength(1);
- expect(calls[0]?.conversationId).toBe("conv-1");
- expect(calls[0]?.text).toBe("hello world");
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("JSON");
- });
-
- it("returns 400 for a non-string text", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: 42 }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("text");
- });
-
- it("logs an info line on success and never logs the enqueued text", async () => {
- const logger = createFakeLogger();
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- enqueue() {
- return { startedTurn: true, queue: [] };
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "secret-ish user message" }),
- });
-
- const infoLogs = logger.records.filter((r) => r.level === "info");
- expect(infoLogs).toHaveLength(1);
- expect(infoLogs[0]?.msg).toBe("conversations: enqueued");
- expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
- expect(infoLogs[0]?.attrs?.startedTurn).toBe(true);
- expect(infoLogs[0]?.attrs?.queueLength).toBe(0);
- // Restraint: the user's message text is never logged (mirrors POST /chat).
- expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message");
- });
-
- it("logs a warn on a malformed body (400)", async () => {
- const logger = createFakeLogger();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger,
- });
-
- await app.request("/conversations/conv1/queue", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text: "" }),
- });
-
- const warnLogs = logger.records.filter((r) => r.level === "warn");
- expect(warnLogs.length).toBeGreaterThanOrEqual(1);
- expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed");
- });
+ it("with valid text → 200 + QueueResponse (startedTurn + queue)", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "queued-msg", queuedAt: 1700000000000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "hello" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueResponse;
+ expect(body.conversationId).toBe("conv1");
+ expect(body.startedTurn).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("with empty/whitespace text → 400 { error } and enqueue is never called", async () => {
+ let enqueueCalled = false;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ enqueueCalled = true;
+ return { startedTurn: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: " " }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("text");
+ expect(enqueueCalled).toBe(false);
+ });
+
+ it("with missing text field → 400 { error } and enqueue is never called", async () => {
+ let enqueueCalled = false;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ enqueueCalled = true;
+ return { startedTurn: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("text");
+ expect(enqueueCalled).toBe(false);
+ });
+
+ it("enqueue returns startedTurn:true (was idle) → response echoes it", async () => {
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-idle/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "go" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueResponse;
+ expect(body.conversationId).toBe("conv-idle");
+ expect(body.startedTurn).toBe(true);
+ expect(body.queue).toEqual([]);
+ });
+
+ it("enqueue returns startedTurn:false (was active) → response carries the queue snapshot", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "second", queuedAt: 1700000000000 },
+ { id: "q2", text: "third", queuedAt: 1700000001000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-active/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "steer" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueResponse;
+ expect(body.conversationId).toBe("conv-active");
+ expect(body.startedTurn).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("forwards the path conversationId and trimmed text to enqueue", async () => {
+ const calls: { conversationId: string; text: string }[] = [];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue(input) {
+ calls.push(input);
+ return { startedTurn: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv-1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: " hello world " }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.conversationId).toBe("conv-1");
+ expect(calls[0]?.text).toBe("hello world");
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("JSON");
+ });
+
+ it("returns 400 for a non-string text", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: 42 }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("text");
+ });
+
+ it("logs an info line on success and never logs the enqueued text", async () => {
+ const logger = createFakeLogger();
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ enqueue() {
+ return { startedTurn: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "secret-ish user message" }),
+ });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: enqueued");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.startedTurn).toBe(true);
+ expect(infoLogs[0]?.attrs?.queueLength).toBe(0);
+ // Restraint: the user's message text is never logged (mirrors POST /chat).
+ expect(JSON.stringify(logger.records)).not.toContain("secret-ish user message");
+ });
+
+ it("logs a warn on a malformed body (400)", async () => {
+ const logger = createFakeLogger();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "" }),
+ });
+
+ const warnLogs = logger.records.filter((r) => r.level === "warn");
+ expect(warnLogs.length).toBeGreaterThanOrEqual(1);
+ expect(warnLogs[0]?.msg).toBe("conversations/queue: validation failed");
+ });
+});
+
+describe("DELETE /conversations/:id/queue/:messageId", () => {
+ it("when a message is cancelled → 200 + QueueCancelResponse (cancelled:true + post-cancel queue)", async () => {
+ const remaining: readonly QueuedMessage[] = [
+ { id: "q1", text: "kept", queuedAt: 1700000000000 },
+ ];
+ let received: { conversationId: string; messageId: string } | undefined;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage(input) {
+ received = input;
+ return { cancelled: true, queue: remaining };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/q2", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cancelled).toBe(true);
+ expect(body.queue).toEqual(remaining);
+ // forwards the path conversationId + messageId
+ expect(received?.conversationId).toBe("conv1");
+ expect(received?.messageId).toBe("q2");
+ });
+
+ it("when the message is not in the queue → 200 cancelled:false (idempotent, not an error)", async () => {
+ const queue: readonly QueuedMessage[] = [
+ { id: "q1", text: "still-queued", queuedAt: 1700000000000 },
+ ];
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/missing", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual(queue);
+ });
+
+ it("when the queue ext is not loaded → 200 cancelled:false, empty queue (degraded)", async () => {
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: false, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/queue/whatever", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as QueueCancelResponse;
+ expect(body.cancelled).toBe(false);
+ expect(body.queue).toEqual([]);
+ });
+
+ it("delegates the cancel to the orchestrator (never reads the body)", async () => {
+ let calls = 0;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ calls += 1;
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // No Content-Type / body — the endpoint takes the messageId from the path.
+ const res = await app.request("/conversations/conv-x/queue/m1", {
+ method: "DELETE",
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toBe(1);
+ });
+
+ it("logs an info line on success and never logs the message text", async () => {
+ const logger = createFakeLogger();
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ cancelQueuedMessage() {
+ return { cancelled: true, queue: [] };
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+
+ await app.request("/conversations/conv1/queue/q-secret", { method: "DELETE" });
+
+ const infoLogs = logger.records.filter((r) => r.level === "info");
+ expect(infoLogs).toHaveLength(1);
+ expect(infoLogs[0]?.msg).toBe("conversations: cancelled queued message");
+ expect(infoLogs[0]?.attrs?.conversationId).toBe("conv1");
+ expect(infoLogs[0]?.attrs?.messageId).toBe("q-secret");
+ expect(infoLogs[0]?.attrs?.cancelled).toBe(true);
+ });
});
describe("GET /conversations/:id/cwd", () => {
- it("returns null when unset", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; cwd: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- });
+ it("returns null when unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ });
});
describe("PUT then GET /conversations/:id/cwd", () => {
- it("round-trips the value", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project" }),
- });
- expect(putRes.status).toBe(200);
- const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
- expect(putBody.conversationId).toBe("conv1");
- expect(putBody.cwd).toBe("/home/user/project");
-
- const getRes = await app.request("/conversations/conv1/cwd");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
- expect(getBody.cwd).toBe("/home/user/project");
- });
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; cwd: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.cwd).toBe("/home/user/project");
+
+ const getRes = await app.request("/conversations/conv1/cwd");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
+ expect(getBody.cwd).toBe("/home/user/project");
+ });
});
describe("DELETE /conversations/:id/cwd", () => {
- it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project" }),
- });
- expect(putRes.status).toBe(200);
-
- const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
- expect(deleteBody.conversationId).toBe("conv1");
- expect(deleteBody.cwd).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/cwd");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
- expect(getBody.cwd).toBeNull();
- });
-
- it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
- expect(deleteBody.conversationId).toBe("conv1");
- expect(deleteBody.cwd).toBeNull();
- });
-
- it("does NOT affect other conversations' cwds (isolation)", async () => {
- const cwdStore = new Map<string, string>([
- ["conv1", "/home/user/project"],
- ["conv2", "/other/path"],
- ]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
-
- const get1Res = await app.request("/conversations/conv1/cwd");
- expect(get1Res.status).toBe(200);
- const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null };
- expect(get1Body.cwd).toBeNull();
-
- const get2Res = await app.request("/conversations/conv2/cwd");
- expect(get2Res.status).toBe(200);
- const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null };
- expect(get2Body.cwd).toBe("/other/path");
- });
+ it("after a PUT cwd → returns { cwd: null } and a subsequent GET returns cwd: null", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.cwd).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/cwd");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; cwd: string | null };
+ expect(getBody.cwd).toBeNull();
+ });
+
+ it("on a conversation that never had a cwd set → returns { cwd: null }, no error (idempotent)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as { conversationId: string; cwd: string | null };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.cwd).toBeNull();
+ });
+
+ it("does NOT affect other conversations' cwds (isolation)", async () => {
+ const cwdStore = new Map<string, string>([
+ ["conv1", "/home/user/project"],
+ ["conv2", "/other/path"],
+ ]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/cwd", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+
+ const get1Res = await app.request("/conversations/conv1/cwd");
+ expect(get1Res.status).toBe(200);
+ const get1Body = (await get1Res.json()) as { conversationId: string; cwd: string | null };
+ expect(get1Body.cwd).toBeNull();
+
+ const get2Res = await app.request("/conversations/conv2/cwd");
+ expect(get2Res.status).toBe(200);
+ const get2Body = (await get2Res.json()) as { conversationId: string; cwd: string | null };
+ expect(get2Body.cwd).toBe("/other/path");
+ });
});
describe("PUT /conversations/:id/cwd", () => {
- it("with missing cwd returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("cwd");
- });
-
- it("with empty cwd returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => {
- const base = createFakeConversationStore();
- const store = createCallTrackingStore(base);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; cwd: string };
- expect(body.cwd).toBe("/home/user/project");
- // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd.
- expect(store.calls).toEqual([
- "ensureWorkspace:my-team",
- "setWorkspaceId:my-team",
- "setCwd:/home/user/project",
- ]);
- });
-
- it("PUT cwd without workspaceId: only setCwd", async () => {
- const base = createFakeConversationStore();
- const store = createCallTrackingStore(base);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project" }),
- });
- expect(res.status).toBe(200);
- // ensureWorkspace and setWorkspaceId NOT called.
- expect(store.calls).toEqual(["setCwd:/home/user/project"]);
- });
-
- it("PUT cwd with invalid workspaceId: returns 400", async () => {
- const base = createFakeConversationStore();
- const store = createCallTrackingStore(base);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- // Uppercase is not a valid workspace slug.
- const res = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("Invalid workspaceId");
- // No mutating calls should have been made.
- expect(store.calls).toEqual([]);
-
- // Empty string is also invalid.
- const res2 = await app.request("/conversations/conv1/cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }),
- });
- expect(res2.status).toBe(400);
- });
+ it("with missing cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("cwd");
+ });
+
+ it("with empty cwd returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("PUT cwd with workspaceId: assigns workspace before setCwd", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "my-team" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; cwd: string };
+ expect(body.cwd).toBe("/home/user/project");
+ // ensureWorkspace + setWorkspaceId called (in that order) BEFORE setCwd.
+ expect(store.calls).toEqual([
+ "ensureWorkspace:my-team",
+ "setWorkspaceId:my-team",
+ "setCwd:/home/user/project",
+ ]);
+ });
+
+ it("PUT cwd without workspaceId: only setCwd", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project" }),
+ });
+ expect(res.status).toBe(200);
+ // ensureWorkspace and setWorkspaceId NOT called.
+ expect(store.calls).toEqual(["setCwd:/home/user/project"]);
+ });
+
+ it("PUT cwd with invalid workspaceId: returns 400", async () => {
+ const base = createFakeConversationStore();
+ const store = createCallTrackingStore(base);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ // Uppercase is not a valid workspace slug.
+ const res = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "UPPER" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("Invalid workspaceId");
+ // No mutating calls should have been made.
+ expect(store.calls).toEqual([]);
+
+ // Empty string is also invalid.
+ const res2 = await app.request("/conversations/conv1/cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cwd: "/home/user/project", workspaceId: "" }),
+ });
+ expect(res2.status).toBe(400);
+ });
});
describe("GET /conversations/:id/lsp", () => {
- it("returns empty servers when cwd is unset", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: createFakeLspService(),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- expect(body.servers).toEqual([]);
- });
-
- it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const lspStatuses = [
- {
- id: "typescript",
- name: "TypeScript",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- },
- {
- id: "lua-lsp",
- name: "Lua LSP",
- root: "/home/user/project",
- extensions: [".luau"],
- state: "error" as const,
- error: "spawn failed",
- },
- ];
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: createFakeLspService(lspStatuses),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- readonly extensions: readonly string[];
- readonly state: string;
- readonly error?: string;
- }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/home/user/project");
- expect(body.servers).toHaveLength(2);
- expect(body.servers[0]?.id).toBe("typescript");
- expect(body.servers[0]?.state).toBe("connected");
- expect(body.servers[0]?.error).toBeUndefined();
- expect(body.servers[1]?.id).toBe("lua-lsp");
- expect(body.servers[1]?.state).toBe("error");
- expect(body.servers[1]?.error).toBe("spawn failed");
- });
-
- it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => {
- const cwdStore = new Map<string, string>(); // no persisted cwd
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const lsp = createCapturingLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/irrelevant",
- extensions: [".ts"],
- state: "connected" as const,
- },
- ]);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lsp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- expect(body.servers).toEqual([]);
- expect(lsp.statusCalls).toEqual([]); // status NOT called
- });
-
- it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
- // Persisted (relative) cwd differs from the resolved effective cwd.
- const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- // Override getEffectiveCwd to return the resolved (absolute) value.
- const resolvedStore: ConversationStore = {
- ...store,
- async getEffectiveCwd() {
- return "/workspace/subdir";
- },
- };
- const lsp = createCapturingLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/workspace/subdir",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- },
- ]);
- const app = createApp({
- conversationStore: resolvedStore,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lsp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly { readonly id: string }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
- expect(lsp.statusCalls).toEqual(["/workspace/subdir"]);
- expect(body.servers).toHaveLength(1);
- expect(body.servers[0]?.id).toBe("typescript");
- });
-
- it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- // Case 1: configSource is defined → reaches the wire verbatim.
- const lspWithSource = createFakeLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- configSource: ".dispatch/lsp.json",
- },
- ]);
- const appWithSource = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lspWithSource,
- logger: noopLogger,
- });
- const resWithSource = await appWithSource.request("/conversations/conv1/lsp");
- expect(resWithSource.status).toBe(200);
- const bodyWithSource = (await resWithSource.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly {
- readonly id: string;
- readonly configSource?: string;
- }[];
- };
- expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json");
-
- // Case 2: configSource is undefined → the field is OMITTED from the
- // response (proves exactOptionalPropertyTypes is respected — never
- // stamping `undefined` onto the wire object).
- const lspWithoutSource = createFakeLspService([
- {
- id: "typescript",
- name: "TypeScript",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected" as const,
- },
- ]);
- const appWithoutSource = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lspWithoutSource,
- logger: noopLogger,
- });
- const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp");
- expect(resWithoutSource.status).toBe(200);
- const bodyWithoutSource = (await resWithoutSource.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly Record<string, unknown>[];
- };
- expect(bodyWithoutSource.servers).toHaveLength(1);
- expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource");
- });
+ it("returns empty servers when cwd is unset", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ });
+
+ it("maps the lsp service statuses to LspServerInfo[] when cwd is set", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lspStatuses = [
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ {
+ id: "lua-lsp",
+ name: "Lua LSP",
+ root: "/home/user/project",
+ extensions: [".luau"],
+ state: "error" as const,
+ error: "spawn failed",
+ },
+ ];
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: createFakeLspService(lspStatuses),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ readonly extensions: readonly string[];
+ readonly state: string;
+ readonly error?: string;
+ }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/home/user/project");
+ expect(body.servers).toHaveLength(2);
+ expect(body.servers[0]?.id).toBe("typescript");
+ expect(body.servers[0]?.state).toBe("connected");
+ expect(body.servers[0]?.error).toBeUndefined();
+ expect(body.servers[1]?.id).toBe("lua-lsp");
+ expect(body.servers[1]?.state).toBe("error");
+ expect(body.servers[1]?.error).toBe("spawn failed");
+ });
+
+ it("LSP: returns null+empty when no persisted cwd — lspService.status NOT called", async () => {
+ const cwdStore = new Map<string, string>(); // no persisted cwd
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const lsp = createCapturingLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/irrelevant",
+ extensions: [".ts"],
+ state: "connected" as const,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ expect(lsp.statusCalls).toEqual([]); // status NOT called
+ });
+
+ it("LSP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
+ // Persisted (relative) cwd differs from the resolved effective cwd.
+ const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ // Override getEffectiveCwd to return the resolved (absolute) value.
+ const resolvedStore: ConversationStore = {
+ ...store,
+ async getEffectiveCwd() {
+ return "/workspace/subdir";
+ },
+ };
+ const lsp = createCapturingLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/workspace/subdir",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: resolvedStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly { readonly id: string }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
+ expect(lsp.statusCalls).toEqual(["/workspace/subdir"]);
+ expect(body.servers).toHaveLength(1);
+ expect(body.servers[0]?.id).toBe("typescript");
+ });
+
+ it("GET /conversations/:id/lsp: configSource passes through to the wire", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ // Case 1: configSource is defined → reaches the wire verbatim.
+ const lspWithSource = createFakeLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ configSource: ".dispatch/lsp.json",
+ },
+ ]);
+ const appWithSource = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lspWithSource,
+ logger: noopLogger,
+ });
+ const resWithSource = await appWithSource.request("/conversations/conv1/lsp");
+ expect(resWithSource.status).toBe(200);
+ const bodyWithSource = (await resWithSource.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly configSource?: string;
+ }[];
+ };
+ expect(bodyWithSource.servers[0]?.configSource).toBe(".dispatch/lsp.json");
+
+ // Case 2: configSource is undefined → the field is OMITTED from the
+ // response (proves exactOptionalPropertyTypes is respected — never
+ // stamping `undefined` onto the wire object).
+ const lspWithoutSource = createFakeLspService([
+ {
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected" as const,
+ },
+ ]);
+ const appWithoutSource = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lspWithoutSource,
+ logger: noopLogger,
+ });
+ const resWithoutSource = await appWithoutSource.request("/conversations/conv1/lsp");
+ expect(resWithoutSource.status).toBe(200);
+ const bodyWithoutSource = (await resWithoutSource.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly Record<string, unknown>[];
+ };
+ expect(bodyWithoutSource.servers).toHaveLength(1);
+ expect(bodyWithoutSource.servers[0]).not.toHaveProperty("configSource");
+ });
});
describe("GET /conversations/:id/mcp", () => {
- it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => {
- const cwdStore = new Map<string, string>(); // no persisted cwd
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const mcp = createCapturingMcpService([
- {
- id: "freecad",
- state: "connected" as const,
- toolCount: 3,
- },
- ]);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- mcpService: mcp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBeNull();
- expect(body.servers).toEqual([]);
- expect(mcp.statusCalls).toEqual([]); // status NOT called
- });
-
- it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const mcpStatuses = [
- {
- id: "freecad",
- state: "connected" as const,
- toolCount: 5,
- },
- {
- id: "broken",
- state: "error" as const,
- toolCount: 0,
- error: "spawn failed",
- },
- ];
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- mcpService: createFakeMcpService(mcpStatuses),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly {
- readonly id: string;
- readonly state: string;
- readonly toolCount: number;
- readonly error?: string;
- }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/home/user/project");
- expect(body.servers).toHaveLength(2);
- expect(body.servers[0]?.id).toBe("freecad");
- expect(body.servers[0]?.state).toBe("connected");
- expect(body.servers[0]?.toolCount).toBe(5);
- expect(body.servers[0]?.error).toBeUndefined();
- expect(body.servers[1]?.id).toBe("broken");
- expect(body.servers[1]?.state).toBe("error");
- expect(body.servers[1]?.toolCount).toBe(0);
- expect(body.servers[1]?.error).toBe("spawn failed");
- });
-
- it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
- const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const resolvedStore: ConversationStore = {
- ...store,
- async getEffectiveCwd() {
- return "/workspace/subdir";
- },
- };
- const mcp = createCapturingMcpService([
- {
- id: "freecad",
- state: "connected" as const,
- toolCount: 2,
- },
- ]);
- const app = createApp({
- conversationStore: resolvedStore,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- mcpService: mcp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly { readonly id: string }[];
- };
- expect(body.conversationId).toBe("conv1");
- expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
- expect(mcp.statusCalls).toEqual(["/workspace/subdir"]);
- expect(body.servers).toHaveLength(1);
- expect(body.servers[0]?.id).toBe("freecad");
- });
-
- it("MCP: returns 503 when mcpService is undefined", async () => {
- const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
- const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- // mcpService intentionally omitted
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/mcp");
- expect(res.status).toBe(503);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("MCP service not available");
- });
+ it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => {
+ const cwdStore = new Map<string, string>(); // no persisted cwd
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const mcp = createCapturingMcpService([
+ {
+ id: "freecad",
+ state: "connected" as const,
+ toolCount: 3,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ mcpService: mcp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBeNull();
+ expect(body.servers).toEqual([]);
+ expect(mcp.statusCalls).toEqual([]); // status NOT called
+ });
+
+ it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const mcpStatuses = [
+ {
+ id: "freecad",
+ state: "connected" as const,
+ toolCount: 5,
+ },
+ {
+ id: "broken",
+ state: "error" as const,
+ toolCount: 0,
+ error: "spawn failed",
+ },
+ ];
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ mcpService: createFakeMcpService(mcpStatuses),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly {
+ readonly id: string;
+ readonly state: string;
+ readonly toolCount: number;
+ readonly error?: string;
+ }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/home/user/project");
+ expect(body.servers).toHaveLength(2);
+ expect(body.servers[0]?.id).toBe("freecad");
+ expect(body.servers[0]?.state).toBe("connected");
+ expect(body.servers[0]?.toolCount).toBe(5);
+ expect(body.servers[0]?.error).toBeUndefined();
+ expect(body.servers[1]?.id).toBe("broken");
+ expect(body.servers[1]?.state).toBe("error");
+ expect(body.servers[1]?.toolCount).toBe(0);
+ expect(body.servers[1]?.error).toBe("spawn failed");
+ });
+
+ it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "subdir"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const resolvedStore: ConversationStore = {
+ ...store,
+ async getEffectiveCwd() {
+ return "/workspace/subdir";
+ },
+ };
+ const mcp = createCapturingMcpService([
+ {
+ id: "freecad",
+ state: "connected" as const,
+ toolCount: 2,
+ },
+ ]);
+ const app = createApp({
+ conversationStore: resolvedStore,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ mcpService: mcp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly { readonly id: string }[];
+ };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted
+ expect(mcp.statusCalls).toEqual(["/workspace/subdir"]);
+ expect(body.servers).toHaveLength(1);
+ expect(body.servers[0]?.id).toBe("freecad");
+ });
+
+ it("MCP: returns 503 when mcpService is undefined", async () => {
+ const cwdStore = new Map<string, string>([["conv1", "/home/user/project"]]);
+ const store = createFakeConversationStore(new Map(), new Map(), cwdStore);
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ // mcpService intentionally omitted
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/mcp");
+ expect(res.status).toBe(503);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("MCP service not available");
+ });
});
describe("POST /chat reasoningEffort", () => {
- const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
-
- for (const level of allLevels) {
- it(`forwards reasoningEffort="${level}" to orchestrator`, async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- reasoningEffort: level,
- }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.reasoningEffort).toBe(level);
- });
- }
-
- it("omits reasoningEffort from orchestrator input when not provided", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
-
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.reasoningEffort).toBeUndefined();
- });
-
- it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => {
- let handleMessageCalled = false;
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- async handleMessage(input) {
- handleMessageCalled = true;
- return createFakeOrchestrator([]).handleMessage(input);
- },
- };
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- reasoningEffort: "banana",
- }),
- });
-
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("reasoningEffort");
- expect(handleMessageCalled).toBe(false);
- });
-
- it("returns 400 for non-string reasoningEffort", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- });
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- reasoningEffort: 42,
- }),
- });
-
- expect(res.status).toBe(400);
- });
+ const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
+
+ for (const level of allLevels) {
+ it(`forwards reasoningEffort="${level}" to orchestrator`, async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ reasoningEffort: level,
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.reasoningEffort).toBe(level);
+ });
+ }
+
+ it("omits reasoningEffort from orchestrator input when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.reasoningEffort).toBeUndefined();
+ });
+
+ it("returns 400 for invalid reasoningEffort and does not call orchestrator", async () => {
+ let handleMessageCalled = false;
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ async handleMessage(input) {
+ handleMessageCalled = true;
+ return createFakeOrchestrator([]).handleMessage(input);
+ },
+ };
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ reasoningEffort: "banana",
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("reasoningEffort");
+ expect(handleMessageCalled).toBe(false);
+ });
+
+ it("returns 400 for non-string reasoningEffort", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ });
+
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ reasoningEffort: 42,
+ }),
+ });
+
+ expect(res.status).toBe(400);
+ });
});
describe("GET /conversations/:id/reasoning-effort", () => {
- it("returns null when never set", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/reasoning-effort");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.reasoningEffort).toBeNull();
- });
-
- it("returns the level after PUT", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "xhigh" }),
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
- expect(body.reasoningEffort).toBe("xhigh");
- });
-
- it("returns null for an unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/unknown/reasoning-effort");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
- expect(body.conversationId).toBe("unknown");
- expect(body.reasoningEffort).toBeNull();
- });
+ it("returns null when never set", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/reasoning-effort");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.reasoningEffort).toBeNull();
+ });
+
+ it("returns the level after PUT", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "xhigh" }),
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
+ expect(body.reasoningEffort).toBe("xhigh");
+ });
+
+ it("returns null for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/unknown/reasoning-effort");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string | null };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.reasoningEffort).toBeNull();
+ });
});
describe("PUT /conversations/:id/reasoning-effort", () => {
- it("persists a valid level and returns it", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "low" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; reasoningEffort: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.reasoningEffort).toBe("low");
- });
-
- it("returns 400 for an invalid level", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "banana" }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("reasoningEffort");
- });
-
- it("returns 400 when reasoningEffort is missing from body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- });
-
- it("does not call store on validation failure", async () => {
- let storeCalled = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setReasoningEffort() {
- storeCalled = true;
- },
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceTitle() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async setWorkspaceDefaultCwd() {
- return {
- id: "default",
- title: "default",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/reasoning-effort", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reasoningEffort: "invalid" }),
- });
- expect(res.status).toBe(400);
- expect(storeCalled).toBe(false);
- });
+ it("persists a valid level and returns it", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "low" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; reasoningEffort: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.reasoningEffort).toBe("low");
+ });
+
+ it("returns 400 for an invalid level", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "banana" }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("reasoningEffort");
+ });
+
+ it("returns 400 when reasoningEffort is missing from body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("does not call store on validation failure", async () => {
+ let storeCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setReasoningEffort() {
+ storeCalled = true;
+ },
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/reasoning-effort", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reasoningEffort: "invalid" }),
+ });
+ expect(res.status).toBe(400);
+ expect(storeCalled).toBe(false);
+ });
});
describe("GET /conversations/:id/model", () => {
- it("returns null when never set", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/model");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.model).toBeNull();
- });
-
- it("returns the model after PUT", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
- });
-
- const res = await app.request("/conversations/conv1/model");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.model).toBe("umans/umans-glm-5.2");
- });
-
- it("returns null for an unknown conversation", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/unknown/model");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.conversationId).toBe("unknown");
- expect(body.model).toBeNull();
- });
+ it("returns null when never set", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBeNull();
+ });
+
+ it("returns the model after PUT", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+
+ const res = await app.request("/conversations/conv1/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("returns null for an unknown conversation", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/unknown/model");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.model).toBeNull();
+ });
});
describe("PUT /conversations/:id/model", () => {
- it("persists a non-empty model and returns it", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.conversationId).toBe("conv1");
- expect(body.model).toBe("umans/umans-glm-5.2");
-
- // A subsequent GET reflects the persisted value.
- const getRes = await app.request("/conversations/conv1/model");
- const getBody = (await getRes.json()) as { model: string | null };
- expect(getBody.model).toBe("umans/umans-glm-5.2");
- });
-
- it("clears the model when model is null and GET returns null", async () => {
- const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
- const store = createFakeConversationStore(
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- modelStore,
- );
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- // Preconditions: a model is set.
- const before = await app.request("/conversations/conv1/model");
- expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: null }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.model).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/model");
- const getBody = (await getRes.json()) as { model: string | null };
- expect(getBody.model).toBeNull();
- });
-
- it("clears the model when model is an empty string and GET returns null", async () => {
- const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
- const store = createFakeConversationStore(
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- modelStore,
- );
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: "" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; model: string | null };
- expect(body.model).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/model");
- const getBody = (await getRes.json()) as { model: string | null };
- expect(getBody.model).toBeNull();
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 when model field is missing", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("model");
- });
-
- it("returns 400 when model is a non-string non-null type", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: 42 }),
- });
- expect(res.status).toBe(400);
- });
-
- it("does not call store on validation failure", async () => {
- let storeCalled = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setModel() {
- storeCalled = true;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- expect(storeCalled).toBe(false);
- });
+ it("persists a non-empty model and returns it", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "umans/umans-glm-5.2" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.model).toBe("umans/umans-glm-5.2");
+
+ // A subsequent GET reflects the persisted value.
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBe("umans/umans-glm-5.2");
+ });
+
+ it("clears the model when model is null and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ // Preconditions: a model is set.
+ const before = await app.request("/conversations/conv1/model");
+ expect(((await before.json()) as { model: string | null }).model).toBe("umans/umans-glm-5.2");
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("clears the model when model is an empty string and GET returns null", async () => {
+ const modelStore = new Map<string, string>([["conv1", "umans/umans-glm-5.2"]]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ modelStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: "" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; model: string | null };
+ expect(body.model).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/model");
+ const getBody = (await getRes.json()) as { model: string | null };
+ expect(getBody.model).toBeNull();
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 when model field is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("model");
+ });
+
+ it("returns 400 when model is a non-string non-null type", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model: 42 }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("does not call store on validation failure", async () => {
+ let storeCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setModel() {
+ storeCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(storeCalled).toBe(false);
+ });
});
describe("GET /conversations", () => {
- const sampleConvos: ConversationMeta[] = [
- {
- id: "conv-1",
- createdAt: 1000,
- lastActivityAt: 2000,
- title: "First",
- status: "idle",
- workspaceId: "default",
- },
- {
- id: "conv-2",
- createdAt: 1500,
- lastActivityAt: 2500,
- title: "Second",
- status: "idle",
- workspaceId: "default",
- },
- {
- id: "other-1",
- createdAt: 3000,
- lastActivityAt: 4000,
- title: "Other",
- status: "idle",
- workspaceId: "default",
- },
- ];
-
- function appWithList(list: ConversationMeta[]) {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listConversations() {
- return list;
- },
- };
- return createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- }
-
- it("returns 200 with list", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(3);
- expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]);
- });
-
- it("?q= filters by id prefix", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations?q=conv-");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(2);
- expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]);
- });
-
- it("?q= returns all when q is empty", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations?q=");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(3);
- });
-
- it("?q= with whitespace-only returns all (trimmed to empty)", async () => {
- const app = appWithList(sampleConvos);
- const res = await app.request("/conversations?q=%20%20%20");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversations: ConversationMeta[] };
- expect(body.conversations).toHaveLength(3);
- });
-
- it("returns 500 when listConversations throws", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listConversations() {
- throw new Error("db down");
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations");
- expect(res.status).toBe(500);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("Failed to list conversations");
- });
+ const sampleConvos: ConversationMeta[] = [
+ {
+ id: "conv-1",
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ title: "First",
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "conv-2",
+ createdAt: 1500,
+ lastActivityAt: 2500,
+ title: "Second",
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "other-1",
+ createdAt: 3000,
+ lastActivityAt: 4000,
+ title: "Other",
+ status: "idle",
+ workspaceId: "default",
+ },
+ ];
+
+ function appWithList(list: ConversationMeta[]) {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations() {
+ return list;
+ },
+ };
+ return createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ }
+
+ it("returns 200 with list", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(3);
+ expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2", "other-1"]);
+ });
+
+ it("?q= filters by id prefix", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations?q=conv-");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(2);
+ expect(body.conversations.map((c) => c.id)).toEqual(["conv-1", "conv-2"]);
+ });
+
+ it("?q= returns all when q is empty", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations?q=");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(3);
+ });
+
+ it("?q= with whitespace-only returns all (trimmed to empty)", async () => {
+ const app = appWithList(sampleConvos);
+ const res = await app.request("/conversations?q=%20%20%20");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversations: ConversationMeta[] };
+ expect(body.conversations).toHaveLength(3);
+ });
+
+ it("returns 500 when listConversations throws", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations() {
+ throw new Error("db down");
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations");
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("Failed to list conversations");
+ });
});
describe("GET /conversations/:id/last", () => {
- function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async load(conversationId) {
- return messagesByConv.get(conversationId) ?? [];
- },
- };
- return createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- }
-
- it("returns last assistant text", async () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
- { role: "user", chunks: [{ type: "text", text: "more" }] },
- { role: "assistant", chunks: [{ type: "text", text: "final reply" }] },
- ];
- const app = appWithMessages(new Map([["conv1", messages]]));
- const res = await app.request("/conversations/conv1/last");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.content).toBe("final reply");
- expect(body.turnId).toBeUndefined();
- });
-
- it("returns empty content for unknown conversation", async () => {
- const app = appWithMessages(new Map());
- const res = await app.request("/conversations/unknown/last");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
- expect(body.conversationId).toBe("unknown");
- expect(body.content).toBe("");
- expect(body.turnId).toBeUndefined();
- });
-
- it("blocks until turn settles", async () => {
- const turnId = "sealed-turn";
- const orchestrator: SessionOrchestrator = {
- ...createFakeOrchestrator([]),
- subscribe(conversationId, listener) {
- const event = { type: "turn-sealed" as const, conversationId, turnId };
- setTimeout(() => listener(event), 0);
- return () => {};
- },
- isActive() {
- return true;
- },
- };
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "after seal" }] },
- ];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async load() {
- return messages;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator,
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const res = await app.request("/conversations/conv1/last");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.content).toBe("after seal");
- expect(body.turnId).toBe(turnId);
- });
+ function appWithMessages(messagesByConv: Map<string, ChatMessage[]>) {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async load(conversationId) {
+ return messagesByConv.get(conversationId) ?? [];
+ },
+ };
+ return createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ }
+
+ it("returns last assistant text", async () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
+ { role: "user", chunks: [{ type: "text", text: "more" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "final reply" }] },
+ ];
+ const app = appWithMessages(new Map([["conv1", messages]]));
+ const res = await app.request("/conversations/conv1/last");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.content).toBe("final reply");
+ expect(body.turnId).toBeUndefined();
+ });
+
+ it("returns empty content for unknown conversation", async () => {
+ const app = appWithMessages(new Map());
+ const res = await app.request("/conversations/unknown/last");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
+ expect(body.conversationId).toBe("unknown");
+ expect(body.content).toBe("");
+ expect(body.turnId).toBeUndefined();
+ });
+
+ it("blocks until turn settles", async () => {
+ const turnId = "sealed-turn";
+ const orchestrator: SessionOrchestrator = {
+ ...createFakeOrchestrator([]),
+ subscribe(conversationId, listener) {
+ const event = { type: "turn-sealed" as const, conversationId, turnId };
+ setTimeout(() => listener(event), 0);
+ return () => {};
+ },
+ isActive() {
+ return true;
+ },
+ };
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "after seal" }] },
+ ];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async load() {
+ return messages;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator,
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const res = await app.request("/conversations/conv1/last");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; content: string; turnId?: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.content).toBe("after seal");
+ expect(body.turnId).toBe(turnId);
+ });
});
describe("POST /conversations/:id/open", () => {
- it("returns 200", async () => {
- const emit: HostAPI["emit"] = () => {};
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- emit,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/open", { method: "POST" });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string };
- expect(body.conversationId).toBe("conv1");
- });
-
- it("calls emit with conversationOpened", async () => {
- const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = [];
- const emit: HostAPI["emit"] = (hook, payload) => {
- emitCalls.push({ hook, payload });
- };
- // A store whose getWorkspaceId returns a non-default id, so the test
- // proves the handler resolves and forwards the PERSISTED workspace id
- // (not a hard-coded "default").
- const store = createFakeConversationStore();
- store.getWorkspaceId = async () => "open-workspace";
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- emit,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/open", { method: "POST" });
- expect(res.status).toBe(200);
- expect(emitCalls).toHaveLength(1);
- expect(emitCalls[0]?.hook).toBe(conversationOpened);
- expect(emitCalls[0]?.payload).toEqual({
- conversationId: "conv1",
- workspaceId: "open-workspace",
- });
- });
-
- it("returns 500 when emit is absent", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/open", { method: "POST" });
- expect(res.status).toBe(500);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("not available");
- });
+ it("returns 200", async () => {
+ const emit: HostAPI["emit"] = () => {};
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ emit,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/open", { method: "POST" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string };
+ expect(body.conversationId).toBe("conv1");
+ });
+
+ it("calls emit with conversationOpened", async () => {
+ const emitCalls: Array<{ readonly hook: unknown; readonly payload: unknown }> = [];
+ const emit: HostAPI["emit"] = (hook, payload) => {
+ emitCalls.push({ hook, payload });
+ };
+ // A store whose getWorkspaceId returns a non-default id, so the test
+ // proves the handler resolves and forwards the PERSISTED workspace id
+ // (not a hard-coded "default").
+ const store = createFakeConversationStore();
+ store.getWorkspaceId = async () => "open-workspace";
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ emit,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/open", { method: "POST" });
+ expect(res.status).toBe(200);
+ expect(emitCalls).toHaveLength(1);
+ expect(emitCalls[0]?.hook).toBe(conversationOpened);
+ expect(emitCalls[0]?.payload).toEqual({
+ conversationId: "conv1",
+ workspaceId: "open-workspace",
+ });
+ });
+
+ it("returns 500 when emit is absent", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/open", { method: "POST" });
+ expect(res.status).toBe(500);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("not available");
+ });
});
describe("PUT /conversations/:id/title", () => {
- it("returns 200 with title", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "My Conversation" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; title: string };
- expect(body.conversationId).toBe("conv1");
- expect(body.title).toBe("My Conversation");
- });
-
- it("rejects empty title with 400", async () => {
- let setTitleCalled = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setConversationTitle() {
- setTitleCalled = true;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: " " }),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("title");
- expect(setTitleCalled).toBe(false);
- });
-
- it("returns 400 when title is missing", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("title");
- });
-
- it("forwards the trimmed title to setConversationTitle", async () => {
- const calls: { conversationId: string; title: string }[] = [];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setConversationTitle(conversationId, title) {
- calls.push({ conversationId, title });
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: " trimmed title " }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { conversationId: string; title: string };
- expect(body.title).toBe("trimmed title");
- expect(calls).toHaveLength(1);
- expect(calls[0]?.conversationId).toBe("conv1");
- expect(calls[0]?.title).toBe("trimmed title");
- });
-
- it("returns 400 for invalid JSON body", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: "not json",
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("JSON");
- });
+ it("returns 200 with title", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "My Conversation" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; title: string };
+ expect(body.conversationId).toBe("conv1");
+ expect(body.title).toBe("My Conversation");
+ });
+
+ it("rejects empty title with 400", async () => {
+ let setTitleCalled = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle() {
+ setTitleCalled = true;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: " " }),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ expect(setTitleCalled).toBe(false);
+ });
+
+ it("returns 400 when title is missing", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("title");
+ });
+
+ it("forwards the trimmed title to setConversationTitle", async () => {
+ const calls: { conversationId: string; title: string }[] = [];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setConversationTitle(conversationId, title) {
+ calls.push({ conversationId, title });
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: " trimmed title " }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { conversationId: string; title: string };
+ expect(body.title).toBe("trimmed title");
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.conversationId).toBe("conv1");
+ expect(calls[0]?.title).toBe("trimmed title");
+ });
+
+ it("returns 400 for invalid JSON body", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: "not json",
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("JSON");
+ });
});
describe("extractLastAssistantText", () => {
- it("returns last assistant text chunk", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
- { role: "user", chunks: [{ type: "text", text: "how are you?" }] },
- { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] },
- ];
- expect(extractLastAssistantText(messages)).toBe("I'm good!");
- });
-
- it("returns empty string when no assistant message", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- { role: "system", chunks: [{ type: "text", text: "system prompt" }] },
- ];
- expect(extractLastAssistantText(messages)).toBe("");
- });
-
- it("returns the LAST text chunk when an assistant message has multiple", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- {
- role: "assistant",
- chunks: [
- { type: "text", text: "first" },
- { type: "thinking", text: "internal reasoning" },
- { type: "text", text: "second" },
- ],
- },
- ];
- expect(extractLastAssistantText(messages)).toBe("second");
- });
-
- it("returns empty string when the last assistant message has no text chunk", () => {
- const messages: ChatMessage[] = [
- { role: "user", chunks: [{ type: "text", text: "hello" }] },
- {
- role: "assistant",
- chunks: [
- {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp" },
- },
- ],
- },
- ];
- expect(extractLastAssistantText(messages)).toBe("");
- });
-
- it("returns empty string for an empty message list", () => {
- expect(extractLastAssistantText([])).toBe("");
- });
+ it("returns last assistant text chunk", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "hi there" }] },
+ { role: "user", chunks: [{ type: "text", text: "how are you?" }] },
+ { role: "assistant", chunks: [{ type: "text", text: "I'm good!" }] },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("I'm good!");
+ });
+
+ it("returns empty string when no assistant message", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ { role: "system", chunks: [{ type: "text", text: "system prompt" }] },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("");
+ });
+
+ it("returns the LAST text chunk when an assistant message has multiple", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ {
+ role: "assistant",
+ chunks: [
+ { type: "text", text: "first" },
+ { type: "thinking", text: "internal reasoning" },
+ { type: "text", text: "second" },
+ ],
+ },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("second");
+ });
+
+ it("returns empty string when the last assistant message has no text chunk", () => {
+ const messages: ChatMessage[] = [
+ { role: "user", chunks: [{ type: "text", text: "hello" }] },
+ {
+ role: "assistant",
+ chunks: [
+ {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp" },
+ },
+ ],
+ },
+ ];
+ expect(extractLastAssistantText(messages)).toBe("");
+ });
+
+ it("returns empty string for an empty message list", () => {
+ expect(extractLastAssistantText([])).toBe("");
+ });
});
describe("Workspaces", () => {
- const sampleWorkspace: Workspace = {
- id: "proj",
- title: "proj",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 1000,
- lastActivityAt: 2000,
- };
-
- it("GET /workspaces returns list", async () => {
- const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listWorkspaces() {
- return workspaceEntries;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces");
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceListResponse;
- expect(body.workspaces).toEqual(workspaceEntries);
- });
-
- it("PUT /workspaces/:id creates on miss", async () => {
- let ensured = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async ensureWorkspace(id, opts) {
- ensured = true;
- return { ...sampleWorkspace, id, ...opts };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
- });
- expect(res.status).toBe(200);
- expect(ensured).toBe(true);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.id).toBe("proj");
- expect(body.title).toBe("Project");
- expect(body.defaultCwd).toBe("/home/proj");
- });
-
- it("PUT /workspaces/:id returns existing", async () => {
- const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async ensureWorkspace() {
- return existing;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "New Title" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.title).toBe("Existing");
- expect(body.defaultCwd).toBe("/old");
- });
-
- it("PUT /workspaces/:id rejects invalid slug", async () => {
- let ensured = false;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async ensureWorkspace() {
- ensured = true;
- return sampleWorkspace;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/Bad Slug!", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- expect(ensured).toBe(false);
- });
-
- it("GET /workspaces/:id returns 404 for missing", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async getWorkspace() {
- return null;
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/unknown");
- expect(res.status).toBe(404);
- });
-
- it("PUT /workspaces/:id/title renames", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceTitle(id, title) {
- return { ...sampleWorkspace, id, title };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/title", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: "Renamed" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.title).toBe("Renamed");
- });
-
- it("PUT /workspaces/:id/default-cwd sets", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceDefaultCwd(id, defaultCwd) {
- return { ...sampleWorkspace, id, defaultCwd };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/default-cwd", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ defaultCwd: "/new/cwd" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.defaultCwd).toBe("/new/cwd");
- });
-
- it("DELETE /workspaces/:id closes conversations", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async deleteWorkspace() {
- return { closedCount: 3 };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj", { method: "DELETE" });
- expect(res.status).toBe(200);
- const body = (await res.json()) as DeleteWorkspaceResponse;
- expect(body.workspaceId).toBe("proj");
- expect(body.closedCount).toBe(3);
- });
-
- it("DELETE /workspaces/default returns 409", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/default", { method: "DELETE" });
- expect(res.status).toBe(409);
- });
+ const sampleWorkspace: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("GET /workspaces returns list", async () => {
+ const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listWorkspaces() {
+ return workspaceEntries;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceListResponse;
+ expect(body.workspaces).toEqual(workspaceEntries);
+ });
+
+ it("PUT /workspaces/:id creates on miss", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace(id, opts) {
+ ensured = true;
+ return { ...sampleWorkspace, id, ...opts };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(ensured).toBe(true);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.id).toBe("proj");
+ expect(body.title).toBe("Project");
+ expect(body.defaultCwd).toBe("/home/proj");
+ });
+
+ it("PUT /workspaces/:id returns existing", async () => {
+ const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ return existing;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "New Title" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Existing");
+ expect(body.defaultCwd).toBe("/old");
+ });
+
+ it("PUT /workspaces/:id rejects invalid slug", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ ensured = true;
+ return sampleWorkspace;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(ensured).toBe(false);
+ });
+
+ it("GET /workspaces/:id returns 404 for missing", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getWorkspace() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("PUT /workspaces/:id/title renames", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceTitle(id, title) {
+ return { ...sampleWorkspace, id, title };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Renamed" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Renamed");
+ });
+
+ it("PUT /workspaces/:id/default-cwd sets", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { ...sampleWorkspace, id, defaultCwd };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd: "/new/cwd" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultCwd).toBe("/new/cwd");
+ });
+
+ it("DELETE /workspaces/:id closes conversations", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 3 };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeleteWorkspaceResponse;
+ expect(body.workspaceId).toBe("proj");
+ expect(body.closedCount).toBe(3);
+ });
+
+ it("DELETE /workspaces/default returns 409", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/default", { method: "DELETE" });
+ expect(res.status).toBe(409);
+ });
+
+ // ─── Star/unstar workspace (concurrency priority) ────────────────────────
+
+ it("PUT /workspaces/:id/star persists + notifies the concurrency service", async () => {
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ ...(concurrencyService !== undefined ? { concurrencyService } : {}),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "PUT" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.starred).toBe(true);
+ expect(starredCalled).toEqual({ id: "proj", starred: true });
+ });
+
+ it("DELETE /workspaces/:id/star persists + notifies the concurrency service", async () => {
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ concurrencyService,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.starred).toBe(false);
+ expect(starredCalled).toEqual({ id: "proj", starred: false });
+ });
+
+ it("PUT /workspaces/:id/star rejects invalid slug", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!/star", { method: "PUT" });
+ expect(res.status).toBe(400);
+ });
+
+ it("DELETE /workspaces/:id cleans up the in-memory starred cache (bug fix)", async () => {
+ // Bug 1 fix: deleting a workspace must notify the concurrency service to
+ // remove the workspace ID from the starred cache, preventing stale IDs.
+ let starredCalled: { id: string; starred: boolean } | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 2 };
+ },
+ };
+ const concurrencyService = {
+ acquire: async () => () => {},
+ reportRateLimit() {},
+ setLimit() {},
+ getLimit: () => undefined,
+ getLimits: () => [],
+ getStatus: () => undefined,
+ getStatusAll: () => [],
+ notifyWorkspaceStarred(id: string, starred: boolean) {
+ starredCalled = { id, starred };
+ },
+ destroy() {},
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ concurrencyService,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ // The concurrency service must be notified to clear the starred cache.
+ expect(starredCalled).toEqual({ id: "proj", starred: false });
+ });
+
+ it("PUT /workspaces/:id/star logs warning when concurrency service is absent (bug fix)", async () => {
+ // Bug 2 fix: when the concurrency service is not loaded, the star toggle
+ // persists but the priority cache is not updated. A warning log makes this
+ // degraded behavior visible.
+ const logger = createFakeLogger();
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceStarred(id, starred) {
+ return { ...sampleWorkspace, id, starred };
+ },
+ };
+ // NOTE: no concurrencyService provided — simulates the extension being absent.
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger,
+ });
+ const res = await app.request("/workspaces/proj/star", { method: "PUT" });
+ expect(res.status).toBe(200);
+ // The star persisted, but a warning was logged about the missing service.
+ const warnings = logger.records.filter((r) => r.level === "warn");
+ expect(warnings.some((r) => r.msg.includes("concurrency service is not loaded"))).toBe(true);
+ });
});
it("POST /chat threads workspaceId", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.workspaceId).toBe("proj");
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.workspaceId).toBe("proj");
});
it("GET /conversations?workspaceId= filters", async () => {
- const calls: Parameters<ConversationStore["listConversations"]>[0][] = [];
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async listConversations(filter) {
- calls.push(filter);
- return [];
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations?workspaceId=proj");
- expect(res.status).toBe(200);
- expect(calls).toHaveLength(1);
- expect(calls[0]).toEqual({ workspaceId: "proj" });
+ const calls: Parameters<ConversationStore["listConversations"]>[0][] = [];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listConversations(filter) {
+ calls.push(filter);
+ return [];
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations?workspaceId=proj");
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toEqual({ workspaceId: "proj" });
});
it("GET /conversations/:id/lsp uses effective cwd", async () => {
- let effectiveCwdCalled = false;
- let getCwdCalled = false;
- let lspCwd: string | null = null;
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async getEffectiveCwd(_conversationId) {
- effectiveCwdCalled = true;
- return "/effective";
- },
- async getCwd(_conversationId) {
- getCwdCalled = true;
- return "/explicit";
- },
- };
- const lsp: LspService = {
- async status(cwd) {
- lspCwd = cwd;
- return [];
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- lspService: lsp,
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/lsp");
- expect(res.status).toBe(200);
- expect(effectiveCwdCalled).toBe(true);
- expect(getCwdCalled).toBe(true); // gated on persisted cwd first
- expect(lspCwd).toBe("/effective");
- const body = (await res.json()) as {
- conversationId: string;
- cwd: string | null;
- servers: readonly unknown[];
- };
- expect(body.cwd).toBe("/effective");
+ let effectiveCwdCalled = false;
+ let getCwdCalled = false;
+ let lspCwd: string | null = null;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getEffectiveCwd(_conversationId) {
+ effectiveCwdCalled = true;
+ return "/effective";
+ },
+ async getCwd(_conversationId) {
+ getCwdCalled = true;
+ return "/explicit";
+ },
+ };
+ const lsp: LspService = {
+ async status(cwd) {
+ lspCwd = cwd;
+ return [];
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ lspService: lsp,
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/lsp");
+ expect(res.status).toBe(200);
+ expect(effectiveCwdCalled).toBe(true);
+ expect(getCwdCalled).toBe(true); // gated on persisted cwd first
+ expect(lspCwd).toBe("/effective");
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.cwd).toBe("/effective");
});
describe("GET /system-prompt", () => {
- it("returns stored template", async () => {
- const service = createFakeSystemPromptService("custom template");
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- systemPromptService: service,
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { template: string };
- expect(body.template).toBe("custom template");
- expect(service.getTemplateCalls).toBe(1);
- });
-
- it("returns default when service unavailable", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { template: string };
- expect(body.template).toBe(DEFAULT_TEMPLATE);
- });
+ it("returns stored template", async () => {
+ const service = createFakeSystemPromptService("custom template");
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe("custom template");
+ expect(service.getTemplateCalls).toBe(1);
+ });
+
+ it("returns default when service unavailable", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe(DEFAULT_TEMPLATE);
+ });
});
describe("PUT /system-prompt", () => {
- it("sets template", async () => {
- const service = createFakeSystemPromptService();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- systemPromptService: service,
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ template: "new" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { template: string };
- expect(body.template).toBe("new");
- expect(service.setTemplateCalls).toEqual(["new"]);
- });
-
- it("missing template → 400", async () => {
- const service = createFakeSystemPromptService();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- systemPromptService: service,
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- expect(service.setTemplateCalls).toEqual([]);
- });
-
- it("service unavailable → 503", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ template: "new" }),
- });
- expect(res.status).toBe(503);
- const body = (await res.json()) as { error: string };
- expect(body.error).toBe("System prompt service not available");
- });
+ it("sets template", async () => {
+ const service = createFakeSystemPromptService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template: "new" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { template: string };
+ expect(body.template).toBe("new");
+ expect(service.setTemplateCalls).toEqual(["new"]);
+ });
+
+ it("missing template → 400", async () => {
+ const service = createFakeSystemPromptService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ systemPromptService: service,
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(service.setTemplateCalls).toEqual([]);
+ });
+
+ it("service unavailable → 503", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ template: "new" }),
+ });
+ expect(res.status).toBe(503);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toBe("System prompt service not available");
+ });
});
describe("GET /system-prompt/variables", () => {
- it("returns catalog", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/system-prompt/variables");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { variables: readonly SystemPromptVariable[] };
- expect(Array.isArray(body.variables)).toBe(true);
- // Contains at least system:time, prompt:cwd, and a dynamic file:<path>.
- const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time");
- const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd");
- const fileEntry = body.variables.find((v) => v.type === "file");
- expect(hasSystemTime).toBe(true);
- expect(hasPromptCwd).toBe(true);
- expect(fileEntry).toBeDefined();
- expect(fileEntry?.dynamic).toBe(true);
- });
+ it("returns catalog", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/system-prompt/variables");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { variables: readonly SystemPromptVariable[] };
+ expect(Array.isArray(body.variables)).toBe(true);
+ // Contains at least system:time, prompt:cwd, and a dynamic file:<path>.
+ const hasSystemTime = body.variables.some((v) => v.type === "system" && v.name === "time");
+ const hasPromptCwd = body.variables.some((v) => v.type === "prompt" && v.name === "cwd");
+ const fileEntry = body.variables.find((v) => v.type === "file");
+ expect(hasSystemTime).toBe(true);
+ expect(hasPromptCwd).toBe(true);
+ expect(fileEntry).toBeDefined();
+ expect(fileEntry?.dynamic).toBe(true);
+ });
});
// ─── Computers (mirrors the cwd / workspace routes) ─────────────────────────
const sampleComputer: Computer = {
- alias: "myserver",
- hostName: "10.0.0.5",
- port: 22,
- user: "deploy",
- identityFile: "/home/user/.ssh/id_ed25519",
- knownHost: true,
+ alias: "myserver",
+ hostName: "10.0.0.5",
+ port: 22,
+ user: "deploy",
+ identityFile: "/home/user/.ssh/id_ed25519",
+ knownHost: true,
};
describe("GET /computers", () => {
- it("returns [] when no ComputerService is wired (graceful degrade)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { computers: readonly ComputerEntry[] };
- expect(body.computers).toEqual([]);
- });
-
- it("delegates to the ComputerService when wired", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]),
- logger: noopLogger,
- });
- const res = await app.request("/computers");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { computers: readonly ComputerEntry[] };
- expect(body.computers).toHaveLength(1);
- expect(body.computers[0]?.alias).toBe("myserver");
- expect(body.computers[0]?.usageCount).toBe(2);
- });
+ it("returns [] when no ComputerService is wired (graceful degrade)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { computers: readonly ComputerEntry[] };
+ expect(body.computers).toEqual([]);
+ });
+
+ it("delegates to the ComputerService when wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { computers: readonly ComputerEntry[] };
+ expect(body.computers).toHaveLength(1);
+ expect(body.computers[0]?.alias).toBe("myserver");
+ expect(body.computers[0]?.usageCount).toBe(2);
+ });
});
describe("GET /computers/:alias", () => {
- it("returns the computer when the alias is configured", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver");
- expect(res.status).toBe(200);
- const body = (await res.json()) as Computer;
- expect(body.alias).toBe("myserver");
- expect(body.hostName).toBe("10.0.0.5");
- });
-
- it("returns 404 when the alias is not in the config", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- computerService: createFakeComputerService([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/unknown");
- expect(res.status).toBe(404);
- });
-
- it("returns 404 when no ComputerService is wired (no ssh)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver");
- expect(res.status).toBe(404);
- });
+ it("returns the computer when the alias is configured", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Computer;
+ expect(body.alias).toBe("myserver");
+ expect(body.hostName).toBe("10.0.0.5");
+ });
+
+ it("returns 404 when the alias is not in the config", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when no ComputerService is wired (no ssh)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver");
+ expect(res.status).toBe(404);
+ });
});
describe("GET /computers/:alias/status", () => {
- it("returns disconnected + knownHost:false when no ComputerService is wired", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver/status");
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- alias: string;
- state: string;
- knownHost: boolean;
- };
- expect(body.alias).toBe("myserver");
- expect(body.state).toBe("disconnected");
- expect(body.knownHost).toBe(false);
- });
+ it("returns disconnected + knownHost:false when no ComputerService is wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver/status");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ alias: string;
+ state: string;
+ knownHost: boolean;
+ };
+ expect(body.alias).toBe("myserver");
+ expect(body.state).toBe("disconnected");
+ expect(body.knownHost).toBe(false);
+ });
});
describe("POST /computers/:alias/test", () => {
- it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/computers/myserver/test", { method: "POST" });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { alias: string; ok: boolean; error?: string };
- expect(body.alias).toBe("myserver");
- expect(body.ok).toBe(false);
- expect(body.error).toBe("SSH not configured");
- });
+ it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver/test", { method: "POST" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { alias: string; ok: boolean; error?: string };
+ expect(body.alias).toBe("myserver");
+ expect(body.ok).toBe(false);
+ expect(body.error).toBe("SSH not configured");
+ });
});
describe("GET then PUT then GET /conversations/:id/computer", () => {
- it("round-trips the value", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const get0 = await app.request("/conversations/conv1/computer");
- expect(get0.status).toBe(200);
- const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null };
- expect(get0Body.conversationId).toBe("conv1");
- expect(get0Body.computerId).toBeNull();
-
- const putRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(putRes.status).toBe(200);
- const putBody = (await putRes.json()) as { conversationId: string; computerId: string };
- expect(putBody.conversationId).toBe("conv1");
- expect(putBody.computerId).toBe("myserver");
-
- const getRes = await app.request("/conversations/conv1/computer");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null };
- expect(getBody.computerId).toBe("myserver");
- });
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const get0 = await app.request("/conversations/conv1/computer");
+ expect(get0.status).toBe(200);
+ const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null };
+ expect(get0Body.conversationId).toBe("conv1");
+ expect(get0Body.computerId).toBeNull();
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; computerId: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.computerId).toBe("myserver");
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null };
+ expect(getBody.computerId).toBe("myserver");
+ });
});
describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => {
- it("PUT null clears a previously-set computer", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(putRes.status).toBe(200);
-
- const clearRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: null }),
- });
- expect(clearRes.status).toBe(200);
- const clearBody = (await clearRes.json()) as {
- conversationId: string;
- computerId: string | null;
- };
- expect(clearBody.computerId).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/computer");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { computerId: string | null };
- expect(getBody.computerId).toBeNull();
- });
+ it("PUT null clears a previously-set computer", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const clearRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: null }),
+ });
+ expect(clearRes.status).toBe(200);
+ const clearBody = (await clearRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(clearBody.computerId).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { computerId: string | null };
+ expect(getBody.computerId).toBeNull();
+ });
});
describe("DELETE /conversations/:id/computer", () => {
- it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => {
- const store = createFakeConversationStore();
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const putRes = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(putRes.status).toBe(200);
-
- const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as {
- conversationId: string;
- computerId: string | null;
- };
- expect(deleteBody.conversationId).toBe("conv1");
- expect(deleteBody.computerId).toBeNull();
-
- const getRes = await app.request("/conversations/conv1/computer");
- expect(getRes.status).toBe(200);
- const getBody = (await getRes.json()) as { computerId: string | null };
- expect(getBody.computerId).toBeNull();
- });
-
- it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
- const deleteBody = (await deleteRes.json()) as {
- conversationId: string;
- computerId: string | null;
- };
- expect(deleteBody.computerId).toBeNull();
- });
-
- it("does NOT affect other conversations' computers (isolation)", async () => {
- const computerStore = new Map<string, string>([
- ["conv1", "myserver"],
- ["conv2", "otherbox"],
- ]);
- const store = createFakeConversationStore(
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- new Map(),
- computerStore,
- );
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
-
- const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
- expect(deleteRes.status).toBe(200);
-
- const get1 = await app.request("/conversations/conv1/computer");
- expect(get1.status).toBe(200);
- expect((await get1.json()).computerId).toBeNull();
-
- const get2 = await app.request("/conversations/conv2/computer");
- expect(get2.status).toBe(200);
- expect((await get2.json()).computerId).toBe("otherbox");
- });
+ it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.computerId).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { computerId: string | null };
+ expect(getBody.computerId).toBeNull();
+ });
+
+ it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(deleteBody.computerId).toBeNull();
+ });
+
+ it("does NOT affect other conversations' computers (isolation)", async () => {
+ const computerStore = new Map<string, string>([
+ ["conv1", "myserver"],
+ ["conv2", "otherbox"],
+ ]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ computerStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+
+ const get1 = await app.request("/conversations/conv1/computer");
+ expect(get1.status).toBe(200);
+ expect((await get1.json()).computerId).toBeNull();
+
+ const get2 = await app.request("/conversations/conv2/computer");
+ expect(get2.status).toBe(200);
+ expect((await get2.json()).computerId).toBe("otherbox");
+ });
});
describe("PUT /conversations/:id/computer validation", () => {
- it("with missing computerId returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- const body = (await res.json()) as { error: string };
- expect(body.error).toContain("computerId");
- });
-
- it("with empty-string computerId returns 400", async () => {
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/conversations/conv1/computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "" }),
- });
- expect(res.status).toBe(400);
- });
+ it("with missing computerId returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("computerId");
+ });
+
+ it("with empty-string computerId returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
});
describe("PUT /workspaces/:id/default-computer", () => {
- const wsSample: Workspace = {
- id: "proj",
- title: "proj",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 1000,
- lastActivityAt: 2000,
- };
-
- it("sets the default computer", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceDefaultComputerId(id, defaultComputerId) {
- return { ...wsSample, id, defaultComputerId };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/default-computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: "myserver" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.defaultComputerId).toBe("myserver");
- });
-
- it("clears the default computer with null", async () => {
- const store: ConversationStore = {
- ...createFakeConversationStore(),
- async setWorkspaceDefaultComputerId(id, defaultComputerId) {
- return { ...wsSample, id, defaultComputerId };
- },
- };
- const app = createApp({
- conversationStore: store,
- orchestrator: createFakeOrchestrator([]),
- credentialStore: createFakeCredentialStore([]),
- logger: noopLogger,
- });
- const res = await app.request("/workspaces/proj/default-computer", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId: null }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as WorkspaceResponse;
- expect(body.defaultComputerId).toBeNull();
- });
+ const wsSample: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("sets the default computer", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...wsSample, id, defaultComputerId };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultComputerId).toBe("myserver");
+ });
+
+ it("clears the default computer with null", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...wsSample, id, defaultComputerId };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultComputerId).toBeNull();
+ });
});
describe("POST /chat threads computerId", () => {
- it("forwards computerId into the orchestrator input when present", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: "hi",
- conversationId: "conv1",
- computerId: "myserver",
- }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.conversationId).toBe("conv1");
- expect(cap.received?.computerId).toBe("myserver");
- });
-
- it("omits computerId when not provided", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.computerId).toBeUndefined();
- });
+ it("forwards computerId into the orchestrator input when present", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ computerId: "myserver",
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.conversationId).toBe("conv1");
+ expect(cap.received?.computerId).toBe("myserver");
+ });
+
+ it("omits computerId when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.computerId).toBeUndefined();
+ });
+});
+
+describe("GET /workspaces/:id/heartbeat/next-run", () => {
+ it("returns { nextRunAt: null } when no HeartbeatService is wired (graceful degrade)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { nextRunAt: string | null };
+ expect(body.nextRunAt).toBeNull();
+ });
+
+ it("delegates to the HeartbeatService and returns the next-run ISO timestamp", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: createFakeHeartbeatService("2026-06-25T14:05:00Z"),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { nextRunAt: string | null };
+ expect(body.nextRunAt).toBe("2026-06-25T14:05:00Z");
+ });
+
+ it("returns { nextRunAt: null } when the service reports no scheduled run", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: createFakeHeartbeatService(null),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat/next-run");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { nextRunAt: string | null };
+ expect(body.nextRunAt).toBeNull();
+ });
+});
+
+describe("PUT /workspaces/:id/heartbeat", () => {
+ it("forwards inactiveOnly to the service and echoes it in the response", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: false }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { inactiveOnly: boolean };
+ expect(body.inactiveOnly).toBe(false);
+ expect(hb.captured).toHaveLength(1);
+ expect(hb.captured[0]?.workspaceId).toBe("ws-1");
+ expect(hb.captured[0]?.update.inactiveOnly).toBe(false);
+ });
+
+ it("rejects a non-boolean inactiveOnly with 400", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ inactiveOnly: "yes" }),
+ });
+ expect(res.status).toBe(400);
+ // The service was NOT called (validation happened first).
+ expect(hb.captured).toHaveLength(0);
+ });
+
+ it("omits inactiveOnly from the forwarded update when absent (leaves it unchanged)", async () => {
+ const hb = createCapturingHeartbeatService();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ heartbeatService: hb,
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/ws-1/heartbeat", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(hb.captured[0]?.update.inactiveOnly).toBeUndefined();
+ });
});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 2e81c46..32a92f1 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1,1433 +1,1960 @@
+import { DEFAULT_HEARTBEAT_CONFIG } from "@dispatch/heartbeat";
import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel";
import { DEFAULT_TEMPLATE, getVariableCatalog } from "@dispatch/system-prompt";
import type {
- CloseConversationResponse,
- CompactPercentResponse,
- CompactResponse,
- ComputerListResponse,
- ComputerResponse,
- ComputerStatusResponse,
- ConversationComputerResponse,
- ConversationHistoryResponse,
- ConversationListResponse,
- ConversationMetricsResponse,
- ConversationStatusResponse,
- CwdResponse,
- DeleteWorkspaceResponse,
- LastMessageResponse,
- LspServerInfo,
- LspStatusResponse,
- McpServerInfo,
- McpStatusResponse,
- ModelResponse,
- ModelsResponse,
- OpenConversationResponse,
- QueueResponse,
- ReasoningEffortResponse,
- SetCompactPercentRequest,
- SetConversationComputerRequest,
- SetSystemPromptTemplateRequest,
- SetWorkspaceDefaultComputerRequest,
- SystemPromptTemplateResponse,
- SystemPromptVariablesResponse,
- TestComputerResponse,
- ThroughputResponse,
- TitleResponse,
- WarmResponse,
- WorkspaceListResponse,
- WorkspaceResponse,
+ CloseConversationResponse,
+ CompactPercentResponse,
+ CompactResponse,
+ ComputerListResponse,
+ ComputerResponse,
+ ComputerStatusResponse,
+ ConcurrencyCooldownResponse,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusResponse,
+ ConversationComputerResponse,
+ ConversationHistoryResponse,
+ ConversationListResponse,
+ ConversationMetricsResponse,
+ ConversationStatusResponse,
+ CwdResponse,
+ DeleteWorkspaceResponse,
+ HeartbeatConfig,
+ HeartbeatRunsResponse,
+ LastMessageResponse,
+ LspServerInfo,
+ LspStatusResponse,
+ McpServerInfo,
+ McpStatusResponse,
+ ModelResponse,
+ ModelsResponse,
+ OpenConversationResponse,
+ QueueCancelResponse,
+ QueueResponse,
+ ReasoningEffortResponse,
+ SetCompactPercentRequest,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+ SetConversationComputerRequest,
+ SetSystemPromptTemplateRequest,
+ SetWorkspaceDefaultComputerRequest,
+ StopHeartbeatRunResponse,
+ SystemPromptTemplateResponse,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ ThroughputResponse,
+ TitleResponse,
+ UpdateHeartbeatRequest,
+ VisionSettingsResponse,
+ WarmResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
import { Hono } from "hono";
import { cors } from "hono/cors";
import {
- computeCachePct,
- computeExpectedCacheRate,
- extractLastAssistantText,
- isModelParseError,
- isParseError,
- isReasoningEffortParseError,
- isSinceSeqError,
- isWindowParamError,
- parseChatBody,
- parseModelBody,
- parseQueueBody,
- parseReasoningEffortBody,
- parseSinceSeq,
- parseStatusFilter,
- parseWarmBody,
- parseWindowParam,
- serializeEventLine,
+ computeCachePct,
+ computeExpectedCacheRate,
+ extractLastAssistantText,
+ isModelParseError,
+ isParseError,
+ isReasoningEffortParseError,
+ isSinceSeqError,
+ isValidReasoningEffort,
+ isWindowParamError,
+ parseChatBody,
+ parseModelBody,
+ parseQueueBody,
+ parseReasoningEffortBody,
+ parseSinceSeq,
+ parseStatusFilter,
+ parseWarmBody,
+ parseWindowParam,
+ serializeEventLine,
} from "./logic.js";
import {
- type CompactionService,
- type ComputerService,
- type ConversationStore,
- type CredentialStore,
- conversationOpened,
- isValidWorkspaceSlug,
- type LspServerStatus,
- type LspService,
- type McpServerStatus,
- type McpService,
- type SessionOrchestrator,
- type SystemPromptService,
- ThroughputQueryError,
- type ThroughputStore,
- type WarmService,
+ type CompactionService,
+ type ComputerService,
+ type ConcurrencyService,
+ type ConversationStore,
+ type CredentialStore,
+ conversationOpened,
+ type HeartbeatService,
+ isValidWorkspaceSlug,
+ type LspServerStatus,
+ type LspService,
+ type McpServerStatus,
+ type McpService,
+ type SessionOrchestrator,
+ type SystemPromptService,
+ ThroughputQueryError,
+ type ThroughputStore,
+ type WarmService,
} from "./seam.js";
export interface CreateServerOptions {
- readonly conversationStore: ConversationStore;
- readonly orchestrator: SessionOrchestrator;
- readonly credentialStore: CredentialStore;
- readonly warmService?: WarmService;
- readonly compactionService?: CompactionService;
- readonly lspService?: LspService;
- readonly mcpService?: McpService;
- /** Optional — system prompt builder service (GET/PUT template). */
- readonly systemPromptService?: SystemPromptService;
- /**
- * Optional — computer discovery + live connection service (provided by the
- * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes
- * degrade: list returns `[]`, status returns "disconnected", test returns
- * a not-configured result. The per-conversation / workspace-default computer
- * endpoints work regardless (they only touch the conversation store).
- */
- readonly computerService?: ComputerService;
- /** Optional — defaults to a no-op store (recording disabled, empty reports). */
- readonly throughputStore?: ThroughputStore;
- readonly logger?: Logger;
- readonly generateId?: () => string;
- /** Injectable clock for sample timestamps (default Date.now). */
- readonly now?: () => number;
- /**
- * Fire-and-forget event-bus emit (bound `host.emit`). Required by
- * `POST /conversations/:id/open` to signal the frontend. When absent,
- * that endpoint responds `500 { error: "not available" }`.
- */
- readonly emit?: HostAPI["emit"];
- /**
- * Directory containing built frontend static files. When set, unmatched GET
- * requests fall through to static file serving (SPA fallback to index.html).
- * When absent, no static serving (API-only — backward compatible).
- */
- readonly webDir?: string;
+ readonly conversationStore: ConversationStore;
+ readonly orchestrator: SessionOrchestrator;
+ readonly credentialStore: CredentialStore;
+ readonly warmService?: WarmService;
+ readonly compactionService?: CompactionService;
+ readonly lspService?: LspService;
+ readonly mcpService?: McpService;
+ /** Optional — system prompt builder service (GET/PUT template). */
+ readonly systemPromptService?: SystemPromptService;
+ /**
+ * Optional — per-workspace heartbeat loop service (provided by the
+ * `heartbeat` extension). When absent (heartbeat not loaded), the
+ * `/workspaces/:id/heartbeat*` routes degrade: GET returns defaults,
+ * PUT/POST return 503.
+ */
+ readonly heartbeatService?: HeartbeatService;
+ /**
+ * Optional — computer discovery + live connection service (provided by the
+ * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes
+ * degrade: list returns `[]`, status returns "disconnected", test returns
+ * a not-configured result. The per-conversation / workspace-default computer
+ * endpoints work regardless (they only touch the conversation store).
+ */
+ readonly computerService?: ComputerService;
+ /** Optional — defaults to a no-op store (recording disabled, empty reports). */
+ readonly throughputStore?: ThroughputStore;
+ /**
+ * Optional — provider concurrency limiter service (provided by the
+ * `provider-concurrency` extension). When absent (extension not loaded),
+ * the `/concurrency/*` routes degrade: limits returns empty, status returns
+ * empty, PUT returns 503.
+ */
+ readonly concurrencyService?: ConcurrencyService;
+ readonly logger?: Logger;
+ readonly generateId?: () => string;
+ /** Injectable clock for sample timestamps (default Date.now). */
+ readonly now?: () => number;
+ /**
+ * Fire-and-forget event-bus emit (bound `host.emit`). Required by
+ * `POST /conversations/:id/open` to signal the frontend. When absent,
+ * that endpoint responds `500 { error: "not available" }`.
+ */
+ readonly emit?: HostAPI["emit"];
+ /**
+ * Directory containing built frontend static files. When set, unmatched GET
+ * requests fall through to static file serving (SPA fallback to index.html).
+ * When absent, no static serving (API-only — backward compatible).
+ */
+ readonly webDir?: string;
}
const noopLogger: Logger = {
- debug() {},
- info() {},
- warn() {},
- error() {},
- child() {
- return noopLogger;
- },
- span() {
- return {
- id: "noop-span",
- log: noopLogger,
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
+ debug() {},
+ info() {},
+ warn() {},
+ error() {},
+ child() {
+ return noopLogger;
+ },
+ span() {
+ return {
+ id: "noop-span",
+ log: noopLogger,
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
};
const noopThroughputStore: ThroughputStore = {
- record: async () => {},
- aggregate: async (q) => ({ period: q.period, date: q.date, start: 0, end: 0, models: [] }),
+ record: async () => {},
+ aggregate: async (q) => ({ period: q.period, date: q.date, start: 0, end: 0, models: [] }),
};
export function createApp(opts: CreateServerOptions): Hono {
- const app = new Hono();
- const log = opts.logger ?? noopLogger;
- const generateId = opts.generateId ?? (() => crypto.randomUUID());
- const now = opts.now ?? (() => Date.now());
- const throughputStore = opts.throughputStore ?? noopThroughputStore;
-
- async function recordThroughput(
- turnEvents: readonly AgentEvent[],
- model: string | undefined,
- ): Promise<void> {
- if (model === undefined) return; // no model selected → nothing to attribute
- let genMs = 0;
- let outputTokens = 0;
- for (const e of turnEvents) {
- if (e.type === "step-complete" && e.genTotalMs !== undefined) genMs += e.genTotalMs;
- if (e.type === "done" && e.usage !== undefined) outputTokens = e.usage.outputTokens;
- }
- if (genMs <= 0) return; // no generation time → can't compute tok/s
- try {
- await throughputStore.record({ model, ts: now(), outputTokens, genMs });
- log.info("throughput: turn recorded", {
- model,
- outputTokens,
- genMs,
- tokensPerSecond: Math.round((outputTokens / (genMs / 1000)) * 100) / 100,
- });
- } catch (err) {
- log.warn("throughput: failed to record sample", {
- error: err instanceof Error ? err.message : String(err),
- });
- }
- }
-
- app.use(
- "*",
- cors({
- origin: "*",
- allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
- allowHeaders: ["Content-Type"],
- }),
- );
-
- app.get("/health", (c) => c.json({ ok: true }));
-
- app.get("/conversations/:id/metrics", async (c) => {
- const conversationId = c.req.param("id");
-
- try {
- const turns = await opts.conversationStore.loadMetrics(conversationId);
- log.info("conversations: metrics read", {
- conversationId,
- count: turns.length,
- });
- const body: ConversationMetricsResponse = { turns };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: metrics store failure", { err });
- return c.json({ error: "Failed to load conversation metrics" }, 500);
- }
- });
-
- app.get("/conversations/:id", async (c) => {
- const conversationId = c.req.param("id");
- const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq"));
- if (isSinceSeqError(sinceSeqResult)) {
- log.warn("conversations: invalid sinceSeq", {
- conversationId,
- error: sinceSeqResult.error,
- });
- return c.json({ error: sinceSeqResult.error }, 400);
- }
-
- // `limit` / `beforeSeq` are optional positive-integer history-window
- // params. The store is deliberately forgiving (a 0/negative bound is
- // treated as ABSENT), so we MUST reject malformed values here and never
- // forward an invalid window.
- const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq");
- if (isWindowParamError(beforeSeqResult)) {
- log.warn("conversations: invalid beforeSeq", {
- conversationId,
- error: beforeSeqResult.error,
- });
- return c.json({ error: beforeSeqResult.error }, 400);
- }
- const limitResult = parseWindowParam(c.req.query("limit"), "limit");
- if (isWindowParamError(limitResult)) {
- log.warn("conversations: invalid limit", {
- conversationId,
- error: limitResult.error,
- });
- return c.json({ error: limitResult.error }, 400);
- }
-
- // Include only the fields actually provided (exactOptionalPropertyTypes),
- // and omit the window argument entirely when neither was given — keeping
- // the pre-windowing call shape byte-identical for existing callers.
- const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined =
- beforeSeqResult !== undefined || limitResult !== undefined
- ? {
- ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}),
- ...(limitResult !== undefined ? { limit: limitResult } : {}),
- }
- : undefined;
-
- try {
- const chunks =
- window !== undefined
- ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window)
- : await opts.conversationStore.loadSince(conversationId, sinceSeqResult);
- const latestSeq =
- chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult;
- log.info("conversations: read", {
- conversationId,
- sinceSeq: sinceSeqResult,
- count: chunks.length,
- });
- const body: ConversationHistoryResponse = { chunks, latestSeq };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: store failure", { err });
- return c.json({ error: "Failed to load conversation" }, 500);
- }
- });
-
- app.get("/conversations/:id/status", async (c) => {
- const conversationId = c.req.param("id");
- const isActive = opts.orchestrator.isActive(conversationId);
- const status = await opts.conversationStore.getConversationStatus(conversationId);
- if (status === null) {
- return c.json({ error: "Conversation not found" }, 404);
- }
- const body: ConversationStatusResponse = { conversationId, isActive, status };
- return c.json(body, 200);
- });
-
- app.get("/models", async (c) => {
- try {
- const models = await opts.credentialStore.listCatalog();
- const modelInfo: Record<string, { contextWindow?: number }> = {};
- for (const modelName of models) {
- const info = await opts.credentialStore.getModelInfo(modelName);
- if (info?.contextWindow !== undefined) {
- modelInfo[modelName] = { contextWindow: info.contextWindow };
- }
- }
- const body: ModelsResponse = {
- models,
- ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}),
- };
- return c.json(body, 200);
- } catch (err) {
- log.error("models: failed to retrieve catalog", { err });
- return c.json({ error: "Failed to retrieve model catalog" }, 502);
- }
- });
-
- // ─── Computers (discovery + live state) ───────────────────────────────────
- // Read-only discovery + connection state is delegated to the ComputerService
- // (provided by the `ssh` extension). When ssh is NOT loaded the routes
- // degrade: list → empty, status → "disconnected", test → not-configured.
-
- app.get("/computers", async (c) => {
- if (opts.computerService === undefined) {
- // Graceful: no ssh configured → no computers discovered.
- const body: ComputerListResponse = { computers: [] };
- return c.json(body, 200);
- }
- try {
- const computers = await opts.computerService.listComputers();
- log.info("computers: list", { count: computers.length });
- const body: ComputerListResponse = { computers };
- return c.json(body, 200);
- } catch (err) {
- log.error("computers: list failure", { err });
- return c.json({ error: "Failed to list computers" }, 500);
- }
- });
-
- app.get("/computers/:alias", async (c) => {
- const alias = c.req.param("alias");
- if (opts.computerService === undefined) {
- // No ssh configured → no computer resolves this alias.
- return c.json({ error: "Computer not found" }, 404);
- }
- try {
- const computer = await opts.computerService.getComputer(alias);
- if (computer === null) {
- return c.json({ error: "Computer not found" }, 404);
- }
- const body: ComputerResponse = computer;
- return c.json(body, 200);
- } catch (err) {
- log.error("computers: get failure", { err, alias });
- return c.json({ error: "Failed to read computer" }, 500);
- }
- });
-
- app.get("/computers/:alias/status", async (c) => {
- const alias = c.req.param("alias");
- if (opts.computerService === undefined) {
- const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false };
- return c.json(body, 200);
- }
- try {
- const body = await opts.computerService.getStatus(alias);
- return c.json(body, 200);
- } catch (err) {
- log.error("computers: status failure", { err, alias });
- return c.json({ error: "Failed to read computer status" }, 500);
- }
- });
-
- app.post("/computers/:alias/test", async (c) => {
- const alias = c.req.param("alias");
- if (opts.computerService === undefined) {
- const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" };
- return c.json(body, 200);
- }
- try {
- const body = await opts.computerService.test(alias);
- return c.json(body, 200);
- } catch (err) {
- log.error("computers: test failure", { err, alias });
- return c.json({ error: "Failed to test computer" }, 500);
- }
- });
-
- app.post("/chat", async (c) => {
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("chat: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- const result = parseChatBody(body, generateId);
- if (isParseError(result)) {
- log.warn("chat: validation failed", { reason: result.error });
- return c.json({ error: result.error }, 400);
- }
-
- const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } =
- result;
- log.info("chat: request accepted", {
- conversationId,
- hasModel: model !== undefined,
- hasCwd: cwd !== undefined,
- hasComputerId: computerId !== undefined,
- hasReasoningEffort: reasoningEffort !== undefined,
- hasWorkspaceId: workspaceId !== undefined,
- });
-
- const events: AgentEvent[] = [];
- let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined;
- let streamClosed = false;
-
- const stream = new ReadableStream<Uint8Array>({
- start(controller) {
- controllerRef = controller;
- },
- });
-
- function safeEnqueue(data: Uint8Array): void {
- if (streamClosed) return;
- try {
- controllerRef?.enqueue(data);
- } catch (err) {
- streamClosed = true;
- log.warn("chat: stream enqueue failed", {
- conversationId,
- error: err instanceof Error ? err.message : String(err),
- });
- }
- }
-
- function safeClose(): void {
- if (streamClosed) return;
- streamClosed = true;
- try {
- controllerRef?.close();
- } catch (err) {
- log.warn("chat: stream close failed", {
- conversationId,
- error: err instanceof Error ? err.message : String(err),
- });
- }
- }
-
- const orchestratorInput: Parameters<SessionOrchestrator["handleMessage"]>[0] = {
- conversationId,
- text: message,
- onEvent: (event) => {
- events.push(event);
- safeEnqueue(new TextEncoder().encode(serializeEventLine(event)));
- },
- ...(model !== undefined ? { modelName: model } : {}),
- ...(cwd !== undefined ? { cwd } : {}),
- ...(computerId !== undefined ? { computerId } : {}),
- ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- };
-
- opts.orchestrator
- .handleMessage(orchestratorInput)
- .then(async () => {
- safeClose();
- await recordThroughput(events, model);
- })
- .catch((err) => {
- log.error("chat: turn failed", { err });
- const errorEvent: AgentEvent = {
- type: "error",
- conversationId,
- turnId: "",
- message: err instanceof Error ? err.message : String(err),
- };
- safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent)));
- safeClose();
- });
-
- return new Response(stream, {
- status: 200,
- headers: {
- "Content-Type": "application/x-ndjson",
- "X-Conversation-Id": conversationId,
- "Transfer-Encoding": "chunked",
- },
- });
- });
-
- app.post("/chat/warm", async (c) => {
- if (opts.warmService === undefined) {
- return c.json({ error: "Warm service not available" }, 503);
- }
-
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("chat/warm: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- const parsed = parseWarmBody(body);
- if ("error" in parsed) {
- log.warn("chat/warm: validation failed", { reason: parsed.error });
- return c.json({ error: parsed.error }, 400);
- }
-
- const { conversationId, model, cwd } = parsed;
- log.info("chat/warm: request accepted", {
- conversationId,
- hasModel: model !== undefined,
- hasCwd: cwd !== undefined,
- });
-
- const warmOpts: { readonly cwd?: string; readonly modelName?: string } | undefined =
- model !== undefined || cwd !== undefined
- ? {
- ...(cwd !== undefined ? { cwd } : {}),
- ...(model !== undefined ? { modelName: model } : {}),
- }
- : undefined;
-
- const result = await opts.warmService.warm(conversationId, warmOpts);
-
- if ("error" in result) {
- log.warn("chat/warm: service returned error", { conversationId, error: result.error });
- return c.json({ error: result.error }, 409);
- }
-
- const response: WarmResponse = {
- inputTokens: result.inputTokens,
- outputTokens: result.outputTokens,
- cacheReadTokens: result.cacheReadTokens,
- cacheWriteTokens: result.cacheWriteTokens,
- cachePct: computeCachePct(result.inputTokens, result.cacheReadTokens),
- expectedCacheRate: computeExpectedCacheRate(result.cacheReadTokens, result.cacheWriteTokens),
- };
- return c.json(response, 200);
- });
-
- app.get("/metrics/throughput", async (c) => {
- const period = c.req.query("period");
- const date = c.req.query("date");
- if (period !== "day" && period !== "week" && period !== "month") {
- return c.json({ error: "query param 'period' must be one of: day, week, month" }, 400);
- }
- if (date === undefined || date === "") {
- return c.json({ error: "query param 'date' is required" }, 400);
- }
- try {
- // Typed against the wire contract: if the store's report shape ever
- // drifts from ThroughputResponse, this assignment fails to compile.
- const body: ThroughputResponse = await throughputStore.aggregate({ period, date });
- return c.json(body);
- } catch (err) {
- if (err instanceof ThroughputQueryError) {
- return c.json({ error: err.message }, 400);
- }
- log.error("throughput: aggregate failed", { err });
- return c.json({ error: "Failed to aggregate throughput" }, 502);
- }
- });
-
- app.post("/conversations/:id/close", (c) => {
- const conversationId = c.req.param("id");
- const { abortedTurn } = opts.orchestrator.closeConversation(conversationId);
- log.info("conversations: closed", { conversationId, abortedTurn });
- const body: CloseConversationResponse = { conversationId, abortedTurn };
- return c.json(body, 200);
- });
-
- app.post("/conversations/:id/stop", (c) => {
- const conversationId = c.req.param("id");
- const { abortedTurn } = opts.orchestrator.stopTurn(conversationId);
- log.info("conversations: stop", { conversationId, abortedTurn });
- return c.json({ conversationId, abortedTurn }, 200);
- });
-
- app.post("/conversations/:id/queue", async (c) => {
- const conversationId = c.req.param("id");
-
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("conversations/queue: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- const parsed = parseQueueBody(body);
- if (isParseError(parsed)) {
- log.warn("conversations/queue: validation failed", { reason: parsed.error });
- return c.json({ error: parsed.error }, 400);
- }
-
- // `enqueue` is synchronous and owns the idle→startTurn vs active→queue
- // decision (no separate `isActive` race) — it does not throw for an
- // unknown/idle conversation, which instead starts a turn. Mirrors the
- // direct sync call used by `POST /conversations/:id/close`.
- const { startedTurn, queue } = opts.orchestrator.enqueue({
- conversationId,
- text: parsed.text,
- ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}),
- });
- log.info("conversations: enqueued", {
- conversationId,
- startedTurn,
- queueLength: queue.length,
- });
- const response: QueueResponse = { conversationId, startedTurn, queue };
- return c.json(response, 200);
- });
-
- app.get("/conversations/:id/cwd", async (c) => {
- const conversationId = c.req.param("id");
- try {
- const cwd = await opts.conversationStore.getCwd(conversationId);
- log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null });
- const body: CwdResponse = { conversationId, cwd };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: cwd read failure", { err });
- return c.json({ error: "Failed to read conversation cwd" }, 500);
- }
- });
-
- app.put("/conversations/:id/cwd", async (c) => {
- const conversationId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("conversations/cwd: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- if (body === null || typeof body !== "object") {
- return c.json({ error: "Request body must be a JSON object" }, 400);
- }
- const obj = body as Record<string, unknown>;
- if (typeof obj.cwd !== "string" || obj.cwd.length === 0) {
- return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400);
- }
-
- // When a workspaceId is provided, assign the conversation to that
- // workspace BEFORE persisting the cwd — so a subsequent
- // GET /conversations/:id/lsp resolves a relative cwd against the
- // workspace's defaultCwd (not the server default). Omit for unchanged
- // workspace assignment (backward compatible).
- if (obj.workspaceId !== undefined) {
- if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) {
- return c.json({ error: "Invalid workspaceId" }, 400);
- }
- }
-
- try {
- if (typeof obj.workspaceId === "string") {
- await opts.conversationStore.ensureWorkspace(obj.workspaceId);
- await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId);
- }
- await opts.conversationStore.setCwd(conversationId, obj.cwd);
- log.info("conversations: cwd set", { conversationId });
- const response: CwdResponse = { conversationId, cwd: obj.cwd };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: cwd set failure", { err });
- return c.json({ error: "Failed to set conversation cwd" }, 500);
- }
- });
-
- app.delete("/conversations/:id/cwd", async (c) => {
- const conversationId = c.req.param("id");
- try {
- await opts.conversationStore.clearCwd(conversationId);
- log.info("conversations: cwd cleared", { conversationId });
- const response: CwdResponse = { conversationId, cwd: null };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: cwd clear failure", { err });
- return c.json({ error: "Failed to clear conversation cwd" }, 500);
- }
- });
-
- // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ──────────
-
- app.get("/conversations/:id/computer", async (c) => {
- const conversationId = c.req.param("id");
- try {
- const computerId = await opts.conversationStore.getComputerId(conversationId);
- log.info("conversations: computer read", {
- conversationId,
- hasComputerId: computerId !== null,
- });
- const body: ConversationComputerResponse = { conversationId, computerId };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: computer read failure", { err });
- return c.json({ error: "Failed to read conversation computer" }, 500);
- }
- });
-
- app.put("/conversations/:id/computer", async (c) => {
- const conversationId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("conversations/computer: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- if (body === null || typeof body !== "object") {
- return c.json({ error: "Request body must be a JSON object" }, 400);
- }
- const obj = body as Record<string, unknown>;
- // `computerId` must be a string (the SSH alias) or null (clear → inherit
- // the workspace defaultComputerId → local). An empty string is rejected
- // (unlike cwd, an alias is never "empty"); null is the explicit clear.
- if (
- obj.computerId !== null &&
- (typeof obj.computerId !== "string" || obj.computerId.length === 0)
- ) {
- return c.json(
- { error: "Field 'computerId' is required and must be a non-empty string or null" },
- 400,
- );
- }
- const { computerId } = obj as unknown as SetConversationComputerRequest;
-
- // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided,
- // assign the conversation to that workspace BEFORE persisting the
- // computer, so a subsequent effective-computer resolution reads the
- // workspace's defaultComputerId. Omit for unchanged workspace assignment.
- if (obj.workspaceId !== undefined) {
- if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) {
- return c.json({ error: "Invalid workspaceId" }, 400);
- }
- }
-
- try {
- if (typeof obj.workspaceId === "string") {
- await opts.conversationStore.ensureWorkspace(obj.workspaceId);
- await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId);
- }
- // null → clear (inherit/local); string → persist the alias.
- await opts.conversationStore.setComputerId(conversationId, computerId);
- log.info("conversations: computer set", { conversationId });
- const response: ConversationComputerResponse = { conversationId, computerId };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: computer set failure", { err });
- return c.json({ error: "Failed to set conversation computer" }, 500);
- }
- });
-
- app.delete("/conversations/:id/computer", async (c) => {
- const conversationId = c.req.param("id");
- try {
- await opts.conversationStore.clearComputerId(conversationId);
- log.info("conversations: computer cleared", { conversationId });
- const response: ConversationComputerResponse = { conversationId, computerId: null };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: computer clear failure", { err });
- return c.json({ error: "Failed to clear conversation computer" }, 500);
- }
- });
-
- app.get("/conversations/:id/reasoning-effort", async (c) => {
- const conversationId = c.req.param("id");
- try {
- const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId);
- log.info("conversations: reasoning-effort read", {
- conversationId,
- hasEffort: reasoningEffort !== null,
- });
- const body: ReasoningEffortResponse = { conversationId, reasoningEffort };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: reasoning-effort read failure", { err });
- return c.json({ error: "Failed to read conversation reasoning effort" }, 500);
- }
- });
-
- app.put("/conversations/:id/reasoning-effort", async (c) => {
- const conversationId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("conversations/reasoning-effort: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- const parsed = parseReasoningEffortBody(body);
- if (isReasoningEffortParseError(parsed)) {
- log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error });
- return c.json({ error: parsed.error }, 400);
- }
-
- try {
- await opts.conversationStore.setReasoningEffort(conversationId, parsed);
- log.info("conversations: reasoning-effort set", { conversationId });
- const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: reasoning-effort set failure", { err });
- return c.json({ error: "Failed to set conversation reasoning effort" }, 500);
- }
- });
-
- app.get("/conversations/:id/model", async (c) => {
- const conversationId = c.req.param("id");
- try {
- const model = await opts.conversationStore.getModel(conversationId);
- log.info("conversations: model read", {
- conversationId,
- hasModel: model !== null,
- });
- const body: ModelResponse = { conversationId, model };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: model read failure", { err });
- return c.json({ error: "Failed to read conversation model" }, 500);
- }
- });
-
- app.put("/conversations/:id/model", async (c) => {
- const conversationId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("conversations/model: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- const parsed = parseModelBody(body);
- if (isModelParseError(parsed)) {
- log.warn("conversations/model: validation failed", { reason: parsed.error });
- return c.json({ error: parsed.error }, 400);
- }
-
- // A non-null non-empty model persists the selection; `null` or an empty
- // string clears the key (the store treats an empty string as "delete").
- // The response carries the resulting value: the model name, or null when
- // cleared (mirroring how `getModel` returns null after a clear).
- const resultModel = parsed !== null && parsed.length > 0 ? parsed : null;
- const persistedValue = resultModel !== null ? resultModel : "";
-
- try {
- await opts.conversationStore.setModel(conversationId, persistedValue);
- log.debug("conversations: model set", { conversationId, model: resultModel });
- const response: ModelResponse = { conversationId, model: resultModel };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: model set failure", { err });
- return c.json({ error: "Failed to set conversation model" }, 500);
- }
- });
-
- app.get("/conversations/:id/lsp", async (c) => {
- const conversationId = c.req.param("id");
- try {
- // Gate on the PERSISTED cwd first: when no cwd has been set for the
- // conversation, the LSP does NOT connect (return null + empty servers)
- // rather than falling through to the server default (process.cwd()).
- const persistedCwd = await opts.conversationStore.getCwd(conversationId);
- if (persistedCwd === null) {
- log.info("conversations: lsp status read (no cwd)", { conversationId });
- const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
- return c.json(body, 200);
- }
-
- // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd
- // resolved against the workspace defaultCwd; absolute → as-is).
- const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId);
- if (effectiveCwd === null) {
- // Edge case: persisted cwd exists but resolution returned null.
- log.info("conversations: lsp status read (no effective cwd)", { conversationId });
- const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
- return c.json(body, 200);
- }
-
- if (opts.lspService === undefined) {
- log.warn("conversations: lsp service not available", { conversationId });
- return c.json({ error: "LSP service not available" }, 503);
- }
-
- const statuses = await opts.lspService.status(effectiveCwd);
- const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => {
- const info: LspServerInfo = {
- id: s.id,
- name: s.name,
- root: s.root,
- extensions: s.extensions,
- state: s.state,
- ...(s.error !== undefined ? { error: s.error } : {}),
- ...(s.configSource !== undefined ? { configSource: s.configSource } : {}),
- };
- return info;
- });
- log.info("conversations: lsp status read", {
- conversationId,
- cwd: effectiveCwd,
- serverCount: servers.length,
- });
- const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: lsp status failure", { err });
- return c.json({ error: "Failed to read LSP status" }, 500);
- }
- });
-
- // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd,
- // 503 when no MCP service, map McpServerStatus → McpServerInfo.
- app.get("/conversations/:id/mcp", async (c) => {
- const conversationId = c.req.param("id");
- try {
- const persistedCwd = await opts.conversationStore.getCwd(conversationId);
- if (persistedCwd === null) {
- log.info("conversations: mcp status read (no cwd)", { conversationId });
- const body: McpStatusResponse = { conversationId, cwd: null, servers: [] };
- return c.json(body, 200);
- }
-
- const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId);
- if (effectiveCwd === null) {
- log.info("conversations: mcp status read (no effective cwd)", { conversationId });
- const body: McpStatusResponse = { conversationId, cwd: null, servers: [] };
- return c.json(body, 200);
- }
-
- if (opts.mcpService === undefined) {
- log.warn("conversations: mcp service not available", { conversationId });
- return c.json({ error: "MCP service not available" }, 503);
- }
-
- const statuses = await opts.mcpService.status(effectiveCwd);
- const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => {
- const info: McpServerInfo = {
- id: s.id,
- state: s.state,
- toolCount: s.toolCount,
- ...(s.error !== undefined ? { error: s.error } : {}),
- };
- return info;
- });
- log.info("conversations: mcp status read", {
- conversationId,
- cwd: effectiveCwd,
- serverCount: servers.length,
- });
- const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: mcp status failure", { err });
- return c.json({ error: "Failed to read MCP status" }, 500);
- }
- });
-
- app.get("/conversations", async (c) => {
- try {
- // Optional `?status=` comma-separated filter (e.g. "active,idle").
- // Default: all statuses. Invalid values are silently ignored.
- const rawStatus = c.req.query("status");
- const statusFilter = parseStatusFilter(rawStatus);
- // Optional `?workspaceId=` filter. A missing/empty/whitespace-only
- // value is ignored → return all workspaces. Composable with `?status=`
- // and `?q=`.
- const rawWorkspaceId = c.req.query("workspaceId");
- const workspaceId =
- rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0
- ? rawWorkspaceId.trim()
- : undefined;
- const filter: Parameters<ConversationStore["listConversations"]>[0] =
- statusFilter !== undefined || workspaceId !== undefined
- ? {
- ...(statusFilter !== undefined ? { status: statusFilter } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- }
- : undefined;
- const all = await opts.conversationStore.listConversations(filter);
- // Optional `?q=` filters by id prefix (short-id resolution). A
- // missing/empty/whitespace-only `q` is ignored → return all.
- const rawQ = c.req.query("q");
- const q = rawQ?.trim() ?? "";
- const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all;
- log.info("conversations: list", {
- count: conversations.length,
- ...(q.length > 0 ? { q } : {}),
- ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- });
- const body: ConversationListResponse = { conversations };
- return c.json(body, 200);
- } catch (err) {
- log.error("conversations: list failure", { err });
- return c.json({ error: "Failed to list conversations" }, 500);
- }
- });
-
- app.get("/conversations/:id/last", async (c) => {
- const conversationId = c.req.param("id");
-
- // Subscribe BEFORE checking isActive — closes the race where a seal
- // fires between the check and the subscribe (we'd miss it). If idle,
- // unsubscribe immediately; if active, wait for a `turn-sealed` event
- // (or a 60s timeout, then proceed regardless of what's available).
- let turnId: string | undefined;
- let unsubscribe: (() => void) | undefined;
- try {
- await new Promise<void>((resolve) => {
- let settled = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
- const finish = (): void => {
- if (settled) return;
- settled = true;
- if (timer !== undefined) clearTimeout(timer);
- resolve();
- };
- unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => {
- if (event.type === "turn-sealed") {
- turnId = event.turnId;
- finish();
- }
- });
- if (!opts.orchestrator.isActive(conversationId)) {
- finish();
- return;
- }
- // A seal may have fired synchronously during subscribe (the
- // real orchestrator never does this, but a fake might) — don't
- // arm a 60s timer for an already-settled promise.
- if (settled) return;
- timer = setTimeout(finish, 60_000);
- });
- } finally {
- unsubscribe?.();
- }
-
- let content = "";
- try {
- const messages = await opts.conversationStore.load(conversationId);
- content = extractLastAssistantText(messages);
- } catch (err) {
- log.error("conversations: last message load failure", { err });
- return c.json({ error: "Failed to load conversation" }, 500);
- }
-
- log.info("conversations: last read", {
- conversationId,
- hasContent: content.length > 0,
- });
- const body: LastMessageResponse = {
- conversationId,
- content,
- ...(turnId !== undefined ? { turnId } : {}),
- };
- return c.json(body, 200);
- });
-
- app.post("/conversations/:id/open", async (c) => {
- const conversationId = c.req.param("id");
- if (opts.emit === undefined) {
- log.warn("conversations: open requested but emit is not available", {
- conversationId,
- });
- return c.json({ error: "not available" }, 500);
- }
- // Resolve the conversation's persisted workspace id so the frontend can
- // open/focus the tab in the correct workspace. The store falls back to
- // `"default"` when no workspaceId is persisted (or the conversation is
- // unknown), so this never throws for a missing conversation.
- const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId);
- opts.emit(conversationOpened, { conversationId, workspaceId });
- log.info("conversations: opened", { conversationId, workspaceId });
- const body: OpenConversationResponse = { conversationId };
- return c.json(body, 200);
- });
-
- app.put("/conversations/:id/title", async (c) => {
- const conversationId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("conversations/title: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- if (body === null || typeof body !== "object") {
- return c.json({ error: "Request body must be a JSON object" }, 400);
- }
- const obj = body as Record<string, unknown>;
- if (typeof obj.title !== "string" || obj.title.trim().length === 0) {
- return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400);
- }
- // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody`
- // forward trimmed text), so a title never carries surrounding whitespace.
- const title = obj.title.trim();
-
- try {
- await opts.conversationStore.setConversationTitle(conversationId, title);
- log.info("conversations: title set", { conversationId });
- const response: TitleResponse = { conversationId, title };
- return c.json(response, 200);
- } catch (err) {
- log.error("conversations: title set failure", { err });
- return c.json({ error: "Failed to set conversation title" }, 500);
- }
- });
-
- // ─── Compaction ──────────────────────────────────────────────────────────
-
- app.post("/conversations/:id/compact", async (c) => {
- if (opts.compactionService === undefined) {
- return c.json({ error: "Compaction service not available" }, 503);
- }
- const conversationId = c.req.param("id");
- let body: unknown = {};
- try {
- body = await c.req.json();
- } catch {
- // No body is fine — use defaults.
- }
- const obj = body as Record<string, unknown>;
- const keepLastN =
- typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0
- ? Math.floor(obj.keepLastN)
- : undefined;
- const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined;
-
- log.info("conversations: compact request", { conversationId });
-
- const result = await opts.compactionService.compact(conversationId, {
- ...(keepLastN !== undefined ? { keepLastN } : {}),
- ...(modelName !== undefined ? { modelName } : {}),
- });
-
- if ("error" in result) {
- log.warn("conversations: compact returned error", {
- conversationId,
- error: result.error,
- });
- return c.json({ error: result.error }, 409);
- }
-
- const response: CompactResponse = {
- conversationId,
- newConversationId: result.newConversationId,
- messagesSummarized: result.messagesSummarized,
- messagesKept: result.messagesKept,
- };
- return c.json(response, 200);
- });
-
- app.get("/conversations/:id/compact-percent", async (c) => {
- const conversationId = c.req.param("id");
- const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0;
- const response: CompactPercentResponse = { conversationId, threshold };
- return c.json(response, 200);
- });
-
- app.put("/conversations/:id/compact-percent", async (c) => {
- const conversationId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- return c.json({ error: "Invalid JSON body" }, 400);
- }
- const parsed = body as SetCompactPercentRequest;
- if (
- typeof parsed.threshold !== "number" ||
- !Number.isFinite(parsed.threshold) ||
- parsed.threshold < 0
- ) {
- return c.json({ error: "threshold must be a non-negative number" }, 400);
- }
- const threshold = Math.floor(parsed.threshold);
- await opts.conversationStore.setCompactPercent(conversationId, threshold);
- log.info("conversations: compact-percent set", { conversationId, threshold });
- const response: CompactPercentResponse = { conversationId, threshold };
- return c.json(response, 200);
- });
-
- // ─── Workspaces ──────────────────────────────────────────────────────────
-
- app.get("/workspaces", async (c) => {
- try {
- const workspaces = await opts.conversationStore.listWorkspaces();
- log.info("workspaces: list", { count: workspaces.length });
- const body: WorkspaceListResponse = { workspaces };
- return c.json(body, 200);
- } catch (err) {
- log.error("workspaces: list failure", { err });
- return c.json({ error: "Failed to list workspaces" }, 500);
- }
- });
-
- app.put("/workspaces/:id", async (c) => {
- const workspaceId = c.req.param("id");
- if (!isValidWorkspaceSlug(workspaceId)) {
- return c.json(
- {
- error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
- },
- 400,
- );
- }
-
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- body = {};
- }
- const obj = body as Record<string, unknown>;
- const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {};
- if (typeof obj.title === "string") {
- (opts_ as { title?: string }).title = obj.title;
- }
- if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) {
- (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd;
- }
-
- try {
- const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_);
- log.info("workspaces: ensured", { workspaceId });
- const response: WorkspaceResponse = workspace;
- return c.json(response, 200);
- } catch (err) {
- log.error("workspaces: ensure failure", { err });
- return c.json({ error: "Failed to ensure workspace" }, 500);
- }
- });
-
- app.get("/workspaces/:id", async (c) => {
- const workspaceId = c.req.param("id");
- try {
- const workspace = await opts.conversationStore.getWorkspace(workspaceId);
- if (workspace === null) {
- return c.json({ error: "Workspace not found" }, 404);
- }
- const response: WorkspaceResponse = workspace;
- return c.json(response, 200);
- } catch (err) {
- log.error("workspaces: get failure", { err });
- return c.json({ error: "Failed to read workspace" }, 500);
- }
- });
-
- app.put("/workspaces/:id/title", async (c) => {
- const workspaceId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("workspaces/title: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- if (body === null || typeof body !== "object") {
- return c.json({ error: "Request body must be a JSON object" }, 400);
- }
- const obj = body as Record<string, unknown>;
- if (typeof obj.title !== "string" || obj.title.trim().length === 0) {
- return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400);
- }
- const title = obj.title.trim();
-
- try {
- const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title);
- log.info("workspaces: title set", { workspaceId });
- const response: WorkspaceResponse = workspace;
- return c.json(response, 200);
- } catch (err) {
- log.error("workspaces: title set failure", { err });
- return c.json({ error: "Failed to set workspace title" }, 500);
- }
- });
-
- app.put("/workspaces/:id/default-cwd", async (c) => {
- const workspaceId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- body = {};
- }
- const obj = body as Record<string, unknown>;
- const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null;
-
- try {
- const workspace = await opts.conversationStore.setWorkspaceDefaultCwd(
- workspaceId,
- defaultCwd,
- );
- log.info("workspaces: default-cwd set", { workspaceId });
- const response: WorkspaceResponse = workspace;
- return c.json(response, 200);
- } catch (err) {
- log.error("workspaces: default-cwd set failure", { err });
- return c.json({ error: "Failed to set workspace default cwd" }, 500);
- }
- });
-
- // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog).
- app.put("/workspaces/:id/default-computer", async (c) => {
- const workspaceId = c.req.param("id");
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- body = {};
- }
- const obj = body as Record<string, unknown>;
- // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias;
- // anything else (null/absent/non-string) → clear (local).
- const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] =
- typeof obj.computerId === "string" ? obj.computerId : null;
-
- try {
- const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId(
- workspaceId,
- defaultComputerId,
- );
- log.info("workspaces: default-computer set", { workspaceId });
- const response: WorkspaceResponse = workspace;
- return c.json(response, 200);
- } catch (err) {
- log.error("workspaces: default-computer set failure", { err });
- return c.json({ error: "Failed to set workspace default computer" }, 500);
- }
- });
-
- app.delete("/workspaces/:id", async (c) => {
- const workspaceId = c.req.param("id");
- if (workspaceId === "default") {
- return c.json({ error: 'The "default" workspace cannot be deleted' }, 409);
- }
-
- try {
- const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId);
- log.info("workspaces: deleted", { workspaceId, closedCount });
- const response: DeleteWorkspaceResponse = { workspaceId, closedCount };
- return c.json(response, 200);
- } catch (err) {
- log.error("workspaces: delete failure", { err });
- return c.json({ error: "Failed to delete workspace" }, 500);
- }
- });
-
- // ─── System prompt template ───────────────────────────────────────────────
-
- app.get("/system-prompt/variables", (c) => {
- // Static catalog — no service call needed. Always available.
- const variables = getVariableCatalog();
- const body: SystemPromptVariablesResponse = { variables };
- return c.json(body, 200);
- });
-
- app.get("/system-prompt", async (c) => {
- if (opts.systemPromptService === undefined) {
- // FE always gets something useful — the built-in default template.
- const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE };
- return c.json(body, 200);
- }
- const template = await opts.systemPromptService.getTemplate();
- const body: SystemPromptTemplateResponse = { template };
- return c.json(body, 200);
- });
-
- app.put("/system-prompt", async (c) => {
- if (opts.systemPromptService === undefined) {
- return c.json({ error: "System prompt service not available" }, 503);
- }
-
- let body: unknown;
- try {
- body = await c.req.json();
- } catch {
- log.warn("system-prompt: invalid JSON body");
- return c.json({ error: "Invalid JSON body" }, 400);
- }
-
- if (body === null || typeof body !== "object") {
- return c.json({ error: "Request body must be a JSON object" }, 400);
- }
- const obj = body as Record<string, unknown>;
- // `template` must be a string; empty string is valid ("no system prompt").
- if (typeof obj.template !== "string") {
- return c.json({ error: "Field 'template' is required and must be a string" }, 400);
- }
-
- const { template } = obj as unknown as SetSystemPromptTemplateRequest;
- await opts.systemPromptService.setTemplate(template);
- log.info("system-prompt: template set");
- const response: SystemPromptTemplateResponse = { template };
- return c.json(response, 200);
- });
-
- // ─── Static frontend serving (catch-all, API routes take precedence) ──────
- if (opts.webDir !== undefined) {
- const webDir = opts.webDir;
- const MIME: Record<string, string> = {
- ".js": "text/javascript; charset=utf-8",
- ".mjs": "text/javascript; charset=utf-8",
- ".css": "text/css; charset=utf-8",
- ".html": "text/html; charset=utf-8",
- ".json": "application/json; charset=utf-8",
- ".svg": "image/svg+xml",
- ".png": "image/png",
- ".jpg": "image/jpeg",
- ".ico": "image/x-icon",
- ".woff": "font/woff",
- ".woff2": "font/woff2",
- ".txt": "text/plain; charset=utf-8",
- ".wasm": "application/wasm",
- };
- app.get("*", async (c) => {
- const urlPath = new URL(c.req.url).pathname;
- const filePath = `${webDir}${urlPath}`;
- const file = Bun.file(filePath);
- if (await file.exists()) {
- const ext = filePath.slice(filePath.lastIndexOf("."));
- const contentType = MIME[ext] ?? "application/octet-stream";
- return new Response(file, {
- headers: { "Content-Type": contentType },
- });
- }
- // SPA fallback: serve index.html for client-side routing
- const indexFile = Bun.file(`${webDir}/index.html`);
- if (await indexFile.exists()) {
- return new Response(indexFile, {
- headers: { "Content-Type": "text/html; charset=utf-8" },
- });
- }
- return c.json({ error: "Not found" }, 404);
- });
- }
-
- return app;
+ const app = new Hono();
+ const log = opts.logger ?? noopLogger;
+ const generateId = opts.generateId ?? (() => crypto.randomUUID());
+ const now = opts.now ?? (() => Date.now());
+ const throughputStore = opts.throughputStore ?? noopThroughputStore;
+
+ async function recordThroughput(
+ turnEvents: readonly AgentEvent[],
+ model: string | undefined,
+ ): Promise<void> {
+ if (model === undefined) return; // no model selected → nothing to attribute
+ let genMs = 0;
+ let outputTokens = 0;
+ for (const e of turnEvents) {
+ if (e.type === "step-complete" && e.genTotalMs !== undefined) genMs += e.genTotalMs;
+ if (e.type === "done" && e.usage !== undefined) outputTokens = e.usage.outputTokens;
+ }
+ if (genMs <= 0) return; // no generation time → can't compute tok/s
+ try {
+ await throughputStore.record({ model, ts: now(), outputTokens, genMs });
+ log.info("throughput: turn recorded", {
+ model,
+ outputTokens,
+ genMs,
+ tokensPerSecond: Math.round((outputTokens / (genMs / 1000)) * 100) / 100,
+ });
+ } catch (err) {
+ log.warn("throughput: failed to record sample", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ app.use(
+ "*",
+ cors({
+ origin: "*",
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allowHeaders: ["Content-Type"],
+ }),
+ );
+
+ app.get("/health", (c) => c.json({ ok: true }));
+
+ // ── Tmp image serving (vision handoff) ──────────────────────────────────────
+ app.get("/images/:conversationId/:imageId", async (c) => {
+ const conversationId = c.req.param("conversationId");
+ const imageId = c.req.param("imageId");
+ if (imageId.includes("/") || imageId.includes("..")) {
+ return c.json({ error: "Invalid image ID" }, 400);
+ }
+ const imageDir = process.env.DISPATCH_IMAGE_DIR ?? "/tmp/dispatch/images";
+ const { join } = await import("node:path");
+ const { readFile: fsReadFile } = await import("node:fs/promises");
+ const filePath = join(imageDir, conversationId, imageId);
+ try {
+ const buf = await fsReadFile(filePath);
+ const ext = imageId.toLowerCase();
+ const mime = ext.endsWith(".png")
+ ? "image/png"
+ : ext.endsWith(".jpg") || ext.endsWith(".jpeg")
+ ? "image/jpeg"
+ : ext.endsWith(".webp")
+ ? "image/webp"
+ : ext.endsWith(".gif")
+ ? "image/gif"
+ : ext.endsWith(".bmp")
+ ? "image/bmp"
+ : "application/octet-stream";
+ return new Response(buf, { headers: { "Content-Type": mime, "Cache-Control": "no-cache" } });
+ } catch {
+ return c.json({ error: "Image not found" }, 404);
+ }
+ });
+
+ app.get("/conversations/:id/metrics", async (c) => {
+ const conversationId = c.req.param("id");
+
+ try {
+ const turns = await opts.conversationStore.loadMetrics(conversationId);
+ log.info("conversations: metrics read", {
+ conversationId,
+ count: turns.length,
+ });
+ const body: ConversationMetricsResponse = { turns };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: metrics store failure", { err });
+ return c.json({ error: "Failed to load conversation metrics" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id", async (c) => {
+ const conversationId = c.req.param("id");
+ const sinceSeqResult = parseSinceSeq(c.req.query("sinceSeq"));
+ if (isSinceSeqError(sinceSeqResult)) {
+ log.warn("conversations: invalid sinceSeq", {
+ conversationId,
+ error: sinceSeqResult.error,
+ });
+ return c.json({ error: sinceSeqResult.error }, 400);
+ }
+
+ // `limit` / `beforeSeq` are optional positive-integer history-window
+ // params. The store is deliberately forgiving (a 0/negative bound is
+ // treated as ABSENT), so we MUST reject malformed values here and never
+ // forward an invalid window.
+ const beforeSeqResult = parseWindowParam(c.req.query("beforeSeq"), "beforeSeq");
+ if (isWindowParamError(beforeSeqResult)) {
+ log.warn("conversations: invalid beforeSeq", {
+ conversationId,
+ error: beforeSeqResult.error,
+ });
+ return c.json({ error: beforeSeqResult.error }, 400);
+ }
+ const limitResult = parseWindowParam(c.req.query("limit"), "limit");
+ if (isWindowParamError(limitResult)) {
+ log.warn("conversations: invalid limit", {
+ conversationId,
+ error: limitResult.error,
+ });
+ return c.json({ error: limitResult.error }, 400);
+ }
+
+ // Include only the fields actually provided (exactOptionalPropertyTypes),
+ // and omit the window argument entirely when neither was given — keeping
+ // the pre-windowing call shape byte-identical for existing callers.
+ const window: { readonly beforeSeq?: number; readonly limit?: number } | undefined =
+ beforeSeqResult !== undefined || limitResult !== undefined
+ ? {
+ ...(beforeSeqResult !== undefined ? { beforeSeq: beforeSeqResult } : {}),
+ ...(limitResult !== undefined ? { limit: limitResult } : {}),
+ }
+ : undefined;
+
+ try {
+ const chunks =
+ window !== undefined
+ ? await opts.conversationStore.loadSince(conversationId, sinceSeqResult, window)
+ : await opts.conversationStore.loadSince(conversationId, sinceSeqResult);
+ const latestSeq =
+ chunks.length > 0 ? (chunks[chunks.length - 1]?.seq ?? sinceSeqResult) : sinceSeqResult;
+ log.info("conversations: read", {
+ conversationId,
+ sinceSeq: sinceSeqResult,
+ count: chunks.length,
+ });
+ const body: ConversationHistoryResponse = { chunks, latestSeq };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: store failure", { err });
+ return c.json({ error: "Failed to load conversation" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/status", async (c) => {
+ const conversationId = c.req.param("id");
+ const isActive = opts.orchestrator.isActive(conversationId);
+ const status = await opts.conversationStore.getConversationStatus(conversationId);
+ if (status === null) {
+ return c.json({ error: "Conversation not found" }, 404);
+ }
+ const body: ConversationStatusResponse = { conversationId, isActive, status };
+ return c.json(body, 200);
+ });
+
+ app.get("/models", async (c) => {
+ try {
+ const models = await opts.credentialStore.listCatalog();
+ const modelInfo: Record<string, { contextWindow?: number; vision?: boolean }> = {};
+ for (const modelName of models) {
+ const info = await opts.credentialStore.getModelInfo(modelName);
+ if (info?.contextWindow !== undefined || info?.vision === true) {
+ const entry: { contextWindow?: number; vision?: boolean } = {};
+ if (info?.contextWindow !== undefined) entry.contextWindow = info.contextWindow;
+ if (info?.vision === true) entry.vision = true;
+ modelInfo[modelName] = entry;
+ }
+ }
+ const body: ModelsResponse = {
+ models,
+ ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}),
+ };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("models: failed to retrieve catalog", { err });
+ return c.json({ error: "Failed to retrieve model catalog" }, 502);
+ }
+ });
+
+ // ─── Computers (discovery + live state) ───────────────────────────────────
+ // Read-only discovery + connection state is delegated to the ComputerService
+ // (provided by the `ssh` extension). When ssh is NOT loaded the routes
+ // degrade: list → empty, status → "disconnected", test → not-configured.
+
+ app.get("/computers", async (c) => {
+ if (opts.computerService === undefined) {
+ // Graceful: no ssh configured → no computers discovered.
+ const body: ComputerListResponse = { computers: [] };
+ return c.json(body, 200);
+ }
+ try {
+ const computers = await opts.computerService.listComputers();
+ log.info("computers: list", { count: computers.length });
+ const body: ComputerListResponse = { computers };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: list failure", { err });
+ return c.json({ error: "Failed to list computers" }, 500);
+ }
+ });
+
+ app.get("/computers/:alias", async (c) => {
+ const alias = c.req.param("alias");
+ if (opts.computerService === undefined) {
+ // No ssh configured → no computer resolves this alias.
+ return c.json({ error: "Computer not found" }, 404);
+ }
+ try {
+ const computer = await opts.computerService.getComputer(alias);
+ if (computer === null) {
+ return c.json({ error: "Computer not found" }, 404);
+ }
+ const body: ComputerResponse = computer;
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: get failure", { err, alias });
+ return c.json({ error: "Failed to read computer" }, 500);
+ }
+ });
+
+ app.get("/computers/:alias/status", async (c) => {
+ const alias = c.req.param("alias");
+ if (opts.computerService === undefined) {
+ const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false };
+ return c.json(body, 200);
+ }
+ try {
+ const body = await opts.computerService.getStatus(alias);
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: status failure", { err, alias });
+ return c.json({ error: "Failed to read computer status" }, 500);
+ }
+ });
+
+ app.post("/computers/:alias/test", async (c) => {
+ const alias = c.req.param("alias");
+ if (opts.computerService === undefined) {
+ const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" };
+ return c.json(body, 200);
+ }
+ try {
+ const body = await opts.computerService.test(alias);
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: test failure", { err, alias });
+ return c.json({ error: "Failed to test computer" }, 500);
+ }
+ });
+
+ app.post("/chat", async (c) => {
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("chat: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const result = parseChatBody(body, generateId);
+ if (isParseError(result)) {
+ log.warn("chat: validation failed", { reason: result.error });
+ return c.json({ error: result.error }, 400);
+ }
+
+ const {
+ conversationId,
+ message,
+ model,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId,
+ images,
+ title,
+ } = result;
+ log.info("chat: request accepted", {
+ conversationId,
+ hasModel: model !== undefined,
+ hasCwd: cwd !== undefined,
+ hasComputerId: computerId !== undefined,
+ hasReasoningEffort: reasoningEffort !== undefined,
+ hasWorkspaceId: workspaceId !== undefined,
+ imageCount: images?.length ?? 0,
+ });
+
+ const events: AgentEvent[] = [];
+ let controllerRef: ReadableStreamDefaultController<Uint8Array> | undefined;
+ let streamClosed = false;
+
+ const stream = new ReadableStream<Uint8Array>({
+ start(controller) {
+ controllerRef = controller;
+ },
+ });
+
+ function safeEnqueue(data: Uint8Array): void {
+ if (streamClosed) return;
+ try {
+ controllerRef?.enqueue(data);
+ } catch (err) {
+ streamClosed = true;
+ log.warn("chat: stream enqueue failed", {
+ conversationId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ function safeClose(): void {
+ if (streamClosed) return;
+ streamClosed = true;
+ try {
+ controllerRef?.close();
+ } catch (err) {
+ log.warn("chat: stream close failed", {
+ conversationId,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ const orchestratorInput: Parameters<SessionOrchestrator["handleMessage"]>[0] = {
+ conversationId,
+ text: message,
+ onEvent: (event) => {
+ events.push(event);
+ safeEnqueue(new TextEncoder().encode(serializeEventLine(event)));
+ },
+ ...(model !== undefined ? { modelName: model } : {}),
+ ...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(images !== undefined ? { images } : {}),
+ ...(title !== undefined ? { title } : {}),
+ };
+
+ opts.orchestrator
+ .handleMessage(orchestratorInput)
+ .then(async () => {
+ safeClose();
+ await recordThroughput(events, model);
+ })
+ .catch((err) => {
+ log.error("chat: turn failed", { err });
+ const errorEvent: AgentEvent = {
+ type: "error",
+ conversationId,
+ turnId: "",
+ message: err instanceof Error ? err.message : String(err),
+ };
+ safeEnqueue(new TextEncoder().encode(serializeEventLine(errorEvent)));
+ safeClose();
+ });
+
+ return new Response(stream, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/x-ndjson",
+ "X-Conversation-Id": conversationId,
+ "Transfer-Encoding": "chunked",
+ },
+ });
+ });
+
+ app.post("/chat/warm", async (c) => {
+ if (opts.warmService === undefined) {
+ return c.json({ error: "Warm service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("chat/warm: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = parseWarmBody(body);
+ if ("error" in parsed) {
+ log.warn("chat/warm: validation failed", { reason: parsed.error });
+ return c.json({ error: parsed.error }, 400);
+ }
+
+ const { conversationId, model, cwd } = parsed;
+ log.info("chat/warm: request accepted", {
+ conversationId,
+ hasModel: model !== undefined,
+ hasCwd: cwd !== undefined,
+ });
+
+ const warmOpts: { readonly cwd?: string; readonly modelName?: string } | undefined =
+ model !== undefined || cwd !== undefined
+ ? {
+ ...(cwd !== undefined ? { cwd } : {}),
+ ...(model !== undefined ? { modelName: model } : {}),
+ }
+ : undefined;
+
+ const result = await opts.warmService.warm(conversationId, warmOpts);
+
+ if ("error" in result) {
+ log.warn("chat/warm: service returned error", { conversationId, error: result.error });
+ return c.json({ error: result.error }, 409);
+ }
+
+ const response: WarmResponse = {
+ inputTokens: result.inputTokens,
+ outputTokens: result.outputTokens,
+ cacheReadTokens: result.cacheReadTokens,
+ cacheWriteTokens: result.cacheWriteTokens,
+ cachePct: computeCachePct(result.inputTokens, result.cacheReadTokens),
+ expectedCacheRate: computeExpectedCacheRate(result.cacheReadTokens, result.cacheWriteTokens),
+ };
+ return c.json(response, 200);
+ });
+
+ app.get("/metrics/throughput", async (c) => {
+ const period = c.req.query("period");
+ const date = c.req.query("date");
+ if (period !== "day" && period !== "week" && period !== "month") {
+ return c.json({ error: "query param 'period' must be one of: day, week, month" }, 400);
+ }
+ if (date === undefined || date === "") {
+ return c.json({ error: "query param 'date' is required" }, 400);
+ }
+ try {
+ // Typed against the wire contract: if the store's report shape ever
+ // drifts from ThroughputResponse, this assignment fails to compile.
+ const body: ThroughputResponse = await throughputStore.aggregate({ period, date });
+ return c.json(body);
+ } catch (err) {
+ if (err instanceof ThroughputQueryError) {
+ return c.json({ error: err.message }, 400);
+ }
+ log.error("throughput: aggregate failed", { err });
+ return c.json({ error: "Failed to aggregate throughput" }, 502);
+ }
+ });
+
+ // ─── Provider concurrency limits ────────────────────────────────────────────
+
+ app.get("/concurrency/limits", (c) => {
+ if (opts.concurrencyService === undefined) {
+ const body: ConcurrencyLimitsResponse = { limits: [] };
+ return c.json(body, 200);
+ }
+ const limits = opts.concurrencyService.getLimits();
+ const body: ConcurrencyLimitsResponse = { limits };
+ return c.json(body, 200);
+ });
+
+ app.get("/concurrency/limits/:providerId", (c) => {
+ const providerId = c.req.param("providerId");
+ if (opts.concurrencyService === undefined) {
+ return c.json({ error: "Concurrency service not available" }, 503);
+ }
+ const limit = opts.concurrencyService.getLimit(providerId);
+ if (limit === undefined) {
+ return c.json({ error: "No concurrency limit configured for this provider" }, 404);
+ }
+ const body: ConcurrencyLimitResponse = { providerId, limit };
+ return c.json(body, 200);
+ });
+
+ app.put("/concurrency/limits/:providerId", async (c) => {
+ const providerId = c.req.param("providerId");
+ if (opts.concurrencyService === undefined) {
+ return c.json({ error: "Concurrency service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("concurrency: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = body as SetConcurrencyLimitRequest;
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ typeof parsed.limit !== "number" ||
+ !Number.isInteger(parsed.limit) ||
+ parsed.limit <= 0
+ ) {
+ return c.json({ error: "Body must be { limit: <positive integer> }" }, 400);
+ }
+
+ opts.concurrencyService.setLimit(providerId, parsed.limit);
+ const responseBody: ConcurrencyLimitResponse = { providerId, limit: parsed.limit };
+ return c.json(responseBody, 200);
+ });
+
+ app.delete("/concurrency/limits/:providerId", (c) => {
+ const providerId = c.req.param("providerId");
+ if (opts.concurrencyService === undefined) {
+ return c.json({ error: "Concurrency service not available" }, 503);
+ }
+ const existing = opts.concurrencyService.getLimit(providerId);
+ if (existing === undefined) {
+ return c.json({ error: "No concurrency limit configured for this provider" }, 404);
+ }
+ opts.concurrencyService.removeLimit(providerId);
+ return c.json({ ok: true, providerId }, 200);
+ });
+
+ app.get("/concurrency/cooldown/:providerId", (c) => {
+ const providerId = c.req.param("providerId");
+ if (opts.concurrencyService === undefined) {
+ return c.json({ error: "Concurrency service not available" }, 503);
+ }
+ // A cooldown may be the default (when a limit is configured but no explicit
+ // cooldown was set) or explicitly set. getCooldown returns undefined only
+ // when the provider has NO state at all (no limit, no cooldown) — treat that
+ // as "not configured".
+ const cooldownMs = opts.concurrencyService.getCooldown(providerId);
+ if (cooldownMs === undefined) {
+ return c.json({ error: "No concurrency configuration for this provider" }, 404);
+ }
+ const body: ConcurrencyCooldownResponse = { providerId, cooldownMs };
+ return c.json(body, 200);
+ });
+
+ app.put("/concurrency/cooldown/:providerId", async (c) => {
+ const providerId = c.req.param("providerId");
+ if (opts.concurrencyService === undefined) {
+ return c.json({ error: "Concurrency service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("concurrency: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = body as SetConcurrencyCooldownRequest;
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ typeof parsed.cooldownMs !== "number" ||
+ !Number.isInteger(parsed.cooldownMs) ||
+ parsed.cooldownMs < 0
+ ) {
+ return c.json({ error: "Body must be { cooldownMs: <non-negative integer> }" }, 400);
+ }
+
+ opts.concurrencyService.setCooldown(providerId, parsed.cooldownMs);
+ const responseBody: ConcurrencyCooldownResponse = { providerId, cooldownMs: parsed.cooldownMs };
+ return c.json(responseBody, 200);
+ });
+
+ app.get("/concurrency/status", (c) => {
+ if (opts.concurrencyService === undefined) {
+ const body: ConcurrencyStatusResponse = { providers: [] };
+ return c.json(body, 200);
+ }
+ const statuses = opts.concurrencyService.getStatusAll();
+ const body: ConcurrencyStatusResponse = { providers: statuses };
+ return c.json(body, 200);
+ });
+
+ app.post("/conversations/:id/close", (c) => {
+ const conversationId = c.req.param("id");
+ const { abortedTurn } = opts.orchestrator.closeConversation(conversationId);
+ log.info("conversations: closed", { conversationId, abortedTurn });
+ const body: CloseConversationResponse = { conversationId, abortedTurn };
+ return c.json(body, 200);
+ });
+
+ app.post("/conversations/:id/stop", (c) => {
+ const conversationId = c.req.param("id");
+ const { abortedTurn } = opts.orchestrator.stopTurn(conversationId);
+ log.info("conversations: stop", { conversationId, abortedTurn });
+ return c.json({ conversationId, abortedTurn }, 200);
+ });
+
+ app.post("/conversations/:id/queue", async (c) => {
+ const conversationId = c.req.param("id");
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/queue: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = parseQueueBody(body);
+ if (isParseError(parsed)) {
+ log.warn("conversations/queue: validation failed", { reason: parsed.error });
+ return c.json({ error: parsed.error }, 400);
+ }
+
+ // `enqueue` is synchronous and owns the idle→startTurn vs active→queue
+ // decision (no separate `isActive` race) — it does not throw for an
+ // unknown/idle conversation, which instead starts a turn. Mirrors the
+ // direct sync call used by `POST /conversations/:id/close`.
+ const { startedTurn, queue } = opts.orchestrator.enqueue({
+ conversationId,
+ text: parsed.text,
+ ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}),
+ });
+ log.info("conversations: enqueued", {
+ conversationId,
+ startedTurn,
+ queueLength: queue.length,
+ });
+ const response: QueueResponse = { conversationId, startedTurn, queue };
+ return c.json(response, 200);
+ });
+
+ app.delete("/conversations/:id/queue/:messageId", (c) => {
+ const conversationId = c.req.param("id");
+ const messageId = c.req.param("messageId");
+
+ // `cancelQueuedMessage` is synchronous and owns the lookup + removal (no
+ // separate race — the pure `cancel` is idempotent). It does not throw for an
+ // unknown/idle conversation, which instead returns cancelled:false. Mirrors
+ // the direct sync call used by `POST /conversations/:id/queue`.
+ const { cancelled, queue } = opts.orchestrator.cancelQueuedMessage({
+ conversationId,
+ messageId,
+ });
+ log.info("conversations: cancelled queued message", {
+ conversationId,
+ messageId,
+ cancelled,
+ queueLength: queue.length,
+ });
+ const response: QueueCancelResponse = { conversationId, cancelled, queue };
+ return c.json(response, 200);
+ });
+
+ app.get("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const cwd = await opts.conversationStore.getCwd(conversationId);
+ log.info("conversations: cwd read", { conversationId, hasCwd: cwd !== null });
+ const body: CwdResponse = { conversationId, cwd };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: cwd read failure", { err });
+ return c.json({ error: "Failed to read conversation cwd" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/cwd: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ if (typeof obj.cwd !== "string" || obj.cwd.length === 0) {
+ return c.json({ error: "Field 'cwd' is required and must be a non-empty string" }, 400);
+ }
+
+ // When a workspaceId is provided, assign the conversation to that
+ // workspace BEFORE persisting the cwd — so a subsequent
+ // GET /conversations/:id/lsp resolves a relative cwd against the
+ // workspace's defaultCwd (not the server default). Omit for unchanged
+ // workspace assignment (backward compatible).
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) {
+ return c.json({ error: "Invalid workspaceId" }, 400);
+ }
+ }
+
+ try {
+ if (typeof obj.workspaceId === "string") {
+ await opts.conversationStore.ensureWorkspace(obj.workspaceId);
+ await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId);
+ }
+ await opts.conversationStore.setCwd(conversationId, obj.cwd);
+ log.info("conversations: cwd set", { conversationId });
+ const response: CwdResponse = { conversationId, cwd: obj.cwd };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: cwd set failure", { err });
+ return c.json({ error: "Failed to set conversation cwd" }, 500);
+ }
+ });
+
+ app.delete("/conversations/:id/cwd", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ await opts.conversationStore.clearCwd(conversationId);
+ log.info("conversations: cwd cleared", { conversationId });
+ const response: CwdResponse = { conversationId, cwd: null };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: cwd clear failure", { err });
+ return c.json({ error: "Failed to clear conversation cwd" }, 500);
+ }
+ });
+
+ // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ──────────
+
+ app.get("/conversations/:id/computer", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const computerId = await opts.conversationStore.getComputerId(conversationId);
+ log.info("conversations: computer read", {
+ conversationId,
+ hasComputerId: computerId !== null,
+ });
+ const body: ConversationComputerResponse = { conversationId, computerId };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: computer read failure", { err });
+ return c.json({ error: "Failed to read conversation computer" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/computer", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/computer: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ // `computerId` must be a string (the SSH alias) or null (clear → inherit
+ // the workspace defaultComputerId → local). An empty string is rejected
+ // (unlike cwd, an alias is never "empty"); null is the explicit clear.
+ if (
+ obj.computerId !== null &&
+ (typeof obj.computerId !== "string" || obj.computerId.length === 0)
+ ) {
+ return c.json(
+ { error: "Field 'computerId' is required and must be a non-empty string or null" },
+ 400,
+ );
+ }
+ const { computerId } = obj as unknown as SetConversationComputerRequest;
+
+ // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided,
+ // assign the conversation to that workspace BEFORE persisting the
+ // computer, so a subsequent effective-computer resolution reads the
+ // workspace's defaultComputerId. Omit for unchanged workspace assignment.
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) {
+ return c.json({ error: "Invalid workspaceId" }, 400);
+ }
+ }
+
+ try {
+ if (typeof obj.workspaceId === "string") {
+ await opts.conversationStore.ensureWorkspace(obj.workspaceId);
+ await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId);
+ }
+ // null → clear (inherit/local); string → persist the alias.
+ await opts.conversationStore.setComputerId(conversationId, computerId);
+ log.info("conversations: computer set", { conversationId });
+ const response: ConversationComputerResponse = { conversationId, computerId };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: computer set failure", { err });
+ return c.json({ error: "Failed to set conversation computer" }, 500);
+ }
+ });
+
+ app.delete("/conversations/:id/computer", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ await opts.conversationStore.clearComputerId(conversationId);
+ log.info("conversations: computer cleared", { conversationId });
+ const response: ConversationComputerResponse = { conversationId, computerId: null };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: computer clear failure", { err });
+ return c.json({ error: "Failed to clear conversation computer" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/reasoning-effort", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const reasoningEffort = await opts.conversationStore.getReasoningEffort(conversationId);
+ log.info("conversations: reasoning-effort read", {
+ conversationId,
+ hasEffort: reasoningEffort !== null,
+ });
+ const body: ReasoningEffortResponse = { conversationId, reasoningEffort };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: reasoning-effort read failure", { err });
+ return c.json({ error: "Failed to read conversation reasoning effort" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/reasoning-effort", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/reasoning-effort: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = parseReasoningEffortBody(body);
+ if (isReasoningEffortParseError(parsed)) {
+ log.warn("conversations/reasoning-effort: validation failed", { reason: parsed.error });
+ return c.json({ error: parsed.error }, 400);
+ }
+
+ try {
+ await opts.conversationStore.setReasoningEffort(conversationId, parsed);
+ log.info("conversations: reasoning-effort set", { conversationId });
+ const response: ReasoningEffortResponse = { conversationId, reasoningEffort: parsed };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: reasoning-effort set failure", { err });
+ return c.json({ error: "Failed to set conversation reasoning effort" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/model", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const model = await opts.conversationStore.getModel(conversationId);
+ log.info("conversations: model read", {
+ conversationId,
+ hasModel: model !== null,
+ });
+ const body: ModelResponse = { conversationId, model };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: model read failure", { err });
+ return c.json({ error: "Failed to read conversation model" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/model", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/model: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ const parsed = parseModelBody(body);
+ if (isModelParseError(parsed)) {
+ log.warn("conversations/model: validation failed", { reason: parsed.error });
+ return c.json({ error: parsed.error }, 400);
+ }
+
+ // A non-null non-empty model persists the selection; `null` or an empty
+ // string clears the key (the store treats an empty string as "delete").
+ // The response carries the resulting value: the model name, or null when
+ // cleared (mirroring how `getModel` returns null after a clear).
+ const resultModel = parsed !== null && parsed.length > 0 ? parsed : null;
+ const persistedValue = resultModel !== null ? resultModel : "";
+
+ try {
+ await opts.conversationStore.setModel(conversationId, persistedValue);
+ log.debug("conversations: model set", { conversationId, model: resultModel });
+ const response: ModelResponse = { conversationId, model: resultModel };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: model set failure", { err });
+ return c.json({ error: "Failed to set conversation model" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/lsp", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ // Gate on the PERSISTED cwd first: when no cwd has been set for the
+ // conversation, the LSP does NOT connect (return null + empty servers)
+ // rather than falling through to the server default (process.cwd()).
+ const persistedCwd = await opts.conversationStore.getCwd(conversationId);
+ if (persistedCwd === null) {
+ log.info("conversations: lsp status read (no cwd)", { conversationId });
+ const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
+ return c.json(body, 200);
+ }
+
+ // A persisted cwd exists → resolve the EFFECTIVE cwd (relative cwd
+ // resolved against the workspace defaultCwd; absolute → as-is).
+ const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId);
+ if (effectiveCwd === null) {
+ // Edge case: persisted cwd exists but resolution returned null.
+ log.info("conversations: lsp status read (no effective cwd)", { conversationId });
+ const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
+ return c.json(body, 200);
+ }
+
+ if (opts.lspService === undefined) {
+ log.warn("conversations: lsp service not available", { conversationId });
+ return c.json({ error: "LSP service not available" }, 503);
+ }
+
+ const statuses = await opts.lspService.status(effectiveCwd);
+ const servers: LspServerInfo[] = statuses.map((s: LspServerStatus) => {
+ const info: LspServerInfo = {
+ id: s.id,
+ name: s.name,
+ root: s.root,
+ extensions: s.extensions,
+ state: s.state,
+ ...(s.error !== undefined ? { error: s.error } : {}),
+ ...(s.configSource !== undefined ? { configSource: s.configSource } : {}),
+ };
+ return info;
+ });
+ log.info("conversations: lsp status read", {
+ conversationId,
+ cwd: effectiveCwd,
+ serverCount: servers.length,
+ });
+ const body: LspStatusResponse = { conversationId, cwd: effectiveCwd, servers };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: lsp status failure", { err });
+ return c.json({ error: "Failed to read LSP status" }, 500);
+ }
+ });
+
+ // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd,
+ // 503 when no MCP service, map McpServerStatus → McpServerInfo.
+ app.get("/conversations/:id/mcp", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const persistedCwd = await opts.conversationStore.getCwd(conversationId);
+ if (persistedCwd === null) {
+ log.info("conversations: mcp status read (no cwd)", { conversationId });
+ const body: McpStatusResponse = { conversationId, cwd: null, servers: [] };
+ return c.json(body, 200);
+ }
+
+ const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId);
+ if (effectiveCwd === null) {
+ log.info("conversations: mcp status read (no effective cwd)", { conversationId });
+ const body: McpStatusResponse = { conversationId, cwd: null, servers: [] };
+ return c.json(body, 200);
+ }
+
+ if (opts.mcpService === undefined) {
+ log.warn("conversations: mcp service not available", { conversationId });
+ return c.json({ error: "MCP service not available" }, 503);
+ }
+
+ const statuses = await opts.mcpService.status(effectiveCwd);
+ const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => {
+ const info: McpServerInfo = {
+ id: s.id,
+ state: s.state,
+ toolCount: s.toolCount,
+ ...(s.error !== undefined ? { error: s.error } : {}),
+ };
+ return info;
+ });
+ log.info("conversations: mcp status read", {
+ conversationId,
+ cwd: effectiveCwd,
+ serverCount: servers.length,
+ });
+ const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: mcp status failure", { err });
+ return c.json({ error: "Failed to read MCP status" }, 500);
+ }
+ });
+
+ app.get("/conversations", async (c) => {
+ try {
+ // Optional `?status=` comma-separated filter (e.g. "active,idle").
+ // Default: all statuses. Invalid values are silently ignored.
+ const rawStatus = c.req.query("status");
+ const statusFilter = parseStatusFilter(rawStatus);
+ // Optional `?workspaceId=` filter. A missing/empty/whitespace-only
+ // value is ignored → return all workspaces. Composable with `?status=`
+ // and `?q=`.
+ const rawWorkspaceId = c.req.query("workspaceId");
+ const workspaceId =
+ rawWorkspaceId !== undefined && rawWorkspaceId.trim().length > 0
+ ? rawWorkspaceId.trim()
+ : undefined;
+ const filter: Parameters<ConversationStore["listConversations"]>[0] =
+ statusFilter !== undefined || workspaceId !== undefined
+ ? {
+ ...(statusFilter !== undefined ? { status: statusFilter } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ }
+ : undefined;
+ const all = await opts.conversationStore.listConversations(filter);
+ // Optional `?q=` filters by id prefix (short-id resolution). A
+ // missing/empty/whitespace-only `q` is ignored → return all.
+ const rawQ = c.req.query("q");
+ const q = rawQ?.trim() ?? "";
+ const conversations = q.length > 0 ? all.filter((m) => m.id.startsWith(q)) : all;
+ log.info("conversations: list", {
+ count: conversations.length,
+ ...(q.length > 0 ? { q } : {}),
+ ...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ });
+ const body: ConversationListResponse = { conversations };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: list failure", { err });
+ return c.json({ error: "Failed to list conversations" }, 500);
+ }
+ });
+
+ app.get("/conversations/:id/last", async (c) => {
+ const conversationId = c.req.param("id");
+
+ // Subscribe BEFORE checking isActive — closes the race where a seal
+ // fires between the check and the subscribe (we'd miss it). If idle,
+ // unsubscribe immediately; if active, wait for a `turn-sealed` event
+ // (or a 60s timeout, then proceed regardless of what's available).
+ let turnId: string | undefined;
+ let unsubscribe: (() => void) | undefined;
+ try {
+ await new Promise<void>((resolve) => {
+ let settled = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ const finish = (): void => {
+ if (settled) return;
+ settled = true;
+ if (timer !== undefined) clearTimeout(timer);
+ resolve();
+ };
+ unsubscribe = opts.orchestrator.subscribe(conversationId, (event) => {
+ if (event.type === "turn-sealed") {
+ turnId = event.turnId;
+ finish();
+ }
+ });
+ if (!opts.orchestrator.isActive(conversationId)) {
+ finish();
+ return;
+ }
+ // A seal may have fired synchronously during subscribe (the
+ // real orchestrator never does this, but a fake might) — don't
+ // arm a 60s timer for an already-settled promise.
+ if (settled) return;
+ timer = setTimeout(finish, 60_000);
+ });
+ } finally {
+ unsubscribe?.();
+ }
+
+ let content = "";
+ try {
+ const messages = await opts.conversationStore.load(conversationId);
+ content = extractLastAssistantText(messages);
+ } catch (err) {
+ log.error("conversations: last message load failure", { err });
+ return c.json({ error: "Failed to load conversation" }, 500);
+ }
+
+ log.info("conversations: last read", {
+ conversationId,
+ hasContent: content.length > 0,
+ });
+ const body: LastMessageResponse = {
+ conversationId,
+ content,
+ ...(turnId !== undefined ? { turnId } : {}),
+ };
+ return c.json(body, 200);
+ });
+
+ app.post("/conversations/:id/open", async (c) => {
+ const conversationId = c.req.param("id");
+ if (opts.emit === undefined) {
+ log.warn("conversations: open requested but emit is not available", {
+ conversationId,
+ });
+ return c.json({ error: "not available" }, 500);
+ }
+ // Resolve the conversation's persisted workspace id so the frontend can
+ // open/focus the tab in the correct workspace. The store falls back to
+ // `"default"` when no workspaceId is persisted (or the conversation is
+ // unknown), so this never throws for a missing conversation.
+ const workspaceId = await opts.conversationStore.getWorkspaceId(conversationId);
+ opts.emit(conversationOpened, { conversationId, workspaceId });
+ log.info("conversations: opened", { conversationId, workspaceId });
+ const body: OpenConversationResponse = { conversationId };
+ return c.json(body, 200);
+ });
+
+ app.put("/conversations/:id/title", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/title: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ if (typeof obj.title !== "string" || obj.title.trim().length === 0) {
+ return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400);
+ }
+ // Trim before persisting (mirrors how `parseQueueBody` / `parseChatBody`
+ // forward trimmed text), so a title never carries surrounding whitespace.
+ const title = obj.title.trim();
+
+ try {
+ await opts.conversationStore.setConversationTitle(conversationId, title);
+ log.info("conversations: title set", { conversationId });
+ const response: TitleResponse = { conversationId, title };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: title set failure", { err });
+ return c.json({ error: "Failed to set conversation title" }, 500);
+ }
+ });
+
+ // ─── Compaction ──────────────────────────────────────────────────────────
+
+ app.post("/conversations/:id/compact", async (c) => {
+ if (opts.compactionService === undefined) {
+ return c.json({ error: "Compaction service not available" }, 503);
+ }
+ const conversationId = c.req.param("id");
+ let body: unknown = {};
+ try {
+ body = await c.req.json();
+ } catch {
+ // No body is fine — use defaults.
+ }
+ const obj = body as Record<string, unknown>;
+ const keepLastN =
+ typeof obj.keepLastN === "number" && Number.isFinite(obj.keepLastN) && obj.keepLastN > 0
+ ? Math.floor(obj.keepLastN)
+ : undefined;
+ const modelName = typeof obj.modelName === "string" ? obj.modelName : undefined;
+
+ log.info("conversations: compact request", { conversationId });
+
+ const result = await opts.compactionService.compact(conversationId, {
+ ...(keepLastN !== undefined ? { keepLastN } : {}),
+ ...(modelName !== undefined ? { modelName } : {}),
+ });
+
+ if ("error" in result) {
+ log.warn("conversations: compact returned error", {
+ conversationId,
+ error: result.error,
+ });
+ return c.json({ error: result.error }, 409);
+ }
+
+ const response: CompactResponse = {
+ conversationId,
+ newConversationId: result.newConversationId,
+ messagesSummarized: result.messagesSummarized,
+ messagesKept: result.messagesKept,
+ };
+ return c.json(response, 200);
+ });
+
+ app.get("/conversations/:id/compact-percent", async (c) => {
+ const conversationId = c.req.param("id");
+ const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0;
+ const response: CompactPercentResponse = { conversationId, threshold };
+ return c.json(response, 200);
+ });
+
+ app.put("/conversations/:id/compact-percent", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+ const parsed = body as SetCompactPercentRequest;
+ if (
+ typeof parsed.threshold !== "number" ||
+ !Number.isFinite(parsed.threshold) ||
+ parsed.threshold < 0
+ ) {
+ return c.json({ error: "threshold must be a non-negative number" }, 400);
+ }
+ const threshold = Math.floor(parsed.threshold);
+ await opts.conversationStore.setCompactPercent(conversationId, threshold);
+ log.info("conversations: compact-percent set", { conversationId, threshold });
+ const response: CompactPercentResponse = { conversationId, threshold };
+ return c.json(response, 200);
+ });
+
+ // ─── Workspaces ──────────────────────────────────────────────────────────
+
+ app.get("/workspaces", async (c) => {
+ try {
+ const workspaces = await opts.conversationStore.listWorkspaces();
+ log.info("workspaces: list", { count: workspaces.length });
+ const body: WorkspaceListResponse = { workspaces };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("workspaces: list failure", { err });
+ return c.json({ error: "Failed to list workspaces" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (!isValidWorkspaceSlug(workspaceId)) {
+ return c.json(
+ {
+ error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
+ },
+ 400,
+ );
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ body = {};
+ }
+ const obj = body as Record<string, unknown>;
+ const opts_: { readonly title?: string; readonly defaultCwd?: string | null } = {};
+ if (typeof obj.title === "string") {
+ (opts_ as { title?: string }).title = obj.title;
+ }
+ if (typeof obj.defaultCwd === "string" || obj.defaultCwd === null) {
+ (opts_ as { defaultCwd?: string | null }).defaultCwd = obj.defaultCwd;
+ }
+
+ try {
+ const workspace = await opts.conversationStore.ensureWorkspace(workspaceId, opts_);
+ log.info("workspaces: ensured", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: ensure failure", { err });
+ return c.json({ error: "Failed to ensure workspace" }, 500);
+ }
+ });
+
+ app.get("/workspaces/:id", async (c) => {
+ const workspaceId = c.req.param("id");
+ try {
+ const workspace = await opts.conversationStore.getWorkspace(workspaceId);
+ if (workspace === null) {
+ return c.json({ error: "Workspace not found" }, 404);
+ }
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: get failure", { err });
+ return c.json({ error: "Failed to read workspace" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id/title", async (c) => {
+ const workspaceId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("workspaces/title: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ if (typeof obj.title !== "string" || obj.title.trim().length === 0) {
+ return c.json({ error: "Field 'title' is required and must be a non-empty string" }, 400);
+ }
+ const title = obj.title.trim();
+
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceTitle(workspaceId, title);
+ log.info("workspaces: title set", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: title set failure", { err });
+ return c.json({ error: "Failed to set workspace title" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id/default-cwd", async (c) => {
+ const workspaceId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ body = {};
+ }
+ const obj = body as Record<string, unknown>;
+ const defaultCwd: string | null = typeof obj.defaultCwd === "string" ? obj.defaultCwd : null;
+
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceDefaultCwd(
+ workspaceId,
+ defaultCwd,
+ );
+ log.info("workspaces: default-cwd set", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: default-cwd set failure", { err });
+ return c.json({ error: "Failed to set workspace default cwd" }, 500);
+ }
+ });
+
+ // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog).
+ app.put("/workspaces/:id/default-computer", async (c) => {
+ const workspaceId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ body = {};
+ }
+ const obj = body as Record<string, unknown>;
+ // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias;
+ // anything else (null/absent/non-string) → clear (local).
+ const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] =
+ typeof obj.computerId === "string" ? obj.computerId : null;
+
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId(
+ workspaceId,
+ defaultComputerId,
+ );
+ log.info("workspaces: default-computer set", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: default-computer set failure", { err });
+ return c.json({ error: "Failed to set workspace default computer" }, 500);
+ }
+ });
+
+ app.delete("/workspaces/:id", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (workspaceId === "default") {
+ return c.json({ error: 'The "default" workspace cannot be deleted' }, 409);
+ }
+
+ try {
+ const { closedCount } = await opts.conversationStore.deleteWorkspace(workspaceId);
+ // Clean up the in-memory starred cache so a deleted workspace's ID
+ // doesn't linger (and so a future workspace re-created with the same
+ // slug doesn't inherit the stale starred state).
+ opts.concurrencyService?.notifyWorkspaceStarred(workspaceId, false);
+ log.info("workspaces: deleted", { workspaceId, closedCount });
+ const response: DeleteWorkspaceResponse = { workspaceId, closedCount };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: delete failure", { err });
+ return c.json({ error: "Failed to delete workspace" }, 500);
+ }
+ });
+
+ // ─── Star/unstar workspace (concurrency priority) ───────────────────────────
+ // Starred workspaces receive PRIORITY in the concurrency limiter queue —
+ // their agents jump ahead of agents from non-starred workspaces. The
+ // starred state is persisted in the conversation store AND the in-memory
+ // cache in the concurrency service is notified so already-queued agents
+ // are re-prioritized immediately.
+
+ app.put("/workspaces/:id/star", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (!isValidWorkspaceSlug(workspaceId)) {
+ return c.json(
+ {
+ error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
+ },
+ 400,
+ );
+ }
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceStarred(workspaceId, true);
+ // Notify the concurrency service's in-memory cache so queued agents
+ // from this workspace jump ahead immediately. When the concurrency
+ // service is absent (extension not loaded), the starred state is
+ // persisted but the in-memory priority cache is NOT updated — log a
+ // warning so the degraded behavior is visible (queued agents keep
+ // their old priority until restart or the extension is loaded).
+ if (opts.concurrencyService !== undefined) {
+ opts.concurrencyService.notifyWorkspaceStarred(workspaceId, true);
+ } else {
+ log.warn(
+ "workspaces: starred but concurrency service is not loaded — priority cache not updated",
+ {
+ workspaceId,
+ },
+ );
+ }
+ log.info("workspaces: starred", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: star failure", { err, workspaceId });
+ return c.json({ error: "Failed to star workspace" }, 500);
+ }
+ });
+
+ app.delete("/workspaces/:id/star", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (!isValidWorkspaceSlug(workspaceId)) {
+ return c.json(
+ {
+ error: "Workspace id must be a valid slug (lowercase alphanumeric + hyphens, 1–40 chars)",
+ },
+ 400,
+ );
+ }
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceStarred(workspaceId, false);
+ if (opts.concurrencyService !== undefined) {
+ opts.concurrencyService.notifyWorkspaceStarred(workspaceId, false);
+ } else {
+ log.warn(
+ "workspaces: unstarred but concurrency service is not loaded — priority cache not updated",
+ {
+ workspaceId,
+ },
+ );
+ }
+ log.info("workspaces: unstarred", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: unstar failure", { err, workspaceId });
+ return c.json({ error: "Failed to unstar workspace" }, 500);
+ }
+ });
+
+ // ─── Heartbeat (per-workspace AI loop) ─────────────────────────────────────
+ // The config + run history for a workspace's heartbeat loop. Delegated to
+ // the HeartbeatService (provided by the `heartbeat` extension). When
+ // heartbeat is NOT loaded the routes degrade: GET config → the defaults,
+ // GET runs → empty, PUT/POST → 503 (mirrors how /system-prompt returns the
+ // default template when its service is absent but 503s writes).
+
+ app.get("/workspaces/:id/heartbeat", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ // Graceful: no heartbeat configured → return the defaults so the FE
+ // always gets a usable config shape (enabled: false, etc.).
+ const body: HeartbeatConfig = DEFAULT_HEARTBEAT_CONFIG;
+ return c.json(body, 200);
+ }
+ try {
+ const config = await opts.heartbeatService.getConfig(workspaceId);
+ log.info("heartbeat: config read", { workspaceId, enabled: config.enabled });
+ const body: HeartbeatConfig = config;
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("heartbeat: config read failure", { err, workspaceId });
+ return c.json({ error: "Failed to read heartbeat config" }, 500);
+ }
+ });
+
+ app.put("/workspaces/:id/heartbeat", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ return c.json({ error: "Heartbeat service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("heartbeat: invalid JSON body", { workspaceId });
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+
+ // Build a partial update, validating each present field. All fields are
+ // optional (a partial update); only provided fields are forwarded.
+ const update: Record<string, unknown> = {};
+
+ if (obj.enabled !== undefined) {
+ if (typeof obj.enabled !== "boolean") {
+ return c.json({ error: "Field 'enabled' must be a boolean" }, 400);
+ }
+ update.enabled = obj.enabled;
+ }
+
+ // inactiveOnly: when true (the default), the heartbeat skips a fire while
+ // the configured workspace has active agents. A boolean; absent leaves it
+ // unchanged.
+ if (obj.inactiveOnly !== undefined) {
+ if (typeof obj.inactiveOnly !== "boolean") {
+ return c.json({ error: "Field 'inactiveOnly' must be a boolean" }, 400);
+ }
+ update.inactiveOnly = obj.inactiveOnly;
+ }
+
+ if (obj.systemPrompt !== undefined) {
+ if (typeof obj.systemPrompt !== "string") {
+ return c.json({ error: "Field 'systemPrompt' must be a string" }, 400);
+ }
+ update.systemPrompt = obj.systemPrompt;
+ }
+
+ if (obj.taskPrompt !== undefined) {
+ if (typeof obj.taskPrompt !== "string") {
+ return c.json({ error: "Field 'taskPrompt' must be a string" }, 400);
+ }
+ update.taskPrompt = obj.taskPrompt;
+ }
+
+ if (obj.intervalMinutes !== undefined) {
+ if (typeof obj.intervalMinutes !== "number" || !Number.isFinite(obj.intervalMinutes)) {
+ return c.json({ error: "Field 'intervalMinutes' must be a number" }, 400);
+ }
+ update.intervalMinutes = obj.intervalMinutes;
+ }
+
+ if (obj.model !== undefined) {
+ if (typeof obj.model !== "string") {
+ return c.json({ error: "Field 'model' must be a string" }, 400);
+ }
+ update.model = obj.model;
+ }
+
+ // `reasoningEffort` accepts a valid level string OR null (clear the
+ // override → inherit the workspace default). Absent (undefined) leaves
+ // it unchanged. An unrecognized string → 400.
+ if (obj.reasoningEffort !== undefined) {
+ if (obj.reasoningEffort !== null && !isValidReasoningEffort(obj.reasoningEffort)) {
+ return c.json(
+ {
+ error: "Field 'reasoningEffort' must be one of: low, medium, high, xhigh, max, or null",
+ },
+ 400,
+ );
+ }
+ update.reasoningEffort = obj.reasoningEffort;
+ }
+
+ try {
+ const config = await opts.heartbeatService.updateConfig(
+ workspaceId,
+ update as UpdateHeartbeatRequest,
+ );
+ log.info("heartbeat: config updated", {
+ workspaceId,
+ enabled: config.enabled,
+ intervalMinutes: config.intervalMinutes,
+ });
+ const response: HeartbeatConfig = config;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("heartbeat: config update failure", { err, workspaceId });
+ return c.json({ error: "Failed to update heartbeat config" }, 500);
+ }
+ });
+
+ app.get("/workspaces/:id/heartbeat/runs", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ // Graceful: no heartbeat → no runs.
+ const body: HeartbeatRunsResponse = { runs: [] };
+ return c.json(body, 200);
+ }
+ try {
+ const runs = await opts.heartbeatService.listRuns(workspaceId);
+ log.info("heartbeat: runs listed", { workspaceId, count: runs.length });
+ const body: HeartbeatRunsResponse = { runs };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("heartbeat: runs list failure", { err, workspaceId });
+ return c.json({ error: "Failed to list heartbeat runs" }, 500);
+ }
+ });
+
+ // The server-authoritative next-fire time for a workspace's heartbeat. A
+ // lightweight read of the scheduler's pending fire time (polled by the FE
+ // alongside the runs list). `nextRunAt` is null when the heartbeat is
+ // disabled/disarmed, or when a run is in flight and the next hasn't been
+ // queued yet — the FE then shows no countdown, not a fabricated one.
+ app.get("/workspaces/:id/heartbeat/next-run", async (c) => {
+ const workspaceId = c.req.param("id");
+ if (opts.heartbeatService === undefined) {
+ // Graceful: no heartbeat configured → no next run scheduled.
+ return c.json({ nextRunAt: null }, 200);
+ }
+ try {
+ const nextRunAt = await opts.heartbeatService.nextRunAt(workspaceId);
+ log.info("heartbeat: next-run read", { workspaceId, nextRunAt });
+ return c.json({ nextRunAt }, 200);
+ } catch (err) {
+ log.error("heartbeat: next-run read failure", { err, workspaceId });
+ return c.json({ error: "Failed to read heartbeat next-run" }, 500);
+ }
+ });
+
+ app.post("/workspaces/:id/heartbeat/runs/:runId/stop", async (c) => {
+ const workspaceId = c.req.param("id");
+ const runId = c.req.param("runId");
+ if (opts.heartbeatService === undefined) {
+ return c.json({ error: "Heartbeat service not available" }, 503);
+ }
+ try {
+ const result = await opts.heartbeatService.stopRun(workspaceId, runId);
+ log.info("heartbeat: run stopped", { workspaceId, runId });
+ const body: StopHeartbeatRunResponse = result;
+ return c.json(body, 200);
+ } catch (err) {
+ // stopRun throws "Heartbeat run not found" for an unknown run id.
+ const message = err instanceof Error ? err.message : String(err);
+ if (message.includes("not found")) {
+ return c.json({ error: "Heartbeat run not found" }, 404);
+ }
+ log.error("heartbeat: run stop failure", { err, workspaceId, runId });
+ return c.json({ error: "Failed to stop heartbeat run" }, 500);
+ }
+ });
+
+ // ─── System prompt template ───────────────────────────────────────────────
+
+ app.get("/system-prompt/variables", (c) => {
+ // Static catalog — no service call needed. Always available.
+ const variables = getVariableCatalog();
+ const body: SystemPromptVariablesResponse = { variables };
+ return c.json(body, 200);
+ });
+
+ app.get("/system-prompt", async (c) => {
+ if (opts.systemPromptService === undefined) {
+ // FE always gets something useful — the built-in default template.
+ const body: SystemPromptTemplateResponse = { template: DEFAULT_TEMPLATE };
+ return c.json(body, 200);
+ }
+ const template = await opts.systemPromptService.getTemplate();
+ const body: SystemPromptTemplateResponse = { template };
+ return c.json(body, 200);
+ });
+
+ app.put("/system-prompt", async (c) => {
+ if (opts.systemPromptService === undefined) {
+ return c.json({ error: "System prompt service not available" }, 503);
+ }
+
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("system-prompt: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ // `template` must be a string; empty string is valid ("no system prompt").
+ if (typeof obj.template !== "string") {
+ return c.json({ error: "Field 'template' is required and must be a string" }, 400);
+ }
+
+ const { template } = obj as unknown as SetSystemPromptTemplateRequest;
+ await opts.systemPromptService.setTemplate(template);
+ log.info("system-prompt: template set");
+ const response: SystemPromptTemplateResponse = { template };
+ return c.json(response, 200);
+ });
+
+ app.get("/settings/vision", async (c) => {
+ const settings = await opts.conversationStore.getVisionSettings();
+ const body: VisionSettingsResponse = settings;
+ return c.json(body, 200);
+ });
+
+ app.put("/settings/vision", async (c) => {
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+ const obj = body as { imageLimit?: unknown; compactionModel?: unknown };
+ if (obj.imageLimit !== undefined) {
+ if (
+ typeof obj.imageLimit !== "number" ||
+ !Number.isInteger(obj.imageLimit) ||
+ obj.imageLimit < 0
+ ) {
+ return c.json({ error: "imageLimit must be a non-negative integer" }, 400);
+ }
+ await opts.conversationStore.setVisionImageLimit(obj.imageLimit);
+ log.info("vision: image limit set", { imageLimit: obj.imageLimit });
+ }
+ if (obj.compactionModel !== undefined) {
+ if (obj.compactionModel !== null && typeof obj.compactionModel !== "string") {
+ return c.json({ error: "compactionModel must be a string or null" }, 400);
+ }
+ await opts.conversationStore.setVisionCompactionModel(obj.compactionModel);
+ log.info("vision: compaction model set", { compactionModel: obj.compactionModel });
+ }
+ const settings = await opts.conversationStore.getVisionSettings();
+ const response: VisionSettingsResponse = settings;
+ return c.json(response, 200);
+ });
+
+ // ─── Static frontend serving (catch-all, API routes take precedence) ──────
+ if (opts.webDir !== undefined) {
+ const webDir = opts.webDir;
+ const MIME: Record<string, string> = {
+ ".js": "text/javascript; charset=utf-8",
+ ".mjs": "text/javascript; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".txt": "text/plain; charset=utf-8",
+ ".wasm": "application/wasm",
+ };
+ app.get("*", async (c) => {
+ const urlPath = new URL(c.req.url).pathname;
+ const filePath = `${webDir}${urlPath}`;
+ const file = Bun.file(filePath);
+ if (await file.exists()) {
+ const ext = filePath.slice(filePath.lastIndexOf("."));
+ const contentType = MIME[ext] ?? "application/octet-stream";
+ return new Response(file, {
+ headers: { "Content-Type": contentType },
+ });
+ }
+ // SPA fallback: serve index.html for client-side routing
+ const indexFile = Bun.file(`${webDir}/index.html`);
+ if (await indexFile.exists()) {
+ return new Response(indexFile, {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+ }
+ return c.json({ error: "Not found" }, 404);
+ });
+ }
+
+ return app;
}
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 4ab43ce..effbadd 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -1,140 +1,171 @@
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import { createApp } from "./app.js";
import {
- type ComputerService,
- cacheWarmHandle,
- compactionHandle,
- computerServiceHandle,
- conversationStoreHandle,
- credentialStoreHandle,
- lspServiceHandle,
- mcpServiceHandle,
- sessionOrchestratorHandle,
- systemPromptHandle,
- throughputStoreHandle,
+ type ComputerService,
+ type ConcurrencyService,
+ cacheWarmHandle,
+ compactionHandle,
+ computerServiceHandle,
+ concurrencyServiceHandle,
+ conversationStoreHandle,
+ credentialStoreHandle,
+ heartbeatServiceHandle,
+ type LspService,
+ lspServiceHandle,
+ mcpServiceHandle,
+ sessionOrchestratorHandle,
+ systemPromptHandle,
+ throughputStoreHandle,
} from "./seam.js";
export const manifest: Manifest = {
- id: "transport-http",
- name: "Transport HTTP",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- dependsOn: [
- "conversation-store",
- "credential-store",
- "lsp",
- "mcp",
- "session-orchestrator",
- "throughput-store",
- ],
- capabilities: { network: true },
- contributes: {
- routes: [
- "/chat",
- "/chat/warm",
- "/computers",
- "/computers/:alias",
- "/computers/:alias/status",
- "/computers/:alias/test",
- "/conversations",
- "/conversations/:id",
- "/conversations/:id/close",
- "/conversations/:id/compact",
- "/conversations/:id/compact-percent",
- "/conversations/:id/computer",
- "/conversations/:id/cwd",
- "/conversations/:id/last",
- "/conversations/:id/lsp",
- "/conversations/:id/mcp",
- "/conversations/:id/open",
- "/conversations/:id/queue",
- "/conversations/:id/reasoning-effort",
- "/conversations/:id/status",
- "/conversations/:id/stop",
- "/conversations/:id/title",
- "/health",
- "/models",
- "/metrics/throughput",
- "/system-prompt",
- "/system-prompt/variables",
- "/workspaces",
- "/workspaces/:id",
- "/workspaces/:id/title",
- "/workspaces/:id/default-cwd",
- "/workspaces/:id/default-computer",
- ],
- },
- activation: "eager",
+ id: "transport-http",
+ name: "Transport HTTP",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ dependsOn: [
+ "conversation-store",
+ "credential-store",
+ "heartbeat",
+ "mcp",
+ "session-orchestrator",
+ "throughput-store",
+ ],
+ capabilities: { network: true },
+ contributes: {
+ routes: [
+ "/chat",
+ "/chat/warm",
+ "/computers",
+ "/computers/:alias",
+ "/computers/:alias/status",
+ "/computers/:alias/test",
+ "/concurrency/limits",
+ "/concurrency/limits/:providerId",
+ "/concurrency/status",
+ "/conversations",
+ "/conversations/:id",
+ "/conversations/:id/close",
+ "/conversations/:id/compact",
+ "/conversations/:id/compact-percent",
+ "/conversations/:id/computer",
+ "/conversations/:id/cwd",
+ "/conversations/:id/last",
+ "/conversations/:id/lsp",
+ "/conversations/:id/mcp",
+ "/conversations/:id/open",
+ "/conversations/:id/queue",
+ "/conversations/:id/queue/:messageId",
+ "/conversations/:id/reasoning-effort",
+ "/conversations/:id/status",
+ "/conversations/:id/stop",
+ "/conversations/:id/title",
+ "/health",
+ "/models",
+ "/metrics/throughput",
+ "/system-prompt",
+ "/system-prompt/variables",
+ "/workspaces",
+ "/workspaces/:id",
+ "/workspaces/:id/default-cwd",
+ "/workspaces/:id/default-computer",
+ "/workspaces/:id/heartbeat",
+ "/workspaces/:id/heartbeat/runs",
+ "/workspaces/:id/heartbeat/runs/:runId/stop",
+ "/workspaces/:id/star",
+ "/workspaces/:id/title",
+ ],
+ },
+ activation: "eager",
};
export function createTransportHttpExtension(): Extension & {
- readonly _testServer: ReturnType<typeof Bun.serve> | undefined;
+ readonly _testServer: ReturnType<typeof Bun.serve> | undefined;
} {
- let server: ReturnType<typeof Bun.serve> | undefined;
+ let server: ReturnType<typeof Bun.serve> | undefined;
- return {
- get _testServer() {
- return server;
- },
- manifest,
- async activate(host: HostAPI) {
- const conversationStore = host.getService(conversationStoreHandle);
- const orchestrator = host.getService(sessionOrchestratorHandle);
- const credentialStore = host.getService(credentialStoreHandle);
- const throughputStore = host.getService(throughputStoreHandle);
- const warmService = host.getService(cacheWarmHandle);
- const compactionService = host.getService(compactionHandle);
- const lspService = host.getService(lspServiceHandle);
- const mcpService = host.getService(mcpServiceHandle);
- const systemPromptService = host.getService(systemPromptHandle);
- // Optional: the `ssh` extension provides ComputerService. It is NOT in
- // dependsOn (ssh may be absent), so resolve defensively — when no
- // provider registered the handle, the computer routes degrade to
- // empty/disconnected (see app.ts). Wrapped because getService throws
- // for an unregistered handle.
- let computerService: ComputerService | undefined;
- try {
- computerService = host.getService(computerServiceHandle);
- } catch {
- computerService = undefined;
- }
- const logger = host.logger;
+ return {
+ get _testServer() {
+ return server;
+ },
+ manifest,
+ async activate(host: HostAPI) {
+ const conversationStore = host.getService(conversationStoreHandle);
+ const orchestrator = host.getService(sessionOrchestratorHandle);
+ const credentialStore = host.getService(credentialStoreHandle);
+ const throughputStore = host.getService(throughputStoreHandle);
+ const warmService = host.getService(cacheWarmHandle);
+ const compactionService = host.getService(compactionHandle);
+ // Optional: the `lsp` extension may be disabled (hot-fix). Wrapped because
+ // getService throws for an unregistered handle — degrades to no diagnostics.
+ let lspService: LspService | undefined;
+ try {
+ lspService = host.getService(lspServiceHandle);
+ } catch {
+ lspService = undefined;
+ }
+ const mcpService = host.getService(mcpServiceHandle);
+ const systemPromptService = host.getService(systemPromptHandle);
+ const heartbeatService = host.getService(heartbeatServiceHandle);
+ // Optional: the `ssh` extension provides ComputerService. It is NOT in
+ // dependsOn (ssh may be absent), so resolve defensively — when no
+ // provider registered the handle, the computer routes degrade to
+ // empty/disconnected (see app.ts). Wrapped because getService throws
+ // for an unregistered handle.
+ let computerService: ComputerService | undefined;
+ try {
+ computerService = host.getService(computerServiceHandle);
+ } catch {
+ computerService = undefined;
+ }
+ // Optional: the `provider-concurrency` extension provides the
+ // concurrency limiter service. NOT in dependsOn (may be absent), so
+ // resolve defensively — when absent the /concurrency/* routes degrade.
+ let concurrencyService: ConcurrencyService | undefined;
+ try {
+ concurrencyService = host.getService(concurrencyServiceHandle);
+ } catch {
+ concurrencyService = undefined;
+ }
+ const logger = host.logger;
- const app = createApp({
- conversationStore,
- orchestrator,
- credentialStore,
- throughputStore,
- warmService,
- compactionService,
- lspService,
- mcpService,
- systemPromptService,
- ...(computerService !== undefined ? { computerService } : {}),
- logger,
- emit: host.emit.bind(host),
- ...(process.env.DISPATCH_WEB_DIR !== undefined
- ? { webDir: process.env.DISPATCH_WEB_DIR }
- : {}),
- });
+ const app = createApp({
+ conversationStore,
+ orchestrator,
+ credentialStore,
+ throughputStore,
+ warmService,
+ compactionService,
+ ...(lspService !== undefined ? { lspService } : {}),
+ mcpService,
+ systemPromptService,
+ heartbeatService,
+ ...(computerService !== undefined ? { computerService } : {}),
+ ...(concurrencyService !== undefined ? { concurrencyService } : {}),
+ logger,
+ emit: host.emit.bind(host),
+ ...(process.env.DISPATCH_WEB_DIR !== undefined
+ ? { webDir: process.env.DISPATCH_WEB_DIR }
+ : {}),
+ });
- const port = host.config.get<number>("httpPort") ?? 24203;
+ const port = host.config.get<number>("httpPort") ?? 24203;
- server = Bun.serve({
- port,
- fetch: app.fetch,
- idleTimeout: 0,
- });
+ server = Bun.serve({
+ port,
+ fetch: app.fetch,
+ idleTimeout: 0,
+ });
- logger.info("transport-http: listening", { port });
- },
+ logger.info("transport-http: listening", { port });
+ },
- deactivate() {
- if (server) {
- server.stop();
- server = undefined;
- }
- },
- };
+ deactivate() {
+ if (server) {
+ server.stop();
+ server = undefined;
+ }
+ },
+ };
}
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index 192b00c..47c06bd 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -2,45 +2,45 @@ export type { CreateServerOptions } from "./app.js";
export { createApp } from "./app.js";
export { createTransportHttpExtension, manifest } from "./extension.js";
export type {
- ChatCommand,
- ParseError,
- ParseResult,
- QueueBodyParsed,
- SinceSeqResult,
- WarmBodyParsed,
- WindowParamResult,
+ ChatCommand,
+ ParseError,
+ ParseResult,
+ QueueBodyParsed,
+ SinceSeqResult,
+ WarmBodyParsed,
+ WindowParamResult,
} from "./logic.js";
export {
- computeCachePct,
- extractLastAssistantText,
- isParseError,
- isReasoningEffortParseError,
- isSinceSeqError,
- isValidReasoningEffort,
- isWindowParamError,
- parseChatBody,
- parseQueueBody,
- parseReasoningEffortBody,
- parseSinceSeq,
- parseWindowParam,
- serializeEventLine,
+ computeCachePct,
+ extractLastAssistantText,
+ isParseError,
+ isReasoningEffortParseError,
+ isSinceSeqError,
+ isValidReasoningEffort,
+ isWindowParamError,
+ parseChatBody,
+ parseQueueBody,
+ parseReasoningEffortBody,
+ parseSinceSeq,
+ parseWindowParam,
+ serializeEventLine,
} from "./logic.js";
export type {
- ComputerService,
- ConversationStore,
- CredentialStore,
- LspService,
- SessionOrchestrator,
- SystemPromptService,
- WarmService,
+ ComputerService,
+ ConversationStore,
+ CredentialStore,
+ LspService,
+ SessionOrchestrator,
+ SystemPromptService,
+ WarmService,
} from "./seam.js";
export {
- cacheWarmHandle,
- computerServiceHandle,
- conversationStoreHandle,
- credentialStoreHandle,
- isValidWorkspaceSlug,
- lspServiceHandle,
- sessionOrchestratorHandle,
- systemPromptHandle,
+ cacheWarmHandle,
+ computerServiceHandle,
+ conversationStoreHandle,
+ credentialStoreHandle,
+ isValidWorkspaceSlug,
+ lspServiceHandle,
+ sessionOrchestratorHandle,
+ systemPromptHandle,
} from "./seam.js";
diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts
index 40a82fd..271ee96 100644
--- a/packages/transport-http/src/logic.test.ts
+++ b/packages/transport-http/src/logic.test.ts
@@ -1,428 +1,541 @@
import type { AgentEvent } from "@dispatch/kernel";
import { describe, expect, it } from "vitest";
import {
- computeExpectedCacheRate,
- isParseError,
- isReasoningEffortParseError,
- isSinceSeqError,
- isValidReasoningEffort,
- isWindowParamError,
- parseChatBody,
- parseQueueBody,
- parseReasoningEffortBody,
- parseSinceSeq,
- parseWindowParam,
- serializeEventLine,
+ computeExpectedCacheRate,
+ isParseError,
+ isReasoningEffortParseError,
+ isSinceSeqError,
+ isValidReasoningEffort,
+ isWindowParamError,
+ parseChatBody,
+ parseQueueBody,
+ parseReasoningEffortBody,
+ parseSinceSeq,
+ parseWindowParam,
+ serializeEventLine,
} from "./logic.js";
describe("parseChatBody", () => {
- const fakeId = () => "test-uuid";
-
- it("returns error for null body", () => {
- const result = parseChatBody(null, fakeId);
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("JSON object");
- }
- });
-
- it("returns error for non-object body", () => {
- const result = parseChatBody("hello", fakeId);
- expect(isParseError(result)).toBe(true);
- });
-
- it("returns error when message is missing", () => {
- const result = parseChatBody({ conversationId: "c1" }, fakeId);
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("message");
- }
- });
-
- it("returns error when message is empty string", () => {
- const result = parseChatBody({ message: "" }, fakeId);
- expect(isParseError(result)).toBe(true);
- });
-
- it("returns error when message is whitespace only", () => {
- const result = parseChatBody({ message: " " }, fakeId);
- expect(isParseError(result)).toBe(true);
- });
-
- it("returns error when message is not a string", () => {
- const result = parseChatBody({ message: 42 }, fakeId);
- expect(isParseError(result)).toBe(true);
- });
-
- it("generates conversationId when absent", () => {
- const result = parseChatBody({ message: "hello" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.conversationId).toBe("test-uuid");
- expect(result.message).toBe("hello");
- }
- });
-
- it("generates conversationId when empty string", () => {
- const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.conversationId).toBe("test-uuid");
- }
- });
-
- it("uses provided conversationId", () => {
- const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.conversationId).toBe("my-conv");
- }
- });
-
- it("trims message whitespace", () => {
- const result = parseChatBody({ message: " hello world " }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.message).toBe("hello world");
- }
- });
-
- it("extracts model when present", () => {
- const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.model).toBe("opencode/m1");
- }
- });
-
- it("extracts cwd when present", () => {
- const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.cwd).toBe("/tmp");
- }
- });
-
- it("extracts both model and cwd", () => {
- const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.model).toBe("openai/gpt-4");
- expect(result.cwd).toBe("/home");
- }
- });
-
- it("omits model when absent", () => {
- const result = parseChatBody({ message: "hi" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.model).toBeUndefined();
- }
- });
-
- it("omits cwd when absent", () => {
- const result = parseChatBody({ message: "hi" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.cwd).toBeUndefined();
- }
- });
-
- it("returns error when model is not a string", () => {
- const result = parseChatBody({ message: "hi", model: 42 }, fakeId);
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("model");
- }
- });
-
- it("returns error when cwd is not a string", () => {
- const result = parseChatBody({ message: "hi", cwd: true }, fakeId);
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("cwd");
- }
- });
-
- it("extracts reasoningEffort when present and valid", () => {
- const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.reasoningEffort).toBe("low");
- }
- });
-
- it("accepts all valid reasoningEffort levels", () => {
- for (const level of ["low", "medium", "high", "xhigh", "max"]) {
- const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.reasoningEffort).toBe(level);
- }
- }
- });
-
- it("returns error for invalid reasoningEffort", () => {
- const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId);
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("reasoningEffort");
- }
- });
-
- it("returns error for non-string reasoningEffort", () => {
- const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId);
- expect(isParseError(result)).toBe(true);
- });
-
- it("omits reasoningEffort when absent", () => {
- const result = parseChatBody({ message: "hi" }, fakeId);
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.reasoningEffort).toBeUndefined();
- }
- });
+ const fakeId = () => "test-uuid";
+
+ it("returns error for null body", () => {
+ const result = parseChatBody(null, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("JSON object");
+ }
+ });
+
+ it("returns error for non-object body", () => {
+ const result = parseChatBody("hello", fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when message is missing", () => {
+ const result = parseChatBody({ conversationId: "c1" }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("message");
+ }
+ });
+
+ it("returns error when message is empty string", () => {
+ const result = parseChatBody({ message: "" }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when message is whitespace only", () => {
+ const result = parseChatBody({ message: " " }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when message is not a string", () => {
+ const result = parseChatBody({ message: 42 }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("generates conversationId when absent", () => {
+ const result = parseChatBody({ message: "hello" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.conversationId).toBe("test-uuid");
+ expect(result.message).toBe("hello");
+ }
+ });
+
+ it("generates conversationId when empty string", () => {
+ const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.conversationId).toBe("test-uuid");
+ }
+ });
+
+ it("uses provided conversationId", () => {
+ const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.conversationId).toBe("my-conv");
+ }
+ });
+
+ it("trims message whitespace", () => {
+ const result = parseChatBody({ message: " hello world " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.message).toBe("hello world");
+ }
+ });
+
+ it("extracts model when present", () => {
+ const result = parseChatBody({ message: "hi", model: "opencode/m1" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.model).toBe("opencode/m1");
+ }
+ });
+
+ it("extracts cwd when present", () => {
+ const result = parseChatBody({ message: "hi", cwd: "/tmp" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.cwd).toBe("/tmp");
+ }
+ });
+
+ it("extracts both model and cwd", () => {
+ const result = parseChatBody({ message: "hi", model: "openai/gpt-4", cwd: "/home" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.model).toBe("openai/gpt-4");
+ expect(result.cwd).toBe("/home");
+ }
+ });
+
+ it("omits model when absent", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.model).toBeUndefined();
+ }
+ });
+
+ it("omits cwd when absent", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.cwd).toBeUndefined();
+ }
+ });
+
+ it("returns error when model is not a string", () => {
+ const result = parseChatBody({ message: "hi", model: 42 }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("model");
+ }
+ });
+
+ it("returns error when cwd is not a string", () => {
+ const result = parseChatBody({ message: "hi", cwd: true }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("cwd");
+ }
+ });
+
+ it("extracts reasoningEffort when present and valid", () => {
+ const result = parseChatBody({ message: "hi", reasoningEffort: "low" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.reasoningEffort).toBe("low");
+ }
+ });
+
+ it("accepts all valid reasoningEffort levels", () => {
+ for (const level of ["low", "medium", "high", "xhigh", "max"]) {
+ const result = parseChatBody({ message: "hi", reasoningEffort: level }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.reasoningEffort).toBe(level);
+ }
+ }
+ });
+
+ it("returns error for invalid reasoningEffort", () => {
+ const result = parseChatBody({ message: "hi", reasoningEffort: "banana" }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("reasoningEffort");
+ }
+ });
+
+ it("returns error for non-string reasoningEffort", () => {
+ const result = parseChatBody({ message: "hi", reasoningEffort: 42 }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("omits reasoningEffort when absent", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.reasoningEffort).toBeUndefined();
+ }
+ });
+
+ // ── title ────────────────────────────────────────────────────────────────
+
+ it("extracts title when present", () => {
+ const result = parseChatBody({ message: "hi", title: "My Task" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBe("My Task");
+ }
+ });
+
+ it("trims title whitespace", () => {
+ const result = parseChatBody({ message: "hi", title: " spaced title " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBe("spaced title");
+ }
+ });
+
+ it("omits title when absent (backward compatible)", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("omits title when whitespace-only (treated as absent)", () => {
+ const result = parseChatBody({ message: "hi", title: " " }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("omits title when empty string (treated as absent)", () => {
+ const result = parseChatBody({ message: "hi", title: "" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.title).toBeUndefined();
+ }
+ });
+
+ it("returns error when title is not a string", () => {
+ const result = parseChatBody({ message: "hi", title: 42 }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("title");
+ }
+ });
+
+ // ── images ──────────────────────────────────────────────────────────────
+
+ it("parses images array with data URLs", () => {
+ const result = parseChatBody(
+ {
+ message: "what is this?",
+ images: [
+ { url: "data:image/png;base64,aaa" },
+ { url: "data:image/jpeg;base64,bbb", mimeType: "image/jpeg" },
+ ],
+ },
+ fakeId,
+ );
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.images).toHaveLength(2);
+ expect(result.images?.[0]?.url).toBe("data:image/png;base64,aaa");
+ expect(result.images?.[1]?.mimeType).toBe("image/jpeg");
+ }
+ });
+
+ it("parses images with http URLs", () => {
+ const result = parseChatBody(
+ { message: "hi", images: [{ url: "https://example.com/x.png" }] },
+ fakeId,
+ );
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.images?.[0]?.url).toBe("https://example.com/x.png");
+ }
+ });
+
+ it("returns error when images is not an array", () => {
+ const result = parseChatBody({ message: "hi", images: "not-an-array" }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when an image lacks a url", () => {
+ const result = parseChatBody({ message: "hi", images: [{ mimeType: "image/png" }] }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when an image url is empty", () => {
+ const result = parseChatBody({ message: "hi", images: [{ url: "" }] }, fakeId);
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("omits images when absent (backward compatible)", () => {
+ const result = parseChatBody({ message: "hi" }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.images).toBeUndefined();
+ }
+ });
+
+ it("omits images when the array is empty", () => {
+ const result = parseChatBody({ message: "hi", images: [] }, fakeId);
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.images).toBeUndefined();
+ }
+ });
});
describe("parseSinceSeq", () => {
- it("returns 0 when undefined", () => {
- expect(parseSinceSeq(undefined)).toBe(0);
- });
-
- it("returns 0 when empty string", () => {
- expect(parseSinceSeq("")).toBe(0);
- });
-
- it("parses valid non-negative integer", () => {
- expect(parseSinceSeq("0")).toBe(0);
- expect(parseSinceSeq("5")).toBe(5);
- expect(parseSinceSeq("42")).toBe(42);
- });
-
- it("returns ParseError for non-integer string", () => {
- const result = parseSinceSeq("abc");
- expect(isSinceSeqError(result)).toBe(true);
- if (isSinceSeqError(result)) {
- expect(result.error).toContain("sinceSeq");
- }
- });
-
- it("returns ParseError for float", () => {
- const result = parseSinceSeq("3.14");
- expect(isSinceSeqError(result)).toBe(true);
- });
-
- it("returns ParseError for negative integer", () => {
- const result = parseSinceSeq("-1");
- expect(isSinceSeqError(result)).toBe(true);
- });
+ it("returns 0 when undefined", () => {
+ expect(parseSinceSeq(undefined)).toBe(0);
+ });
+
+ it("returns 0 when empty string", () => {
+ expect(parseSinceSeq("")).toBe(0);
+ });
+
+ it("parses valid non-negative integer", () => {
+ expect(parseSinceSeq("0")).toBe(0);
+ expect(parseSinceSeq("5")).toBe(5);
+ expect(parseSinceSeq("42")).toBe(42);
+ });
+
+ it("returns ParseError for non-integer string", () => {
+ const result = parseSinceSeq("abc");
+ expect(isSinceSeqError(result)).toBe(true);
+ if (isSinceSeqError(result)) {
+ expect(result.error).toContain("sinceSeq");
+ }
+ });
+
+ it("returns ParseError for float", () => {
+ const result = parseSinceSeq("3.14");
+ expect(isSinceSeqError(result)).toBe(true);
+ });
+
+ it("returns ParseError for negative integer", () => {
+ const result = parseSinceSeq("-1");
+ expect(isSinceSeqError(result)).toBe(true);
+ });
});
describe("parseWindowParam", () => {
- it("returns undefined (absent) when undefined", () => {
- expect(parseWindowParam(undefined, "limit")).toBeUndefined();
- });
-
- it("returns undefined (absent) when empty string", () => {
- expect(parseWindowParam("", "limit")).toBeUndefined();
- });
-
- it("parses a valid positive integer", () => {
- expect(parseWindowParam("1", "limit")).toBe(1);
- expect(parseWindowParam("42", "beforeSeq")).toBe(42);
- });
-
- it("returns ParseError for zero (store would treat it as absent)", () => {
- const result = parseWindowParam("0", "limit");
- expect(isWindowParamError(result)).toBe(true);
- if (isWindowParamError(result)) {
- expect(result.error).toContain("limit");
- expect(result.error).toContain("positive integer");
- }
- });
-
- it("returns ParseError for a negative integer", () => {
- expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true);
- });
-
- it("returns ParseError for a non-integer", () => {
- expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true);
- });
-
- it("returns ParseError for a non-numeric string", () => {
- const result = parseWindowParam("abc", "beforeSeq");
- expect(isWindowParamError(result)).toBe(true);
- if (isWindowParamError(result)) {
- expect(result.error).toContain("beforeSeq");
- }
- });
-
- it("names the param in the error message", () => {
- const limit = parseWindowParam("0", "limit");
- const before = parseWindowParam("0", "beforeSeq");
- if (isWindowParamError(limit)) expect(limit.error).toContain("limit");
- if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq");
- });
-
- it("isWindowParamError is false for absent and for a valid number", () => {
- expect(isWindowParamError(undefined)).toBe(false);
- expect(isWindowParamError(5)).toBe(false);
- });
+ it("returns undefined (absent) when undefined", () => {
+ expect(parseWindowParam(undefined, "limit")).toBeUndefined();
+ });
+
+ it("returns undefined (absent) when empty string", () => {
+ expect(parseWindowParam("", "limit")).toBeUndefined();
+ });
+
+ it("parses a valid positive integer", () => {
+ expect(parseWindowParam("1", "limit")).toBe(1);
+ expect(parseWindowParam("42", "beforeSeq")).toBe(42);
+ });
+
+ it("returns ParseError for zero (store would treat it as absent)", () => {
+ const result = parseWindowParam("0", "limit");
+ expect(isWindowParamError(result)).toBe(true);
+ if (isWindowParamError(result)) {
+ expect(result.error).toContain("limit");
+ expect(result.error).toContain("positive integer");
+ }
+ });
+
+ it("returns ParseError for a negative integer", () => {
+ expect(isWindowParamError(parseWindowParam("-1", "limit"))).toBe(true);
+ });
+
+ it("returns ParseError for a non-integer", () => {
+ expect(isWindowParamError(parseWindowParam("1.5", "beforeSeq"))).toBe(true);
+ });
+
+ it("returns ParseError for a non-numeric string", () => {
+ const result = parseWindowParam("abc", "beforeSeq");
+ expect(isWindowParamError(result)).toBe(true);
+ if (isWindowParamError(result)) {
+ expect(result.error).toContain("beforeSeq");
+ }
+ });
+
+ it("names the param in the error message", () => {
+ const limit = parseWindowParam("0", "limit");
+ const before = parseWindowParam("0", "beforeSeq");
+ if (isWindowParamError(limit)) expect(limit.error).toContain("limit");
+ if (isWindowParamError(before)) expect(before.error).toContain("beforeSeq");
+ });
+
+ it("isWindowParamError is false for absent and for a valid number", () => {
+ expect(isWindowParamError(undefined)).toBe(false);
+ expect(isWindowParamError(5)).toBe(false);
+ });
});
describe("serializeEventLine", () => {
- it("serializes an event as JSON followed by newline", () => {
- const event: AgentEvent = {
- type: "text-delta",
- conversationId: "tab1",
- turnId: "turn1",
- delta: "hello",
- };
- const line = serializeEventLine(event);
- expect(line).toBe(`${JSON.stringify(event)}\n`);
- });
-
- it("serializes a done event", () => {
- const event: AgentEvent = {
- type: "done",
- conversationId: "tab1",
- turnId: "turn1",
- reason: "stop",
- };
- const line = serializeEventLine(event);
- const parsed = JSON.parse(line.trim());
- expect(parsed.type).toBe("done");
- expect(parsed.reason).toBe("stop");
- });
+ it("serializes an event as JSON followed by newline", () => {
+ const event: AgentEvent = {
+ type: "text-delta",
+ conversationId: "tab1",
+ turnId: "turn1",
+ delta: "hello",
+ };
+ const line = serializeEventLine(event);
+ expect(line).toBe(`${JSON.stringify(event)}\n`);
+ });
+
+ it("serializes a done event", () => {
+ const event: AgentEvent = {
+ type: "done",
+ conversationId: "tab1",
+ turnId: "turn1",
+ reason: "stop",
+ };
+ const line = serializeEventLine(event);
+ const parsed = JSON.parse(line.trim());
+ expect(parsed.type).toBe("done");
+ expect(parsed.reason).toBe("stop");
+ });
});
describe("computeExpectedCacheRate", () => {
- it("returns round(cacheRead/(cacheRead+cacheWrite)*100)", () => {
- expect(computeExpectedCacheRate(800, 200)).toBe(80);
- });
-
- it("returns 0 when cacheRead+cacheWrite is 0", () => {
- expect(computeExpectedCacheRate(0, 0)).toBe(0);
- });
-
- it("returns 100 when all tokens are cacheRead", () => {
- expect(computeExpectedCacheRate(500, 0)).toBe(100);
- });
-
- it("returns 0 when all tokens are cacheWrite", () => {
- expect(computeExpectedCacheRate(0, 500)).toBe(0);
- });
-
- it("rounds to nearest integer", () => {
- expect(computeExpectedCacheRate(1, 2)).toBe(33);
- expect(computeExpectedCacheRate(2, 1)).toBe(67);
- });
+ it("returns round(cacheRead/(cacheRead+cacheWrite)*100)", () => {
+ expect(computeExpectedCacheRate(800, 200)).toBe(80);
+ });
+
+ it("returns 0 when cacheRead+cacheWrite is 0", () => {
+ expect(computeExpectedCacheRate(0, 0)).toBe(0);
+ });
+
+ it("returns 100 when all tokens are cacheRead", () => {
+ expect(computeExpectedCacheRate(500, 0)).toBe(100);
+ });
+
+ it("returns 0 when all tokens are cacheWrite", () => {
+ expect(computeExpectedCacheRate(0, 500)).toBe(0);
+ });
+
+ it("rounds to nearest integer", () => {
+ expect(computeExpectedCacheRate(1, 2)).toBe(33);
+ expect(computeExpectedCacheRate(2, 1)).toBe(67);
+ });
});
describe("isValidReasoningEffort", () => {
- it("returns true for all valid levels", () => {
- expect(isValidReasoningEffort("low")).toBe(true);
- expect(isValidReasoningEffort("medium")).toBe(true);
- expect(isValidReasoningEffort("high")).toBe(true);
- expect(isValidReasoningEffort("xhigh")).toBe(true);
- expect(isValidReasoningEffort("max")).toBe(true);
- });
-
- it("returns false for invalid strings", () => {
- expect(isValidReasoningEffort("banana")).toBe(false);
- expect(isValidReasoningEffort("")).toBe(false);
- expect(isValidReasoningEffort("LOW")).toBe(false);
- });
-
- it("returns false for non-strings", () => {
- expect(isValidReasoningEffort(42)).toBe(false);
- expect(isValidReasoningEffort(null)).toBe(false);
- expect(isValidReasoningEffort(undefined)).toBe(false);
- expect(isValidReasoningEffort(true)).toBe(false);
- });
+ it("returns true for all valid levels", () => {
+ expect(isValidReasoningEffort("low")).toBe(true);
+ expect(isValidReasoningEffort("medium")).toBe(true);
+ expect(isValidReasoningEffort("high")).toBe(true);
+ expect(isValidReasoningEffort("xhigh")).toBe(true);
+ expect(isValidReasoningEffort("max")).toBe(true);
+ });
+
+ it("returns false for invalid strings", () => {
+ expect(isValidReasoningEffort("banana")).toBe(false);
+ expect(isValidReasoningEffort("")).toBe(false);
+ expect(isValidReasoningEffort("LOW")).toBe(false);
+ });
+
+ it("returns false for non-strings", () => {
+ expect(isValidReasoningEffort(42)).toBe(false);
+ expect(isValidReasoningEffort(null)).toBe(false);
+ expect(isValidReasoningEffort(undefined)).toBe(false);
+ expect(isValidReasoningEffort(true)).toBe(false);
+ });
});
describe("parseReasoningEffortBody", () => {
- it("returns the level for a valid body", () => {
- expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low");
- expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max");
- });
-
- it("returns ParseError for missing reasoningEffort", () => {
- const result = parseReasoningEffortBody({});
- expect(isReasoningEffortParseError(result)).toBe(true);
- if (isReasoningEffortParseError(result)) {
- expect(result.error).toContain("reasoningEffort");
- }
- });
-
- it("returns ParseError for invalid level", () => {
- const result = parseReasoningEffortBody({ reasoningEffort: "banana" });
- expect(isReasoningEffortParseError(result)).toBe(true);
- if (isReasoningEffortParseError(result)) {
- expect(result.error).toContain("reasoningEffort");
- }
- });
-
- it("returns ParseError for non-object body", () => {
- expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true);
- expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true);
- });
+ it("returns the level for a valid body", () => {
+ expect(parseReasoningEffortBody({ reasoningEffort: "low" })).toBe("low");
+ expect(parseReasoningEffortBody({ reasoningEffort: "max" })).toBe("max");
+ });
+
+ it("returns ParseError for missing reasoningEffort", () => {
+ const result = parseReasoningEffortBody({});
+ expect(isReasoningEffortParseError(result)).toBe(true);
+ if (isReasoningEffortParseError(result)) {
+ expect(result.error).toContain("reasoningEffort");
+ }
+ });
+
+ it("returns ParseError for invalid level", () => {
+ const result = parseReasoningEffortBody({ reasoningEffort: "banana" });
+ expect(isReasoningEffortParseError(result)).toBe(true);
+ if (isReasoningEffortParseError(result)) {
+ expect(result.error).toContain("reasoningEffort");
+ }
+ });
+
+ it("returns ParseError for non-object body", () => {
+ expect(isReasoningEffortParseError(parseReasoningEffortBody(null))).toBe(true);
+ expect(isReasoningEffortParseError(parseReasoningEffortBody("string"))).toBe(true);
+ });
});
describe("parseQueueBody", () => {
- it("returns error for null body", () => {
- const result = parseQueueBody(null);
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("JSON object");
- }
- });
-
- it("returns error for non-object body", () => {
- const result = parseQueueBody("hello");
- expect(isParseError(result)).toBe(true);
- });
-
- it("returns error when text is missing", () => {
- const result = parseQueueBody({});
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("text");
- }
- });
-
- it("returns error when text is empty string", () => {
- const result = parseQueueBody({ text: "" });
- expect(isParseError(result)).toBe(true);
- });
-
- it("returns error when text is whitespace only", () => {
- const result = parseQueueBody({ text: " " });
- expect(isParseError(result)).toBe(true);
- });
-
- it("returns error when text is not a string", () => {
- const result = parseQueueBody({ text: 42 });
- expect(isParseError(result)).toBe(true);
- if (isParseError(result)) {
- expect(result.error).toContain("text");
- }
- });
-
- it("returns the trimmed text for a valid body", () => {
- const result = parseQueueBody({ text: "hello" });
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.text).toBe("hello");
- }
- });
-
- it("trims text whitespace", () => {
- const result = parseQueueBody({ text: " hello world " });
- expect(isParseError(result)).toBe(false);
- if (!isParseError(result)) {
- expect(result.text).toBe("hello world");
- }
- });
+ it("returns error for null body", () => {
+ const result = parseQueueBody(null);
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("JSON object");
+ }
+ });
+
+ it("returns error for non-object body", () => {
+ const result = parseQueueBody("hello");
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when text is missing", () => {
+ const result = parseQueueBody({});
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("text");
+ }
+ });
+
+ it("returns error when text is empty string", () => {
+ const result = parseQueueBody({ text: "" });
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when text is whitespace only", () => {
+ const result = parseQueueBody({ text: " " });
+ expect(isParseError(result)).toBe(true);
+ });
+
+ it("returns error when text is not a string", () => {
+ const result = parseQueueBody({ text: 42 });
+ expect(isParseError(result)).toBe(true);
+ if (isParseError(result)) {
+ expect(result.error).toContain("text");
+ }
+ });
+
+ it("returns the trimmed text for a valid body", () => {
+ const result = parseQueueBody({ text: "hello" });
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.text).toBe("hello");
+ }
+ });
+
+ it("trims text whitespace", () => {
+ const result = parseQueueBody({ text: " hello world " });
+ expect(isParseError(result)).toBe(false);
+ if (!isParseError(result)) {
+ expect(result.text).toBe("hello world");
+ }
+ });
});
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index 4e099c4..c703049 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -1,19 +1,19 @@
import type {
- AgentEvent,
- ChatMessage,
- ConversationStatus,
- ReasoningEffort,
+ AgentEvent,
+ ChatMessage,
+ ConversationStatus,
+ ReasoningEffort,
} from "@dispatch/kernel";
const VALID_REASONING_EFFORTS: readonly ReasoningEffort[] = [
- "low",
- "medium",
- "high",
- "xhigh",
- "max",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
];
-const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed"];
+const VALID_STATUSES: readonly ConversationStatus[] = ["active", "queued", "idle", "closed"];
/**
* Pure: parse a `?status=` query value into a list of valid ConversationStatus
@@ -22,43 +22,60 @@ const VALID_STATUSES: readonly ConversationStatus[] = ["active", "idle", "closed
* `undefined` (no filter — shows all).
*/
export function parseStatusFilter(
- raw: string | undefined,
+ raw: string | undefined,
): readonly ConversationStatus[] | undefined {
- if (raw === undefined) return undefined;
- const trimmed = raw.trim();
- if (trimmed.length === 0) return undefined;
- const parts = trimmed
- .split(",")
- .map((s) => s.trim())
- .filter((s) => s.length > 0);
- const valid = parts.filter((p): p is ConversationStatus =>
- VALID_STATUSES.includes(p as ConversationStatus),
- );
- return valid.length > 0 ? valid : undefined;
+ if (raw === undefined) return undefined;
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) return undefined;
+ const parts = trimmed
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const valid = parts.filter((p): p is ConversationStatus =>
+ VALID_STATUSES.includes(p as ConversationStatus),
+ );
+ return valid.length > 0 ? valid : undefined;
}
export function isValidReasoningEffort(value: unknown): value is ReasoningEffort {
- return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort);
+ return typeof value === "string" && VALID_REASONING_EFFORTS.includes(value as ReasoningEffort);
}
export interface ChatCommand {
- readonly conversationId: string;
- readonly message: string;
- readonly model?: string;
- readonly cwd?: string;
- /**
- * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded
- * to the orchestrator verbatim and never part of the model prompt. When
- * absent, the orchestrator resolves the per-conversation → workspace
- * default → local chain.
- */
- readonly computerId?: string;
- readonly reasoningEffort?: ReasoningEffort;
- readonly workspaceId?: string;
+ readonly conversationId: string;
+ readonly message: string;
+ readonly model?: string;
+ readonly cwd?: string;
+ /**
+ * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded
+ * to the orchestrator verbatim and never part of the model prompt. When
+ * absent, the orchestrator resolves the per-conversation → workspace
+ * default → local chain.
+ */
+ readonly computerId?: string;
+ readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
+ /**
+ * A human-readable title for the conversation tab, set at creation time.
+ * Parsed from the `ChatRequest.title` field; trimmed server-side. A
+ * whitespace-only value is treated as absent (omitted) so the auto-derived
+ * title applies. Forwarded to the orchestrator, which persists it via the
+ * conversation store's `setConversationTitle` AFTER the new-conversation
+ * workspace setup (so workspace assignment / first-turn system-prompt
+ * construction are not skipped) and before the first message append.
+ */
+ readonly title?: string;
+ /**
+ * Images attached to this turn (data URLs or http URLs). Parsed from the
+ * `ChatRequest.images` field; forwarded to the orchestrator which converts
+ * them to `image` chunks on the user message. Each entry must have a non-empty
+ * string `url`; `mimeType` is optional.
+ */
+ readonly images?: readonly { readonly url: string; readonly mimeType?: string }[];
}
export interface ParseError {
- readonly error: string;
+ readonly error: string;
}
export type ParseResult = ChatCommand | ParseError;
@@ -66,83 +83,122 @@ export type ParseResult = ChatCommand | ParseError;
export type SinceSeqResult = number | ParseError;
export function parseChatBody(body: unknown, generateId: () => string): ParseResult {
- if (body === null || typeof body !== "object") {
- return { error: "Request body must be a JSON object" };
- }
-
- const obj = body as Record<string, unknown>;
-
- const message = obj.message;
- if (typeof message !== "string" || message.trim().length === 0) {
- return { error: "Field 'message' is required and must be a non-empty string" };
- }
-
- const conversationId =
- typeof obj.conversationId === "string" && obj.conversationId.length > 0
- ? obj.conversationId
- : generateId();
-
- const result: ChatCommand = { conversationId, message: message.trim() };
-
- if (obj.model !== undefined) {
- if (typeof obj.model !== "string") {
- return { error: "Field 'model' must be a string" };
- }
- (result as { model?: string }).model = obj.model;
- }
-
- if (obj.cwd !== undefined) {
- if (typeof obj.cwd !== "string") {
- return { error: "Field 'cwd' must be a string" };
- }
- (result as { cwd?: string }).cwd = obj.cwd;
- }
-
- if (obj.computerId !== undefined) {
- if (typeof obj.computerId !== "string") {
- return { error: "Field 'computerId' must be a string" };
- }
- (result as { computerId?: string }).computerId = obj.computerId;
- }
-
- if (obj.reasoningEffort !== undefined) {
- if (!isValidReasoningEffort(obj.reasoningEffort)) {
- return {
- error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`,
- };
- }
- (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort;
- }
-
- if (obj.workspaceId !== undefined) {
- if (typeof obj.workspaceId !== "string") {
- return { error: "Field 'workspaceId' must be a string" };
- }
- (result as { workspaceId?: string }).workspaceId = obj.workspaceId;
- }
-
- return result;
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
+
+ const obj = body as Record<string, unknown>;
+
+ const message = obj.message;
+ if (typeof message !== "string" || message.trim().length === 0) {
+ return { error: "Field 'message' is required and must be a non-empty string" };
+ }
+
+ const conversationId =
+ typeof obj.conversationId === "string" && obj.conversationId.length > 0
+ ? obj.conversationId
+ : generateId();
+
+ const result: ChatCommand = { conversationId, message: message.trim() };
+
+ if (obj.model !== undefined) {
+ if (typeof obj.model !== "string") {
+ return { error: "Field 'model' must be a string" };
+ }
+ (result as { model?: string }).model = obj.model;
+ }
+
+ if (obj.cwd !== undefined) {
+ if (typeof obj.cwd !== "string") {
+ return { error: "Field 'cwd' must be a string" };
+ }
+ (result as { cwd?: string }).cwd = obj.cwd;
+ }
+
+ if (obj.computerId !== undefined) {
+ if (typeof obj.computerId !== "string") {
+ return { error: "Field 'computerId' must be a string" };
+ }
+ (result as { computerId?: string }).computerId = obj.computerId;
+ }
+
+ if (obj.reasoningEffort !== undefined) {
+ if (!isValidReasoningEffort(obj.reasoningEffort)) {
+ return {
+ error: `Field 'reasoningEffort' must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`,
+ };
+ }
+ (result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort;
+ }
+
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string") {
+ return { error: "Field 'workspaceId' must be a string" };
+ }
+ (result as { workspaceId?: string }).workspaceId = obj.workspaceId;
+ }
+
+ if (obj.title !== undefined) {
+ if (typeof obj.title !== "string") {
+ return { error: "Field 'title' must be a string" };
+ }
+ const title = obj.title.trim();
+ // A whitespace-only title is treated as absent so the auto-derived title
+ // applies (mirrors omitting the field) — never persist an empty title.
+ if (title.length > 0) {
+ (result as { title?: string }).title = title;
+ }
+ }
+
+ if (obj.images !== undefined) {
+ if (!Array.isArray(obj.images)) {
+ return { error: "Field 'images' must be an array" };
+ }
+ const images: { url: string; mimeType?: string }[] = [];
+ for (const entry of obj.images) {
+ if (entry === null || typeof entry !== "object") {
+ return { error: "Each image must be an object with a 'url' string" };
+ }
+ const img = entry as { url?: unknown; mimeType?: unknown };
+ if (typeof img.url !== "string" || img.url.length === 0) {
+ return { error: "Each image must have a non-empty string 'url'" };
+ }
+ const parsed: { url: string; mimeType?: string } = { url: img.url };
+ if (img.mimeType !== undefined) {
+ if (typeof img.mimeType !== "string") {
+ return { error: "Field 'mimeType' on an image must be a string" };
+ }
+ parsed.mimeType = img.mimeType;
+ }
+ images.push(parsed);
+ }
+ if (images.length > 0) {
+ (result as { images?: readonly { url: string; mimeType?: string }[] }).images = images;
+ }
+ }
+
+ return result;
}
export function isParseError<T>(result: T | ParseError): result is ParseError {
- return typeof result === "object" && result !== null && "error" in result;
+ return typeof result === "object" && result !== null && "error" in result;
}
export function serializeEventLine(event: AgentEvent): string {
- return `${JSON.stringify(event)}\n`;
+ return `${JSON.stringify(event)}\n`;
}
export function parseSinceSeq(raw: string | undefined): SinceSeqResult {
- if (raw === undefined || raw === "") return 0;
- const n = Number(raw);
- if (!Number.isInteger(n) || n < 0) {
- return { error: "sinceSeq must be a non-negative integer" };
- }
- return n;
+ if (raw === undefined || raw === "") return 0;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n < 0) {
+ return { error: "sinceSeq must be a non-negative integer" };
+ }
+ return n;
}
export function isSinceSeqError(result: SinceSeqResult): result is ParseError {
- return typeof result === "object";
+ return typeof result === "object";
}
/**
@@ -160,67 +216,67 @@ export type WindowParamResult = number | undefined | ParseError;
* Absent (`undefined` / empty) is the valid "no window" case → `undefined`.
*/
export function parseWindowParam(raw: string | undefined, name: string): WindowParamResult {
- if (raw === undefined || raw === "") return undefined;
- const n = Number(raw);
- if (!Number.isInteger(n) || n <= 0) {
- return { error: `${name} must be a positive integer` };
- }
- return n;
+ if (raw === undefined || raw === "") return undefined;
+ const n = Number(raw);
+ if (!Number.isInteger(n) || n <= 0) {
+ return { error: `${name} must be a positive integer` };
+ }
+ return n;
}
export function isWindowParamError(result: WindowParamResult): result is ParseError {
- return typeof result === "object" && result !== null;
+ return typeof result === "object" && result !== null;
}
export interface WarmBodyParsed {
- readonly conversationId: string;
- readonly model?: string;
- readonly cwd?: string;
+ readonly conversationId: string;
+ readonly model?: string;
+ readonly cwd?: string;
}
export function parseWarmBody(body: unknown): WarmBodyParsed | ParseError {
- if (body === null || typeof body !== "object") {
- return { error: "Request body must be a JSON object" };
- }
-
- const obj = body as Record<string, unknown>;
-
- const conversationId = obj.conversationId;
- if (typeof conversationId !== "string" || conversationId.length === 0) {
- return { error: "Field 'conversationId' is required and must be a non-empty string" };
- }
-
- const result: Record<string, unknown> = { conversationId };
-
- if (obj.model !== undefined) {
- if (typeof obj.model !== "string") {
- return { error: "Field 'model' must be a string" };
- }
- result.model = obj.model;
- }
-
- if (obj.cwd !== undefined) {
- if (typeof obj.cwd !== "string") {
- return { error: "Field 'cwd' must be a string" };
- }
- result.cwd = obj.cwd;
- }
-
- return result as unknown as WarmBodyParsed;
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
+
+ const obj = body as Record<string, unknown>;
+
+ const conversationId = obj.conversationId;
+ if (typeof conversationId !== "string" || conversationId.length === 0) {
+ return { error: "Field 'conversationId' is required and must be a non-empty string" };
+ }
+
+ const result: Record<string, unknown> = { conversationId };
+
+ if (obj.model !== undefined) {
+ if (typeof obj.model !== "string") {
+ return { error: "Field 'model' must be a string" };
+ }
+ result.model = obj.model;
+ }
+
+ if (obj.cwd !== undefined) {
+ if (typeof obj.cwd !== "string") {
+ return { error: "Field 'cwd' must be a string" };
+ }
+ result.cwd = obj.cwd;
+ }
+
+ return result as unknown as WarmBodyParsed;
}
export function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
- if (inputTokens <= 0) return 0;
- return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100);
+ if (inputTokens <= 0) return 0;
+ return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100);
}
export function computeExpectedCacheRate(
- cacheReadTokens: number,
- cacheWriteTokens: number,
+ cacheReadTokens: number,
+ cacheWriteTokens: number,
): number {
- const denom = cacheReadTokens + cacheWriteTokens;
- if (denom <= 0) return 0;
- return Math.round((cacheReadTokens / denom) * 100);
+ const denom = cacheReadTokens + cacheWriteTokens;
+ if (denom <= 0) return 0;
+ return Math.round((cacheReadTokens / denom) * 100);
}
/**
@@ -229,8 +285,8 @@ export function computeExpectedCacheRate(
* is deliberately NOT part of this parse result.
*/
export interface QueueBodyParsed {
- readonly text: string;
- readonly workspaceId?: string;
+ readonly text: string;
+ readonly workspaceId?: string;
}
/**
@@ -241,46 +297,46 @@ export interface QueueBodyParsed {
* `message`.
*/
export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError {
- if (body === null || typeof body !== "object") {
- return { error: "Request body must be a JSON object" };
- }
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
- const obj = body as Record<string, unknown>;
+ const obj = body as Record<string, unknown>;
- const text = obj.text;
- if (typeof text !== "string" || text.trim().length === 0) {
- return { error: "Field 'text' is required and must be a non-empty string" };
- }
+ const text = obj.text;
+ if (typeof text !== "string" || text.trim().length === 0) {
+ return { error: "Field 'text' is required and must be a non-empty string" };
+ }
- const result: QueueBodyParsed = { text: text.trim() };
+ const result: QueueBodyParsed = { text: text.trim() };
- if (obj.workspaceId !== undefined) {
- if (typeof obj.workspaceId !== "string") {
- return { error: "Field 'workspaceId' must be a string" };
- }
- return { text: text.trim(), workspaceId: obj.workspaceId };
- }
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string") {
+ return { error: "Field 'workspaceId' must be a string" };
+ }
+ return { text: text.trim(), workspaceId: obj.workspaceId };
+ }
- return result;
+ return result;
}
export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError {
- if (body === null || typeof body !== "object") {
- return { error: "Request body must be a JSON object" };
- }
- const obj = body as Record<string, unknown>;
- if (!isValidReasoningEffort(obj.reasoningEffort)) {
- return {
- error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`,
- };
- }
- return obj.reasoningEffort;
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
+ const obj = body as Record<string, unknown>;
+ if (!isValidReasoningEffort(obj.reasoningEffort)) {
+ return {
+ error: `Field 'reasoningEffort' is required and must be one of: ${VALID_REASONING_EFFORTS.join(", ")}`,
+ };
+ }
+ return obj.reasoningEffort;
}
export function isReasoningEffortParseError(
- result: ReasoningEffort | ParseError,
+ result: ReasoningEffort | ParseError,
): result is ParseError {
- return typeof result === "object" && result !== null && "error" in result;
+ return typeof result === "object" && result !== null && "error" in result;
}
/**
@@ -293,21 +349,21 @@ export function isReasoningEffortParseError(
* Returns the validated `model` value (`string | null`) on success.
*/
export function parseModelBody(body: unknown): string | null | ParseError {
- if (body === null || typeof body !== "object") {
- return { error: "Request body must be a JSON object" };
- }
- const obj = body as Record<string, unknown>;
- if (obj.model === undefined) {
- return { error: "Field 'model' is required and must be a string or null" };
- }
- if (obj.model !== null && typeof obj.model !== "string") {
- return { error: "Field 'model' must be a string or null" };
- }
- return obj.model as string | null;
+ if (body === null || typeof body !== "object") {
+ return { error: "Request body must be a JSON object" };
+ }
+ const obj = body as Record<string, unknown>;
+ if (obj.model === undefined) {
+ return { error: "Field 'model' is required and must be a string or null" };
+ }
+ if (obj.model !== null && typeof obj.model !== "string") {
+ return { error: "Field 'model' must be a string or null" };
+ }
+ return obj.model as string | null;
}
export function isModelParseError(result: string | null | ParseError): result is ParseError {
- return typeof result === "object" && result !== null && "error" in result;
+ return typeof result === "object" && result !== null && "error" in result;
}
/**
@@ -322,20 +378,20 @@ export function isModelParseError(result: string | null | ParseError): result is
* Pure (input → output); zero I/O, so it tests directly without mocks.
*/
export function extractLastAssistantText(messages: readonly ChatMessage[]): string {
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (msg === undefined || msg.role !== "assistant") continue;
- // Found the last assistant message — scan its chunks from the end for
- // the last `text` chunk. Stop here (do not keep scanning earlier
- // assistant messages): the contract is "the last assistant message's
- // last text chunk", not "the most recent text chunk anywhere".
- for (let j = msg.chunks.length - 1; j >= 0; j--) {
- const chunk = msg.chunks[j];
- if (chunk !== undefined && chunk.type === "text") {
- return chunk.text;
- }
- }
- return "";
- }
- return "";
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i];
+ if (msg === undefined || msg.role !== "assistant") continue;
+ // Found the last assistant message — scan its chunks from the end for
+ // the last `text` chunk. Stop here (do not keep scanning earlier
+ // assistant messages): the contract is "the last assistant message's
+ // last text chunk", not "the most recent text chunk anywhere".
+ for (let j = msg.chunks.length - 1; j >= 0; j--) {
+ const chunk = msg.chunks[j];
+ if (chunk !== undefined && chunk.type === "text") {
+ return chunk.text;
+ }
+ }
+ return "";
+ }
+ return "";
}
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index ef28a09..dcb3f80 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -6,20 +6,24 @@ export type { ConversationStore } from "@dispatch/conversation-store";
export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
export { credentialStoreHandle } from "@dispatch/credential-store";
+export type { HeartbeatService } from "@dispatch/heartbeat";
+export { heartbeatServiceHandle } from "@dispatch/heartbeat";
export type { LspServerStatus, LspService } from "@dispatch/lsp";
export { lspServiceHandle } from "@dispatch/lsp";
export type { McpServerStatus, McpService } from "@dispatch/mcp";
export { mcpServiceHandle } from "@dispatch/mcp";
+export type { ConcurrencyService } from "@dispatch/provider-concurrency";
+export { concurrencyServiceHandle } from "@dispatch/provider-concurrency";
export type {
- CompactionService,
- SessionOrchestrator,
- WarmService,
+ CompactionService,
+ SessionOrchestrator,
+ WarmService,
} from "@dispatch/session-orchestrator";
export {
- cacheWarmHandle,
- compactionHandle,
- conversationOpened,
- sessionOrchestratorHandle,
+ cacheWarmHandle,
+ compactionHandle,
+ conversationOpened,
+ sessionOrchestratorHandle,
} from "@dispatch/session-orchestrator";
export type { SystemPromptService } from "@dispatch/system-prompt";
export { systemPromptHandle } from "@dispatch/system-prompt";
@@ -45,14 +49,14 @@ export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughpu
* — an ABSENT service (ssh extension not loaded) is the graceful-degrade path.
*/
export interface ComputerService {
- /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */
- readonly listComputers: () => Promise<readonly ComputerEntry[]>;
- /** One computer by alias, or `null` when the alias isn't in the config. */
- readonly getComputer: (alias: string) => Promise<Computer | null>;
- /** Live connection state for a computer alias. */
- readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>;
- /** One-shot connectivity probe (open, run a trivial command, close). */
- readonly test: (alias: string) => Promise<TestComputerResponse>;
+ /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */
+ readonly listComputers: () => Promise<readonly ComputerEntry[]>;
+ /** One computer by alias, or `null` when the alias isn't in the config. */
+ readonly getComputer: (alias: string) => Promise<Computer | null>;
+ /** Live connection state for a computer alias. */
+ readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>;
+ /** One-shot connectivity probe (open, run a trivial command, close). */
+ readonly test: (alias: string) => Promise<TestComputerResponse>;
}
/**
@@ -60,4 +64,4 @@ export interface ComputerService {
* consume. Mirrors `lspServiceHandle` / `mcpServiceHandle`.
*/
export const computerServiceHandle: ServiceHandle<ComputerService> =
- defineService<ComputerService>("ssh");
+ defineService<ComputerService>("ssh");
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index f552507..f93e259 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -3,305 +3,305 @@ import type { ConfigAccess, HostAPI, Logger } from "@dispatch/kernel";
import { createApp } from "./app.js";
import { createTransportHttpExtension } from "./index.js";
import type {
- ConversationStore,
- CredentialStore,
- LspService,
- SessionOrchestrator,
+ ConversationStore,
+ CredentialStore,
+ LspService,
+ SessionOrchestrator,
} from "./seam.js";
function fakeLogger(): Logger {
- return {
- debug() {},
- info() {},
- warn() {},
- error() {},
- child() {
- return fakeLogger();
- },
- span() {
- return {
- id: "fake-span",
- log: fakeLogger(),
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
- };
+ return {
+ debug() {},
+ info() {},
+ warn() {},
+ error() {},
+ child() {
+ return fakeLogger();
+ },
+ span() {
+ return {
+ id: "fake-span",
+ log: fakeLogger(),
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
+ };
}
function fakeConversationStore(): ConversationStore {
- return {
- async append() {},
- async load() {
- return [];
- },
- async loadSince() {
- return [];
- },
- async appendMetrics() {},
- async loadMetrics() {
- return [];
- },
- async getCwd() {
- return null;
- },
- async setCwd() {},
- async getReasoningEffort() {
- return null;
- },
- async setReasoningEffort() {},
- async listConversations() {
- return [];
- },
- async getConversationMeta() {
- return null;
- },
- async setConversationTitle() {},
- async getConversationStatus() {
- return null;
- },
- async setConversationStatus() {},
- async replaceHistory() {},
- async getCompactPercent() {
- return null;
- },
- async setCompactPercent() {},
- async forkHistory() {},
- async setCompactedFrom() {},
- async getWorkspace() {
- return null;
- },
- async ensureWorkspace() {
- return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
- },
- async setWorkspaceTitle() {
- return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
- },
- async setWorkspaceDefaultCwd() {
- return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
- },
- async deleteWorkspace() {
- return { closedCount: 0 };
- },
- async listWorkspaces() {
- return [];
- },
- async getWorkspaceId() {
- return "default";
- },
- async setWorkspaceId() {},
- async getEffectiveCwd() {
- return null;
- },
- };
+ return {
+ async append() {},
+ async load() {
+ return [];
+ },
+ async loadSince() {
+ return [];
+ },
+ async appendMetrics() {},
+ async loadMetrics() {
+ return [];
+ },
+ async getCwd() {
+ return null;
+ },
+ async setCwd() {},
+ async getReasoningEffort() {
+ return null;
+ },
+ async setReasoningEffort() {},
+ async listConversations() {
+ return [];
+ },
+ async getConversationMeta() {
+ return null;
+ },
+ async setConversationTitle() {},
+ async getConversationStatus() {
+ return null;
+ },
+ async setConversationStatus() {},
+ async replaceHistory() {},
+ async getCompactPercent() {
+ return null;
+ },
+ async setCompactPercent() {},
+ async forkHistory() {},
+ async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
+ };
}
function fakeOrchestrator(): SessionOrchestrator {
- return {
- startTurn() {
- return { started: true, turnId: "fake-turn" };
- },
- subscribe() {
- return () => {};
- },
- isActive() {
- return false;
- },
- enqueue() {
- return { startedTurn: false, queue: [] };
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage() {},
- };
+ return {
+ startTurn() {
+ return { started: true, turnId: "fake-turn" };
+ },
+ subscribe() {
+ return () => {};
+ },
+ isActive() {
+ return false;
+ },
+ enqueue() {
+ return { startedTurn: false, queue: [] };
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage() {},
+ };
}
function fakeCredentialStore(): CredentialStore {
- return {
- resolve() {
- return undefined;
- },
- async getModelInfo() {
- return undefined;
- },
- async listCatalog() {
- return [];
- },
- };
+ return {
+ resolve() {
+ return undefined;
+ },
+ async getModelInfo() {
+ return undefined;
+ },
+ async listCatalog() {
+ return [];
+ },
+ };
}
function fakeLspService(): LspService {
- return {
- async status() {
- return [];
- },
- };
+ return {
+ async status() {
+ return [];
+ },
+ };
}
function fakeConfig(overrides: Record<string, unknown> = {}): ConfigAccess {
- return {
- get<T>(key: string): T | undefined {
- return overrides[key] as T | undefined;
- },
- getAll() {
- return overrides;
- },
- };
+ return {
+ get<T>(key: string): T | undefined {
+ return overrides[key] as T | undefined;
+ },
+ getAll() {
+ return overrides;
+ },
+ };
}
const SERVICES = new Map<string, unknown>([
- ["conversation-store/store", fakeConversationStore()],
- ["session-orchestrator/orchestrator", fakeOrchestrator()],
- ["credential-store/registry", fakeCredentialStore()],
- ["lsp", fakeLspService()],
+ ["conversation-store/store", fakeConversationStore()],
+ ["session-orchestrator/orchestrator", fakeOrchestrator()],
+ ["credential-store/registry", fakeCredentialStore()],
+ ["lsp", fakeLspService()],
]);
function createFakeHostAPI(configOverrides: Record<string, unknown> = {}): HostAPI {
- return {
- defineTool() {},
- defineProvider() {},
- defineAuth() {},
- on() {
- return () => {};
- },
- emit() {},
- addFilter() {
- return () => {};
- },
- async applyFilters(_hook, value) {
- return value;
- },
- provideService() {},
- getService(handle) {
- return SERVICES.get(handle.id) as never;
- },
- storage() {
- return {
- get: async () => null,
- set: async () => {},
- delete: async () => {},
- has: async () => false,
- keys: async () => [],
- };
- },
- config: fakeConfig(configOverrides),
- secrets: { get: async () => null, set: async () => {}, delete: async () => {} },
- permissions: { check: async () => ({ allowed: true }) },
- events: { emit() {} },
- logger: fakeLogger(),
- getProviders() {
- return new Map();
- },
- getTools() {
- return new Map();
- },
- getAuthProviders() {
- return new Map();
- },
- getAuthProvider() {
- return undefined;
- },
- getExtensions() {
- return [];
- },
- scheduler: { register() {} },
- };
+ return {
+ defineTool() {},
+ defineProvider() {},
+ defineAuth() {},
+ on() {
+ return () => {};
+ },
+ emit() {},
+ addFilter() {
+ return () => {};
+ },
+ async applyFilters(_hook, value) {
+ return value;
+ },
+ provideService() {},
+ getService(handle) {
+ return SERVICES.get(handle.id) as never;
+ },
+ storage() {
+ return {
+ get: async () => null,
+ set: async () => {},
+ delete: async () => {},
+ has: async () => false,
+ keys: async () => [],
+ };
+ },
+ config: fakeConfig(configOverrides),
+ secrets: { get: async () => null, set: async () => {}, delete: async () => {} },
+ permissions: { check: async () => ({ allowed: true }) },
+ events: { emit() {} },
+ logger: fakeLogger(),
+ getProviders() {
+ return new Map();
+ },
+ getTools() {
+ return new Map();
+ },
+ getAuthProviders() {
+ return new Map();
+ },
+ getAuthProvider() {
+ return undefined;
+ },
+ getExtensions() {
+ return [];
+ },
+ scheduler: { register() {} },
+ };
}
// ── Server helper (mirrors extension.ts logic for direct testing) ────────
function startServer(port = 0) {
- const app = createApp({
- conversationStore: fakeConversationStore(),
- orchestrator: fakeOrchestrator(),
- credentialStore: fakeCredentialStore(),
- });
- return Bun.serve({ port, fetch: app.fetch });
+ const app = createApp({
+ conversationStore: fakeConversationStore(),
+ orchestrator: fakeOrchestrator(),
+ credentialStore: fakeCredentialStore(),
+ });
+ return Bun.serve({ port, fetch: app.fetch });
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("serves HTTP on the configured port", () => {
- let server: ReturnType<typeof Bun.serve>;
- let port: number;
+ let server: ReturnType<typeof Bun.serve>;
+ let port: number;
- afterEach(() => {
- server.stop();
- });
+ afterEach(() => {
+ server.stop();
+ });
- test("GET /health returns 200", async () => {
- server = startServer();
- port = server.port as number;
+ test("GET /health returns 200", async () => {
+ server = startServer();
+ port = server.port as number;
- const res = await fetch(`http://localhost:${port}/health`);
- expect(res.status).toBe(200);
- const body = (await res.json()) as { ok: boolean };
- expect(body.ok).toBe(true);
- });
+ const res = await fetch(`http://localhost:${port}/health`);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { ok: boolean };
+ expect(body.ok).toBe(true);
+ });
- test("POST /chat returns NDJSON", async () => {
- server = startServer();
- port = server.port as number;
+ test("POST /chat returns NDJSON", async () => {
+ server = startServer();
+ port = server.port as number;
- const res = await fetch(`http://localhost:${port}/chat`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
- });
- expect(res.status).toBe(200);
- expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
- });
+ const res = await fetch(`http://localhost:${port}/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/x-ndjson");
+ });
- test("GET /models returns 200", async () => {
- server = startServer();
- port = server.port as number;
+ test("GET /models returns 200", async () => {
+ server = startServer();
+ port = server.port as number;
- const res = await fetch(`http://localhost:${port}/models`);
- expect(res.status).toBe(200);
- const body = (await res.json()) as { models: readonly string[] };
- expect(body.models).toEqual([]);
- });
+ const res = await fetch(`http://localhost:${port}/models`);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { models: readonly string[] };
+ expect(body.models).toEqual([]);
+ });
- test("GET /conversations/:id returns 200", async () => {
- server = startServer();
- port = server.port as number;
+ test("GET /conversations/:id returns 200", async () => {
+ server = startServer();
+ port = server.port as number;
- const res = await fetch(`http://localhost:${port}/conversations/conv1`);
- expect(res.status).toBe(200);
- const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number };
- expect(body.chunks).toEqual([]);
- expect(body.latestSeq).toBe(0);
- });
+ const res = await fetch(`http://localhost:${port}/conversations/conv1`);
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { chunks: readonly unknown[]; latestSeq: number };
+ expect(body.chunks).toEqual([]);
+ expect(body.latestSeq).toBe(0);
+ });
});
describe("extension lifecycle", () => {
- test("activate starts server on config port and deactivate stops it", async () => {
- const ext = createTransportHttpExtension();
- const host = createFakeHostAPI({ httpPort: 0 });
- await ext.activate(host);
- const server = ext._testServer;
- expect(server).toBeDefined();
- const port = server?.port as number;
- expect(port).toBeGreaterThan(0);
+ test("activate starts server on config port and deactivate stops it", async () => {
+ const ext = createTransportHttpExtension();
+ const host = createFakeHostAPI({ httpPort: 0 });
+ await ext.activate(host);
+ const server = ext._testServer;
+ expect(server).toBeDefined();
+ const port = server?.port as number;
+ expect(port).toBeGreaterThan(0);
- const res = await fetch(`http://localhost:${port}/health`);
- expect(res.status).toBe(200);
+ const res = await fetch(`http://localhost:${port}/health`);
+ expect(res.status).toBe(200);
- ext.deactivate?.();
+ ext.deactivate?.();
- try {
- await fetch(`http://localhost:${port}/health`);
- expect(true).toBe(false);
- } catch {
- // expected — server stopped
- }
- });
+ try {
+ await fetch(`http://localhost:${port}/health`);
+ expect(true).toBe(false);
+ } catch {
+ // expected — server stopped
+ }
+ });
});
diff --git a/packages/transport-http/tsconfig.json b/packages/transport-http/tsconfig.json
index 8dd2439..7759bce 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -1,15 +1,18 @@
{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
- "include": ["src/**/*.ts"],
- "references": [
- { "path": "../conversation-store" },
- { "path": "../credential-store" },
- { "path": "../kernel" },
- { "path": "../lsp" },
- { "path": "../session-orchestrator" },
- { "path": "../system-prompt" },
- { "path": "../throughput-store" },
- { "path": "../transport-contract" }
- ]
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../conversation-store" },
+ { "path": "../credential-store" },
+ { "path": "../heartbeat" },
+ { "path": "../kernel" },
+ { "path": "../lsp" },
+ { "path": "../mcp" },
+ { "path": "../provider-concurrency" },
+ { "path": "../session-orchestrator" },
+ { "path": "../system-prompt" },
+ { "path": "../throughput-store" },
+ { "path": "../transport-contract" }
+ ]
}