summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:13:10 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:13:10 +0900
commitad9d135e583c99a0d93327115defa43187cde1c3 (patch)
treedb704495d3a3d3e24fdadf422976367f2313486d
parent98b0638838a8e754927d8c030ce8bded18d63e7d (diff)
downloaddispatch-ad9d135e583c99a0d93327115defa43187cde1c3.tar.gz
dispatch-ad9d135e583c99a0d93327115defa43187cde1c3.zip
style: reformat heartbeat merge to 2-space indentation
-rw-r--r--packages/heartbeat/package.json26
-rw-r--r--packages/heartbeat/src/config-store.test.ts188
-rw-r--r--packages/heartbeat/src/config-store.ts130
-rw-r--r--packages/heartbeat/src/extension.ts142
-rw-r--r--packages/heartbeat/src/heartbeat.test.ts1232
-rw-r--r--packages/heartbeat/src/heartbeat.ts548
-rw-r--r--packages/heartbeat/src/index.ts20
-rw-r--r--packages/heartbeat/src/run-store.test.ts108
-rw-r--r--packages/heartbeat/src/run-store.ts142
-rw-r--r--packages/heartbeat/src/scheduler.test.ts588
-rw-r--r--packages/heartbeat/src/scheduler.ts304
-rw-r--r--packages/heartbeat/tsconfig.json20
-rw-r--r--packages/host-bin/package.json72
-rw-r--r--packages/host-bin/src/main.ts350
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts2136
-rw-r--r--packages/system-prompt/src/service.test.ts614
-rw-r--r--packages/system-prompt/src/service.ts188
-rw-r--r--packages/system-prompt/src/types.ts116
-rw-r--r--packages/transport-contract/package.json20
-rw-r--r--packages/transport-contract/src/index.ts48
-rw-r--r--packages/transport-http/package.json40
-rw-r--r--packages/transport-http/src/app.test.ts8322
-rw-r--r--packages/transport-http/src/app.ts3204
-rw-r--r--packages/transport-http/src/extension.ts260
-rw-r--r--packages/transport-http/tsconfig.json28
-rw-r--r--tsconfig.json240
26 files changed, 9543 insertions, 9543 deletions
diff --git a/packages/heartbeat/package.json b/packages/heartbeat/package.json
index dc3d9a2..d83e631 100644
--- a/packages/heartbeat/package.json
+++ b/packages/heartbeat/package.json
@@ -1,15 +1,15 @@
{
- "name": "@dispatch/heartbeat",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "dependencies": {
- "@dispatch/conversation-store": "workspace:*",
- "@dispatch/kernel": "workspace:*",
- "@dispatch/session-orchestrator": "workspace:*",
- "@dispatch/system-prompt": "workspace:*",
- "@dispatch/transport-contract": "workspace:*"
- }
+ "name": "@dispatch/heartbeat",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*"
+ }
}
diff --git a/packages/heartbeat/src/config-store.test.ts b/packages/heartbeat/src/config-store.test.ts
index 17d3ee9..e77d10a 100644
--- a/packages/heartbeat/src/config-store.test.ts
+++ b/packages/heartbeat/src/config-store.test.ts
@@ -1,115 +1,115 @@
import type { StorageNamespace } from "@dispatch/kernel";
import { describe, expect, it } from "vitest";
import {
- applyConfigUpdate,
- createHeartbeatConfigStore,
- DEFAULT_HEARTBEAT_CONFIG,
+ applyConfigUpdate,
+ createHeartbeatConfigStore,
+ DEFAULT_HEARTBEAT_CONFIG,
} from "./config-store.js";
function createMemoryStorage(): StorageNamespace {
- const data = new Map<string, string>();
- return {
- get: async (key) => data.get(key) ?? null,
- set: async (key, value) => {
- data.set(key, value);
- },
- delete: async (key) => {
- data.delete(key);
- },
- has: async (key) => data.has(key),
- keys: async (prefix) => {
- const all = [...data.keys()];
- if (prefix === undefined) return all;
- return all.filter((k) => k.startsWith(prefix));
- },
- };
+ const data = new Map<string, string>();
+ return {
+ get: async (key) => data.get(key) ?? null,
+ set: async (key, value) => {
+ data.set(key, value);
+ },
+ delete: async (key) => {
+ data.delete(key);
+ },
+ has: async (key) => data.has(key),
+ keys: async (prefix) => {
+ const all = [...data.keys()];
+ if (prefix === undefined) return all;
+ return all.filter((k) => k.startsWith(prefix));
+ },
+ };
}
describe("applyConfigUpdate (pure)", () => {
- it("leaves omitted fields unchanged from the default", () => {
- const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {});
- expect(next).toEqual(DEFAULT_HEARTBEAT_CONFIG);
- });
+ it("leaves omitted fields unchanged from the default", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {});
+ expect(next).toEqual(DEFAULT_HEARTBEAT_CONFIG);
+ });
- it("applies provided fields", () => {
- const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {
- enabled: true,
- systemPrompt: "you are a monitor",
- taskPrompt: "check for stuck chats",
- model: "opencode/gpt-4o",
- });
- expect(next.enabled).toBe(true);
- expect(next.systemPrompt).toBe("you are a monitor");
- expect(next.taskPrompt).toBe("check for stuck chats");
- expect(next.model).toBe("opencode/gpt-4o");
- // Untouched fields keep defaults.
- expect(next.intervalMinutes).toBe(30);
- expect(next.reasoningEffort).toBeNull();
- });
+ it("applies provided fields", () => {
+ const next = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, {
+ enabled: true,
+ systemPrompt: "you are a monitor",
+ taskPrompt: "check for stuck chats",
+ model: "opencode/gpt-4o",
+ });
+ expect(next.enabled).toBe(true);
+ expect(next.systemPrompt).toBe("you are a monitor");
+ expect(next.taskPrompt).toBe("check for stuck chats");
+ expect(next.model).toBe("opencode/gpt-4o");
+ // Untouched fields keep defaults.
+ expect(next.intervalMinutes).toBe(30);
+ expect(next.reasoningEffort).toBeNull();
+ });
- it("clamps intervalMinutes to a minimum of 1", () => {
- expect(
- applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes,
- ).toBe(1);
- expect(
- applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes,
- ).toBe(1);
- expect(
- applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes,
- ).toBe(7);
- });
+ it("clamps intervalMinutes to a minimum of 1", () => {
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 0 }).intervalMinutes,
+ ).toBe(1);
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: -5 }).intervalMinutes,
+ ).toBe(1);
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 7 }).intervalMinutes,
+ ).toBe(7);
+ });
- it("truncates a non-integer interval to an integer", () => {
- expect(
- applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 12.9 }).intervalMinutes,
- ).toBe(12);
- });
+ it("truncates a non-integer interval to an integer", () => {
+ expect(
+ applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { intervalMinutes: 12.9 }).intervalMinutes,
+ ).toBe(12);
+ });
- it("clears reasoningEffort when null is passed", () => {
- const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "high" });
- expect(withEffort.reasoningEffort).toBe("high");
- const cleared = applyConfigUpdate(withEffort, { reasoningEffort: null });
- expect(cleared.reasoningEffort).toBeNull();
- });
+ it("clears reasoningEffort when null is passed", () => {
+ const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "high" });
+ expect(withEffort.reasoningEffort).toBe("high");
+ const cleared = applyConfigUpdate(withEffort, { reasoningEffort: null });
+ expect(cleared.reasoningEffort).toBeNull();
+ });
- it("treats an absent reasoningEffort as unchanged (distinct from null)", () => {
- const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "low" });
- const untouched = applyConfigUpdate(withEffort, { enabled: true });
- expect(untouched.reasoningEffort).toBe("low");
- });
+ it("treats an absent reasoningEffort as unchanged (distinct from null)", () => {
+ const withEffort = applyConfigUpdate(DEFAULT_HEARTBEAT_CONFIG, { reasoningEffort: "low" });
+ const untouched = applyConfigUpdate(withEffort, { enabled: true });
+ expect(untouched.reasoningEffort).toBe("low");
+ });
});
describe("createHeartbeatConfigStore", () => {
- it("returns the default config for an unknown workspace", async () => {
- const store = createHeartbeatConfigStore(createMemoryStorage());
- expect(await store.get("ws-1")).toEqual(DEFAULT_HEARTBEAT_CONFIG);
- });
+ it("returns the default config for an unknown workspace", async () => {
+ const store = createHeartbeatConfigStore(createMemoryStorage());
+ expect(await store.get("ws-1")).toEqual(DEFAULT_HEARTBEAT_CONFIG);
+ });
- it("persists and round-trips an update", async () => {
- const storage = createMemoryStorage();
- const store = createHeartbeatConfigStore(storage);
- const next = await store.update("ws-1", { enabled: true, intervalMinutes: 5 });
- expect(next.enabled).toBe(true);
- expect(next.intervalMinutes).toBe(5);
- // A fresh store over the same storage reads the persisted value.
- const store2 = createHeartbeatConfigStore(storage);
- expect(await store2.get("ws-1")).toEqual(next);
- });
+ it("persists and round-trips an update", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ const next = await store.update("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(next.enabled).toBe(true);
+ expect(next.intervalMinutes).toBe(5);
+ // A fresh store over the same storage reads the persisted value.
+ const store2 = createHeartbeatConfigStore(storage);
+ expect(await store2.get("ws-1")).toEqual(next);
+ });
- it("applies a partial update on top of existing config", async () => {
- const storage = createMemoryStorage();
- const store = createHeartbeatConfigStore(storage);
- await store.update("ws-1", { enabled: true, systemPrompt: "a", taskPrompt: "b" });
- const next = await store.update("ws-1", { taskPrompt: "c" });
- expect(next.enabled).toBe(true);
- expect(next.systemPrompt).toBe("a");
- expect(next.taskPrompt).toBe("c");
- });
+ it("applies a partial update on top of existing config", async () => {
+ const storage = createMemoryStorage();
+ const store = createHeartbeatConfigStore(storage);
+ await store.update("ws-1", { enabled: true, systemPrompt: "a", taskPrompt: "b" });
+ const next = await store.update("ws-1", { taskPrompt: "c" });
+ expect(next.enabled).toBe(true);
+ expect(next.systemPrompt).toBe("a");
+ expect(next.taskPrompt).toBe("c");
+ });
- it("lists persisted workspace ids", async () => {
- const store = createHeartbeatConfigStore(createMemoryStorage());
- await store.update("ws-b", { enabled: true });
- await store.update("ws-a", { enabled: false });
- expect(await store.listWorkspaceIds()).toEqual(["ws-a", "ws-b"]);
- });
+ it("lists persisted workspace ids", async () => {
+ const store = createHeartbeatConfigStore(createMemoryStorage());
+ await store.update("ws-b", { enabled: true });
+ await store.update("ws-a", { enabled: false });
+ expect(await store.listWorkspaceIds()).toEqual(["ws-a", "ws-b"]);
+ });
});
diff --git a/packages/heartbeat/src/config-store.ts b/packages/heartbeat/src/config-store.ts
index 5791601..f06c9e8 100644
--- a/packages/heartbeat/src/config-store.ts
+++ b/packages/heartbeat/src/config-store.ts
@@ -6,12 +6,12 @@ import type { HeartbeatConfig, UpdateHeartbeatRequest } from "@dispatch/transpor
* heartbeat. A heartbeat is OFF until explicitly enabled.
*/
export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = {
- enabled: false,
- systemPrompt: "",
- taskPrompt: "",
- intervalMinutes: 30,
- model: "",
- reasoningEffort: null,
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
};
/** Minimum scheduling interval, in minutes (a heartbeat can't fire faster). */
@@ -19,7 +19,7 @@ export const MIN_INTERVAL_MINUTES = 1;
/** Storage key for a workspace's heartbeat config. */
function configKey(workspaceId: string): string {
- return `config:${workspaceId}`;
+ return `config:${workspaceId}`;
}
/**
@@ -33,71 +33,71 @@ function configKey(workspaceId: string): string {
* already validated, but only ever produces a valid `HeartbeatConfig`.
*/
export function applyConfigUpdate(
- current: HeartbeatConfig,
- update: UpdateHeartbeatRequest,
+ current: HeartbeatConfig,
+ update: UpdateHeartbeatRequest,
): HeartbeatConfig {
- const next: HeartbeatConfig = {
- enabled: update.enabled !== undefined ? update.enabled : current.enabled,
- systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt,
- taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt,
- intervalMinutes:
- update.intervalMinutes !== undefined
- ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes))
- : current.intervalMinutes,
- model: update.model !== undefined ? update.model : current.model,
- reasoningEffort:
- update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort,
- };
- return next;
+ const next: HeartbeatConfig = {
+ enabled: update.enabled !== undefined ? update.enabled : current.enabled,
+ systemPrompt: update.systemPrompt !== undefined ? update.systemPrompt : current.systemPrompt,
+ taskPrompt: update.taskPrompt !== undefined ? update.taskPrompt : current.taskPrompt,
+ intervalMinutes:
+ update.intervalMinutes !== undefined
+ ? Math.max(MIN_INTERVAL_MINUTES, Math.trunc(update.intervalMinutes))
+ : current.intervalMinutes,
+ model: update.model !== undefined ? update.model : current.model,
+ reasoningEffort:
+ update.reasoningEffort !== undefined ? update.reasoningEffort : current.reasoningEffort,
+ };
+ return next;
}
export interface HeartbeatConfigStore {
- /** The config for a workspace (the default when never set). */
- readonly get: (workspaceId: string) => Promise<HeartbeatConfig>;
- /** Apply a partial update and persist it; returns the new config. */
- readonly update: (
- workspaceId: string,
- update: UpdateHeartbeatRequest,
- ) => Promise<HeartbeatConfig>;
- /** Every workspace id that has a persisted (non-default) config. */
- readonly listWorkspaceIds: () => Promise<readonly string[]>;
+ /** The config for a workspace (the default when never set). */
+ readonly get: (workspaceId: string) => Promise<HeartbeatConfig>;
+ /** Apply a partial update and persist it; returns the new config. */
+ readonly update: (
+ workspaceId: string,
+ update: UpdateHeartbeatRequest,
+ ) => Promise<HeartbeatConfig>;
+ /** Every workspace id that has a persisted (non-default) config. */
+ readonly listWorkspaceIds: () => Promise<readonly string[]>;
}
export function createHeartbeatConfigStore(storage: StorageNamespace): HeartbeatConfigStore {
- return {
- async get(workspaceId: string): Promise<HeartbeatConfig> {
- const raw = await storage.get(configKey(workspaceId));
- if (raw === null) return DEFAULT_HEARTBEAT_CONFIG;
- try {
- const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>;
- return {
- enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
- systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "",
- taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "",
- intervalMinutes:
- typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0
- ? Math.trunc(parsed.intervalMinutes)
- : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes,
- model: typeof parsed.model === "string" ? parsed.model : "",
- reasoningEffort: parsed.reasoningEffort ?? null,
- };
- } catch {
- return DEFAULT_HEARTBEAT_CONFIG;
- }
- },
+ return {
+ async get(workspaceId: string): Promise<HeartbeatConfig> {
+ const raw = await storage.get(configKey(workspaceId));
+ if (raw === null) return DEFAULT_HEARTBEAT_CONFIG;
+ try {
+ const parsed = JSON.parse(raw) as Partial<HeartbeatConfig>;
+ return {
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : false,
+ systemPrompt: typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : "",
+ taskPrompt: typeof parsed.taskPrompt === "string" ? parsed.taskPrompt : "",
+ intervalMinutes:
+ typeof parsed.intervalMinutes === "number" && parsed.intervalMinutes > 0
+ ? Math.trunc(parsed.intervalMinutes)
+ : DEFAULT_HEARTBEAT_CONFIG.intervalMinutes,
+ model: typeof parsed.model === "string" ? parsed.model : "",
+ reasoningEffort: parsed.reasoningEffort ?? null,
+ };
+ } catch {
+ return DEFAULT_HEARTBEAT_CONFIG;
+ }
+ },
- async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> {
- const current = await this.get(workspaceId);
- const next = applyConfigUpdate(current, update);
- await storage.set(configKey(workspaceId), JSON.stringify(next));
- return next;
- },
+ async update(workspaceId: string, update: UpdateHeartbeatRequest): Promise<HeartbeatConfig> {
+ const current = await this.get(workspaceId);
+ const next = applyConfigUpdate(current, update);
+ await storage.set(configKey(workspaceId), JSON.stringify(next));
+ return next;
+ },
- async listWorkspaceIds(): Promise<readonly string[]> {
- const keys = await storage.keys("config:");
- const ids = keys.map((k) => k.slice("config:".length));
- // De-duplicate + sort for a stable boot-scan order.
- return [...new Set(ids)].sort();
- },
- };
+ async listWorkspaceIds(): Promise<readonly string[]> {
+ const keys = await storage.keys("config:");
+ const ids = keys.map((k) => k.slice("config:".length));
+ // De-duplicate + sort for a stable boot-scan order.
+ return [...new Set(ids)].sort();
+ },
+ };
}
diff --git a/packages/heartbeat/src/extension.ts b/packages/heartbeat/src/extension.ts
index 81e675a..f245b7e 100644
--- a/packages/heartbeat/src/extension.ts
+++ b/packages/heartbeat/src/extension.ts
@@ -16,93 +16,93 @@ import type { ConversationStore } from "@dispatch/conversation-store";
import { conversationStoreHandle } from "@dispatch/conversation-store";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import {
- type SessionOrchestrator,
- sessionOrchestratorHandle,
+ type SessionOrchestrator,
+ sessionOrchestratorHandle,
} from "@dispatch/session-orchestrator";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { systemPromptHandle } from "@dispatch/system-prompt";
import { createHeartbeatService, heartbeatServiceHandle } from "./heartbeat.js";
export const manifest: Manifest = {
- id: "heartbeat",
- name: "Heartbeat",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- // system-prompt provides `resolveText` (the variable resolver used to
- // substitute [type:name] placeholders in heartbeat prompts); conversation-
- // store resolves the workspace's default cwd (the resolver runs git / reads
- // files against it, mirroring the global template). Both lookups are lazy
- // (at fire time, not activation), but declaring them keeps the DAG honest.
- dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"],
- activation: "eager",
- contributes: { services: ["heartbeat"] },
+ id: "heartbeat",
+ name: "Heartbeat",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ // system-prompt provides `resolveText` (the variable resolver used to
+ // substitute [type:name] placeholders in heartbeat prompts); conversation-
+ // store resolves the workspace's default cwd (the resolver runs git / reads
+ // files against it, mirroring the global template). Both lookups are lazy
+ // (at fire time, not activation), but declaring them keeps the DAG honest.
+ dependsOn: ["session-orchestrator", "system-prompt", "conversation-store"],
+ activation: "eager",
+ contributes: { services: ["heartbeat"] },
};
// Module-scoped store for deactivate (the extension object is created once).
const store: { service: { stopAll: () => void } | null } = { service: null };
export const extension: Extension = {
- manifest,
- async activate(host: HostAPI) {
- const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle);
- const storage = host.storage("heartbeat");
- const logger = host.logger;
+ manifest,
+ async activate(host: HostAPI) {
+ const orchestrator = host.getService<SessionOrchestrator>(sessionOrchestratorHandle);
+ const storage = host.storage("heartbeat");
+ const logger = host.logger;
- // Resolve [type:name] placeholders in heartbeat prompts via the
- // system-prompt service (same resolver + variables as the global
- // template). The cwd is the workspace's defaultCwd (resolved the same
- // way the orchestrator resolves a new conversation's effective cwd);
- // falling back to process.cwd() when the workspace has none. Both
- // services are declared `dependsOn` (always activated before heartbeat).
- const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle);
- const conversationStore = host.getService<ConversationStore>(conversationStoreHandle);
+ // Resolve [type:name] placeholders in heartbeat prompts via the
+ // system-prompt service (same resolver + variables as the global
+ // template). The cwd is the workspace's defaultCwd (resolved the same
+ // way the orchestrator resolves a new conversation's effective cwd);
+ // falling back to process.cwd() when the workspace has none. Both
+ // services are declared `dependsOn` (always activated before heartbeat).
+ const systemPromptService = host.getService<SystemPromptService>(systemPromptHandle);
+ const conversationStore = host.getService<ConversationStore>(conversationStoreHandle);
- const resolvePrompt = async (
- template: string,
- ctx: {
- readonly workspaceId: string;
- readonly conversationId: string;
- readonly model: string;
- },
- ): Promise<string> => {
- const workspace = await conversationStore.getWorkspace(ctx.workspaceId);
- const cwd = workspace?.defaultCwd ?? process.cwd();
- return systemPromptService.resolveText(template, cwd, {
- ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}),
- ...(ctx.model !== "" ? { model: ctx.model } : {}),
- workspaceId: ctx.workspaceId,
- });
- };
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ const workspace = await conversationStore.getWorkspace(ctx.workspaceId);
+ const cwd = workspace?.defaultCwd ?? process.cwd();
+ return systemPromptService.resolveText(template, cwd, {
+ ...(ctx.conversationId !== "" ? { conversationId: ctx.conversationId } : {}),
+ ...(ctx.model !== "" ? { model: ctx.model } : {}),
+ workspaceId: ctx.workspaceId,
+ });
+ };
- const service = createHeartbeatService({
- storage,
- orchestrator,
- logger,
- resolvePrompt,
- // CR-HB-2: an empty heartbeat systemPrompt inherits the global
- // system prompt template (GET /system-prompt / regular
- // conversations resolve) before variable resolution runs.
- getGlobalSystemPrompt: () => systemPromptService.getTemplate(),
- // Pin the heartbeat turn's cwd to the CONFIGURED workspace's
- // defaultCwd (not the heartbeat workspace's empty defaultCwd) so
- // the turn's tools run where the prompt's [prompt:cwd] advertises.
- // Lazy (resolved at fire time, mirroring resolvePrompt).
- getWorkspaceCwd: async (wsId) =>
- (await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null,
- });
+ const service = createHeartbeatService({
+ storage,
+ orchestrator,
+ logger,
+ resolvePrompt,
+ // CR-HB-2: an empty heartbeat systemPrompt inherits the global
+ // system prompt template (GET /system-prompt / regular
+ // conversations resolve) before variable resolution runs.
+ getGlobalSystemPrompt: () => systemPromptService.getTemplate(),
+ // Pin the heartbeat turn's cwd to the CONFIGURED workspace's
+ // defaultCwd (not the heartbeat workspace's empty defaultCwd) so
+ // the turn's tools run where the prompt's [prompt:cwd] advertises.
+ // Lazy (resolved at fire time, mirroring resolvePrompt).
+ getWorkspaceCwd: async (wsId) =>
+ (await conversationStore.getWorkspace(wsId))?.defaultCwd ?? null,
+ });
- // Reconcile stale runs + arm enabled workspaces on boot.
- await service.startAll();
- store.service = service;
+ // Reconcile stale runs + arm enabled workspaces on boot.
+ await service.startAll();
+ store.service = service;
- host.provideService(heartbeatServiceHandle, service);
+ host.provideService(heartbeatServiceHandle, service);
- host.logger.info("heartbeat extension activated");
- },
- deactivate() {
- // Stop schedulers so no new fires happen during shutdown.
- store.service?.stopAll();
- store.service = null;
- },
+ host.logger.info("heartbeat extension activated");
+ },
+ deactivate() {
+ // Stop schedulers so no new fires happen during shutdown.
+ store.service?.stopAll();
+ store.service = null;
+ },
};
diff --git a/packages/heartbeat/src/heartbeat.test.ts b/packages/heartbeat/src/heartbeat.test.ts
index 7e82611..ff1ec10 100644
--- a/packages/heartbeat/src/heartbeat.test.ts
+++ b/packages/heartbeat/src/heartbeat.test.ts
@@ -1,72 +1,72 @@
import type { StorageNamespace } from "@dispatch/kernel";
import type {
- EnqueueInput,
- EnqueueResult,
- SessionOrchestrator,
- StartTurnResult,
- TurnEventListener,
+ EnqueueInput,
+ EnqueueResult,
+ SessionOrchestrator,
+ StartTurnResult,
+ TurnEventListener,
} from "@dispatch/session-orchestrator";
import { describe, expect, it } from "vitest";
import { createHeartbeatService } from "./heartbeat.js";
function createMemoryStorage(): StorageNamespace {
- const data = new Map<string, string>();
- return {
- get: async (key) => data.get(key) ?? null,
- set: async (key, value) => {
- data.set(key, value);
- },
- delete: async (key) => {
- data.delete(key);
- },
- has: async (key) => data.has(key),
- keys: async (prefix) => {
- const all = [...data.keys()];
- if (prefix === undefined) return all;
- return all.filter((k) => k.startsWith(prefix));
- },
- };
+ const data = new Map<string, string>();
+ return {
+ get: async (key) => data.get(key) ?? null,
+ set: async (key, value) => {
+ data.set(key, value);
+ },
+ delete: async (key) => {
+ data.delete(key);
+ },
+ has: async (key) => data.has(key),
+ keys: async (prefix) => {
+ const all = [...data.keys()];
+ if (prefix === undefined) return all;
+ return all.filter((k) => k.startsWith(prefix));
+ },
+ };
}
/** A controllable fake clock with an `advance` to move virtual time. */
function createFakeTimers() {
- let now = 0;
- let nextId = 1;
- const timers = new Map<number, { readonly fn: () => void; readonly firesAt: number }>();
- return {
- timers: {
- now: () => now,
- setTimeout: (fn: () => void, ms: number) => {
- const id = nextId++;
- timers.set(id, { fn, firesAt: now + ms });
- return id as unknown as ReturnType<typeof setTimeout>;
- },
- clearTimeout: (handle: ReturnType<typeof setTimeout> | undefined) => {
- if (handle !== undefined) timers.delete(handle as unknown as number);
- },
- },
- advance(ms: number): void {
- now += ms;
- const due = [...timers.entries()]
- .filter(([, t]) => t.firesAt <= now)
- .sort((a, b) => a[0] - b[0]);
- for (const [id, t] of due) {
- timers.delete(id);
- t.fn();
- }
- },
- };
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map<number, { readonly fn: () => void; readonly firesAt: number }>();
+ return {
+ timers: {
+ now: () => now,
+ setTimeout: (fn: () => void, ms: number) => {
+ const id = nextId++;
+ timers.set(id, { fn, firesAt: now + ms });
+ return id as unknown as ReturnType<typeof setTimeout>;
+ },
+ clearTimeout: (handle: ReturnType<typeof setTimeout> | undefined) => {
+ if (handle !== undefined) timers.delete(handle as unknown as number);
+ },
+ },
+ advance(ms: number): void {
+ now += ms;
+ const due = [...timers.entries()]
+ .filter(([, t]) => t.firesAt <= now)
+ .sort((a, b) => a[0] - b[0]);
+ for (const [id, t] of due) {
+ timers.delete(id);
+ t.fn();
+ }
+ },
+ };
}
interface PendingTurn {
- readonly conversationId: string;
- readonly text: string;
- readonly systemPrompt?: string;
- readonly modelName?: string;
- readonly reasoningEffort?: unknown;
- readonly workspaceId?: string;
- readonly cwd?: string;
- resolve: () => void;
+ readonly conversationId: string;
+ readonly text: string;
+ readonly systemPrompt?: string;
+ readonly modelName?: string;
+ readonly reasoningEffort?: unknown;
+ readonly workspaceId?: string;
+ readonly cwd?: string;
+ resolve: () => void;
}
/**
@@ -75,65 +75,65 @@ interface PendingTurn {
* on the SessionOrchestrator interface, not its implementation.
*/
function createFakeOrchestrator(): SessionOrchestrator & {
- readonly pending: readonly PendingTurn[];
- readonly stopped: readonly string[];
+ readonly pending: readonly PendingTurn[];
+ readonly stopped: readonly string[];
} {
- const pending: PendingTurn[] = [];
- const stopped: string[] = [];
- const turns = new Map<string, { resolve: () => void; reject: (e: unknown) => void }>();
-
- const fake: SessionOrchestrator = {
- startTurn(): StartTurnResult {
- return { started: false, reason: "already-active" };
- },
- enqueue(_input: EnqueueInput): EnqueueResult {
- return { startedTurn: false, queue: [] };
- },
- subscribe(_conversationId: string, _listener: TurnEventListener): () => void {
- return () => {};
- },
- isActive(_conversationId: string): boolean {
- return false;
- },
- closeConversation(_conversationId: string): { abortedTurn: boolean } {
- return { abortedTurn: false };
- },
- stopTurn(conversationId: string): { abortedTurn: boolean } {
- stopped.push(conversationId);
- const t = turns.get(conversationId);
- if (t !== undefined) {
- turns.delete(conversationId);
- t.resolve();
- }
- return { abortedTurn: true };
- },
- handleMessage(input): Promise<void> {
- return new Promise<void>((resolve, reject) => {
- const entry: PendingTurn = {
- conversationId: input.conversationId,
- text: input.text,
- ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}),
- ...(input.modelName !== undefined ? { modelName: input.modelName } : {}),
- ...(input.reasoningEffort !== undefined
- ? { reasoningEffort: input.reasoningEffort }
- : {}),
- ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
- ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
- resolve,
- };
- pending.push(entry);
- turns.set(input.conversationId, { resolve, reject });
- });
- },
- };
- return Object.assign(fake, {
- get pending(): readonly PendingTurn[] {
- return pending;
- },
- get stopped(): readonly string[] {
- return stopped;
- },
- });
+ const pending: PendingTurn[] = [];
+ const stopped: string[] = [];
+ const turns = new Map<string, { resolve: () => void; reject: (e: unknown) => void }>();
+
+ const fake: SessionOrchestrator = {
+ startTurn(): StartTurnResult {
+ return { started: false, reason: "already-active" };
+ },
+ enqueue(_input: EnqueueInput): EnqueueResult {
+ return { startedTurn: false, queue: [] };
+ },
+ subscribe(_conversationId: string, _listener: TurnEventListener): () => void {
+ return () => {};
+ },
+ isActive(_conversationId: string): boolean {
+ return false;
+ },
+ closeConversation(_conversationId: string): { abortedTurn: boolean } {
+ return { abortedTurn: false };
+ },
+ stopTurn(conversationId: string): { abortedTurn: boolean } {
+ stopped.push(conversationId);
+ const t = turns.get(conversationId);
+ if (t !== undefined) {
+ turns.delete(conversationId);
+ t.resolve();
+ }
+ return { abortedTurn: true };
+ },
+ handleMessage(input): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ const entry: PendingTurn = {
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}),
+ ...(input.modelName !== undefined ? { modelName: input.modelName } : {}),
+ ...(input.reasoningEffort !== undefined
+ ? { reasoningEffort: input.reasoningEffort }
+ : {}),
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
+ resolve,
+ };
+ pending.push(entry);
+ turns.set(input.conversationId, { resolve, reject });
+ });
+ },
+ };
+ return Object.assign(fake, {
+ get pending(): readonly PendingTurn[] {
+ return pending;
+ },
+ get stopped(): readonly string[] {
+ return stopped;
+ },
+ });
}
// Drain microtasks so async .finally handlers (run completion) run. `fire` has
@@ -141,514 +141,514 @@ function createFakeOrchestrator(): SessionOrchestrator & {
// before it reaches handleMessage, so a single queueMicrotask isn't enough —
// setTimeout(0) schedules a macrotask, letting ALL pending microtasks drain.
const flush = async (): Promise<void> => {
- await new Promise((r) => setTimeout(r, 0));
+ await new Promise((r) => setTimeout(r, 0));
};
function createService(opts: {
- readonly orch: ReturnType<typeof createFakeOrchestrator>;
- readonly storage?: StorageNamespace;
- readonly resolvePrompt?: (
- template: string,
- ctx: {
- readonly workspaceId: string;
- readonly conversationId: string;
- readonly model: string;
- },
- ) => Promise<string>;
- readonly getGlobalSystemPrompt?: () => Promise<string>;
- readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ readonly orch: ReturnType<typeof createFakeOrchestrator>;
+ readonly storage?: StorageNamespace;
+ readonly resolvePrompt?: (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ) => Promise<string>;
+ readonly getGlobalSystemPrompt?: () => Promise<string>;
+ readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
}) {
- const fake = createFakeTimers();
- let id = 0;
- const storage = opts.storage ?? createMemoryStorage();
- const svc = createHeartbeatService({
- storage,
- orchestrator: opts.orch,
- timers: fake.timers,
- generateId: () => `id-${++id}`,
- ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}),
- ...(opts.getGlobalSystemPrompt !== undefined
- ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt }
- : {}),
- ...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}),
- });
- return { svc, advance: fake.advance, storage };
+ const fake = createFakeTimers();
+ let id = 0;
+ const storage = opts.storage ?? createMemoryStorage();
+ const svc = createHeartbeatService({
+ storage,
+ orchestrator: opts.orch,
+ timers: fake.timers,
+ generateId: () => `id-${++id}`,
+ ...(opts.resolvePrompt !== undefined ? { resolvePrompt: opts.resolvePrompt } : {}),
+ ...(opts.getGlobalSystemPrompt !== undefined
+ ? { getGlobalSystemPrompt: opts.getGlobalSystemPrompt }
+ : {}),
+ ...(opts.getWorkspaceCwd !== undefined ? { getWorkspaceCwd: opts.getWorkspaceCwd } : {}),
+ });
+ return { svc, advance: fake.advance, storage };
}
describe("createHeartbeatService", () => {
- it("returns the default config for an unknown workspace", async () => {
- const { svc } = createService({ orch: createFakeOrchestrator() });
- const cfg = await svc.getConfig("ws-1");
- expect(cfg.enabled).toBe(false);
- expect(cfg.intervalMinutes).toBe(30);
- });
-
- it("arming an enabled config does not fire immediately (waits for the interval)", () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- void svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" });
- // Just arming → no turn yet.
- expect(orch.pending).toHaveLength(0);
- // Advancing just shy of the interval still no fire.
- advance(59_999);
- expect(orch.pending).toHaveLength(0);
- });
-
- it("fire sends the task prompt with the explicit system prompt + model + effort, and marks completed on seal", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", {
- enabled: true,
- systemPrompt: "you are a monitor",
- taskPrompt: "check stuck chats",
- model: "opencode/gpt-4o",
- reasoningEffort: "high",
- intervalMinutes: 1,
- });
-
- advance(60_000); // 1-minute interval → fire
- await flush(); // let the async fire() reach handleMessage
- expect(orch.pending).toHaveLength(1);
- const turn = orch.pending[0]!;
- expect(turn.text).toBe("check stuck chats");
- expect(turn.systemPrompt).toBe("you are a monitor");
- expect(turn.modelName).toBe("opencode/gpt-4o");
- expect(turn.reasoningEffort).toBe("high");
- // Spawned conversations go to the DEDICATED heartbeat workspace (not
- // the configured workspace), while the run stays tracked under ws-1.
- expect(turn.workspaceId).toBe("heartbeat");
-
- expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
-
- turn.resolve();
- await flush();
- expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed");
- });
-
- it("heartbeat conversations always go to the dedicated heartbeat workspace, regardless of the configured workspace", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- // Two DIFFERENT workspaces each configure a heartbeat.
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- await svc.updateConfig("ws-2", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
-
- advance(60_000); // both fire (1-minute interval)
- await flush();
- expect(orch.pending).toHaveLength(2);
- // Every spawned conversation is filed in the heartbeat workspace —
- // NOT ws-1 or ws-2 — so heartbeat runs don't clog either workspace's
- // tabs. The run history, however, stays tracked per configured workspace.
- for (const turn of orch.pending) {
- expect(turn.workspaceId).toBe("heartbeat");
- }
- // Run history is still per configured workspace.
- expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
- expect((await svc.listRuns("ws-2"))[0]?.status).toBe("running");
- for (const turn of orch.pending) {
- turn.resolve();
- }
- await flush();
- });
-
- it("the turn cwd is pinned to the CONFIGURED workspace's defaultCwd (not the heartbeat workspace's empty defaultCwd)", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({
- orch,
- // The configured workspace has a defaultCwd.
- getWorkspaceCwd: (wsId) => Promise.resolve(wsId === "ws-1" ? "/home/proj/ws-1" : null),
- });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
-
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- // The conversation is filed in the heartbeat workspace …
- expect(turn.workspaceId).toBe("heartbeat");
- // … but the turn's cwd is the CONFIGURED workspace's defaultCwd, so
- // tools run where the prompt's [prompt:cwd] advertises (not the
- // heartbeat workspace's empty defaultCwd → process.cwd()).
- expect(turn.cwd).toBe("/home/proj/ws-1");
- turn.resolve();
- await flush();
- });
-
- it("omits the cwd override when the configured workspace has no defaultCwd (orchestrator falls back to the server default)", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({
- orch,
- // The configured workspace has NO defaultCwd.
- getWorkspaceCwd: () => Promise.resolve(null),
- });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
-
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- expect(turn.workspaceId).toBe("heartbeat");
- // No cwd override sent — the orchestrator resolves the turn cwd from
- // the heartbeat workspace (no defaultCwd → server default cwd).
- expect(turn.cwd).toBeUndefined();
- turn.resolve();
- await flush();
- });
-
- it("omits the cwd override when getWorkspaceCwd is not wired (resolution is optional)", async () => {
- const orch = createFakeOrchestrator();
- // No getWorkspaceCwd → no cwd override (the default).
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
-
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- expect(turn.workspaceId).toBe("heartbeat");
- expect(turn.cwd).toBeUndefined();
- turn.resolve();
- await flush();
- });
-
- it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", {
- enabled: true,
- taskPrompt: "go",
- model: "",
- reasoningEffort: null,
- intervalMinutes: 1,
- });
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- expect(turn.modelName).toBeUndefined();
- expect(turn.reasoningEffort).toBeUndefined();
- turn.resolve();
- await flush();
- });
-
- it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => {
- const orch = createFakeOrchestrator();
- // A fake resolver that mirrors the real resolver's contract: substitute
- // known [type:name] placeholders, leave unknown text verbatim.
- const resolvePrompt = async (
- template: string,
- ctx: {
- readonly workspaceId: string;
- readonly conversationId: string;
- readonly model: string;
- },
- ): Promise<string> => {
- return template
- .replaceAll("[system:os]", "Linux (WSL)")
- .replaceAll("[prompt:cwd]", "/repo")
- .replaceAll("[prompt:workspace_id]", ctx.workspaceId)
- .replaceAll("[prompt:conversation_id]", ctx.conversationId)
- .replaceAll("[prompt:model]", ctx.model);
- };
- const { svc, advance } = createService({ orch, resolvePrompt });
- await svc.updateConfig("ws-1", {
- enabled: true,
- systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).",
- taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].",
- model: "opencode/gpt-4o",
- intervalMinutes: 1,
- });
-
- advance(60_000);
- await flush();
- expect(orch.pending).toHaveLength(1);
- const turn = orch.pending[0]!;
- // Variables substituted — NOT left as literal [type:name] text.
- expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
- expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`);
- expect(turn.modelName).toBe("opencode/gpt-4o");
- turn.resolve();
- await flush();
- });
-
- it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => {
- const orch = createFakeOrchestrator();
- // No resolvePrompt → raw pass-through (the default).
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", {
- enabled: true,
- systemPrompt: "raw [system:os] prompt",
- taskPrompt: "raw [system:date] task",
- intervalMinutes: 1,
- });
-
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- // Unresolved — literals reach the orchestrator verbatim.
- expect(turn.systemPrompt).toBe("raw [system:os] prompt");
- expect(turn.text).toBe("raw [system:date] task");
- turn.resolve();
- await flush();
- });
-
- it("an empty systemPrompt inherits the global system prompt template (CR-HB-2)", async () => {
- const orch = createFakeOrchestrator();
- // The global getter returns the workspace's regular system prompt
- // template (the same one GET /system-prompt returns). No resolver → the
- // inherited template reaches the orchestrator verbatim (isolates CR-HB-2
- // from CR-HB-1).
- const { svc, advance } = createService({
- orch,
- getGlobalSystemPrompt: () => Promise.resolve("GLOBAL DEFAULT TEMPLATE"),
- });
- await svc.updateConfig("ws-1", {
- enabled: true,
- // Empty systemPrompt = inherit the global default, NOT "no prompt".
- systemPrompt: "",
- taskPrompt: "go",
- intervalMinutes: 1,
- });
-
- advance(60_000);
- await flush();
- expect(orch.pending).toHaveLength(1);
- const turn = orch.pending[0]!;
- expect(turn.systemPrompt).toBe("GLOBAL DEFAULT TEMPLATE");
- turn.resolve();
- await flush();
- });
-
- it("CR-HB-2 composes with CR-HB-1: the inherited global template's [type:name] placeholders are resolved", async () => {
- const orch = createFakeOrchestrator();
- const resolvePrompt = async (
- template: string,
- ctx: {
- readonly workspaceId: string;
- readonly conversationId: string;
- readonly model: string;
- },
- ): Promise<string> => {
- return template
- .replaceAll("[system:os]", "Linux (WSL)")
- .replaceAll("[prompt:cwd]", "/repo")
- .replaceAll("[prompt:workspace_id]", ctx.workspaceId);
- };
- // The global template carries [type:name] placeholders (like the real
- // default template embeds [prompt:cwd] / [file:AGENTS.md]).
- const { svc, advance } = createService({
- orch,
- resolvePrompt,
- getGlobalSystemPrompt: () =>
- Promise.resolve("You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id])."),
- });
- await svc.updateConfig("ws-1", {
- enabled: true,
- // Empty → inherit the global template, THEN resolve its placeholders.
- systemPrompt: "",
- taskPrompt: "go",
- intervalMinutes: 1,
- });
-
- advance(60_000);
- await flush();
- expect(orch.pending).toHaveLength(1);
- const turn = orch.pending[0]!;
- // The global template was inherited (not empty), then its [type:name]
- // placeholders were substituted — NOT left literal, NOT empty.
- expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
- turn.resolve();
- await flush();
- });
-
- it("a non-empty systemPrompt override bypasses the global template (only CR-HB-1 applies)", async () => {
- const orch = createFakeOrchestrator();
- const resolvePrompt = async (template: string): Promise<string> =>
- template.replaceAll("[system:os]", "Linux (WSL)");
- const { svc, advance } = createService({
- orch,
- resolvePrompt,
- // A DISTINCT global template — must NOT be used when overriding.
- getGlobalSystemPrompt: () => Promise.resolve("GLOBAL SHOULD NOT APPEAR [system:os]"),
- });
- await svc.updateConfig("ws-1", {
- enabled: true,
- systemPrompt: "custom override on [system:os]",
- taskPrompt: "go",
- intervalMinutes: 1,
- });
-
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- // The override is used (and resolved), not the global template.
- expect(turn.systemPrompt).toBe("custom override on Linux (WSL)");
- turn.resolve();
- await flush();
- });
-
- it("an empty systemPrompt stays empty when no global getter is wired (resolution is optional)", async () => {
- const orch = createFakeOrchestrator();
- // No getGlobalSystemPrompt → empty stays empty (no system prompt).
- // Mirrors the no-resolver pass-through default: both deps are optional.
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", {
- enabled: true,
- systemPrompt: "",
- taskPrompt: "go",
- intervalMinutes: 1,
- });
-
- advance(60_000);
- await flush();
- const turn = orch.pending[0]!;
- expect(turn.systemPrompt).toBe("");
- turn.resolve();
- await flush();
- });
-
- it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- advance(60_000);
- await flush();
- const conversationId = orch.pending[0]?.conversationId;
- const runId = (await svc.listRuns("ws-1"))[0]?.id;
-
- const res = await svc.stopRun("ws-1", runId);
- expect(res).toEqual({ ok: true });
- expect(orch.stopped).toEqual([conversationId]);
-
- await flush(); // the aborted turn's handleMessage resolves
- expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped");
- });
-
- it("stopRun is idempotent for an already-finished run", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- advance(60_000);
- await flush();
- orch.pending[0]?.resolve();
- await flush();
-
- const runId = (await svc.listRuns("ws-1"))[0]?.id;
- const res = await svc.stopRun("ws-1", runId);
- expect(res).toEqual({ ok: true });
- expect(orch.stopped).toEqual([]); // no abort on a completed run
- });
-
- it("stopRun throws for an unknown run id", async () => {
- const { svc } = createService({ orch: createFakeOrchestrator() });
- await expect(svc.stopRun("ws-1", "nope")).rejects.toThrow();
- });
-
- it("disabling the config stops the schedule (no further fires)", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" });
- await svc.updateConfig("ws-1", { enabled: false });
- advance(60_000);
- expect(orch.pending).toHaveLength(0);
- });
-
- it("startAll sweeps stale running runs to stopped", async () => {
- const storage = createMemoryStorage();
- await storage.set(
- "run:ws-1:stale",
- JSON.stringify({
- id: "stale",
- conversationId: "c-stale",
- triggeredAt: "2026-01-01T00:00:00.000Z",
- status: "running",
- }),
- );
- await storage.set(
- "config:ws-1",
- JSON.stringify({
- enabled: false,
- systemPrompt: "",
- taskPrompt: "",
- intervalMinutes: 30,
- model: "",
- reasoningEffort: null,
- }),
- );
-
- const { svc } = createService({ orch: createFakeOrchestrator(), storage });
- await svc.startAll();
- expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped");
- });
-
- it("startAll arms enabled workspaces and skips disabled ones", async () => {
- const orch = createFakeOrchestrator();
- const { svc: svc1, storage } = createService({ orch });
- await svc1.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- await svc1.updateConfig("ws-2", { enabled: false, taskPrompt: "no" });
- svc1.stopAll();
-
- // Re-create a fresh service over the SAME storage (simulates a reboot),
- // with new fake timers we can advance.
- const fake = createFakeTimers();
- let id = 1000;
- const svc2 = createHeartbeatService({
- storage,
- orchestrator: orch,
- timers: fake.timers,
- generateId: () => `id-${++id}`,
- });
- await svc2.startAll();
- fake.advance(60_000);
- await flush();
- // ws-1 (enabled) fired → its spawned conversation is filed in the
- // heartbeat workspace; ws-2 (disabled) never fired.
- expect(orch.pending).toHaveLength(1);
- expect(orch.pending[0]?.workspaceId).toBe("heartbeat");
- });
-
- // ─── nextRunAt (CR-HB-3: server-authoritative next-run time) ────────────────
-
- it("nextRunAt returns null when the heartbeat is disabled", async () => {
- const { svc } = createService({ orch: createFakeOrchestrator() });
- // Default config → disabled → no schedule armed.
- expect(await svc.nextRunAt("ws-1")).toBeNull();
- });
-
- it("nextRunAt returns the ISO timestamp of the next fire when enabled", async () => {
- const { svc } = createService({ orch: createFakeOrchestrator() });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- // now=0, interval=1m → next fire at epoch-ms 60_000 → its ISO form.
- expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
- });
-
- it("nextRunAt returns null while a run is in progress, then the next fire after it completes", async () => {
- const orch = createFakeOrchestrator();
- const { svc, advance } = createService({ orch });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
-
- advance(60_000); // fire → run in progress
- await flush();
- expect(orch.pending).toHaveLength(1);
- // In flight → no next run queued yet.
- expect(await svc.nextRunAt("ws-1")).toBeNull();
-
- orch.pending[0]?.resolve();
- await flush();
- // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
- expect(await svc.nextRunAt("ws-1")).toBe(new Date(120_000).toISOString());
- });
-
- it("nextRunAt returns null after disabling the heartbeat", async () => {
- const { svc } = createService({ orch: createFakeOrchestrator() });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- expect(await svc.nextRunAt("ws-1")).not.toBeNull();
- await svc.updateConfig("ws-1", { enabled: false });
- expect(await svc.nextRunAt("ws-1")).toBeNull();
- });
-
- it("nextRunAt reflects a changed interval on the next re-arm", async () => {
- const { svc } = createService({ orch: createFakeOrchestrator() });
- await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
- expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
- // Re-arm with a 5-minute interval (not running) → recomputed fire time.
- await svc.updateConfig("ws-1", { intervalMinutes: 5 });
- expect(await svc.nextRunAt("ws-1")).toBe(new Date(5 * 60_000).toISOString());
- });
+ it("returns the default config for an unknown workspace", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ const cfg = await svc.getConfig("ws-1");
+ expect(cfg.enabled).toBe(false);
+ expect(cfg.intervalMinutes).toBe(30);
+ });
+
+ it("arming an enabled config does not fire immediately (waits for the interval)", () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ void svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" });
+ // Just arming → no turn yet.
+ expect(orch.pending).toHaveLength(0);
+ // Advancing just shy of the interval still no fire.
+ advance(59_999);
+ expect(orch.pending).toHaveLength(0);
+ });
+
+ it("fire sends the task prompt with the explicit system prompt + model + effort, and marks completed on seal", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "you are a monitor",
+ taskPrompt: "check stuck chats",
+ model: "opencode/gpt-4o",
+ reasoningEffort: "high",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000); // 1-minute interval → fire
+ await flush(); // let the async fire() reach handleMessage
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ expect(turn.text).toBe("check stuck chats");
+ expect(turn.systemPrompt).toBe("you are a monitor");
+ expect(turn.modelName).toBe("opencode/gpt-4o");
+ expect(turn.reasoningEffort).toBe("high");
+ // Spawned conversations go to the DEDICATED heartbeat workspace (not
+ // the configured workspace), while the run stays tracked under ws-1.
+ expect(turn.workspaceId).toBe("heartbeat");
+
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+
+ turn.resolve();
+ await flush();
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("completed");
+ });
+
+ it("heartbeat conversations always go to the dedicated heartbeat workspace, regardless of the configured workspace", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ // Two DIFFERENT workspaces each configure a heartbeat.
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc.updateConfig("ws-2", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000); // both fire (1-minute interval)
+ await flush();
+ expect(orch.pending).toHaveLength(2);
+ // Every spawned conversation is filed in the heartbeat workspace —
+ // NOT ws-1 or ws-2 — so heartbeat runs don't clog either workspace's
+ // tabs. The run history, however, stays tracked per configured workspace.
+ for (const turn of orch.pending) {
+ expect(turn.workspaceId).toBe("heartbeat");
+ }
+ // Run history is still per configured workspace.
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("running");
+ expect((await svc.listRuns("ws-2"))[0]?.status).toBe("running");
+ for (const turn of orch.pending) {
+ turn.resolve();
+ }
+ await flush();
+ });
+
+ it("the turn cwd is pinned to the CONFIGURED workspace's defaultCwd (not the heartbeat workspace's empty defaultCwd)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // The configured workspace has a defaultCwd.
+ getWorkspaceCwd: (wsId) => Promise.resolve(wsId === "ws-1" ? "/home/proj/ws-1" : null),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // The conversation is filed in the heartbeat workspace …
+ expect(turn.workspaceId).toBe("heartbeat");
+ // … but the turn's cwd is the CONFIGURED workspace's defaultCwd, so
+ // tools run where the prompt's [prompt:cwd] advertises (not the
+ // heartbeat workspace's empty defaultCwd → process.cwd()).
+ expect(turn.cwd).toBe("/home/proj/ws-1");
+ turn.resolve();
+ await flush();
+ });
+
+ it("omits the cwd override when the configured workspace has no defaultCwd (orchestrator falls back to the server default)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({
+ orch,
+ // The configured workspace has NO defaultCwd.
+ getWorkspaceCwd: () => Promise.resolve(null),
+ });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.workspaceId).toBe("heartbeat");
+ // No cwd override sent — the orchestrator resolves the turn cwd from
+ // the heartbeat workspace (no defaultCwd → server default cwd).
+ expect(turn.cwd).toBeUndefined();
+ turn.resolve();
+ await flush();
+ });
+
+ it("omits the cwd override when getWorkspaceCwd is not wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No getWorkspaceCwd → no cwd override (the default).
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.workspaceId).toBe("heartbeat");
+ expect(turn.cwd).toBeUndefined();
+ turn.resolve();
+ await flush();
+ });
+
+ it("omits modelName/reasoningEffort when empty/null (inherit defaults)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ taskPrompt: "go",
+ model: "",
+ reasoningEffort: null,
+ intervalMinutes: 1,
+ });
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.modelName).toBeUndefined();
+ expect(turn.reasoningEffort).toBeUndefined();
+ turn.resolve();
+ await flush();
+ });
+
+ it("resolves [type:name] variables in both systemPrompt and taskPrompt before sending", async () => {
+ const orch = createFakeOrchestrator();
+ // A fake resolver that mirrors the real resolver's contract: substitute
+ // known [type:name] placeholders, leave unknown text verbatim.
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ return template
+ .replaceAll("[system:os]", "Linux (WSL)")
+ .replaceAll("[prompt:cwd]", "/repo")
+ .replaceAll("[prompt:workspace_id]", ctx.workspaceId)
+ .replaceAll("[prompt:conversation_id]", ctx.conversationId)
+ .replaceAll("[prompt:model]", ctx.model);
+ };
+ const { svc, advance } = createService({ orch, resolvePrompt });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id]).",
+ taskPrompt: "Check chats for [prompt:conversation_id] on [system:os].",
+ model: "opencode/gpt-4o",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ // Variables substituted — NOT left as literal [type:name] text.
+ expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
+ expect(turn.text).toBe(`Check chats for ${turn.conversationId} on Linux (WSL).`);
+ expect(turn.modelName).toBe("opencode/gpt-4o");
+ turn.resolve();
+ await flush();
+ });
+
+ it("passes prompts through raw when no resolver is wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No resolvePrompt → raw pass-through (the default).
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "raw [system:os] prompt",
+ taskPrompt: "raw [system:date] task",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // Unresolved — literals reach the orchestrator verbatim.
+ expect(turn.systemPrompt).toBe("raw [system:os] prompt");
+ expect(turn.text).toBe("raw [system:date] task");
+ turn.resolve();
+ await flush();
+ });
+
+ it("an empty systemPrompt inherits the global system prompt template (CR-HB-2)", async () => {
+ const orch = createFakeOrchestrator();
+ // The global getter returns the workspace's regular system prompt
+ // template (the same one GET /system-prompt returns). No resolver → the
+ // inherited template reaches the orchestrator verbatim (isolates CR-HB-2
+ // from CR-HB-1).
+ const { svc, advance } = createService({
+ orch,
+ getGlobalSystemPrompt: () => Promise.resolve("GLOBAL DEFAULT TEMPLATE"),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ // Empty systemPrompt = inherit the global default, NOT "no prompt".
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ expect(turn.systemPrompt).toBe("GLOBAL DEFAULT TEMPLATE");
+ turn.resolve();
+ await flush();
+ });
+
+ it("CR-HB-2 composes with CR-HB-1: the inherited global template's [type:name] placeholders are resolved", async () => {
+ const orch = createFakeOrchestrator();
+ const resolvePrompt = async (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ): Promise<string> => {
+ return template
+ .replaceAll("[system:os]", "Linux (WSL)")
+ .replaceAll("[prompt:cwd]", "/repo")
+ .replaceAll("[prompt:workspace_id]", ctx.workspaceId);
+ };
+ // The global template carries [type:name] placeholders (like the real
+ // default template embeds [prompt:cwd] / [file:AGENTS.md]).
+ const { svc, advance } = createService({
+ orch,
+ resolvePrompt,
+ getGlobalSystemPrompt: () =>
+ Promise.resolve("You run on [system:os] in [prompt:cwd] (ws [prompt:workspace_id])."),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ // Empty → inherit the global template, THEN resolve its placeholders.
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ const turn = orch.pending[0]!;
+ // The global template was inherited (not empty), then its [type:name]
+ // placeholders were substituted — NOT left literal, NOT empty.
+ expect(turn.systemPrompt).toBe("You run on Linux (WSL) in /repo (ws ws-1).");
+ turn.resolve();
+ await flush();
+ });
+
+ it("a non-empty systemPrompt override bypasses the global template (only CR-HB-1 applies)", async () => {
+ const orch = createFakeOrchestrator();
+ const resolvePrompt = async (template: string): Promise<string> =>
+ template.replaceAll("[system:os]", "Linux (WSL)");
+ const { svc, advance } = createService({
+ orch,
+ resolvePrompt,
+ // A DISTINCT global template — must NOT be used when overriding.
+ getGlobalSystemPrompt: () => Promise.resolve("GLOBAL SHOULD NOT APPEAR [system:os]"),
+ });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "custom override on [system:os]",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ // The override is used (and resolved), not the global template.
+ expect(turn.systemPrompt).toBe("custom override on Linux (WSL)");
+ turn.resolve();
+ await flush();
+ });
+
+ it("an empty systemPrompt stays empty when no global getter is wired (resolution is optional)", async () => {
+ const orch = createFakeOrchestrator();
+ // No getGlobalSystemPrompt → empty stays empty (no system prompt).
+ // Mirrors the no-resolver pass-through default: both deps are optional.
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", {
+ enabled: true,
+ systemPrompt: "",
+ taskPrompt: "go",
+ intervalMinutes: 1,
+ });
+
+ advance(60_000);
+ await flush();
+ const turn = orch.pending[0]!;
+ expect(turn.systemPrompt).toBe("");
+ turn.resolve();
+ await flush();
+ });
+
+ it("stopRun aborts the turn and marks the run stopped (not overwritten on completion)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ advance(60_000);
+ await flush();
+ const conversationId = orch.pending[0]?.conversationId;
+ const runId = (await svc.listRuns("ws-1"))[0]?.id;
+
+ const res = await svc.stopRun("ws-1", runId);
+ expect(res).toEqual({ ok: true });
+ expect(orch.stopped).toEqual([conversationId]);
+
+ await flush(); // the aborted turn's handleMessage resolves
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped");
+ });
+
+ it("stopRun is idempotent for an already-finished run", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ advance(60_000);
+ await flush();
+ orch.pending[0]?.resolve();
+ await flush();
+
+ const runId = (await svc.listRuns("ws-1"))[0]?.id;
+ const res = await svc.stopRun("ws-1", runId);
+ expect(res).toEqual({ ok: true });
+ expect(orch.stopped).toEqual([]); // no abort on a completed run
+ });
+
+ it("stopRun throws for an unknown run id", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await expect(svc.stopRun("ws-1", "nope")).rejects.toThrow();
+ });
+
+ it("disabling the config stops the schedule (no further fires)", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go" });
+ await svc.updateConfig("ws-1", { enabled: false });
+ advance(60_000);
+ expect(orch.pending).toHaveLength(0);
+ });
+
+ it("startAll sweeps stale running runs to stopped", async () => {
+ const storage = createMemoryStorage();
+ await storage.set(
+ "run:ws-1:stale",
+ JSON.stringify({
+ id: "stale",
+ conversationId: "c-stale",
+ triggeredAt: "2026-01-01T00:00:00.000Z",
+ status: "running",
+ }),
+ );
+ await storage.set(
+ "config:ws-1",
+ JSON.stringify({
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
+ }),
+ );
+
+ const { svc } = createService({ orch: createFakeOrchestrator(), storage });
+ await svc.startAll();
+ expect((await svc.listRuns("ws-1"))[0]?.status).toBe("stopped");
+ });
+
+ it("startAll arms enabled workspaces and skips disabled ones", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc: svc1, storage } = createService({ orch });
+ await svc1.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ await svc1.updateConfig("ws-2", { enabled: false, taskPrompt: "no" });
+ svc1.stopAll();
+
+ // Re-create a fresh service over the SAME storage (simulates a reboot),
+ // with new fake timers we can advance.
+ const fake = createFakeTimers();
+ let id = 1000;
+ const svc2 = createHeartbeatService({
+ storage,
+ orchestrator: orch,
+ timers: fake.timers,
+ generateId: () => `id-${++id}`,
+ });
+ await svc2.startAll();
+ fake.advance(60_000);
+ await flush();
+ // ws-1 (enabled) fired → its spawned conversation is filed in the
+ // heartbeat workspace; ws-2 (disabled) never fired.
+ expect(orch.pending).toHaveLength(1);
+ expect(orch.pending[0]?.workspaceId).toBe("heartbeat");
+ });
+
+ // ─── nextRunAt (CR-HB-3: server-authoritative next-run time) ────────────────
+
+ it("nextRunAt returns null when the heartbeat is disabled", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ // Default config → disabled → no schedule armed.
+ expect(await svc.nextRunAt("ws-1")).toBeNull();
+ });
+
+ it("nextRunAt returns the ISO timestamp of the next fire when enabled", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ // now=0, interval=1m → next fire at epoch-ms 60_000 → its ISO form.
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
+ });
+
+ it("nextRunAt returns null while a run is in progress, then the next fire after it completes", async () => {
+ const orch = createFakeOrchestrator();
+ const { svc, advance } = createService({ orch });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
+
+ advance(60_000); // fire → run in progress
+ await flush();
+ expect(orch.pending).toHaveLength(1);
+ // In flight → no next run queued yet.
+ expect(await svc.nextRunAt("ws-1")).toBeNull();
+
+ orch.pending[0]?.resolve();
+ await flush();
+ // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(120_000).toISOString());
+ });
+
+ it("nextRunAt returns null after disabling the heartbeat", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ expect(await svc.nextRunAt("ws-1")).not.toBeNull();
+ await svc.updateConfig("ws-1", { enabled: false });
+ expect(await svc.nextRunAt("ws-1")).toBeNull();
+ });
+
+ it("nextRunAt reflects a changed interval on the next re-arm", async () => {
+ const { svc } = createService({ orch: createFakeOrchestrator() });
+ await svc.updateConfig("ws-1", { enabled: true, taskPrompt: "go", intervalMinutes: 1 });
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(60_000).toISOString());
+ // Re-arm with a 5-minute interval (not running) → recomputed fire time.
+ await svc.updateConfig("ws-1", { intervalMinutes: 5 });
+ expect(await svc.nextRunAt("ws-1")).toBe(new Date(5 * 60_000).toISOString());
+ });
});
diff --git a/packages/heartbeat/src/heartbeat.ts b/packages/heartbeat/src/heartbeat.ts
index 8623907..12e70e3 100644
--- a/packages/heartbeat/src/heartbeat.ts
+++ b/packages/heartbeat/src/heartbeat.ts
@@ -2,10 +2,10 @@ import type { Logger, ServiceHandle } from "@dispatch/kernel";
import { defineService } from "@dispatch/kernel";
import type { SessionOrchestrator } from "@dispatch/session-orchestrator";
import type {
- HeartbeatConfig,
- HeartbeatRun,
- StopHeartbeatRunResponse,
- UpdateHeartbeatRequest,
+ HeartbeatConfig,
+ HeartbeatRun,
+ StopHeartbeatRunResponse,
+ UpdateHeartbeatRequest,
} from "@dispatch/transport-contract";
import { createHeartbeatConfigStore, type HeartbeatConfigStore } from "./config-store.js";
import { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
@@ -26,300 +26,300 @@ export const HEARTBEAT_WORKSPACE_ID = "heartbeat";
* extension wires into the host.
*/
export interface HeartbeatService {
- /** The per-workspace heartbeat config (defaults when never set). */
- readonly getConfig: (workspaceId: string) => Promise<HeartbeatConfig>;
- /**
- * Apply a partial config update. Side effect: arms/disarms the scheduler
- * for this workspace (enabled → schedule; disabled → stop). Returns the new
- * config.
- */
- readonly updateConfig: (
- workspaceId: string,
- update: UpdateHeartbeatRequest,
- ) => Promise<HeartbeatConfig>;
- /** Heartbeat runs for a workspace, most-recent first. */
- readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
- /**
- * The server-authoritative next-fire time for a workspace's heartbeat, as
- * an ISO 8601 string — the moment the scheduler will fire the next run
- * (the last run's completion + `intervalMinutes`, or the moment `enabled`
- * was toggled on + `intervalMinutes` for the first run). `null` when the
- * heartbeat is disabled/disarmed, or when a run is in flight and the next
- * hasn't been queued yet (no countdown to show). A cheap read of the
- * scheduler's pending fire time.
- */
- readonly nextRunAt: (workspaceId: string) => Promise<string | null>;
- /**
- * Stop an in-flight run (abort its turn). Idempotent for an already-finished
- * run. Throws when the run id is unknown (→ HTTP 404).
- */
- readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>;
- /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */
- readonly startAll: () => Promise<void>;
- /** Shutdown: stop every scheduler. */
- readonly stopAll: () => void;
+ /** The per-workspace heartbeat config (defaults when never set). */
+ readonly getConfig: (workspaceId: string) => Promise<HeartbeatConfig>;
+ /**
+ * Apply a partial config update. Side effect: arms/disarms the scheduler
+ * for this workspace (enabled → schedule; disabled → stop). Returns the new
+ * config.
+ */
+ readonly updateConfig: (
+ workspaceId: string,
+ update: UpdateHeartbeatRequest,
+ ) => Promise<HeartbeatConfig>;
+ /** Heartbeat runs for a workspace, most-recent first. */
+ readonly listRuns: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
+ /**
+ * The server-authoritative next-fire time for a workspace's heartbeat, as
+ * an ISO 8601 string — the moment the scheduler will fire the next run
+ * (the last run's completion + `intervalMinutes`, or the moment `enabled`
+ * was toggled on + `intervalMinutes` for the first run). `null` when the
+ * heartbeat is disabled/disarmed, or when a run is in flight and the next
+ * hasn't been queued yet (no countdown to show). A cheap read of the
+ * scheduler's pending fire time.
+ */
+ readonly nextRunAt: (workspaceId: string) => Promise<string | null>;
+ /**
+ * Stop an in-flight run (abort its turn). Idempotent for an already-finished
+ * run. Throws when the run id is unknown (→ HTTP 404).
+ */
+ readonly stopRun: (workspaceId: string, runId: string) => Promise<StopHeartbeatRunResponse>;
+ /** Boot: sweep stale runs + arm every enabled workspace's scheduler. */
+ readonly startAll: () => Promise<void>;
+ /** Shutdown: stop every scheduler. */
+ readonly stopAll: () => void;
}
/** Typed service handle the heartbeat extension provides and transport consumes. */
export const heartbeatServiceHandle: ServiceHandle<HeartbeatService> =
- defineService<HeartbeatService>("heartbeat");
+ defineService<HeartbeatService>("heartbeat");
export interface HeartbeatServiceDeps {
- /** Namespaced storage (from `host.storage("heartbeat")`). */
- readonly storage: import("@dispatch/kernel").StorageNamespace;
- /** The session orchestrator (drives heartbeat turns). */
- readonly orchestrator: SessionOrchestrator;
- readonly logger?: Logger;
- /** Injectable timers (default: real). */
- readonly timers?: Timers;
- /** Injectable id generator (default: crypto.randomUUID). */
- readonly generateId?: () => string;
- /**
- * Resolve `[type:name]` variable placeholders in a prompt template against
- * the current environment — the SAME resolver + variable catalog the global
- * system-prompt template uses (system/file/prompt/git groups). Applied once
- * per run, when the turn is constructed (mirrors the global template's
- * construct-once-per-conversation resolution). When omitted, templates pass
- * through UNRESOLVED (raw) — the extension wires the real resolver; tests
- * inject a fake.
- */
- readonly resolvePrompt?: (
- template: string,
- ctx: {
- readonly workspaceId: string;
- readonly conversationId: string;
- readonly model: string;
- },
- ) => Promise<string>;
- /**
- * Resolve an empty heartbeat `systemPrompt` to the GLOBAL system prompt
- * template — the same one `GET /system-prompt` returns / that regular
- * conversations resolve. Applied ONLY when the heartbeat's persisted
- * `systemPrompt` is `""` (inherit), and BEFORE variable resolution
- * (`resolvePrompt` / CR-HB-1) runs on the result, so both apply in order:
- * empty ⇒ global template, then `[type:name]` placeholders resolved. A
- * non-empty `systemPrompt` is an explicit override and bypasses this.
- * When omitted, empty stays empty (no system prompt) — the extension
- * wires the real getter; tests inject a fake.
- */
- readonly getGlobalSystemPrompt?: () => Promise<string>;
- /**
- * The configured workspace's `defaultCwd` (or `null` when the workspace has
- * none). Used to pin the heartbeat turn's cwd to the CONFIGURED workspace's
- * directory — NOT the heartbeat workspace's (empty) defaultCwd — so the
- * turn's tools run in the same directory the prompt's `[prompt:cwd]`
- * variable advertises. Passed to the orchestrator as an explicit `cwd`
- * override only when non-null; when `null` no override is sent and the
- * orchestrator falls back to the server default cwd (matching the
- * pre-heartbeat-workspace behavior for a workspace without a defaultCwd).
- * When omitted, `null` (no override) — the extension wires the real getter
- * (against `conversationStore.getWorkspace`); tests inject a fake.
- */
- readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
+ /** Namespaced storage (from `host.storage("heartbeat")`). */
+ readonly storage: import("@dispatch/kernel").StorageNamespace;
+ /** The session orchestrator (drives heartbeat turns). */
+ readonly orchestrator: SessionOrchestrator;
+ readonly logger?: Logger;
+ /** Injectable timers (default: real). */
+ readonly timers?: Timers;
+ /** Injectable id generator (default: crypto.randomUUID). */
+ readonly generateId?: () => string;
+ /**
+ * Resolve `[type:name]` variable placeholders in a prompt template against
+ * the current environment — the SAME resolver + variable catalog the global
+ * system-prompt template uses (system/file/prompt/git groups). Applied once
+ * per run, when the turn is constructed (mirrors the global template's
+ * construct-once-per-conversation resolution). When omitted, templates pass
+ * through UNRESOLVED (raw) — the extension wires the real resolver; tests
+ * inject a fake.
+ */
+ readonly resolvePrompt?: (
+ template: string,
+ ctx: {
+ readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ },
+ ) => Promise<string>;
+ /**
+ * Resolve an empty heartbeat `systemPrompt` to the GLOBAL system prompt
+ * template — the same one `GET /system-prompt` returns / that regular
+ * conversations resolve. Applied ONLY when the heartbeat's persisted
+ * `systemPrompt` is `""` (inherit), and BEFORE variable resolution
+ * (`resolvePrompt` / CR-HB-1) runs on the result, so both apply in order:
+ * empty ⇒ global template, then `[type:name]` placeholders resolved. A
+ * non-empty `systemPrompt` is an explicit override and bypasses this.
+ * When omitted, empty stays empty (no system prompt) — the extension
+ * wires the real getter; tests inject a fake.
+ */
+ readonly getGlobalSystemPrompt?: () => Promise<string>;
+ /**
+ * The configured workspace's `defaultCwd` (or `null` when the workspace has
+ * none). Used to pin the heartbeat turn's cwd to the CONFIGURED workspace's
+ * directory — NOT the heartbeat workspace's (empty) defaultCwd — so the
+ * turn's tools run in the same directory the prompt's `[prompt:cwd]`
+ * variable advertises. Passed to the orchestrator as an explicit `cwd`
+ * override only when non-null; when `null` no override is sent and the
+ * orchestrator falls back to the server default cwd (matching the
+ * pre-heartbeat-workspace behavior for a workspace without a defaultCwd).
+ * When omitted, `null` (no override) — the extension wires the real getter
+ * (against `conversationStore.getWorkspace`); tests inject a fake.
+ */
+ readonly getWorkspaceCwd?: (workspaceId: string) => Promise<string | null>;
}
interface ActiveRun {
- readonly conversationId: string;
- readonly workspaceId: string;
- stopped: boolean;
+ readonly conversationId: string;
+ readonly workspaceId: string;
+ stopped: boolean;
}
export function createHeartbeatService(deps: HeartbeatServiceDeps): HeartbeatService {
- const logger = deps.logger;
- const timers = deps.timers ?? realTimers;
- const generateId = deps.generateId ?? (() => crypto.randomUUID());
- const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage);
- const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage);
- const orchestrator = deps.orchestrator;
- // Default: pass templates through UNRESOLVED (raw). The extension wires the
- // real resolver so [type:name] placeholders are substituted like the global
- // system-prompt template; tests inject a fake.
- const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template));
- // Default: an empty systemPrompt stays empty (no system prompt). The
- // extension wires the real getter so empty INHERITS the global system
- // prompt template (GET /system-prompt); tests inject a fake.
- const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve(""));
- // Default: no cwd override (the orchestrator resolves the turn cwd from the
- // conversation's workspace). The extension wires the real getter so the
- // turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake.
- const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null));
+ const logger = deps.logger;
+ const timers = deps.timers ?? realTimers;
+ const generateId = deps.generateId ?? (() => crypto.randomUUID());
+ const configStore: HeartbeatConfigStore = createHeartbeatConfigStore(deps.storage);
+ const runStore: HeartbeatRunStore = createHeartbeatRunStore(deps.storage);
+ const orchestrator = deps.orchestrator;
+ // Default: pass templates through UNRESOLVED (raw). The extension wires the
+ // real resolver so [type:name] placeholders are substituted like the global
+ // system-prompt template; tests inject a fake.
+ const resolvePrompt = deps.resolvePrompt ?? ((template: string) => Promise.resolve(template));
+ // Default: an empty systemPrompt stays empty (no system prompt). The
+ // extension wires the real getter so empty INHERITS the global system
+ // prompt template (GET /system-prompt); tests inject a fake.
+ const getGlobalSystemPrompt = deps.getGlobalSystemPrompt ?? (() => Promise.resolve(""));
+ // Default: no cwd override (the orchestrator resolves the turn cwd from the
+ // conversation's workspace). The extension wires the real getter so the
+ // turn pins to the CONFIGURED workspace's defaultCwd; tests inject a fake.
+ const getWorkspaceCwd = deps.getWorkspaceCwd ?? (() => Promise.resolve(null));
- // runId → active-run tracking (in-memory; the durable record lives in the
- // run store). Used to (a) map a stop request to its conversation, and
- // (b) keep a "stopped" flag so the turn's completion doesn't clobber a
- // user-initiated stop.
- const activeRuns = new Map<string, ActiveRun>();
+ // runId → active-run tracking (in-memory; the durable record lives in the
+ // run store). Used to (a) map a stop request to its conversation, and
+ // (b) keep a "stopped" flag so the turn's completion doesn't clobber a
+ // user-initiated stop.
+ const activeRuns = new Map<string, ActiveRun>();
- const scheduler = new HeartbeatScheduler({
- timers,
- fire: (workspaceId) => fire(workspaceId),
- });
+ const scheduler = new HeartbeatScheduler({
+ timers,
+ fire: (workspaceId) => fire(workspaceId),
+ });
- async function fire(workspaceId: string): Promise<void> {
- const config = await configStore.get(workspaceId);
- // Race: disabled/disarmed between the timer firing and now.
- if (!config.enabled) return;
+ async function fire(workspaceId: string): Promise<void> {
+ const config = await configStore.get(workspaceId);
+ // Race: disabled/disarmed between the timer firing and now.
+ if (!config.enabled) return;
- const conversationId = generateId();
- const runId = generateId();
- const triggeredAt = new Date(timers.now()).toISOString();
- const run: HeartbeatRun = {
- id: runId,
- conversationId,
- triggeredAt,
- status: "running",
- };
- await runStore.create(workspaceId, run);
- activeRuns.set(runId, { conversationId, workspaceId, stopped: false });
+ const conversationId = generateId();
+ const runId = generateId();
+ const triggeredAt = new Date(timers.now()).toISOString();
+ const run: HeartbeatRun = {
+ id: runId,
+ conversationId,
+ triggeredAt,
+ status: "running",
+ };
+ await runStore.create(workspaceId, run);
+ activeRuns.set(runId, { conversationId, workspaceId, stopped: false });
- logger?.info("heartbeat: run started", { workspaceId, runId, conversationId });
+ logger?.info("heartbeat: run started", { workspaceId, runId, conversationId });
- // Resolve the heartbeat's prompts ONCE, when the turn is constructed.
- //
- // CR-HB-2: an empty `systemPrompt` INHERITS the global system prompt
- // template (the same one `GET /system-prompt` returns / that regular
- // conversations resolve) — empty is an override-means-inherit flag, not
- // "no system prompt". A non-empty `systemPrompt` is an explicit override.
- // This step runs FIRST.
- //
- // CR-HB-1: then `[type:name]` variable placeholders in whichever prompt is
- // in effect are resolved via the same resolver + variable catalog the
- // global system-prompt template uses. The orchestrator sends an explicit
- // systemPrompt override AS-IS (bypassing its own templated prompt), so
- // resolution must happen HERE, before handleMessage. For prompts using
- // stable variables (os/cwd/git) this yields a stable, cache-warm prompt
- // across runs; time-bearing variables refresh per run (mirroring the
- // global template's per-conversation resolution).
- const baseSystemPrompt =
- config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt;
- const resolveCtx = { workspaceId, conversationId, model: config.model };
- // resolveCtx uses the CONFIGURED workspaceId — the heartbeat operates
- // ON BEHALF OF the configured workspace, so `[prompt:workspace_id]` and
- // `[prompt:cwd]` refer to it (not the heartbeat workspace the spawned
- // conversation is filed in). The turn's cwd is pinned to the SAME
- // configured workspace's defaultCwd (below) so tools run where the
- // prompt's `[prompt:cwd]` advertises.
- const [systemPrompt, taskPrompt, configuredWorkspaceCwd] = await Promise.all([
- resolvePrompt(baseSystemPrompt, resolveCtx),
- resolvePrompt(config.taskPrompt, resolveCtx),
- getWorkspaceCwd(workspaceId),
- ]);
+ // Resolve the heartbeat's prompts ONCE, when the turn is constructed.
+ //
+ // CR-HB-2: an empty `systemPrompt` INHERITS the global system prompt
+ // template (the same one `GET /system-prompt` returns / that regular
+ // conversations resolve) — empty is an override-means-inherit flag, not
+ // "no system prompt". A non-empty `systemPrompt` is an explicit override.
+ // This step runs FIRST.
+ //
+ // CR-HB-1: then `[type:name]` variable placeholders in whichever prompt is
+ // in effect are resolved via the same resolver + variable catalog the
+ // global system-prompt template uses. The orchestrator sends an explicit
+ // systemPrompt override AS-IS (bypassing its own templated prompt), so
+ // resolution must happen HERE, before handleMessage. For prompts using
+ // stable variables (os/cwd/git) this yields a stable, cache-warm prompt
+ // across runs; time-bearing variables refresh per run (mirroring the
+ // global template's per-conversation resolution).
+ const baseSystemPrompt =
+ config.systemPrompt === "" ? await getGlobalSystemPrompt() : config.systemPrompt;
+ const resolveCtx = { workspaceId, conversationId, model: config.model };
+ // resolveCtx uses the CONFIGURED workspaceId — the heartbeat operates
+ // ON BEHALF OF the configured workspace, so `[prompt:workspace_id]` and
+ // `[prompt:cwd]` refer to it (not the heartbeat workspace the spawned
+ // conversation is filed in). The turn's cwd is pinned to the SAME
+ // configured workspace's defaultCwd (below) so tools run where the
+ // prompt's `[prompt:cwd]` advertises.
+ const [systemPrompt, taskPrompt, configuredWorkspaceCwd] = await Promise.all([
+ resolvePrompt(baseSystemPrompt, resolveCtx),
+ resolvePrompt(config.taskPrompt, resolveCtx),
+ getWorkspaceCwd(workspaceId),
+ ]);
- try {
- await orchestrator.handleMessage({
- conversationId,
- text: taskPrompt,
- // Fire-and-forget: the heartbeat loop does not consume the
- // streamed events (it only awaits turn completion to mark the
- // run done). A no-op onEvent satisfies the required callback.
- onEvent: () => {},
- // Always passed explicitly — bypasses the orchestrator's templated
- // workspace prompt. Resolved above: an empty config systemPrompt
- // inherited the global template (CR-HB-2), then [type:name]
- // placeholders were substituted (CR-HB-1). Still "" when the global
- // template itself is empty (no system prompt).
- systemPrompt,
- ...(config.model !== "" ? { modelName: config.model } : {}),
- ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}),
- // Pin the turn cwd to the CONFIGURED workspace's defaultCwd so
- // the heartbeat's tools run in the same directory its prompt
- // variables advertise — NOT the heartbeat workspace's (empty)
- // defaultCwd → process.cwd(). Omitted when the configured
- // workspace has no defaultCwd (the orchestrator then falls back
- // to the server default cwd, matching the pre-heartbeat-
- // workspace behavior for a workspace without one).
- ...(configuredWorkspaceCwd !== null ? { cwd: configuredWorkspaceCwd } : {}),
- // File the spawned conversation in the DEDICATED heartbeat
- // workspace (not the configured workspace) so heartbeat
- // conversations don't clog the configured workspace's tabs. The
- // orchestrator auto-creates this workspace on first fire
- // (ensureWorkspace), so it appears on the workspaces home page.
- workspaceId: HEARTBEAT_WORKSPACE_ID,
- });
- } finally {
- const entry = activeRuns.get(runId);
- activeRuns.delete(runId);
- // If the user stopped it, stopRun already set "stopped"; don't
- // clobber. Otherwise the turn sealed (normally or via abort) → done.
- if (entry !== undefined && !entry.stopped) {
- await runStore.setStatus(workspaceId, runId, "completed");
- logger?.info("heartbeat: run completed", { workspaceId, runId });
- }
- }
- }
+ try {
+ await orchestrator.handleMessage({
+ conversationId,
+ text: taskPrompt,
+ // Fire-and-forget: the heartbeat loop does not consume the
+ // streamed events (it only awaits turn completion to mark the
+ // run done). A no-op onEvent satisfies the required callback.
+ onEvent: () => {},
+ // Always passed explicitly — bypasses the orchestrator's templated
+ // workspace prompt. Resolved above: an empty config systemPrompt
+ // inherited the global template (CR-HB-2), then [type:name]
+ // placeholders were substituted (CR-HB-1). Still "" when the global
+ // template itself is empty (no system prompt).
+ systemPrompt,
+ ...(config.model !== "" ? { modelName: config.model } : {}),
+ ...(config.reasoningEffort !== null ? { reasoningEffort: config.reasoningEffort } : {}),
+ // Pin the turn cwd to the CONFIGURED workspace's defaultCwd so
+ // the heartbeat's tools run in the same directory its prompt
+ // variables advertise — NOT the heartbeat workspace's (empty)
+ // defaultCwd → process.cwd(). Omitted when the configured
+ // workspace has no defaultCwd (the orchestrator then falls back
+ // to the server default cwd, matching the pre-heartbeat-
+ // workspace behavior for a workspace without one).
+ ...(configuredWorkspaceCwd !== null ? { cwd: configuredWorkspaceCwd } : {}),
+ // File the spawned conversation in the DEDICATED heartbeat
+ // workspace (not the configured workspace) so heartbeat
+ // conversations don't clog the configured workspace's tabs. The
+ // orchestrator auto-creates this workspace on first fire
+ // (ensureWorkspace), so it appears on the workspaces home page.
+ workspaceId: HEARTBEAT_WORKSPACE_ID,
+ });
+ } finally {
+ const entry = activeRuns.get(runId);
+ activeRuns.delete(runId);
+ // If the user stopped it, stopRun already set "stopped"; don't
+ // clobber. Otherwise the turn sealed (normally or via abort) → done.
+ if (entry !== undefined && !entry.stopped) {
+ await runStore.setStatus(workspaceId, runId, "completed");
+ logger?.info("heartbeat: run completed", { workspaceId, runId });
+ }
+ }
+ }
- return {
- async getConfig(workspaceId) {
- return configStore.get(workspaceId);
- },
+ return {
+ async getConfig(workspaceId) {
+ return configStore.get(workspaceId);
+ },
- async updateConfig(workspaceId, update) {
- const next = await configStore.update(workspaceId, update);
- // Arm/disarm from the new config. An in-progress run is left alone;
- // the new interval takes effect on the next re-arm.
- scheduler.arm(workspaceId, next);
- logger?.info("heartbeat: config updated", {
- workspaceId,
- enabled: next.enabled,
- intervalMinutes: next.intervalMinutes,
- });
- return next;
- },
+ async updateConfig(workspaceId, update) {
+ const next = await configStore.update(workspaceId, update);
+ // Arm/disarm from the new config. An in-progress run is left alone;
+ // the new interval takes effect on the next re-arm.
+ scheduler.arm(workspaceId, next);
+ logger?.info("heartbeat: config updated", {
+ workspaceId,
+ enabled: next.enabled,
+ intervalMinutes: next.intervalMinutes,
+ });
+ return next;
+ },
- async listRuns(workspaceId) {
- return runStore.list(workspaceId);
- },
+ async listRuns(workspaceId) {
+ return runStore.list(workspaceId);
+ },
- async nextRunAt(workspaceId) {
- const ms = scheduler.nextFireAt(workspaceId);
- return ms === null ? null : new Date(ms).toISOString();
- },
+ async nextRunAt(workspaceId) {
+ const ms = scheduler.nextFireAt(workspaceId);
+ return ms === null ? null : new Date(ms).toISOString();
+ },
- async stopRun(workspaceId, runId) {
- const run = await runStore.get(workspaceId, runId);
- if (run === null) {
- throw new Error("Heartbeat run not found");
- }
- // Idempotent: an already-finished run is a no-op.
- if (run.status !== "running") {
- return { ok: true };
- }
- const entry = activeRuns.get(runId);
- const conversationId = entry?.conversationId ?? run.conversationId;
- if (entry !== undefined) {
- entry.stopped = true;
- }
- await runStore.setStatus(workspaceId, runId, "stopped");
- orchestrator.stopTurn(conversationId);
- logger?.info("heartbeat: run stopped", { workspaceId, runId, conversationId });
- return { ok: true };
- },
+ async stopRun(workspaceId, runId) {
+ const run = await runStore.get(workspaceId, runId);
+ if (run === null) {
+ throw new Error("Heartbeat run not found");
+ }
+ // Idempotent: an already-finished run is a no-op.
+ if (run.status !== "running") {
+ return { ok: true };
+ }
+ const entry = activeRuns.get(runId);
+ const conversationId = entry?.conversationId ?? run.conversationId;
+ if (entry !== undefined) {
+ entry.stopped = true;
+ }
+ await runStore.setStatus(workspaceId, runId, "stopped");
+ orchestrator.stopTurn(conversationId);
+ logger?.info("heartbeat: run stopped", { workspaceId, runId, conversationId });
+ return { ok: true };
+ },
- async startAll() {
- // Sweep stale "running" runs (orphaned by a prior crash/restart) →
- // "stopped". Never leave the system showing an in-flight run that
- // can never finish.
- const workspaceIds = await configStore.listWorkspaceIds();
- for (const workspaceId of workspaceIds) {
- const runs = await runStore.list(workspaceId);
- for (const run of runs) {
- if (run.status === "running") {
- await runStore.setStatus(workspaceId, run.id, "stopped");
- }
- }
- const config = await configStore.get(workspaceId);
- if (config.enabled) {
- scheduler.arm(workspaceId, config);
- logger?.info("heartbeat: scheduler armed on boot", {
- workspaceId,
- intervalMinutes: config.intervalMinutes,
- });
- }
- }
- },
+ async startAll() {
+ // Sweep stale "running" runs (orphaned by a prior crash/restart) →
+ // "stopped". Never leave the system showing an in-flight run that
+ // can never finish.
+ const workspaceIds = await configStore.listWorkspaceIds();
+ for (const workspaceId of workspaceIds) {
+ const runs = await runStore.list(workspaceId);
+ for (const run of runs) {
+ if (run.status === "running") {
+ await runStore.setStatus(workspaceId, run.id, "stopped");
+ }
+ }
+ const config = await configStore.get(workspaceId);
+ if (config.enabled) {
+ scheduler.arm(workspaceId, config);
+ logger?.info("heartbeat: scheduler armed on boot", {
+ workspaceId,
+ intervalMinutes: config.intervalMinutes,
+ });
+ }
+ }
+ },
- stopAll() {
- scheduler.disarmAll();
- },
- };
+ stopAll() {
+ scheduler.disarmAll();
+ },
+ };
}
diff --git a/packages/heartbeat/src/index.ts b/packages/heartbeat/src/index.ts
index 3c2cbaa..d25e338 100644
--- a/packages/heartbeat/src/index.ts
+++ b/packages/heartbeat/src/index.ts
@@ -1,17 +1,17 @@
export {
- applyConfigUpdate,
- createHeartbeatConfigStore,
- DEFAULT_HEARTBEAT_CONFIG,
- type HeartbeatConfigStore,
- MIN_INTERVAL_MINUTES,
+ applyConfigUpdate,
+ createHeartbeatConfigStore,
+ DEFAULT_HEARTBEAT_CONFIG,
+ type HeartbeatConfigStore,
+ MIN_INTERVAL_MINUTES,
} from "./config-store.js";
export { extension, manifest } from "./extension.js";
export {
- createHeartbeatService,
- HEARTBEAT_WORKSPACE_ID,
- type HeartbeatService,
- type HeartbeatServiceDeps,
- heartbeatServiceHandle,
+ createHeartbeatService,
+ HEARTBEAT_WORKSPACE_ID,
+ type HeartbeatService,
+ type HeartbeatServiceDeps,
+ heartbeatServiceHandle,
} from "./heartbeat.js";
export { createHeartbeatRunStore, type HeartbeatRunStore } from "./run-store.js";
export { HeartbeatScheduler, realTimers, type TimerHandle, type Timers } from "./scheduler.js";
diff --git a/packages/heartbeat/src/run-store.test.ts b/packages/heartbeat/src/run-store.test.ts
index 9448838..8be21f5 100644
--- a/packages/heartbeat/src/run-store.test.ts
+++ b/packages/heartbeat/src/run-store.test.ts
@@ -4,69 +4,69 @@ import { describe, expect, it } from "vitest";
import { createHeartbeatRunStore } from "./run-store.js";
function createMemoryStorage(): StorageNamespace {
- const data = new Map<string, string>();
- return {
- get: async (key) => data.get(key) ?? null,
- set: async (key, value) => {
- data.set(key, value);
- },
- delete: async (key) => {
- data.delete(key);
- },
- has: async (key) => data.has(key),
- keys: async (prefix) => {
- const all = [...data.keys()];
- if (prefix === undefined) return all;
- return all.filter((k) => k.startsWith(prefix));
- },
- };
+ const data = new Map<string, string>();
+ return {
+ get: async (key) => data.get(key) ?? null,
+ set: async (key, value) => {
+ data.set(key, value);
+ },
+ delete: async (key) => {
+ data.delete(key);
+ },
+ has: async (key) => data.has(key),
+ keys: async (prefix) => {
+ const all = [...data.keys()];
+ if (prefix === undefined) return all;
+ return all.filter((k) => k.startsWith(prefix));
+ },
+ };
}
function run(id: string, conversationId: string, triggeredAt: string): HeartbeatRun {
- return { id, conversationId, triggeredAt, status: "running" };
+ return { id, conversationId, triggeredAt, status: "running" };
}
describe("createHeartbeatRunStore", () => {
- it("creates and reads back a run", async () => {
- const store = createHeartbeatRunStore(createMemoryStorage());
- const created = await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
- expect(created.status).toBe("running");
- const read = await store.get("ws-1", "r1");
- expect(read).toEqual(created);
- });
+ it("creates and reads back a run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ const created = await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ expect(created.status).toBe("running");
+ const read = await store.get("ws-1", "r1");
+ expect(read).toEqual(created);
+ });
- it("returns null for an unknown run", async () => {
- const store = createHeartbeatRunStore(createMemoryStorage());
- expect(await store.get("ws-1", "nope")).toBeNull();
- });
+ it("returns null for an unknown run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ expect(await store.get("ws-1", "nope")).toBeNull();
+ });
- it("updates the status of a run", async () => {
- const store = createHeartbeatRunStore(createMemoryStorage());
- await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
- const updated = await store.setStatus("ws-1", "r1", "completed");
- expect(updated?.status).toBe("completed");
- expect((await store.get("ws-1", "r1"))?.status).toBe("completed");
- });
+ it("updates the status of a run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ const updated = await store.setStatus("ws-1", "r1", "completed");
+ expect(updated?.status).toBe("completed");
+ expect((await store.get("ws-1", "r1"))?.status).toBe("completed");
+ });
- it("setStatus is a no-op (returns null) for an unknown run", async () => {
- const store = createHeartbeatRunStore(createMemoryStorage());
- expect(await store.setStatus("ws-1", "ghost", "stopped")).toBeNull();
- });
+ it("setStatus is a no-op (returns null) for an unknown run", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ expect(await store.setStatus("ws-1", "ghost", "stopped")).toBeNull();
+ });
- it("lists runs most-recent first by triggeredAt", async () => {
- const store = createHeartbeatRunStore(createMemoryStorage());
- await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
- await store.create("ws-1", run("r2", "c2", "2026-02-01T00:00:00.000Z"));
- await store.create("ws-1", run("r3", "c3", "2026-01-15T00:00:00.000Z"));
- const runs = await store.list("ws-1");
- expect(runs.map((r) => r.id)).toEqual(["r2", "r3", "r1"]);
- });
+ it("lists runs most-recent first by triggeredAt", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ await store.create("ws-1", run("r2", "c2", "2026-02-01T00:00:00.000Z"));
+ await store.create("ws-1", run("r3", "c3", "2026-01-15T00:00:00.000Z"));
+ const runs = await store.list("ws-1");
+ expect(runs.map((r) => r.id)).toEqual(["r2", "r3", "r1"]);
+ });
- it("scopes runs per workspace", async () => {
- const store = createHeartbeatRunStore(createMemoryStorage());
- await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
- await store.create("ws-2", run("r2", "c2", "2026-01-01T00:00:00.000Z"));
- expect((await store.list("ws-1")).map((r) => r.id)).toEqual(["r1"]);
- expect((await store.list("ws-2")).map((r) => r.id)).toEqual(["r2"]);
- });
+ it("scopes runs per workspace", async () => {
+ const store = createHeartbeatRunStore(createMemoryStorage());
+ await store.create("ws-1", run("r1", "c1", "2026-01-01T00:00:00.000Z"));
+ await store.create("ws-2", run("r2", "c2", "2026-01-01T00:00:00.000Z"));
+ expect((await store.list("ws-1")).map((r) => r.id)).toEqual(["r1"]);
+ expect((await store.list("ws-2")).map((r) => r.id)).toEqual(["r2"]);
+ });
});
diff --git a/packages/heartbeat/src/run-store.ts b/packages/heartbeat/src/run-store.ts
index 819ae00..9650181 100644
--- a/packages/heartbeat/src/run-store.ts
+++ b/packages/heartbeat/src/run-store.ts
@@ -3,93 +3,93 @@ import type { HeartbeatRun, HeartbeatRunStatus } from "@dispatch/transport-contr
/** Storage key for a single heartbeat run record. */
function runKey(workspaceId: string, runId: string): string {
- return `run:${workspaceId}:${runId}`;
+ return `run:${workspaceId}:${runId}`;
}
/** Prefix matching every run record for a workspace (for enumeration). */
function runPrefix(workspaceId: string): string {
- return `run:${workspaceId}:`;
+ return `run:${workspaceId}:`;
}
/** Extract the runId from a full `run:<workspaceId>:<runId>` key. */
function parseRunId(key: string, workspaceId: string): string {
- const prefix = runPrefix(workspaceId);
- return key.startsWith(prefix) ? key.slice(prefix.length) : key;
+ const prefix = runPrefix(workspaceId);
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
}
export interface HeartbeatRunStore {
- /** Create a new run record (status `"running"`) and persist it. */
- readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>;
- /** Update the status of an existing run. No-op if the run is unknown. */
- readonly setStatus: (
- workspaceId: string,
- runId: string,
- status: HeartbeatRunStatus,
- ) => Promise<HeartbeatRun | null>;
- /** A single run by id, or `null` when unknown. */
- readonly get: (workspaceId: string, runId: string) => Promise<HeartbeatRun | null>;
- /** All runs for a workspace, most-recent first (by `triggeredAt`). */
- readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
+ /** Create a new run record (status `"running"`) and persist it. */
+ readonly create: (workspaceId: string, run: HeartbeatRun) => Promise<HeartbeatRun>;
+ /** Update the status of an existing run. No-op if the run is unknown. */
+ readonly setStatus: (
+ workspaceId: string,
+ runId: string,
+ status: HeartbeatRunStatus,
+ ) => Promise<HeartbeatRun | null>;
+ /** A single run by id, or `null` when unknown. */
+ readonly get: (workspaceId: string, runId: string) => Promise<HeartbeatRun | null>;
+ /** All runs for a workspace, most-recent first (by `triggeredAt`). */
+ readonly list: (workspaceId: string) => Promise<readonly HeartbeatRun[]>;
}
export function createHeartbeatRunStore(storage: StorageNamespace): HeartbeatRunStore {
- async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
- const raw = await storage.get(runKey(workspaceId, runId));
- if (raw === null) return null;
- try {
- const parsed = JSON.parse(raw) as Partial<HeartbeatRun>;
- if (
- typeof parsed.id !== "string" ||
- typeof parsed.conversationId !== "string" ||
- typeof parsed.triggeredAt !== "string" ||
- typeof parsed.status !== "string"
- ) {
- return null;
- }
- return {
- id: parsed.id,
- conversationId: parsed.conversationId,
- triggeredAt: parsed.triggeredAt,
- status: parsed.status as HeartbeatRunStatus,
- };
- } catch {
- return null;
- }
- }
+ async function readRun(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
+ const raw = await storage.get(runKey(workspaceId, runId));
+ if (raw === null) return null;
+ try {
+ const parsed = JSON.parse(raw) as Partial<HeartbeatRun>;
+ if (
+ typeof parsed.id !== "string" ||
+ typeof parsed.conversationId !== "string" ||
+ typeof parsed.triggeredAt !== "string" ||
+ typeof parsed.status !== "string"
+ ) {
+ return null;
+ }
+ return {
+ id: parsed.id,
+ conversationId: parsed.conversationId,
+ triggeredAt: parsed.triggeredAt,
+ status: parsed.status as HeartbeatRunStatus,
+ };
+ } catch {
+ return null;
+ }
+ }
- return {
- async create(workspaceId: string, run: HeartbeatRun): Promise<HeartbeatRun> {
- await storage.set(runKey(workspaceId, run.id), JSON.stringify(run));
- return run;
- },
+ return {
+ async create(workspaceId: string, run: HeartbeatRun): Promise<HeartbeatRun> {
+ await storage.set(runKey(workspaceId, run.id), JSON.stringify(run));
+ return run;
+ },
- async setStatus(
- workspaceId: string,
- runId: string,
- status: HeartbeatRunStatus,
- ): Promise<HeartbeatRun | null> {
- const run = await readRun(workspaceId, runId);
- if (run === null) return null;
- const updated: HeartbeatRun = { ...run, status };
- await storage.set(runKey(workspaceId, runId), JSON.stringify(updated));
- return updated;
- },
+ async setStatus(
+ workspaceId: string,
+ runId: string,
+ status: HeartbeatRunStatus,
+ ): Promise<HeartbeatRun | null> {
+ const run = await readRun(workspaceId, runId);
+ if (run === null) return null;
+ const updated: HeartbeatRun = { ...run, status };
+ await storage.set(runKey(workspaceId, runId), JSON.stringify(updated));
+ return updated;
+ },
- async get(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
- return readRun(workspaceId, runId);
- },
+ async get(workspaceId: string, runId: string): Promise<HeartbeatRun | null> {
+ return readRun(workspaceId, runId);
+ },
- async list(workspaceId: string): Promise<readonly HeartbeatRun[]> {
- const keys = await storage.keys(runPrefix(workspaceId));
- const runs: HeartbeatRun[] = [];
- for (const key of keys) {
- const runId = parseRunId(key, workspaceId);
- const run = await readRun(workspaceId, runId);
- if (run !== null) runs.push(run);
- }
- // Most-recent first by triggeredAt (ISO-8601 sorts lexicographically).
- runs.sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt));
- return runs;
- },
- };
+ async list(workspaceId: string): Promise<readonly HeartbeatRun[]> {
+ const keys = await storage.keys(runPrefix(workspaceId));
+ const runs: HeartbeatRun[] = [];
+ for (const key of keys) {
+ const runId = parseRunId(key, workspaceId);
+ const run = await readRun(workspaceId, runId);
+ if (run !== null) runs.push(run);
+ }
+ // Most-recent first by triggeredAt (ISO-8601 sorts lexicographically).
+ runs.sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt));
+ return runs;
+ },
+ };
}
diff --git a/packages/heartbeat/src/scheduler.test.ts b/packages/heartbeat/src/scheduler.test.ts
index 826322c..82e5c4d 100644
--- a/packages/heartbeat/src/scheduler.test.ts
+++ b/packages/heartbeat/src/scheduler.test.ts
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import { HeartbeatScheduler, type Timers } from "./scheduler.js";
interface FakeTimer {
- readonly fn: () => void;
- readonly firesAt: number;
+ readonly fn: () => void;
+ readonly firesAt: number;
}
/**
@@ -13,331 +13,331 @@ interface FakeTimer {
* happens only AFTER the run completes.
*/
function createFakeTimers() {
- let now = 0;
- let nextId = 1;
- const timers = new Map<number, FakeTimer>();
- const timersApi: Timers = {
- now: () => now,
- setTimeout: (fn, ms) => {
- const id = nextId++;
- timers.set(id, { fn, firesAt: now + ms });
- return id as unknown as ReturnType<typeof setTimeout>;
- },
- clearTimeout: (handle) => {
- if (handle !== undefined) timers.delete(handle as unknown as number);
- },
- };
- return {
- timers: timersApi,
- advance(ms: number): number {
- now += ms;
- let fired = 0;
- const due = [...timers.entries()]
- .filter(([, t]) => t.firesAt <= now)
- .sort((a, b) => a[0] - b[0]);
- for (const [id, t] of due) {
- timers.delete(id);
- t.fn();
- fired++;
- }
- return fired;
- },
- pendingCount(): number {
- return timers.size;
- },
- };
+ let now = 0;
+ let nextId = 1;
+ const timers = new Map<number, FakeTimer>();
+ const timersApi: Timers = {
+ now: () => now,
+ setTimeout: (fn, ms) => {
+ const id = nextId++;
+ timers.set(id, { fn, firesAt: now + ms });
+ return id as unknown as ReturnType<typeof setTimeout>;
+ },
+ clearTimeout: (handle) => {
+ if (handle !== undefined) timers.delete(handle as unknown as number);
+ },
+ };
+ return {
+ timers: timersApi,
+ advance(ms: number): number {
+ now += ms;
+ let fired = 0;
+ const due = [...timers.entries()]
+ .filter(([, t]) => t.firesAt <= now)
+ .sort((a, b) => a[0] - b[0]);
+ for (const [id, t] of due) {
+ timers.delete(id);
+ t.fn();
+ fired++;
+ }
+ return fired;
+ },
+ pendingCount(): number {
+ return timers.size;
+ },
+ };
}
/** A deferred promise the test resolves to signal run completion. */
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
- let resolve!: () => void;
- const promise = new Promise<void>((r) => {
- resolve = r;
- });
- return { promise, resolve };
+ let resolve!: () => void;
+ const promise = new Promise<void>((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
}
// Flush pending microtasks (the scheduler's .finally re-arm runs as a microtask
// after the fire promise resolves).
const flush = async (): Promise<void> => {
- await new Promise((r) => queueMicrotask(r));
+ await new Promise((r) => queueMicrotask(r));
};
describe("HeartbeatScheduler", () => {
- it("arms a timer that fires after intervalMinutes", () => {
- const fake = createFakeTimers();
- const fires: string[] = [];
- const deferreds: Array<{ resolve: () => void }> = [];
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: (ws) => {
- fires.push(ws);
- const d = createDeferred();
- deferreds.push(d);
- return d.promise;
- },
- });
+ it("arms a timer that fires after intervalMinutes", () => {
+ const fake = createFakeTimers();
+ const fires: string[] = [];
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: (ws) => {
+ fires.push(ws);
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- expect(scheduler.isArmed("ws-1")).toBe(true);
- expect(fake.pendingCount()).toBe(1);
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.isArmed("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(1);
- // Just shy of the interval → no fire.
- expect(fake.advance(59_999)).toBe(0);
- expect(fires).toEqual([]);
+ // Just shy of the interval → no fire.
+ expect(fake.advance(59_999)).toBe(0);
+ expect(fires).toEqual([]);
- // Exactly the interval (1 minute = 60_000ms) → fires.
- expect(fake.advance(1)).toBe(1);
- expect(fires).toEqual(["ws-1"]);
- // While the run is in progress: running, no pending timer.
- expect(scheduler.isRunning("ws-1")).toBe(true);
- expect(fake.pendingCount()).toBe(0);
- });
+ // Exactly the interval (1 minute = 60_000ms) → fires.
+ expect(fake.advance(1)).toBe(1);
+ expect(fires).toEqual(["ws-1"]);
+ // While the run is in progress: running, no pending timer.
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(0);
+ });
- it("re-arms only after the run completes (reset timer after each run)", async () => {
- const fake = createFakeTimers();
- const fires: string[] = [];
- const deferreds: Array<{ resolve: () => void }> = [];
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: (ws) => {
- fires.push(ws);
- const d = createDeferred();
- deferreds.push(d);
- return d.promise;
- },
- });
+ it("re-arms only after the run completes (reset timer after each run)", async () => {
+ const fake = createFakeTimers();
+ const fires: string[] = [];
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: (ws) => {
+ fires.push(ws);
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- fake.advance(60_000); // first fire
- expect(fires).toEqual(["ws-1"]);
- // Run in progress — no new timer yet.
- expect(fake.pendingCount()).toBe(0);
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // first fire
+ expect(fires).toEqual(["ws-1"]);
+ // Run in progress — no new timer yet.
+ expect(fake.pendingCount()).toBe(0);
- // Completing the run schedules the next fire.
- deferreds[0]?.resolve();
- await flush();
- expect(scheduler.isRunning("ws-1")).toBe(false);
- expect(fake.pendingCount()).toBe(1);
+ // Completing the run schedules the next fire.
+ deferreds[0]?.resolve();
+ await flush();
+ expect(scheduler.isRunning("ws-1")).toBe(false);
+ expect(fake.pendingCount()).toBe(1);
- // Next fire after another interval.
- fake.advance(60_000);
- expect(fires).toEqual(["ws-1", "ws-1"]);
- });
+ // Next fire after another interval.
+ fake.advance(60_000);
+ expect(fires).toEqual(["ws-1", "ws-1"]);
+ });
- it("uses a longer interval for a higher intervalMinutes", () => {
- const fake = createFakeTimers();
- let fireCount = 0;
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => {
- fireCount++;
- return createDeferred().promise;
- },
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
- // 1 minute wouldn't fire a 30-minute schedule.
- expect(fake.advance(60_000)).toBe(0);
- // 28 more minutes (29 total) still wouldn't fire.
- expect(fake.advance(28 * 60_000)).toBe(0);
- // The remaining minute completes the 30 minutes → fires.
- expect(fake.advance(60_000)).toBe(1);
- expect(fireCount).toBe(1);
- });
+ it("uses a longer interval for a higher intervalMinutes", () => {
+ const fake = createFakeTimers();
+ let fireCount = 0;
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ fireCount++;
+ return createDeferred().promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
+ // 1 minute wouldn't fire a 30-minute schedule.
+ expect(fake.advance(60_000)).toBe(0);
+ // 28 more minutes (29 total) still wouldn't fire.
+ expect(fake.advance(28 * 60_000)).toBe(0);
+ // The remaining minute completes the 30 minutes → fires.
+ expect(fake.advance(60_000)).toBe(1);
+ expect(fireCount).toBe(1);
+ });
- it("disarms (clears the pending timer) when disabled", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- expect(fake.pendingCount()).toBe(1);
- scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 });
- expect(scheduler.isArmed("ws-1")).toBe(false);
- expect(fake.pendingCount()).toBe(0);
- });
+ it("disarms (clears the pending timer) when disabled", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(fake.pendingCount()).toBe(1);
+ scheduler.arm("ws-1", { enabled: false, intervalMinutes: 1 });
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ expect(fake.pendingCount()).toBe(0);
+ });
- it("disarmAll stops every schedule", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 });
- expect(fake.pendingCount()).toBe(2);
- scheduler.disarmAll();
- expect(fake.pendingCount()).toBe(0);
- expect(scheduler.isArmed("ws-1")).toBe(false);
- expect(scheduler.isArmed("ws-2")).toBe(false);
- });
+ it("disarmAll stops every schedule", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ scheduler.arm("ws-2", { enabled: true, intervalMinutes: 1 });
+ expect(fake.pendingCount()).toBe(2);
+ scheduler.disarmAll();
+ expect(fake.pendingCount()).toBe(0);
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ expect(scheduler.isArmed("ws-2")).toBe(false);
+ });
- it("does not re-arm after a disarm during a run", async () => {
- const fake = createFakeTimers();
- const deferreds: Array<{ resolve: () => void }> = [];
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => {
- const d = createDeferred();
- deferreds.push(d);
- return d.promise;
- },
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- fake.advance(60_000); // fire in progress
- scheduler.disarm("ws-1"); // disarm mid-run
- deferreds[0]?.resolve();
- await flush();
- // Disarmed → no re-arm scheduled.
- expect(fake.pendingCount()).toBe(0);
- expect(scheduler.isArmed("ws-1")).toBe(false);
- });
+ it("does not re-arm after a disarm during a run", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // fire in progress
+ scheduler.disarm("ws-1"); // disarm mid-run
+ deferreds[0]?.resolve();
+ await flush();
+ // Disarmed → no re-arm scheduled.
+ expect(fake.pendingCount()).toBe(0);
+ expect(scheduler.isArmed("ws-1")).toBe(false);
+ });
- it("a config update during a run applies the new interval on the next re-arm", async () => {
- const fake = createFakeTimers();
- const deferreds: Array<{ resolve: () => void }> = [];
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => {
- const d = createDeferred();
- deferreds.push(d);
- return d.promise;
- },
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- fake.advance(60_000); // first fire (interval=1m)
- // Update interval to 5m while the run is in progress.
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
- expect(scheduler.isRunning("ws-1")).toBe(true);
- expect(fake.pendingCount()).toBe(0); // no new timer while running
- deferreds[0]?.resolve();
- await flush();
- // Re-armed with the NEW 5-minute interval.
- expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now
- expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires
- });
+ it("a config update during a run applies the new interval on the next re-arm", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // first fire (interval=1m)
+ // Update interval to 5m while the run is in progress.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ expect(fake.pendingCount()).toBe(0); // no new timer while running
+ deferreds[0]?.resolve();
+ await flush();
+ // Re-armed with the NEW 5-minute interval.
+ expect(fake.advance(60_000)).toBe(0); // 1 minute isn't enough now
+ expect(fake.advance(4 * 60_000)).toBe(1); // completes 5 minutes → fires
+ });
- it("a thrown fire is swallowed and the loop continues", async () => {
- const fake = createFakeTimers();
- let fireCount = 0;
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => {
- fireCount++;
- return Promise.reject(new Error("boom"));
- },
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- fake.advance(60_000);
- await flush();
- expect(fireCount).toBe(1);
- // Loop continues: next fire scheduled.
- expect(fake.pendingCount()).toBe(1);
- fake.advance(60_000);
- expect(fireCount).toBe(2);
- });
+ it("a thrown fire is swallowed and the loop continues", async () => {
+ const fake = createFakeTimers();
+ let fireCount = 0;
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ fireCount++;
+ return Promise.reject(new Error("boom"));
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000);
+ await flush();
+ expect(fireCount).toBe(1);
+ // Loop continues: next fire scheduled.
+ expect(fake.pendingCount()).toBe(1);
+ fake.advance(60_000);
+ expect(fireCount).toBe(2);
+ });
- // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ──────────────
+ // ─── nextFireAt (CR-HB-3: server-authoritative next-run time) ──────────────
- it("nextFireAt returns null for a workspace with no schedule", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- expect(scheduler.nextFireAt("unknown")).toBeNull();
- });
+ it("nextFireAt returns null for a workspace with no schedule", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ expect(scheduler.nextFireAt("unknown")).toBeNull();
+ });
- it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- // now=0, interval=1m → next fire at epoch-ms 60_000.
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
- });
+ it("nextFireAt returns the absolute fire time when armed (now + intervalMs)", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ // now=0, interval=1m → next fire at epoch-ms 60_000.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ });
- it("nextFireAt reflects a longer interval", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
- expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000);
- });
+ it("nextFireAt reflects a longer interval", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 30 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(30 * 60_000);
+ });
- it("nextFireAt reflects a new interval on re-arm (not running)", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
- // Re-arm with a 5-minute interval (armed, not running) → recomputed.
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
- expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000);
- });
+ it("nextFireAt reflects a new interval on re-arm (not running)", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ // Re-arm with a 5-minute interval (armed, not running) → recomputed.
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 5 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(5 * 60_000);
+ });
- it("nextFireAt returns null when disarmed", () => {
- const fake = createFakeTimers();
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => createDeferred().promise,
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
- scheduler.disarm("ws-1");
- expect(scheduler.nextFireAt("ws-1")).toBeNull();
- });
+ it("nextFireAt returns null when disarmed", () => {
+ const fake = createFakeTimers();
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => createDeferred().promise,
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ scheduler.disarm("ws-1");
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ });
- it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => {
- const fake = createFakeTimers();
- const deferreds: Array<{ resolve: () => void }> = [];
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => {
- const d = createDeferred();
- deferreds.push(d);
- return d.promise;
- },
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
+ it("nextFireAt returns null while a run is in progress, then the next fire after it completes", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ expect(scheduler.nextFireAt("ws-1")).toBe(60_000);
- fake.advance(60_000); // fire → run in progress
- expect(scheduler.isRunning("ws-1")).toBe(true);
- // In flight → no next run queued yet.
- expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ fake.advance(60_000); // fire → run in progress
+ expect(scheduler.isRunning("ws-1")).toBe(true);
+ // In flight → no next run queued yet.
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
- deferreds[0]?.resolve();
- await flush();
- // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
- expect(scheduler.nextFireAt("ws-1")).toBe(120_000);
- });
+ deferreds[0]?.resolve();
+ await flush();
+ // Re-armed at completion-time (60_000) + interval (60_000) = 120_000.
+ expect(scheduler.nextFireAt("ws-1")).toBe(120_000);
+ });
- it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => {
- const fake = createFakeTimers();
- const deferreds: Array<{ resolve: () => void }> = [];
- const scheduler = new HeartbeatScheduler({
- timers: fake.timers,
- fire: () => {
- const d = createDeferred();
- deferreds.push(d);
- return d.promise;
- },
- });
- scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
- fake.advance(60_000); // fire in progress
- expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running
- scheduler.disarm("ws-1"); // disarm mid-run
- deferreds[0]?.resolve();
- await flush();
- // Disarmed → no re-arm, no fire time.
- expect(scheduler.nextFireAt("ws-1")).toBeNull();
- });
+ it("nextFireAt returns null after disarming mid-run (no re-arm)", async () => {
+ const fake = createFakeTimers();
+ const deferreds: Array<{ resolve: () => void }> = [];
+ const scheduler = new HeartbeatScheduler({
+ timers: fake.timers,
+ fire: () => {
+ const d = createDeferred();
+ deferreds.push(d);
+ return d.promise;
+ },
+ });
+ scheduler.arm("ws-1", { enabled: true, intervalMinutes: 1 });
+ fake.advance(60_000); // fire in progress
+ expect(scheduler.nextFireAt("ws-1")).toBeNull(); // running
+ scheduler.disarm("ws-1"); // disarm mid-run
+ deferreds[0]?.resolve();
+ await flush();
+ // Disarmed → no re-arm, no fire time.
+ expect(scheduler.nextFireAt("ws-1")).toBeNull();
+ });
});
diff --git a/packages/heartbeat/src/scheduler.ts b/packages/heartbeat/src/scheduler.ts
index c495b1e..8b00b61 100644
--- a/packages/heartbeat/src/scheduler.ts
+++ b/packages/heartbeat/src/scheduler.ts
@@ -19,43 +19,43 @@ export type TimerHandle = ReturnType<typeof setTimeout>;
/** Injectable timers — the only I/O effect the scheduler touches. */
export interface Timers {
- readonly now: () => number;
- readonly setTimeout: (fn: () => void, ms: number) => TimerHandle;
- readonly clearTimeout: (handle: TimerHandle | undefined) => void;
+ readonly now: () => number;
+ readonly setTimeout: (fn: () => void, ms: number) => TimerHandle;
+ readonly clearTimeout: (handle: TimerHandle | undefined) => void;
}
/** The real timers (used at runtime; overridable in tests). */
export const realTimers: Timers = {
- now: () => Date.now(),
- setTimeout: (fn, ms) => setTimeout(fn, ms),
- clearTimeout: (handle) => {
- if (handle !== undefined) clearTimeout(handle);
- },
+ now: () => Date.now(),
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
+ clearTimeout: (handle) => {
+ if (handle !== undefined) clearTimeout(handle);
+ },
};
export interface SchedulerDeps {
- readonly timers: Timers;
- /** Do the run work for a workspace. Resolves when the turn seals. */
- readonly fire: (workspaceId: string) => Promise<void>;
+ readonly timers: Timers;
+ /** Do the run work for a workspace. Resolves when the turn seals. */
+ readonly fire: (workspaceId: string) => Promise<void>;
}
interface WorkspaceSchedule {
- /** Current interval (minutes), updated by `arm` on config changes. */
- intervalMinutes: number;
- /** Pending wait timer (null while a fire is in progress). */
- timer: TimerHandle | undefined;
- /** A fire is in progress for this workspace. */
- running: boolean;
- /** Scheduling is active (the workspace's heartbeat is enabled). */
- armed: boolean;
- /**
- * Absolute epoch-ms timestamp of the next scheduled fire, or null when no
- * fire is pending — the schedule is disarmed, or a run is in progress
- * (the next fire is scheduled only after the run completes). This is the
- * server-authoritative next-run time the `/heartbeat/next-run` endpoint
- * derives its response from.
- */
- nextFireMs: number | null;
+ /** Current interval (minutes), updated by `arm` on config changes. */
+ intervalMinutes: number;
+ /** Pending wait timer (null while a fire is in progress). */
+ timer: TimerHandle | undefined;
+ /** A fire is in progress for this workspace. */
+ running: boolean;
+ /** Scheduling is active (the workspace's heartbeat is enabled). */
+ armed: boolean;
+ /**
+ * Absolute epoch-ms timestamp of the next scheduled fire, or null when no
+ * fire is pending — the schedule is disarmed, or a run is in progress
+ * (the next fire is scheduled only after the run completes). This is the
+ * server-authoritative next-run time the `/heartbeat/next-run` endpoint
+ * derives its response from.
+ */
+ nextFireMs: number | null;
}
const MINUTE_MS = 60_000;
@@ -65,129 +65,129 @@ const MINUTE_MS = 60_000;
* heartbeat service for its lifetime.
*/
export class HeartbeatScheduler {
- private readonly schedules = new Map<string, WorkspaceSchedule>();
- private readonly timers: Timers;
- private readonly fire: (workspaceId: string) => Promise<void>;
-
- constructor(deps: SchedulerDeps) {
- this.timers = deps.timers;
- this.fire = deps.fire;
- }
-
- /**
- * Arm (or re-arm) a workspace's schedule from its config. When `enabled`
- * is false the schedule is disarmed. When a config update arrives while a
- * run is in progress, the new `intervalMinutes` takes effect on the next
- * re-arm (the in-progress run is never cancelled here).
- */
- arm(
- workspaceId: string,
- config: {
- readonly enabled: boolean;
- readonly intervalMinutes: number;
- },
- ): void {
- if (!config.enabled) {
- this.disarm(workspaceId);
- return;
- }
- let schedule = this.schedules.get(workspaceId);
- if (schedule === undefined) {
- schedule = {
- intervalMinutes: config.intervalMinutes,
- timer: undefined,
- running: false,
- armed: true,
- nextFireMs: null,
- };
- this.schedules.set(workspaceId, schedule);
- } else {
- schedule.intervalMinutes = config.intervalMinutes;
- schedule.armed = true;
- }
- // If a fire is in progress, let it finish — it will re-arm with the
- // (possibly new) interval. Otherwise schedule the next fire now.
- if (!schedule.running) {
- this.clearTimer(schedule);
- this.scheduleNext(workspaceId, schedule);
- }
- }
-
- /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */
- disarm(workspaceId: string): void {
- const schedule = this.schedules.get(workspaceId);
- if (schedule === undefined) return;
- schedule.armed = false;
- this.clearTimer(schedule);
- this.schedules.delete(workspaceId);
- }
-
- /** Stop all schedules (deactivate). */
- disarmAll(): void {
- for (const workspaceId of [...this.schedules.keys()]) {
- this.disarm(workspaceId);
- }
- }
-
- /** Whether a schedule is currently armed (enabled) for a workspace. */
- isArmed(workspaceId: string): boolean {
- return this.schedules.get(workspaceId)?.armed ?? false;
- }
-
- /** Whether a fire is currently in progress for a workspace. */
- isRunning(workspaceId: string): boolean {
- return this.schedules.get(workspaceId)?.running ?? false;
- }
-
- /**
- * The absolute epoch-ms timestamp of the next scheduled fire for a workspace,
- * or null when no fire is pending — the heartbeat is disabled/disarmed, or a
- * run is in progress (the next fire is scheduled only after the run
- * completes). This is the server-authoritative next-run time.
- */
- nextFireAt(workspaceId: string): number | null {
- return this.schedules.get(workspaceId)?.nextFireMs ?? null;
- }
-
- private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void {
- const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS);
- // Record the absolute fire time (now + delay) BEFORE arming the timer
- // so `nextFireAt` reports it while the timer is pending.
- schedule.nextFireMs = this.timers.now() + ms;
- schedule.timer = this.timers.setTimeout(() => {
- this.onTick(workspaceId);
- }, ms);
- }
-
- private onTick(workspaceId: string): void {
- const schedule = this.schedules.get(workspaceId);
- // Race: the schedule was disarmed after the timer was queued.
- if (schedule === undefined || !schedule.armed) return;
- schedule.timer = undefined;
- // A fire is now in progress — no next run is queued yet (it's scheduled
- // only after the run completes), so report no pending fire time.
- schedule.nextFireMs = null;
- schedule.running = true;
- this.fire(workspaceId)
- .catch(() => {
- // The service records the run outcome; a thrown fire is logged
- // by the service. Swallow so the loop continues.
- })
- .finally(() => {
- const current = this.schedules.get(workspaceId);
- if (current === undefined) return;
- current.running = false;
- if (current.armed) {
- this.scheduleNext(workspaceId, current);
- }
- });
- }
-
- private clearTimer(schedule: WorkspaceSchedule): void {
- if (schedule.timer !== undefined) {
- this.timers.clearTimeout(schedule.timer);
- schedule.timer = undefined;
- }
- schedule.nextFireMs = null;
- }
+ private readonly schedules = new Map<string, WorkspaceSchedule>();
+ private readonly timers: Timers;
+ private readonly fire: (workspaceId: string) => Promise<void>;
+
+ constructor(deps: SchedulerDeps) {
+ this.timers = deps.timers;
+ this.fire = deps.fire;
+ }
+
+ /**
+ * Arm (or re-arm) a workspace's schedule from its config. When `enabled`
+ * is false the schedule is disarmed. When a config update arrives while a
+ * run is in progress, the new `intervalMinutes` takes effect on the next
+ * re-arm (the in-progress run is never cancelled here).
+ */
+ arm(
+ workspaceId: string,
+ config: {
+ readonly enabled: boolean;
+ readonly intervalMinutes: number;
+ },
+ ): void {
+ if (!config.enabled) {
+ this.disarm(workspaceId);
+ return;
+ }
+ let schedule = this.schedules.get(workspaceId);
+ if (schedule === undefined) {
+ schedule = {
+ intervalMinutes: config.intervalMinutes,
+ timer: undefined,
+ running: false,
+ armed: true,
+ nextFireMs: null,
+ };
+ this.schedules.set(workspaceId, schedule);
+ } else {
+ schedule.intervalMinutes = config.intervalMinutes;
+ schedule.armed = true;
+ }
+ // If a fire is in progress, let it finish — it will re-arm with the
+ // (possibly new) interval. Otherwise schedule the next fire now.
+ if (!schedule.running) {
+ this.clearTimer(schedule);
+ this.scheduleNext(workspaceId, schedule);
+ }
+ }
+
+ /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */
+ disarm(workspaceId: string): void {
+ const schedule = this.schedules.get(workspaceId);
+ if (schedule === undefined) return;
+ schedule.armed = false;
+ this.clearTimer(schedule);
+ this.schedules.delete(workspaceId);
+ }
+
+ /** Stop all schedules (deactivate). */
+ disarmAll(): void {
+ for (const workspaceId of [...this.schedules.keys()]) {
+ this.disarm(workspaceId);
+ }
+ }
+
+ /** Whether a schedule is currently armed (enabled) for a workspace. */
+ isArmed(workspaceId: string): boolean {
+ return this.schedules.get(workspaceId)?.armed ?? false;
+ }
+
+ /** Whether a fire is currently in progress for a workspace. */
+ isRunning(workspaceId: string): boolean {
+ return this.schedules.get(workspaceId)?.running ?? false;
+ }
+
+ /**
+ * The absolute epoch-ms timestamp of the next scheduled fire for a workspace,
+ * or null when no fire is pending — the heartbeat is disabled/disarmed, or a
+ * run is in progress (the next fire is scheduled only after the run
+ * completes). This is the server-authoritative next-run time.
+ */
+ nextFireAt(workspaceId: string): number | null {
+ return this.schedules.get(workspaceId)?.nextFireMs ?? null;
+ }
+
+ private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void {
+ const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS);
+ // Record the absolute fire time (now + delay) BEFORE arming the timer
+ // so `nextFireAt` reports it while the timer is pending.
+ schedule.nextFireMs = this.timers.now() + ms;
+ schedule.timer = this.timers.setTimeout(() => {
+ this.onTick(workspaceId);
+ }, ms);
+ }
+
+ private onTick(workspaceId: string): void {
+ const schedule = this.schedules.get(workspaceId);
+ // Race: the schedule was disarmed after the timer was queued.
+ if (schedule === undefined || !schedule.armed) return;
+ schedule.timer = undefined;
+ // A fire is now in progress — no next run is queued yet (it's scheduled
+ // only after the run completes), so report no pending fire time.
+ schedule.nextFireMs = null;
+ schedule.running = true;
+ this.fire(workspaceId)
+ .catch(() => {
+ // The service records the run outcome; a thrown fire is logged
+ // by the service. Swallow so the loop continues.
+ })
+ .finally(() => {
+ const current = this.schedules.get(workspaceId);
+ if (current === undefined) return;
+ current.running = false;
+ if (current.armed) {
+ this.scheduleNext(workspaceId, current);
+ }
+ });
+ }
+
+ private clearTimer(schedule: WorkspaceSchedule): void {
+ if (schedule.timer !== undefined) {
+ this.timers.clearTimeout(schedule.timer);
+ schedule.timer = undefined;
+ }
+ schedule.nextFireMs = null;
+ }
}
diff --git a/packages/heartbeat/tsconfig.json b/packages/heartbeat/tsconfig.json
index 0c18985..b947b31 100644
--- a/packages/heartbeat/tsconfig.json
+++ b/packages/heartbeat/tsconfig.json
@@ -1,12 +1,12 @@
{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
- "include": ["src/**/*.ts"],
- "references": [
- { "path": "../kernel" },
- { "path": "../transport-contract" },
- { "path": "../session-orchestrator" },
- { "path": "../system-prompt" },
- { "path": "../conversation-store" }
- ]
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../transport-contract" },
+ { "path": "../session-orchestrator" },
+ { "path": "../system-prompt" },
+ { "path": "../conversation-store" }
+ ]
}
diff --git a/packages/host-bin/package.json b/packages/host-bin/package.json
index f27bf06..65ea305 100644
--- a/packages/host-bin/package.json
+++ b/packages/host-bin/package.json
@@ -1,38 +1,38 @@
{
- "name": "@dispatch/host-bin",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "dependencies": {
- "@dispatch/kernel": "workspace:*",
- "@dispatch/storage-sqlite": "workspace:*",
- "@dispatch/conversation-store": "workspace:*",
- "@dispatch/auth-apikey": "workspace:*",
- "@dispatch/cache-warming": "workspace:*",
- "@dispatch/credential-store": "workspace:*",
- "@dispatch/exec-backend": "workspace:*",
- "@dispatch/heartbeat": "workspace:*",
- "@dispatch/provider-openai-compat": "workspace:*",
- "@dispatch/provider-umans": "workspace:*",
- "@dispatch/message-queue": "workspace:*",
- "@dispatch/mcp": "workspace:*",
- "@dispatch/session-orchestrator": "workspace:*",
- "@dispatch/skills": "workspace:*",
- "@dispatch/ssh": "workspace:*",
- "@dispatch/throughput-store": "workspace:*",
- "@dispatch/todo": "workspace:*",
- "@dispatch/transport-http": "workspace:*",
- "@dispatch/tool-read-file": "workspace:*",
- "@dispatch/tool-shell": "workspace:*",
- "@dispatch/tool-edit-file": "workspace:*",
- "@dispatch/tool-write-file": "workspace:*",
- "@dispatch/tool-web-search": "workspace:*",
- "@dispatch/tool-youtube-transcript": "workspace:*",
- "@dispatch/journal-sink": "workspace:*",
- "@dispatch/lsp": "workspace:*",
- "@dispatch/surface-loaded-extensions": "workspace:*",
- "@dispatch/surface-registry": "workspace:*",
- "@dispatch/transport-ws": "workspace:*",
- "@dispatch/system-prompt": "workspace:*"
- }
+ "name": "@dispatch/host-bin",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/storage-sqlite": "workspace:*",
+ "@dispatch/conversation-store": "workspace:*",
+ "@dispatch/auth-apikey": "workspace:*",
+ "@dispatch/cache-warming": "workspace:*",
+ "@dispatch/credential-store": "workspace:*",
+ "@dispatch/exec-backend": "workspace:*",
+ "@dispatch/heartbeat": "workspace:*",
+ "@dispatch/provider-openai-compat": "workspace:*",
+ "@dispatch/provider-umans": "workspace:*",
+ "@dispatch/message-queue": "workspace:*",
+ "@dispatch/mcp": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/skills": "workspace:*",
+ "@dispatch/ssh": "workspace:*",
+ "@dispatch/throughput-store": "workspace:*",
+ "@dispatch/todo": "workspace:*",
+ "@dispatch/transport-http": "workspace:*",
+ "@dispatch/tool-read-file": "workspace:*",
+ "@dispatch/tool-shell": "workspace:*",
+ "@dispatch/tool-edit-file": "workspace:*",
+ "@dispatch/tool-write-file": "workspace:*",
+ "@dispatch/tool-web-search": "workspace:*",
+ "@dispatch/tool-youtube-transcript": "workspace:*",
+ "@dispatch/journal-sink": "workspace:*",
+ "@dispatch/lsp": "workspace:*",
+ "@dispatch/surface-loaded-extensions": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/transport-ws": "workspace:*",
+ "@dispatch/system-prompt": "workspace:*"
+ }
}
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts
index 9919432..8633052 100644
--- a/packages/host-bin/src/main.ts
+++ b/packages/host-bin/src/main.ts
@@ -8,18 +8,18 @@ import { createExecBackendExtension } from "@dispatch/exec-backend";
import { extension as heartbeatExt } from "@dispatch/heartbeat";
import { createJournalSink } from "@dispatch/journal-sink";
import {
- type ConfigAccess,
- createBus,
- createHost,
- createLogger,
- type EventsEmitter,
- type Extension,
- type HostDeps,
- type LogDeps,
- type PermissionGate,
- type ScheduledJob,
- type SecretsAccess,
- type StorageNamespace,
+ type ConfigAccess,
+ createBus,
+ createHost,
+ createLogger,
+ type EventsEmitter,
+ type Extension,
+ type HostDeps,
+ type LogDeps,
+ type PermissionGate,
+ type ScheduledJob,
+ type SecretsAccess,
+ type StorageNamespace,
} from "@dispatch/kernel";
import { extension as lspExt } from "@dispatch/lsp";
import { extension as mcpExt } from "@dispatch/mcp";
@@ -49,192 +49,192 @@ import { configMapToAccess, envToConfigMap } from "./config.js";
import { loadExternalExtensions } from "./load-external.js";
function createEmptySecrets(): SecretsAccess {
- return {
- get: async () => null,
- set: async () => {},
- delete: async () => {},
- };
+ return {
+ get: async () => null,
+ set: async () => {},
+ delete: async () => {},
+ };
}
function createAllowAllPermissions(): PermissionGate {
- return {
- check: async () => ({ allowed: true }),
- };
+ return {
+ check: async () => ({ allowed: true }),
+ };
}
function createNoopScheduler(): { readonly register: (job: ScheduledJob) => void } {
- return { register: () => {} };
+ return { register: () => {} };
}
function createNoopEvents(): EventsEmitter {
- return { emit: () => {} };
+ return { emit: () => {} };
}
// Core extensions EXCEPT the credential-store, which is assembled in boot() so
// its credential list can include any credentials backed by external providers
// (e.g. a `claude` credential once the external Anthropic provider is loaded).
const CORE_EXTENSIONS: readonly Extension[] = [
- storageSqliteExt,
- conversationStoreExt,
- authApikeyExt,
- providerOpenaiCompatExt,
- providerUmansExt,
- // exec-backend must precede the tool extensions that
- // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It
- // provides the ExecBackendResolver the tools resolve through; placing it
- // here keeps the activation DAG honest (it depends only on kernel).
- createExecBackendExtension(),
- toolEditFileExt,
- toolReadFileExt,
- toolShellExt,
- toolWriteFileExt,
- toolWebSearchExt,
- toolYoutubeTranscriptExt,
- throughputStoreExt,
- todoExt,
- messageQueueExt,
- mcpExt,
- sessionOrchestratorExt,
- skillsExt,
- systemPromptExt,
- cacheWarmingExt,
- lspExt,
- // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote
- // exec-backend factory + the ComputerService the HTTP routes delegate to.
- // Its lookups are lazy (tool-/request-time), but it is placed after
- // exec-backend and the tool extensions (alongside the other standard
- // tool-serving extensions) to keep the DAG honest — and before
- // transport-http, whose routes consume the ComputerService it provides.
- sshExt,
- // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the
- // HTTP routes delegate to. Placed before transport-http (which depends on
- // it) — mirrors how ssh precedes transport-http for the same reason.
- heartbeatExt,
- createTransportHttpExtension(),
- // Surface extensions — dependency order: surface-registry first, then consumers.
- createSurfaceRegistryExtension(),
- createTransportWsExtension(),
- createLoadedExtensionsExtension(),
+ storageSqliteExt,
+ conversationStoreExt,
+ authApikeyExt,
+ providerOpenaiCompatExt,
+ providerUmansExt,
+ // exec-backend must precede the tool extensions that
+ // `dependsOn: ["exec-backend"]` (tool-edit-file/read/shell/write). It
+ // provides the ExecBackendResolver the tools resolve through; placing it
+ // here keeps the activation DAG honest (it depends only on kernel).
+ createExecBackendExtension(),
+ toolEditFileExt,
+ toolReadFileExt,
+ toolShellExt,
+ toolWriteFileExt,
+ toolWebSearchExt,
+ toolYoutubeTranscriptExt,
+ throughputStoreExt,
+ todoExt,
+ messageQueueExt,
+ mcpExt,
+ sessionOrchestratorExt,
+ skillsExt,
+ systemPromptExt,
+ cacheWarmingExt,
+ lspExt,
+ // ssh declares `dependsOn: ["exec-backend"]` and PROVIDES the remote
+ // exec-backend factory + the ComputerService the HTTP routes delegate to.
+ // Its lookups are lazy (tool-/request-time), but it is placed after
+ // exec-backend and the tool extensions (alongside the other standard
+ // tool-serving extensions) to keep the DAG honest — and before
+ // transport-http, whose routes consume the ComputerService it provides.
+ sshExt,
+ // heartbeat PROVIDES the HeartbeatService (per-workspace AI loop) the
+ // HTTP routes delegate to. Placed before transport-http (which depends on
+ // it) — mirrors how ssh precedes transport-http for the same reason.
+ heartbeatExt,
+ createTransportHttpExtension(),
+ // Surface extensions — dependency order: surface-registry first, then consumers.
+ createSurfaceRegistryExtension(),
+ createTransportWsExtension(),
+ createLoadedExtensionsExtension(),
];
/** Parse the comma-separated list of external extension module specifiers. */
function parseExternalSpecifiers(env: Readonly<Record<string, string | undefined>>): string[] {
- return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "")
- .split(",")
- .map((s) => s.trim())
- .filter((s) => s.length > 0);
+ return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
}
async function boot(): Promise<void> {
- const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson";
- mkdirSync(dirname(journalPath), { recursive: true });
- const logSink = createJournalSink({ path: journalPath });
- const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() };
- const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps);
-
- const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db";
-
- // Only start the collector supervisor in dev mode (source files available).
- // Compiled binaries don't have the source tree, so the collector can't spawn.
- let supervisor: ReturnType<typeof createCollectorSupervisor> | undefined;
- if (existsSync("packages/observability-collector/src/main.ts")) {
- supervisor = createCollectorSupervisor({
- spawn: (cmd: string[]) => {
- const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
- const handle: ChildHandle = {
- kill: (signal?: string) => proc.kill(signal as NodeJS.Signals),
- exited: proc.exited,
- };
- return handle;
- },
- journalPath,
- dbPath: traceDbPath,
- logger: logger.child({ extensionId: "collector-supervisor" }),
- });
- supervisor.start();
- }
-
- const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db";
- mkdirSync(dirname(dbPath), { recursive: true });
- const sqliteBackend = createSqliteStorage({ path: dbPath });
- const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace);
-
- const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>);
- const config: ConfigAccess = configMapToAccess(configMap);
-
- const deps: HostDeps = {
- logger,
- config,
- storageFactory,
- secrets: createEmptySecrets(),
- permissions: createAllowAllPermissions(),
- scheduler: createNoopScheduler(),
- bus: createBus(logger),
- events: createNoopEvents(),
- logSink,
- logDeps,
- };
-
- // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS.
- const externalSpecifiers = parseExternalSpecifiers(
- process.env as Readonly<Record<string, string | undefined>>,
- );
- const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger);
-
- // Assemble the credential list. MVP keeps the hardcoded `opencode` credential
- // and adds a `claude` credential when an external Anthropic provider is loaded.
- const credentials = [{ name: "opencode", providerId: "openai-compat" }];
-
- // The umans credential is always listed (it's the model-catalog index); the
- // provider itself only registers when UMANS_API_KEY is set, so listCatalog
- // gracefully skips it when the provider is absent.
- if (process.env.UMANS_API_KEY) {
- credentials.push({ name: "umans", providerId: "umans" });
- logger.info(`Registered credential "umans" → umans provider`);
- }
- const hasAnthropic = externalExtensions.some((e) =>
- e.manifest.contributes?.providers?.includes("anthropic"),
- );
- if (hasAnthropic) {
- const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude";
- credentials.push({ name: claudeName, providerId: "anthropic" });
- logger.info(`Registered credential "${claudeName}" → anthropic provider`);
- }
-
- const extensions: Extension[] = [
- ...CORE_EXTENSIONS,
- createCredentialStoreExtension({ credentials }),
- ...externalExtensions,
- ];
-
- const host = createHost(extensions, deps);
- await host.activate();
-
- const disabled = host.getDisabled();
- if (disabled.length > 0) {
- for (const d of disabled) {
- logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`);
- }
- }
-
- let shuttingDown = false;
- const shutdown = async () => {
- if (shuttingDown) return;
- shuttingDown = true;
- logger.info("Shutting down — deactivating extensions");
- await host.deactivate();
- logger.info("Draining collector");
- await supervisor?.stop();
- process.exit(0);
- };
- process.on("SIGINT", shutdown);
- process.on("SIGTERM", shutdown);
-
- logger.info("Dispatch booted");
- console.info("Dispatch booted");
+ const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson";
+ mkdirSync(dirname(journalPath), { recursive: true });
+ const logSink = createJournalSink({ path: journalPath });
+ const logDeps: LogDeps = { now: () => Date.now(), newId: () => crypto.randomUUID() };
+ const logger = createLogger({ extensionId: "host-bin" }, logSink, logDeps);
+
+ const traceDbPath = process.env.DISPATCH_TRACE_DB ?? "./.dispatch-data/traces.db";
+
+ // Only start the collector supervisor in dev mode (source files available).
+ // Compiled binaries don't have the source tree, so the collector can't spawn.
+ let supervisor: ReturnType<typeof createCollectorSupervisor> | undefined;
+ if (existsSync("packages/observability-collector/src/main.ts")) {
+ supervisor = createCollectorSupervisor({
+ spawn: (cmd: string[]) => {
+ const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" });
+ const handle: ChildHandle = {
+ kill: (signal?: string) => proc.kill(signal as NodeJS.Signals),
+ exited: proc.exited,
+ };
+ return handle;
+ },
+ journalPath,
+ dbPath: traceDbPath,
+ logger: logger.child({ extensionId: "collector-supervisor" }),
+ });
+ supervisor.start();
+ }
+
+ const dbPath = process.env.DISPATCH_DB ?? "./.dispatch-data/dispatch.db";
+ mkdirSync(dirname(dbPath), { recursive: true });
+ const sqliteBackend = createSqliteStorage({ path: dbPath });
+ const storageFactory = (namespace: string): StorageNamespace => sqliteBackend.storage(namespace);
+
+ const configMap = envToConfigMap(process.env as Readonly<Record<string, string | undefined>>);
+ const config: ConfigAccess = configMapToAccess(configMap);
+
+ const deps: HostDeps = {
+ logger,
+ config,
+ storageFactory,
+ secrets: createEmptySecrets(),
+ permissions: createAllowAllPermissions(),
+ scheduler: createNoopScheduler(),
+ bus: createBus(logger),
+ events: createNoopEvents(),
+ logSink,
+ logDeps,
+ };
+
+ // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS.
+ const externalSpecifiers = parseExternalSpecifiers(
+ process.env as Readonly<Record<string, string | undefined>>,
+ );
+ const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger);
+
+ // Assemble the credential list. MVP keeps the hardcoded `opencode` credential
+ // and adds a `claude` credential when an external Anthropic provider is loaded.
+ const credentials = [{ name: "opencode", providerId: "openai-compat" }];
+
+ // The umans credential is always listed (it's the model-catalog index); the
+ // provider itself only registers when UMANS_API_KEY is set, so listCatalog
+ // gracefully skips it when the provider is absent.
+ if (process.env.UMANS_API_KEY) {
+ credentials.push({ name: "umans", providerId: "umans" });
+ logger.info(`Registered credential "umans" → umans provider`);
+ }
+ const hasAnthropic = externalExtensions.some((e) =>
+ e.manifest.contributes?.providers?.includes("anthropic"),
+ );
+ if (hasAnthropic) {
+ const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude";
+ credentials.push({ name: claudeName, providerId: "anthropic" });
+ logger.info(`Registered credential "${claudeName}" → anthropic provider`);
+ }
+
+ const extensions: Extension[] = [
+ ...CORE_EXTENSIONS,
+ createCredentialStoreExtension({ credentials }),
+ ...externalExtensions,
+ ];
+
+ const host = createHost(extensions, deps);
+ await host.activate();
+
+ const disabled = host.getDisabled();
+ if (disabled.length > 0) {
+ for (const d of disabled) {
+ logger.warn(`Extension "${d.manifest.id}" disabled: ${d.reason}`);
+ }
+ }
+
+ let shuttingDown = false;
+ const shutdown = async () => {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ logger.info("Shutting down — deactivating extensions");
+ await host.deactivate();
+ logger.info("Draining collector");
+ await supervisor?.stop();
+ process.exit(0);
+ };
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+
+ logger.info("Dispatch booted");
+ console.info("Dispatch booted");
}
boot().catch((err) => {
- console.error("Fatal boot error:", err);
- process.exit(1);
+ console.error("Fatal boot error:", err);
+ process.exit(1);
});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index ca9ce93..96cd3a3 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -1,88 +1,88 @@
import type { ConversationStore } from "@dispatch/conversation-store";
import type {
- AgentEvent,
- ChatMessage,
- CompactionResult,
- ConversationStatus,
- EventHookDescriptor,
- Logger,
- ModelInfo,
- ProviderContract,
- ProviderEvent,
- ProviderStreamOptions,
- ReasoningEffort,
- RetryStrategy,
- RunTurnInput,
- RunTurnResult,
- ToolContract,
- ToolDispatchPolicy,
- UsageEvent,
+ AgentEvent,
+ ChatMessage,
+ CompactionResult,
+ ConversationStatus,
+ EventHookDescriptor,
+ Logger,
+ ModelInfo,
+ ProviderContract,
+ ProviderEvent,
+ ProviderStreamOptions,
+ ReasoningEffort,
+ RetryStrategy,
+ RunTurnInput,
+ RunTurnResult,
+ ToolContract,
+ ToolDispatchPolicy,
+ UsageEvent,
} from "@dispatch/kernel";
import { defineEventHook, defineService, type ServiceHandle } from "@dispatch/kernel";
import type { MessageQueueService, QueuedMessage } from "@dispatch/message-queue";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { createMetricsAccumulator } from "./metrics.js";
import {
- buildUserMessage,
- defaultDispatchPolicy,
- delayFor,
- generateTurnId,
- resolveModelName,
- resolveReasoningEffort,
+ buildUserMessage,
+ defaultDispatchPolicy,
+ delayFor,
+ generateTurnId,
+ resolveModelName,
+ resolveReasoningEffort,
} from "./pure.js";
import type { ToolAssembly } from "./tools-filter.js";
// --- Broadcast hub types ---
export interface StartTurnInput {
- readonly conversationId: string;
- readonly text: string;
- readonly modelName?: string;
- readonly cwd?: string;
- /**
- * The computer to execute this turn's tools on (SSH config alias). Mirrors
- * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`.
- * Omitted/`undefined` = use the persisted per-conversation / workspace
- * default (LOCAL when none set). The orchestrator never interprets it — it
- * forwards the alias string verbatim (like cwd forwards a path).
- */
- readonly computerId?: string;
- readonly reasoningEffort?: ReasoningEffort;
- /**
- * The workspace this conversation belongs to. Defaults to `"default"` when
- * omitted. On the first turn for a new conversation, the workspaceId is
- * persisted (the workspace is auto-created if missing) so subsequent turns
- * resolve the effective cwd from the workspace's `defaultCwd`.
- */
- readonly workspaceId?: string;
- /**
- * An EXPLICIT system-prompt override. When provided (a string, including the
- * empty string), it is sent to the provider AS-IS and the system-prompt
- * SERVICE is bypassed entirely (no template construction, no
- * persist/reuse). This is how a caller that owns its own prompt (e.g. the
- * heartbeat extension) drives a turn without the templated workspace prompt.
- * Omitted/`undefined` = the existing behavior (construct/reuse via the
- * system-prompt service when loaded).
- */
- readonly systemPrompt?: string;
+ readonly conversationId: string;
+ readonly text: string;
+ readonly modelName?: string;
+ readonly cwd?: string;
+ /**
+ * The computer to execute this turn's tools on (SSH config alias). Mirrors
+ * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`.
+ * Omitted/`undefined` = use the persisted per-conversation / workspace
+ * default (LOCAL when none set). The orchestrator never interprets it — it
+ * forwards the alias string verbatim (like cwd forwards a path).
+ */
+ readonly computerId?: string;
+ readonly reasoningEffort?: ReasoningEffort;
+ /**
+ * The workspace this conversation belongs to. Defaults to `"default"` when
+ * omitted. On the first turn for a new conversation, the workspaceId is
+ * persisted (the workspace is auto-created if missing) so subsequent turns
+ * resolve the effective cwd from the workspace's `defaultCwd`.
+ */
+ readonly workspaceId?: string;
+ /**
+ * An EXPLICIT system-prompt override. When provided (a string, including the
+ * empty string), it is sent to the provider AS-IS and the system-prompt
+ * SERVICE is bypassed entirely (no template construction, no
+ * persist/reuse). This is how a caller that owns its own prompt (e.g. the
+ * heartbeat extension) drives a turn without the templated workspace prompt.
+ * Omitted/`undefined` = the existing behavior (construct/reuse via the
+ * system-prompt service when loaded).
+ */
+ readonly systemPrompt?: string;
}
export type StartTurnResult =
- | { readonly started: true; readonly turnId: string }
- | { readonly started: false; readonly reason: "already-active" };
+ | { readonly started: true; readonly turnId: string }
+ | { readonly started: false; readonly reason: "already-active" };
/** Input to `SessionOrchestrator.enqueue` — the single entry transports call. */
export interface EnqueueInput {
- readonly conversationId: string;
- readonly text: string;
- /** Workspace to stamp on a new conversation. Defaults to `"default"`. */
- readonly workspaceId?: string;
- /**
- * Per-turn computer override (SSH alias), threaded to `startTurn` when the
- * conversation is idle (the message starts a turn). Additive optional —
- * mirrors `workspaceId` on this type (enqueue does not carry `cwd`).
- */
- readonly computerId?: string;
+ readonly conversationId: string;
+ readonly text: string;
+ /** Workspace to stamp on a new conversation. Defaults to `"default"`. */
+ readonly workspaceId?: string;
+ /**
+ * Per-turn computer override (SSH alias), threaded to `startTurn` when the
+ * conversation is idle (the message starts a turn). Additive optional —
+ * mirrors `workspaceId` on this type (enqueue does not carry `cwd`).
+ */
+ readonly computerId?: string;
}
/**
@@ -94,41 +94,41 @@ export interface EnqueueInput {
* is dropped, see `enqueue` docs).
*/
export interface EnqueueResult {
- readonly startedTurn: boolean;
- readonly queue: readonly QueuedMessage[];
+ readonly startedTurn: boolean;
+ readonly queue: readonly QueuedMessage[];
}
export type TurnEventListener = (event: AgentEvent) => void;
interface ActiveTurn {
- buffer: AgentEvent[];
- turnId: string;
- /** Aborts this turn's kernel runTurn (closeConversation). */
- controller: AbortController;
+ buffer: AgentEvent[];
+ turnId: string;
+ /** Aborts this turn's kernel runTurn (closeConversation). */
+ controller: AbortController;
}
// --- Lifecycle event hooks ---
/** Context carried on turn-lifecycle events, enough to replicate the turn's request prefix. */
export interface TurnLifecyclePayload {
- readonly conversationId: string;
- readonly cwd?: string;
- /** The computer this turn executes on (SSH alias), mirroring `cwd`. */
- readonly computerId?: string;
- readonly modelName?: string;
+ readonly conversationId: string;
+ readonly cwd?: string;
+ /** The computer this turn executes on (SSH alias), mirroring `cwd`. */
+ readonly computerId?: string;
+ readonly modelName?: string;
}
/** Fired when a turn STARTS driving a conversation (consumers cancel warming timers). */
export const turnStarted: EventHookDescriptor<TurnLifecyclePayload> =
- defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started");
+ defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-started");
/** Fired when a turn SETTLES (sealed) for a conversation (consumers arm warming timers). */
export const turnSettled: EventHookDescriptor<TurnLifecyclePayload> =
- defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled");
+ defineEventHook<TurnLifecyclePayload>("session-orchestrator/turn-settled");
/** Payload for the conversationClosed bus event. */
export interface ConversationClosedPayload {
- readonly conversationId: string;
+ readonly conversationId: string;
}
/**
@@ -137,17 +137,17 @@ export interface ConversationClosedPayload {
* disables its schedule). Emitted by `SessionOrchestrator.closeConversation`.
*/
export const conversationClosed: EventHookDescriptor<ConversationClosedPayload> =
- defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed");
+ defineEventHook<ConversationClosedPayload>("session-orchestrator/conversation-closed");
/** Payload for the conversationOpened bus event. */
export interface ConversationOpenedPayload {
- readonly conversationId: string;
- /**
- * The conversation's actual persisted workspace id (resolved from the
- * store, not the per-turn start option), so a frontend can open/focus the
- * tab in the correct workspace. Falls back to `"default"`.
- */
- readonly workspaceId: string;
+ readonly conversationId: string;
+ /**
+ * The conversation's actual persisted workspace id (resolved from the
+ * store, not the per-turn start option), so a frontend can open/focus the
+ * tab in the correct workspace. Falls back to `"default"`.
+ */
+ readonly workspaceId: string;
}
/**
@@ -157,18 +157,18 @@ export interface ConversationOpenedPayload {
* open/focus a tab — the backend just signals.
*/
export const conversationOpened: EventHookDescriptor<ConversationOpenedPayload> =
- defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened");
+ defineEventHook<ConversationOpenedPayload>("session-orchestrator/conversation-opened");
/** Payload for the conversationStatusChanged bus event. */
export interface ConversationStatusChangedPayload {
- readonly conversationId: string;
- readonly status: ConversationStatus;
- /**
- * The conversation's actual persisted workspace id (resolved from the
- * store, not the per-turn start option), so a frontend can sync the tab
- * in the correct workspace. Falls back to `"default"`.
- */
- readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly status: ConversationStatus;
+ /**
+ * The conversation's actual persisted workspace id (resolved from the
+ * store, not the per-turn start option), so a frontend can sync the tab
+ * in the correct workspace. Falls back to `"default"`.
+ */
+ readonly workspaceId: string;
}
/**
@@ -177,16 +177,16 @@ export interface ConversationStatusChangedPayload {
* message to all connected frontend clients so tabs sync across devices.
*/
export const conversationStatusChanged: EventHookDescriptor<ConversationStatusChangedPayload> =
- defineEventHook<ConversationStatusChangedPayload>(
- "session-orchestrator/conversation-status-changed",
- );
+ defineEventHook<ConversationStatusChangedPayload>(
+ "session-orchestrator/conversation-status-changed",
+ );
/** Payload for the conversationCompacted bus event. */
export interface ConversationCompactedPayload {
- readonly conversationId: string;
- readonly newConversationId: string;
- readonly messagesSummarized: number;
- readonly messagesKept: number;
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
}
/**
@@ -195,165 +195,165 @@ export interface ConversationCompactedPayload {
* broadcasts a `conversation.compacted` WS message so the FE reloads history.
*/
export const conversationCompacted: EventHookDescriptor<ConversationCompactedPayload> =
- defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted");
+ defineEventHook<ConversationCompactedPayload>("session-orchestrator/conversation-compacted");
/** Payload for the warmCompleted bus event. */
export interface WarmCompletedPayload {
- readonly conversationId: string;
- readonly usage: WarmResult;
+ readonly conversationId: string;
+ readonly usage: WarmResult;
}
/** Fired when a warm probe succeeds (both automatic and manual paths). */
export const warmCompleted: EventHookDescriptor<WarmCompletedPayload> =
- defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed");
+ defineEventHook<WarmCompletedPayload>("session-orchestrator/warm-completed");
// --- Warm service ---
export interface WarmResult {
- readonly inputTokens: number;
- readonly outputTokens: number;
- readonly cacheReadTokens: number;
- readonly cacheWriteTokens: number;
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cacheReadTokens: number;
+ readonly cacheWriteTokens: number;
}
export interface WarmService {
- readonly warm: (
- conversationId: string,
- opts?: { readonly cwd?: string; readonly modelName?: string },
- ) => Promise<WarmResult | { readonly error: string }>;
+ readonly warm: (
+ conversationId: string,
+ opts?: { readonly cwd?: string; readonly modelName?: string },
+ ) => Promise<WarmResult | { readonly error: string }>;
}
export const cacheWarmHandle: ServiceHandle<WarmService> = defineService<WarmService>(
- "session-orchestrator/warm",
+ "session-orchestrator/warm",
);
// --- Compaction service ---
export interface CompactionService {
- /**
- * Compact a conversation: summarize old messages and replace history with
- * the summary + the most recent `keepLastN` messages. Returns the result
- * or an error object. No-ops if the conversation is too short (≤ keepLastN
- * messages). When `auto` is true, checks the compact-threshold setting and
- * only compacts if the last turn's input tokens exceeded it.
- */
- readonly compact: (
- conversationId: string,
- opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean },
- ) => Promise<CompactionResult | { readonly error: string }>;
+ /**
+ * Compact a conversation: summarize old messages and replace history with
+ * the summary + the most recent `keepLastN` messages. Returns the result
+ * or an error object. No-ops if the conversation is too short (≤ keepLastN
+ * messages). When `auto` is true, checks the compact-threshold setting and
+ * only compacts if the last turn's input tokens exceeded it.
+ */
+ readonly compact: (
+ conversationId: string,
+ opts?: { readonly keepLastN?: number; readonly modelName?: string; readonly auto?: boolean },
+ ) => Promise<CompactionResult | { readonly error: string }>;
}
export const compactionHandle: ServiceHandle<CompactionService> = defineService<CompactionService>(
- "session-orchestrator/compaction",
+ "session-orchestrator/compaction",
);
export interface SessionOrchestrator {
- startTurn(input: StartTurnInput): StartTurnResult;
- /**
- * The single entry transports call to deliver a user message. Owns the
- * idle→startTurn vs active→queue decision (no separate `isActive` race —
- * `startTurn`'s single-flight guard is authoritative). When the conversation
- * is idle, starts a turn (the message is the opening prompt). When active,
- * enqueues onto the steering queue (if the message-queue extension is
- * loaded); with no queue extension loaded the message is dropped and the
- * returned snapshot is empty (degraded — feature off).
- */
- enqueue(input: EnqueueInput): EnqueueResult;
- subscribe(conversationId: string, listener: TurnEventListener): () => void;
- isActive(conversationId: string): boolean;
- /**
- * Explicitly close a conversation (the user closed its tab — distinct from a
- * socket disconnect, which never touches the turn): aborts any in-flight turn
- * (the kernel finishes with `finishReason: "aborted"`, partial messages are
- * persisted and the turn seals normally) and emits the `conversationClosed`
- * hook so per-conversation background work (cache-warming) stops.
- * Idempotent — closing an idle/unknown conversation just emits the hook.
- */
- closeConversation(conversationId: string): { readonly abortedTurn: boolean };
- /**
- * Stop an in-flight generation WITHOUT closing the conversation. Aborts
- * the turn's AbortController — the kernel finishes with
- * `finishReason: "aborted"`, partial messages are persisted, and the turn
- * seals normally (status transitions active → idle via the normal settle
- * path). Idempotent — stopping an idle/unknown conversation is a no-op.
- */
- stopTurn(conversationId: string): { readonly abortedTurn: boolean };
- handleMessage(input: {
- conversationId: string;
- text: string;
- onEvent: (event: AgentEvent) => void;
- modelName?: string;
- cwd?: string;
- computerId?: string;
- reasoningEffort?: ReasoningEffort;
- workspaceId?: string;
- /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */
- systemPrompt?: string;
- }): Promise<void>;
+ startTurn(input: StartTurnInput): StartTurnResult;
+ /**
+ * The single entry transports call to deliver a user message. Owns the
+ * idle→startTurn vs active→queue decision (no separate `isActive` race —
+ * `startTurn`'s single-flight guard is authoritative). When the conversation
+ * is idle, starts a turn (the message is the opening prompt). When active,
+ * enqueues onto the steering queue (if the message-queue extension is
+ * loaded); with no queue extension loaded the message is dropped and the
+ * returned snapshot is empty (degraded — feature off).
+ */
+ enqueue(input: EnqueueInput): EnqueueResult;
+ subscribe(conversationId: string, listener: TurnEventListener): () => void;
+ isActive(conversationId: string): boolean;
+ /**
+ * Explicitly close a conversation (the user closed its tab — distinct from a
+ * socket disconnect, which never touches the turn): aborts any in-flight turn
+ * (the kernel finishes with `finishReason: "aborted"`, partial messages are
+ * persisted and the turn seals normally) and emits the `conversationClosed`
+ * hook so per-conversation background work (cache-warming) stops.
+ * Idempotent — closing an idle/unknown conversation just emits the hook.
+ */
+ closeConversation(conversationId: string): { readonly abortedTurn: boolean };
+ /**
+ * Stop an in-flight generation WITHOUT closing the conversation. Aborts
+ * the turn's AbortController — the kernel finishes with
+ * `finishReason: "aborted"`, partial messages are persisted, and the turn
+ * seals normally (status transitions active → idle via the normal settle
+ * path). Idempotent — stopping an idle/unknown conversation is a no-op.
+ */
+ stopTurn(conversationId: string): { readonly abortedTurn: boolean };
+ handleMessage(input: {
+ conversationId: string;
+ text: string;
+ onEvent: (event: AgentEvent) => void;
+ modelName?: string;
+ cwd?: string;
+ computerId?: string;
+ reasoningEffort?: ReasoningEffort;
+ workspaceId?: string;
+ /** Explicit system-prompt override — see {@link StartTurnInput.systemPrompt}. */
+ systemPrompt?: string;
+ }): Promise<void>;
}
export const sessionOrchestratorHandle = defineService<SessionOrchestrator>(
- "session-orchestrator/orchestrator",
+ "session-orchestrator/orchestrator",
);
export interface SessionOrchestratorDeps {
- readonly conversationStore: ConversationStore;
- readonly resolveProvider: () => ProviderContract;
- readonly resolveTools: () => readonly ToolContract[];
- readonly resolveDispatch?: () => ToolDispatchPolicy;
- readonly resolveModel?: (
- modelName: string,
- ) => { provider: ProviderContract; model: string } | undefined;
- /**
- * Resolve full `ModelInfo` (including `contextWindow`) for a model name.
- * Used by the compaction service to calculate the auto-compact threshold
- * as a percentage of the context window.
- */
- readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>;
- readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
- /**
- * Lazily resolves the message-queue service (the steering queue), or
- * `undefined` when the message-queue extension isn't loaded (the feature
- * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops
- * messages when active). host-bin wires this via `host.getService`; the
- * orchestrator calls it per-turn / per-enqueue so activation order with the
- * message-queue extension doesn't matter. Injected (not ambient) so a turn
- * stays reproducible from its inputs and tests use a fake queue.
- */
- readonly resolveQueue?: () => MessageQueueService | undefined;
- /**
- * Lazily resolves the compaction service, or `undefined` when not loaded.
- * Used for automatic compaction after a turn settles (if the compact
- * threshold is exceeded). Lazy so activation order doesn't matter.
- */
- readonly resolveCompaction?: () => CompactionService | undefined;
- /**
- * Lazily resolves the system-prompt service, or `undefined` when the
- * system-prompt extension isn't loaded. Used to construct the per-
- * conversation system prompt once (first turn) and reuse it (cache-safe) on
- * subsequent turns, and to reconstruct it on compaction. Lazy so activation
- * order doesn't matter.
- */
- readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
- /** Apply the per-turn tools filter chain. Injected for testability. */
- readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>;
- /** Base logger (auto-scoped to this extension); childed per turn for span capture. */
- readonly logger?: Logger;
- /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */
- readonly now?: () => number;
- /** Emit a lifecycle event hook to subscribers. Injected from host. */
- readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+ readonly conversationStore: ConversationStore;
+ readonly resolveProvider: () => ProviderContract;
+ readonly resolveTools: () => readonly ToolContract[];
+ readonly resolveDispatch?: () => ToolDispatchPolicy;
+ readonly resolveModel?: (
+ modelName: string,
+ ) => { provider: ProviderContract; model: string } | undefined;
+ /**
+ * Resolve full `ModelInfo` (including `contextWindow`) for a model name.
+ * Used by the compaction service to calculate the auto-compact threshold
+ * as a percentage of the context window.
+ */
+ readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>;
+ readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
+ /**
+ * Lazily resolves the message-queue service (the steering queue), or
+ * `undefined` when the message-queue extension isn't loaded (the feature
+ * degrades off: no `drainSteering`, no post-seal carry, `enqueue` drops
+ * messages when active). host-bin wires this via `host.getService`; the
+ * orchestrator calls it per-turn / per-enqueue so activation order with the
+ * message-queue extension doesn't matter. Injected (not ambient) so a turn
+ * stays reproducible from its inputs and tests use a fake queue.
+ */
+ readonly resolveQueue?: () => MessageQueueService | undefined;
+ /**
+ * Lazily resolves the compaction service, or `undefined` when not loaded.
+ * Used for automatic compaction after a turn settles (if the compact
+ * threshold is exceeded). Lazy so activation order doesn't matter.
+ */
+ readonly resolveCompaction?: () => CompactionService | undefined;
+ /**
+ * Lazily resolves the system-prompt service, or `undefined` when the
+ * system-prompt extension isn't loaded. Used to construct the per-
+ * conversation system prompt once (first turn) and reuse it (cache-safe) on
+ * subsequent turns, and to reconstruct it on compaction. Lazy so activation
+ * order doesn't matter.
+ */
+ readonly resolveSystemPrompt?: () => SystemPromptService | undefined;
+ /** Apply the per-turn tools filter chain. Injected for testability. */
+ readonly applyToolsFilter: (assembly: ToolAssembly) => Promise<ToolAssembly>;
+ /** Base logger (auto-scoped to this extension); childed per turn for span capture. */
+ readonly logger?: Logger;
+ /** Injected monotonic-ish clock (ms) forwarded to RunTurnInput for timing events. */
+ readonly now?: () => number;
+ /** Emit a lifecycle event hook to subscribers. Injected from host. */
+ readonly emit?: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
}
/** Deps for the warm service — emit is REQUIRED so warmCompleted is never silently dropped. */
export type WarmServiceDeps = SessionOrchestratorDeps & {
- readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+ readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
};
export interface SessionOrchestratorBundle {
- readonly orchestrator: SessionOrchestrator;
- /** The shared active-conversations set, for use by createWarmService. */
- readonly activeConversations: ReadonlySet<string>;
+ readonly orchestrator: SessionOrchestrator;
+ /** The shared active-conversations set, for use by createWarmService. */
+ readonly activeConversations: ReadonlySet<string>;
}
/**
@@ -366,871 +366,871 @@ export interface SessionOrchestratorBundle {
* turn `aborted`). The kernel imports no timer; this is the shell-provided I/O.
*/
export function createRetryStrategy(): RetryStrategy {
- const sleep = (ms: number, signal: AbortSignal): Promise<void> => {
- return new Promise((resolve, reject) => {
- if (signal.aborted) {
- reject(new Error("aborted"));
- return;
- }
- const timer = setTimeout(() => {
- signal.removeEventListener("abort", onAbort);
- resolve();
- }, ms);
- const onAbort = () => {
- clearTimeout(timer);
- reject(new Error("aborted"));
- };
- signal.addEventListener("abort", onAbort, { once: true });
- });
- };
- return { delayFor, sleep };
+ const sleep = (ms: number, signal: AbortSignal): Promise<void> => {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new Error("aborted"));
+ return;
+ }
+ const timer = setTimeout(() => {
+ signal.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new Error("aborted"));
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+ };
+ return { delayFor, sleep };
}
export function createSessionOrchestrator(
- deps: SessionOrchestratorDeps,
+ deps: SessionOrchestratorDeps,
): SessionOrchestratorBundle {
- const activeConversations = new Set<string>();
- const subscribers = new Map<string, Set<TurnEventListener>>();
- const activeTurns = new Map<string, ActiveTurn>();
- // One stateless retry strategy shared by every turn (delayFor is pure; sleep
- // is a stateless setTimeout closure). Wired into each RunTurnInput.retry.
- const retryStrategy = createRetryStrategy();
-
- function emitToHub(conversationId: string, event: AgentEvent): void {
- const turn = activeTurns.get(conversationId);
- if (turn !== undefined) {
- turn.buffer.push(event);
- }
- const listeners = subscribers.get(conversationId);
- if (listeners !== undefined) {
- for (const listener of listeners) {
- listener(event);
- }
- }
- }
-
- /**
- * Post-seal carry: if a steering queue is available and non-empty, drain it,
- * combine, and start a NEW detached turn whose opening `user-message` carries
- * the combined text (no `steering` event — that's only for mid-turn drain).
- * Returns true iff a new turn was started. Called from `runTurnDetached`'s
- * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard
- * passes) and BEFORE `activeConversations.delete` (skipped when carried, since
- * the new turn re-adds it). May chain — the new turn's own finally re-checks.
- */
- function tryCarryQueue(conversationId: string): boolean {
- const queue = deps.resolveQueue?.();
- if (queue === undefined) return false;
- if (queue.getQueue(conversationId).length === 0) return false;
- const drained = queue.drain(conversationId);
- const combined = drained.map((q) => q.text).join("\n\n");
- const result = orchestrator.startTurn({ conversationId, text: combined });
- return result.started;
- }
-
- function runTurnDetached(
- conversationId: string,
- text: string,
- modelName: string | undefined,
- cwd: string | undefined,
- computerId: string | undefined,
- reasoningEffortOverride: ReasoningEffort | undefined,
- workspaceId: string,
- systemPromptOverride: string | undefined,
- ): void {
- const turnId = generateTurnId();
- const controller = new AbortController();
- activeTurns.set(conversationId, { buffer: [], turnId, controller });
- activeConversations.add(conversationId);
-
- emitToHub(conversationId, { type: "user-message", conversationId, turnId, text });
-
- // For a NEW conversation the workspace MUST be assigned (persisted)
- // BEFORE getEffectiveCwd runs, so the effective cwd resolves against
- // the intended workspace's defaultCwd rather than the stale "default"
- // workspace returned by getWorkspaceId for a not-yet-persisted
- // conversation. Detect newness via getConversationMeta === null
- // (equivalent to history.length === 0 in practice). Existing
- // conversations keep their assigned workspace — never overwritten.
- // The newness flag is also reused to decide whether to construct
- // (first turn) or get (subsequent turn) the system prompt — see the
- // providerOpts assembly below.
- const workspaceSetupPromise = (async (): Promise<boolean> => {
- const meta = await deps.conversationStore.getConversationMeta(conversationId);
- if (meta === null) {
- await deps.conversationStore.ensureWorkspace(workspaceId);
- await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
- return true;
- }
- return false;
- })();
-
- // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the
- // per-turn cwd as the overrideCwd when present. A relative per-turn cwd
- // (e.g. "arch-rewrite") must be resolved against the workspace's
- // defaultCwd via the same workspace-relative algorithm the persisted cwd
- // uses — NOT used raw (which would resolve against process.cwd() and
- // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd.
- // Chained after workspaceSetupPromise so the workspace is assigned
- // first for new conversations (the timing invariant this enforces).
- const effectiveCwdPromise = workspaceSetupPromise.then(() =>
- deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined),
- );
-
- // Resolve the effective computer the SAME way cwd resolves — pass the
- // per-turn computerId as the overrideAlias. When computerId is
- // undefined, getEffectiveComputer reads the persisted per-conversation
- // computerId → workspace defaultComputerId → null (LOCAL). Chained
- // after workspaceSetupPromise (same timing invariant as cwd). The
- // orchestrator never interprets the alias — it forwards the string
- // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise.
- const effectiveComputerIdPromise = workspaceSetupPromise.then(() =>
- deps.conversationStore
- .getEffectiveComputer(conversationId, computerId)
- .then((c) => c ?? undefined),
- );
-
- const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
- // Resolve the persisted model (if any) in parallel with the other
- // per-conversation reads. The effective model name is
- // per-turn override → persisted → (undefined → default provider), the
- // same resolution chain as `resolveReasoningEffort`.
- const storedModelPromise = deps.conversationStore.getModel(conversationId);
-
- const payloadPromise = Promise.all([
- effectiveCwdPromise,
- effectiveComputerIdPromise,
- storedEffortPromise,
- storedModelPromise,
- ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => {
- const effectiveModelName = resolveModelName(modelName, storedModel);
- return {
- conversationId,
- ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}),
- };
- });
-
- payloadPromise.then((payload) => {
- deps.emit?.(turnStarted, payload);
- // Resolve the persisted workspace id (not the per-turn start option)
- // before emitting so the broadcast carries the correct workspace.
- void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "active",
- workspaceId,
- });
- });
- void deps.conversationStore.setConversationStatus(conversationId, "active");
- });
-
- void (async () => {
- let sealed = false;
- try {
- const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] =
- await Promise.all([
- effectiveCwdPromise,
- effectiveComputerIdPromise,
- storedEffortPromise,
- workspaceSetupPromise,
- storedModelPromise,
- ]);
-
- if (cwd !== undefined) {
- await deps.conversationStore.setCwd(conversationId, cwd);
- }
-
- // Persist the per-turn computer override, mirroring the cwd
- // persistence above. Only stamped when a computerId was actually
- // provided — NOT when it resolved to undefined (LOCAL) via the
- // workspace default. Idempotent when the value is unchanged.
- if (computerId !== undefined) {
- await deps.conversationStore.setComputerId(conversationId, computerId);
- }
-
- const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort);
- // Effective model name: per-turn override → persisted → undefined
- // (→ default provider). Resolved here so every downstream consumer
- // (resolveModel, system prompt, payload) sees the same model as if
- // the caller had passed it explicitly.
- const effectiveModelName = resolveModelName(modelName, storedModel);
-
- const history = await deps.conversationStore.load(conversationId);
- const userMsg = buildUserMessage(text);
-
- // Workspace assignment for new conversations happens BEFORE
- // effective-cwd resolution (see workspaceSetupPromise above) so
- // getEffectiveCwd resolves against the intended workspace, not
- // the stale "default". The history-load + append flow below is
- // otherwise unchanged.
-
- let provider: ProviderContract;
- let modelOverride: string | undefined;
-
- if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(effectiveModelName);
- if (resolved === undefined) {
- emitToHub(conversationId, {
- type: "error",
- conversationId,
- turnId,
- message: `unknown model: ${effectiveModelName}`,
- });
- return;
- }
- provider = resolved.provider;
- modelOverride = resolved.model;
- // Persist the resolved model so it sticks for future turns
- // and browser sessions (per-conversation model persistence).
- // Only stamped when a model was actually used — NOT on the
- // default-provider fallthrough (nothing to persist). Idempotent
- // when the value is unchanged (re-stamps the same persisted
- // model). The early `return` above means an unknown model is
- // never persisted.
- await deps.conversationStore.setModel(conversationId, effectiveModelName);
- } else {
- provider = deps.resolveProvider();
- }
-
- const baseTools = deps.resolveTools();
- const assembled = await deps.applyToolsFilter({
- tools: baseTools,
- conversationId,
- ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- });
- const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
- const turnLogger = deps.logger?.child({ conversationId, turnId });
- const metrics = createMetricsAccumulator();
-
- const emitAndAccumulate = (event: AgentEvent): void => {
- metrics.ingest(event);
- emitToHub(conversationId, event);
- };
-
- // Resolve the system prompt for this turn (cache-safe). On the
- // FIRST turn of a new conversation, construct it once (resolves all
- // template variables + persists the result). On subsequent turns,
- // reuse the persisted prompt via `getWithMeta` — but ONLY when the
- // stored cwd matches the current effective cwd. If the cwd changed
- // since the prompt was constructed (or no prompt was ever stored),
- // reconstruct against the new cwd so the prompt is never stale.
- // This preserves the cache-safe design (construct once per cwd,
- // reuse on subsequent turns with the same cwd) while fixing the bug
- // where a cwd change left the prompt stale. When the system-prompt
- // service isn't loaded, no system prompt is sent (current behavior
- // preserved).
- //
- // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a
- // string, incl. empty), it is sent to the provider AS-IS and the
- // system-prompt service is bypassed entirely (no construct/reuse).
- // This lets a caller that owns its own prompt (e.g. the heartbeat
- // extension) drive a turn without the templated workspace prompt.
- let systemPrompt: string | undefined;
- if (systemPromptOverride !== undefined) {
- systemPrompt = systemPromptOverride;
- } else {
- const systemPromptService = deps.resolveSystemPrompt?.();
- if (systemPromptService !== undefined) {
- if (isNewConversation) {
- systemPrompt = await systemPromptService.construct(
- conversationId,
- effectiveCwd ?? process.cwd(),
- {
- ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- },
- );
- } else {
- const meta = await systemPromptService.getWithMeta(conversationId);
- const currentCwd = effectiveCwd ?? process.cwd();
- const currentComputerId = effectiveComputerId ?? null;
- // Invalidate when cwd OR computerId changed (switching computers
- // must rebuild the prompt against the remote OS/hostname).
- if (
- meta.prompt !== null &&
- meta.cwd === currentCwd &&
- meta.computerId === currentComputerId
- ) {
- systemPrompt = meta.prompt;
- } else {
- systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
- ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- });
- }
- }
- }
- }
-
- const providerOpts: ProviderStreamOptions = {
- reasoningEffort: resolvedEffort,
- ...(modelOverride !== undefined ? { model: modelOverride } : {}),
- ...(systemPrompt !== undefined ? { systemPrompt } : {}),
- };
-
- // Resolve the steering queue once for this turn. When present, wire
- // `drainSteering`: the kernel calls it at the tool-result boundary and
- // appends whatever it returns as user-role messages alongside the tool
- // results (mid-turn steering). The wrapper emits a `steering` AgentEvent
- // into the hub (buffered for late-join like `user-message`) so a
- // frontend can place a user bubble in the transcript live; the kernel
- // only appends the returned messages — it does NOT emit the event.
- const queue = deps.resolveQueue?.();
- const drainSteering =
- queue === undefined
- ? undefined
- : (): readonly ChatMessage[] => {
- const queued = queue.drain(conversationId);
- if (queued.length === 0) return [];
- const steerText = queued.map((q) => q.text).join("\n\n");
- emitToHub(conversationId, {
- type: "steering",
- conversationId,
- turnId,
- text: steerText,
- });
- return [{ role: "user", chunks: [{ type: "text", text: steerText }] }];
- };
-
- const opts: RunTurnInput = {
- provider,
- messages: [...history, userMsg],
- tools: assembled.tools,
- dispatch,
- emit: emitAndAccumulate,
- conversationId,
- turnId,
- signal: controller.signal,
- providerOpts,
- retry: retryStrategy,
- ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
- ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
- ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
- ...(deps.now !== undefined ? { now: deps.now } : {}),
- ...(drainSteering !== undefined ? { drainSteering } : {}),
- };
-
- // Persist the user message at turn start so it has a seq
- // number before the first step generates. This enables the
- // FE to syncTail during generation (CR-6).
- await deps.conversationStore.append(conversationId, [userMsg]);
-
- let stepsPersisted = false;
- const result = await deps.runTurn({
- ...opts,
- // Incremental persistence: persist each step's messages
- // as they are finalized. Seq numbers are assigned during
- // generation, so the FE can GET /conversations/:id?sinceSeq=N
- // mid-turn and pick up committed chunks (CR-6).
- onStepComplete: async (stepMessages) => {
- await deps.conversationStore.append(conversationId, stepMessages);
- stepsPersisted = true;
- },
- });
-
- // Fallback: if onStepComplete was never called (e.g., a fake
- // runTurn in tests), persist all result messages as a batch.
- if (!stepsPersisted && result.messages.length > 0) {
- await deps.conversationStore.append(conversationId, result.messages);
- }
-
- const turnMetrics = metrics.build(turnId);
- await deps.conversationStore.appendMetrics(conversationId, turnMetrics);
-
- emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId });
- sealed = true;
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- emitToHub(conversationId, {
- type: "error",
- conversationId,
- turnId,
- message,
- });
- } finally {
- activeTurns.delete(conversationId);
- // Post-seal carry: if the turn sealed with a non-empty steering queue
- // (no tool call fired → drainSteering never drained it), start a NEW
- // detached turn whose opening user-message carries the combined text.
- // The new turn re-adds to activeTurns + activeConversations, so skip
- // the activeConversations.delete when carried. May chain (user keeps
- // steering) — each carried turn's own finally re-checks the queue.
- const carried = sealed && tryCarryQueue(conversationId);
- if (!carried) {
- activeConversations.delete(conversationId);
- }
- void payloadPromise.then((payload) => {
- deps.emit?.(turnSettled, payload);
- if (!carried) {
- // Resolve the persisted workspace id before emitting so the
- // broadcast carries the correct workspace.
- void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "idle",
- workspaceId,
- });
- });
- void deps.conversationStore.setConversationStatus(conversationId, "idle");
- // Fire-and-forget auto-compaction: check threshold and
- // compact if exceeded. Non-blocking — the next turn
- // starts fresh either way.
- const compaction = deps.resolveCompaction?.();
- if (compaction !== undefined) {
- void compaction
- .compact(conversationId, {
- auto: true,
- ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
- })
- .catch(() => {});
- }
- }
- });
- }
- })();
- }
-
- const orchestrator: SessionOrchestrator = {
- startTurn({
- conversationId,
- text,
- modelName,
- cwd,
- computerId,
- reasoningEffort,
- workspaceId,
- systemPrompt,
- }) {
- if (activeTurns.has(conversationId)) {
- return { started: false, reason: "already-active" };
- }
- runTurnDetached(
- conversationId,
- text,
- modelName,
- cwd,
- computerId,
- reasoningEffort,
- workspaceId ?? "default",
- systemPrompt,
- );
- const turn = activeTurns.get(conversationId);
- const turnId = turn !== undefined ? turn.turnId : "";
- return { started: true, turnId };
- },
-
- enqueue({ conversationId, text, workspaceId, computerId }) {
- const result = orchestrator.startTurn({
- conversationId,
- text,
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(computerId !== undefined ? { computerId } : {}),
- });
- if (result.started) {
- return { startedTurn: true, queue: [] };
- }
- // Already active → enqueue onto the steering queue. When the
- // message-queue extension isn't loaded this degrades: the message is
- // dropped and the snapshot is empty (feature off).
- const queue = deps.resolveQueue?.();
- const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : [];
- return { startedTurn: false, queue: snapshot };
- },
-
- subscribe(conversationId, listener) {
- let listeners = subscribers.get(conversationId);
- if (listeners === undefined) {
- listeners = new Set();
- subscribers.set(conversationId, listeners);
- }
- const turn = activeTurns.get(conversationId);
- if (turn !== undefined) {
- const snapshot = [...turn.buffer];
- listeners.add(listener);
- for (const event of snapshot) {
- listener(event);
- }
- } else {
- listeners.add(listener);
- }
- return () => {
- const set = subscribers.get(conversationId);
- if (set !== undefined) {
- set.delete(listener);
- if (set.size === 0) {
- subscribers.delete(conversationId);
- }
- }
- };
- },
-
- isActive(conversationId) {
- return activeTurns.has(conversationId);
- },
-
- closeConversation(conversationId) {
- const turn = activeTurns.get(conversationId);
- const abortedTurn = turn !== undefined;
- if (turn !== undefined) {
- turn.controller.abort();
- }
- deps.emit?.(conversationClosed, { conversationId });
- // Resolve the persisted workspace id before emitting so the
- // broadcast carries the correct workspace. The hook is
- // fire-and-forget; closeConversation stays synchronous (returns
- // immediately) while the status-changed emit resolves async.
- void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "closed",
- workspaceId,
- });
- });
- void deps.conversationStore.setConversationStatus(conversationId, "closed");
- return { abortedTurn };
- },
-
- stopTurn(conversationId) {
- const turn = activeTurns.get(conversationId);
- const abortedTurn = turn !== undefined;
- if (turn !== undefined) {
- turn.controller.abort();
- }
- return { abortedTurn };
- },
-
- async handleMessage({
- conversationId,
- text,
- onEvent,
- modelName,
- cwd,
- computerId,
- reasoningEffort,
- workspaceId,
- systemPrompt,
- }) {
- const turnInput: StartTurnInput = {
- conversationId,
- text,
- ...(modelName !== undefined ? { modelName } : {}),
- ...(cwd !== undefined ? { cwd } : {}),
- ...(computerId !== undefined ? { computerId } : {}),
- ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
- ...(workspaceId !== undefined ? { workspaceId } : {}),
- ...(systemPrompt !== undefined ? { systemPrompt } : {}),
- };
- const result = orchestrator.startTurn(turnInput);
- if (!result.started) {
- const errorTurnId = generateTurnId();
- onEvent({
- type: "error",
- conversationId,
- turnId: errorTurnId,
- message: "turn already active for this conversation",
- });
- return;
- }
-
- await new Promise<void>((resolve) => {
- const unsubscribe = orchestrator.subscribe(conversationId, (event) => {
- onEvent(event);
- if (event.type === "turn-sealed" || event.type === "error") {
- unsubscribe();
- resolve();
- }
- });
- });
- },
- };
-
- return { orchestrator, activeConversations };
+ const activeConversations = new Set<string>();
+ const subscribers = new Map<string, Set<TurnEventListener>>();
+ const activeTurns = new Map<string, ActiveTurn>();
+ // One stateless retry strategy shared by every turn (delayFor is pure; sleep
+ // is a stateless setTimeout closure). Wired into each RunTurnInput.retry.
+ const retryStrategy = createRetryStrategy();
+
+ function emitToHub(conversationId: string, event: AgentEvent): void {
+ const turn = activeTurns.get(conversationId);
+ if (turn !== undefined) {
+ turn.buffer.push(event);
+ }
+ const listeners = subscribers.get(conversationId);
+ if (listeners !== undefined) {
+ for (const listener of listeners) {
+ listener(event);
+ }
+ }
+ }
+
+ /**
+ * Post-seal carry: if a steering queue is available and non-empty, drain it,
+ * combine, and start a NEW detached turn whose opening `user-message` carries
+ * the combined text (no `steering` event — that's only for mid-turn drain).
+ * Returns true iff a new turn was started. Called from `runTurnDetached`'s
+ * finally AFTER `activeTurns.delete` (so the new turn's single-flight guard
+ * passes) and BEFORE `activeConversations.delete` (skipped when carried, since
+ * the new turn re-adds it). May chain — the new turn's own finally re-checks.
+ */
+ function tryCarryQueue(conversationId: string): boolean {
+ const queue = deps.resolveQueue?.();
+ if (queue === undefined) return false;
+ if (queue.getQueue(conversationId).length === 0) return false;
+ const drained = queue.drain(conversationId);
+ const combined = drained.map((q) => q.text).join("\n\n");
+ const result = orchestrator.startTurn({ conversationId, text: combined });
+ return result.started;
+ }
+
+ function runTurnDetached(
+ conversationId: string,
+ text: string,
+ modelName: string | undefined,
+ cwd: string | undefined,
+ computerId: string | undefined,
+ reasoningEffortOverride: ReasoningEffort | undefined,
+ workspaceId: string,
+ systemPromptOverride: string | undefined,
+ ): void {
+ const turnId = generateTurnId();
+ const controller = new AbortController();
+ activeTurns.set(conversationId, { buffer: [], turnId, controller });
+ activeConversations.add(conversationId);
+
+ emitToHub(conversationId, { type: "user-message", conversationId, turnId, text });
+
+ // For a NEW conversation the workspace MUST be assigned (persisted)
+ // BEFORE getEffectiveCwd runs, so the effective cwd resolves against
+ // the intended workspace's defaultCwd rather than the stale "default"
+ // workspace returned by getWorkspaceId for a not-yet-persisted
+ // conversation. Detect newness via getConversationMeta === null
+ // (equivalent to history.length === 0 in practice). Existing
+ // conversations keep their assigned workspace — never overwritten.
+ // The newness flag is also reused to decide whether to construct
+ // (first turn) or get (subsequent turn) the system prompt — see the
+ // providerOpts assembly below.
+ const workspaceSetupPromise = (async (): Promise<boolean> => {
+ const meta = await deps.conversationStore.getConversationMeta(conversationId);
+ if (meta === null) {
+ await deps.conversationStore.ensureWorkspace(workspaceId);
+ await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
+ return true;
+ }
+ return false;
+ })();
+
+ // ALWAYS resolve the effective cwd through getEffectiveCwd, passing the
+ // per-turn cwd as the overrideCwd when present. A relative per-turn cwd
+ // (e.g. "arch-rewrite") must be resolved against the workspace's
+ // defaultCwd via the same workspace-relative algorithm the persisted cwd
+ // uses — NOT used raw (which would resolve against process.cwd() and
+ // break). When cwd is undefined, getEffectiveCwd reads the persisted cwd.
+ // Chained after workspaceSetupPromise so the workspace is assigned
+ // first for new conversations (the timing invariant this enforces).
+ const effectiveCwdPromise = workspaceSetupPromise.then(() =>
+ deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined),
+ );
+
+ // Resolve the effective computer the SAME way cwd resolves — pass the
+ // per-turn computerId as the overrideAlias. When computerId is
+ // undefined, getEffectiveComputer reads the persisted per-conversation
+ // computerId → workspace defaultComputerId → null (LOCAL). Chained
+ // after workspaceSetupPromise (same timing invariant as cwd). The
+ // orchestrator never interprets the alias — it forwards the string
+ // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise.
+ const effectiveComputerIdPromise = workspaceSetupPromise.then(() =>
+ deps.conversationStore
+ .getEffectiveComputer(conversationId, computerId)
+ .then((c) => c ?? undefined),
+ );
+
+ const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
+ // Resolve the persisted model (if any) in parallel with the other
+ // per-conversation reads. The effective model name is
+ // per-turn override → persisted → (undefined → default provider), the
+ // same resolution chain as `resolveReasoningEffort`.
+ const storedModelPromise = deps.conversationStore.getModel(conversationId);
+
+ const payloadPromise = Promise.all([
+ effectiveCwdPromise,
+ effectiveComputerIdPromise,
+ storedEffortPromise,
+ storedModelPromise,
+ ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => {
+ const effectiveModelName = resolveModelName(modelName, storedModel);
+ return {
+ conversationId,
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ ...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}),
+ };
+ });
+
+ payloadPromise.then((payload) => {
+ deps.emit?.(turnStarted, payload);
+ // Resolve the persisted workspace id (not the per-turn start option)
+ // before emitting so the broadcast carries the correct workspace.
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status: "active",
+ workspaceId,
+ });
+ });
+ void deps.conversationStore.setConversationStatus(conversationId, "active");
+ });
+
+ void (async () => {
+ let sealed = false;
+ try {
+ const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] =
+ await Promise.all([
+ effectiveCwdPromise,
+ effectiveComputerIdPromise,
+ storedEffortPromise,
+ workspaceSetupPromise,
+ storedModelPromise,
+ ]);
+
+ if (cwd !== undefined) {
+ await deps.conversationStore.setCwd(conversationId, cwd);
+ }
+
+ // Persist the per-turn computer override, mirroring the cwd
+ // persistence above. Only stamped when a computerId was actually
+ // provided — NOT when it resolved to undefined (LOCAL) via the
+ // workspace default. Idempotent when the value is unchanged.
+ if (computerId !== undefined) {
+ await deps.conversationStore.setComputerId(conversationId, computerId);
+ }
+
+ const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort);
+ // Effective model name: per-turn override → persisted → undefined
+ // (→ default provider). Resolved here so every downstream consumer
+ // (resolveModel, system prompt, payload) sees the same model as if
+ // the caller had passed it explicitly.
+ const effectiveModelName = resolveModelName(modelName, storedModel);
+
+ const history = await deps.conversationStore.load(conversationId);
+ const userMsg = buildUserMessage(text);
+
+ // Workspace assignment for new conversations happens BEFORE
+ // effective-cwd resolution (see workspaceSetupPromise above) so
+ // getEffectiveCwd resolves against the intended workspace, not
+ // the stale "default". The history-load + append flow below is
+ // otherwise unchanged.
+
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+
+ if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(effectiveModelName);
+ if (resolved === undefined) {
+ emitToHub(conversationId, {
+ type: "error",
+ conversationId,
+ turnId,
+ message: `unknown model: ${effectiveModelName}`,
+ });
+ return;
+ }
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ // Persist the resolved model so it sticks for future turns
+ // and browser sessions (per-conversation model persistence).
+ // Only stamped when a model was actually used — NOT on the
+ // default-provider fallthrough (nothing to persist). Idempotent
+ // when the value is unchanged (re-stamps the same persisted
+ // model). The early `return` above means an unknown model is
+ // never persisted.
+ await deps.conversationStore.setModel(conversationId, effectiveModelName);
+ } else {
+ provider = deps.resolveProvider();
+ }
+
+ const baseTools = deps.resolveTools();
+ const assembled = await deps.applyToolsFilter({
+ tools: baseTools,
+ conversationId,
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ });
+ const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
+ const turnLogger = deps.logger?.child({ conversationId, turnId });
+ const metrics = createMetricsAccumulator();
+
+ const emitAndAccumulate = (event: AgentEvent): void => {
+ metrics.ingest(event);
+ emitToHub(conversationId, event);
+ };
+
+ // Resolve the system prompt for this turn (cache-safe). On the
+ // FIRST turn of a new conversation, construct it once (resolves all
+ // template variables + persists the result). On subsequent turns,
+ // reuse the persisted prompt via `getWithMeta` — but ONLY when the
+ // stored cwd matches the current effective cwd. If the cwd changed
+ // since the prompt was constructed (or no prompt was ever stored),
+ // reconstruct against the new cwd so the prompt is never stale.
+ // This preserves the cache-safe design (construct once per cwd,
+ // reuse on subsequent turns with the same cwd) while fixing the bug
+ // where a cwd change left the prompt stale. When the system-prompt
+ // service isn't loaded, no system prompt is sent (current behavior
+ // preserved).
+ //
+ // EXPLICIT OVERRIDE: when `systemPromptOverride` is provided (a
+ // string, incl. empty), it is sent to the provider AS-IS and the
+ // system-prompt service is bypassed entirely (no construct/reuse).
+ // This lets a caller that owns its own prompt (e.g. the heartbeat
+ // extension) drive a turn without the templated workspace prompt.
+ let systemPrompt: string | undefined;
+ if (systemPromptOverride !== undefined) {
+ systemPrompt = systemPromptOverride;
+ } else {
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ if (systemPromptService !== undefined) {
+ if (isNewConversation) {
+ systemPrompt = await systemPromptService.construct(
+ conversationId,
+ effectiveCwd ?? process.cwd(),
+ {
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ },
+ );
+ } else {
+ const meta = await systemPromptService.getWithMeta(conversationId);
+ const currentCwd = effectiveCwd ?? process.cwd();
+ const currentComputerId = effectiveComputerId ?? null;
+ // Invalidate when cwd OR computerId changed (switching computers
+ // must rebuild the prompt against the remote OS/hostname).
+ if (
+ meta.prompt !== null &&
+ meta.cwd === currentCwd &&
+ meta.computerId === currentComputerId
+ ) {
+ systemPrompt = meta.prompt;
+ } else {
+ systemPrompt = await systemPromptService.construct(conversationId, currentCwd, {
+ ...(effectiveModelName !== undefined ? { model: effectiveModelName } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ });
+ }
+ }
+ }
+ }
+
+ const providerOpts: ProviderStreamOptions = {
+ reasoningEffort: resolvedEffort,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
+ };
+
+ // Resolve the steering queue once for this turn. When present, wire
+ // `drainSteering`: the kernel calls it at the tool-result boundary and
+ // appends whatever it returns as user-role messages alongside the tool
+ // results (mid-turn steering). The wrapper emits a `steering` AgentEvent
+ // into the hub (buffered for late-join like `user-message`) so a
+ // frontend can place a user bubble in the transcript live; the kernel
+ // only appends the returned messages — it does NOT emit the event.
+ const queue = deps.resolveQueue?.();
+ const drainSteering =
+ queue === undefined
+ ? undefined
+ : (): readonly ChatMessage[] => {
+ const queued = queue.drain(conversationId);
+ if (queued.length === 0) return [];
+ const steerText = queued.map((q) => q.text).join("\n\n");
+ emitToHub(conversationId, {
+ type: "steering",
+ conversationId,
+ turnId,
+ text: steerText,
+ });
+ return [{ role: "user", chunks: [{ type: "text", text: steerText }] }];
+ };
+
+ const opts: RunTurnInput = {
+ provider,
+ messages: [...history, userMsg],
+ tools: assembled.tools,
+ dispatch,
+ emit: emitAndAccumulate,
+ conversationId,
+ turnId,
+ signal: controller.signal,
+ providerOpts,
+ retry: retryStrategy,
+ ...(turnLogger !== undefined ? { logger: turnLogger } : {}),
+ ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
+ ...(drainSteering !== undefined ? { drainSteering } : {}),
+ };
+
+ // Persist the user message at turn start so it has a seq
+ // number before the first step generates. This enables the
+ // FE to syncTail during generation (CR-6).
+ await deps.conversationStore.append(conversationId, [userMsg]);
+
+ let stepsPersisted = false;
+ const result = await deps.runTurn({
+ ...opts,
+ // Incremental persistence: persist each step's messages
+ // as they are finalized. Seq numbers are assigned during
+ // generation, so the FE can GET /conversations/:id?sinceSeq=N
+ // mid-turn and pick up committed chunks (CR-6).
+ onStepComplete: async (stepMessages) => {
+ await deps.conversationStore.append(conversationId, stepMessages);
+ stepsPersisted = true;
+ },
+ });
+
+ // Fallback: if onStepComplete was never called (e.g., a fake
+ // runTurn in tests), persist all result messages as a batch.
+ if (!stepsPersisted && result.messages.length > 0) {
+ await deps.conversationStore.append(conversationId, result.messages);
+ }
+
+ const turnMetrics = metrics.build(turnId);
+ await deps.conversationStore.appendMetrics(conversationId, turnMetrics);
+
+ emitToHub(conversationId, { type: "turn-sealed", conversationId, turnId });
+ sealed = true;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ emitToHub(conversationId, {
+ type: "error",
+ conversationId,
+ turnId,
+ message,
+ });
+ } finally {
+ activeTurns.delete(conversationId);
+ // Post-seal carry: if the turn sealed with a non-empty steering queue
+ // (no tool call fired → drainSteering never drained it), start a NEW
+ // detached turn whose opening user-message carries the combined text.
+ // The new turn re-adds to activeTurns + activeConversations, so skip
+ // the activeConversations.delete when carried. May chain (user keeps
+ // steering) — each carried turn's own finally re-checks the queue.
+ const carried = sealed && tryCarryQueue(conversationId);
+ if (!carried) {
+ activeConversations.delete(conversationId);
+ }
+ void payloadPromise.then((payload) => {
+ deps.emit?.(turnSettled, payload);
+ if (!carried) {
+ // Resolve the persisted workspace id before emitting so the
+ // broadcast carries the correct workspace.
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status: "idle",
+ workspaceId,
+ });
+ });
+ void deps.conversationStore.setConversationStatus(conversationId, "idle");
+ // Fire-and-forget auto-compaction: check threshold and
+ // compact if exceeded. Non-blocking — the next turn
+ // starts fresh either way.
+ const compaction = deps.resolveCompaction?.();
+ if (compaction !== undefined) {
+ void compaction
+ .compact(conversationId, {
+ auto: true,
+ ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
+ })
+ .catch(() => {});
+ }
+ }
+ });
+ }
+ })();
+ }
+
+ const orchestrator: SessionOrchestrator = {
+ startTurn({
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId,
+ systemPrompt,
+ }) {
+ if (activeTurns.has(conversationId)) {
+ return { started: false, reason: "already-active" };
+ }
+ runTurnDetached(
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId ?? "default",
+ systemPrompt,
+ );
+ const turn = activeTurns.get(conversationId);
+ const turnId = turn !== undefined ? turn.turnId : "";
+ return { started: true, turnId };
+ },
+
+ enqueue({ conversationId, text, workspaceId, computerId }) {
+ const result = orchestrator.startTurn({
+ conversationId,
+ text,
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ });
+ if (result.started) {
+ return { startedTurn: true, queue: [] };
+ }
+ // Already active → enqueue onto the steering queue. When the
+ // message-queue extension isn't loaded this degrades: the message is
+ // dropped and the snapshot is empty (feature off).
+ const queue = deps.resolveQueue?.();
+ const snapshot = queue !== undefined ? queue.enqueue(conversationId, text) : [];
+ return { startedTurn: false, queue: snapshot };
+ },
+
+ subscribe(conversationId, listener) {
+ let listeners = subscribers.get(conversationId);
+ if (listeners === undefined) {
+ listeners = new Set();
+ subscribers.set(conversationId, listeners);
+ }
+ const turn = activeTurns.get(conversationId);
+ if (turn !== undefined) {
+ const snapshot = [...turn.buffer];
+ listeners.add(listener);
+ for (const event of snapshot) {
+ listener(event);
+ }
+ } else {
+ listeners.add(listener);
+ }
+ return () => {
+ const set = subscribers.get(conversationId);
+ if (set !== undefined) {
+ set.delete(listener);
+ if (set.size === 0) {
+ subscribers.delete(conversationId);
+ }
+ }
+ };
+ },
+
+ isActive(conversationId) {
+ return activeTurns.has(conversationId);
+ },
+
+ closeConversation(conversationId) {
+ const turn = activeTurns.get(conversationId);
+ const abortedTurn = turn !== undefined;
+ if (turn !== undefined) {
+ turn.controller.abort();
+ }
+ deps.emit?.(conversationClosed, { conversationId });
+ // Resolve the persisted workspace id before emitting so the
+ // broadcast carries the correct workspace. The hook is
+ // fire-and-forget; closeConversation stays synchronous (returns
+ // immediately) while the status-changed emit resolves async.
+ void deps.conversationStore.getWorkspaceId(conversationId).then((workspaceId) => {
+ deps.emit?.(conversationStatusChanged, {
+ conversationId,
+ status: "closed",
+ workspaceId,
+ });
+ });
+ void deps.conversationStore.setConversationStatus(conversationId, "closed");
+ return { abortedTurn };
+ },
+
+ stopTurn(conversationId) {
+ const turn = activeTurns.get(conversationId);
+ const abortedTurn = turn !== undefined;
+ if (turn !== undefined) {
+ turn.controller.abort();
+ }
+ return { abortedTurn };
+ },
+
+ async handleMessage({
+ conversationId,
+ text,
+ onEvent,
+ modelName,
+ cwd,
+ computerId,
+ reasoningEffort,
+ workspaceId,
+ systemPrompt,
+ }) {
+ const turnInput: StartTurnInput = {
+ conversationId,
+ text,
+ ...(modelName !== undefined ? { modelName } : {}),
+ ...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
+ };
+ const result = orchestrator.startTurn(turnInput);
+ if (!result.started) {
+ const errorTurnId = generateTurnId();
+ onEvent({
+ type: "error",
+ conversationId,
+ turnId: errorTurnId,
+ message: "turn already active for this conversation",
+ });
+ return;
+ }
+
+ await new Promise<void>((resolve) => {
+ const unsubscribe = orchestrator.subscribe(conversationId, (event) => {
+ onEvent(event);
+ if (event.type === "turn-sealed" || event.type === "error") {
+ unsubscribe();
+ resolve();
+ }
+ });
+ });
+ },
+ };
+
+ return { orchestrator, activeConversations };
}
export function createWarmService(
- deps: WarmServiceDeps,
- activeConversations: ReadonlySet<string>,
+ deps: WarmServiceDeps,
+ activeConversations: ReadonlySet<string>,
): WarmService {
- return {
- async warm(conversationId, opts) {
- if (activeConversations.has(conversationId)) {
- return { error: "conversation is generating" };
- }
-
- const history = await deps.conversationStore.load(conversationId);
- if (history.length === 0) {
- return { error: "no history" };
- }
-
- let provider: ProviderContract;
- let modelOverride: string | undefined;
-
- // Resolve the model the SAME way the real turn does: per-turn override
- // → persisted per-conversation model → default provider. A mismatch here
- // silently busts the prompt cache (the model block of the prompt prefix
- // diverges from the real turn's). Warm is a probe — it does NOT persist
- // (no setModel), it only reads so it sends the same model the next real
- // turn will. See notes/observability-design.md §3.1.
- const storedModel = await deps.conversationStore.getModel(conversationId);
- const effectiveModelName = resolveModelName(opts?.modelName, storedModel);
-
- if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(effectiveModelName);
- if (resolved === undefined) {
- return { error: `unknown model: ${effectiveModelName}` };
- }
- provider = resolved.provider;
- modelOverride = resolved.model;
- } else {
- provider = deps.resolveProvider();
- }
-
- const baseTools = deps.resolveTools();
- // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd
- // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the
- // `load_skill` description per-cwd). If the warm assembles tools under a
- // different cwd than the real turn, the tools block — the FIRST bytes of
- // the prompt-cache prefix — diverges and the cache misses entirely (0%).
- // A manual reheat sends no cwd, so without this fallback it would warm the
- // wrong prefix. See notes/observability-design.md §3.1.
- const cwd =
- (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined;
- const assembled = await deps.applyToolsFilter({
- tools: baseTools,
- conversationId,
- ...(cwd !== undefined ? { cwd } : {}),
- });
-
- // Resolve reasoning effort the SAME way the real turn does (stored → "high";
- // no per-turn override on warm). A mismatch here silently busts the prompt cache.
- const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId);
- const resolvedEffort = resolveReasoningEffort(undefined, storedEffort);
-
- const probeMsg: ChatMessage = {
- role: "user",
- chunks: [{ type: "text", text: "reply with just a ." }],
- };
- const messages = [...history, probeMsg];
-
- // Capture the warm send as a `provider.request` span, flagged `warm: true`
- // so it can be diffed against the corresponding real turn's request (the
- // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md
- // §3.1). Without this the warm body is invisible and the cache bust is
- // undebuggable. The child-bound `warm` attribute flows into the span the
- // provider opens (kernel logger merges child attrs into span attributes).
- const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } });
- const providerOpts: ProviderStreamOptions = {
- maxTokens: 1,
- reasoningEffort: resolvedEffort,
- ...(modelOverride !== undefined ? { model: modelOverride } : {}),
- ...(warmLogger !== undefined ? { logger: warmLogger } : {}),
- };
-
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
-
- for await (const event of provider.stream(messages, assembled.tools, providerOpts)) {
- if ((event as ProviderEvent).type === "usage") {
- const usageEvent = event as UsageEvent;
- inputTokens = usageEvent.usage.inputTokens;
- outputTokens = usageEvent.usage.outputTokens;
- cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0;
- cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0;
- }
- }
-
- const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
- deps.emit(warmCompleted, { conversationId, usage: result });
- return result;
- },
- };
+ return {
+ async warm(conversationId, opts) {
+ if (activeConversations.has(conversationId)) {
+ return { error: "conversation is generating" };
+ }
+
+ const history = await deps.conversationStore.load(conversationId);
+ if (history.length === 0) {
+ return { error: "no history" };
+ }
+
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+
+ // Resolve the model the SAME way the real turn does: per-turn override
+ // → persisted per-conversation model → default provider. A mismatch here
+ // silently busts the prompt cache (the model block of the prompt prefix
+ // diverges from the real turn's). Warm is a probe — it does NOT persist
+ // (no setModel), it only reads so it sends the same model the next real
+ // turn will. See notes/observability-design.md §3.1.
+ const storedModel = await deps.conversationStore.getModel(conversationId);
+ const effectiveModelName = resolveModelName(opts?.modelName, storedModel);
+
+ if (effectiveModelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(effectiveModelName);
+ if (resolved === undefined) {
+ return { error: `unknown model: ${effectiveModelName}` };
+ }
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ } else {
+ provider = deps.resolveProvider();
+ }
+
+ const baseTools = deps.resolveTools();
+ // Resolve cwd the SAME way handleMessage does — pass opts.cwd as the overrideCwd
+ // The tools filter is cwd-sensitive (e.g. skill discovery rewrites the
+ // `load_skill` description per-cwd). If the warm assembles tools under a
+ // different cwd than the real turn, the tools block — the FIRST bytes of
+ // the prompt-cache prefix — diverges and the cache misses entirely (0%).
+ // A manual reheat sends no cwd, so without this fallback it would warm the
+ // wrong prefix. See notes/observability-design.md §3.1.
+ const cwd =
+ (await deps.conversationStore.getEffectiveCwd(conversationId, opts?.cwd)) ?? undefined;
+ const assembled = await deps.applyToolsFilter({
+ tools: baseTools,
+ conversationId,
+ ...(cwd !== undefined ? { cwd } : {}),
+ });
+
+ // Resolve reasoning effort the SAME way the real turn does (stored → "high";
+ // no per-turn override on warm). A mismatch here silently busts the prompt cache.
+ const storedEffort = await deps.conversationStore.getReasoningEffort(conversationId);
+ const resolvedEffort = resolveReasoningEffort(undefined, storedEffort);
+
+ const probeMsg: ChatMessage = {
+ role: "user",
+ chunks: [{ type: "text", text: "reply with just a ." }],
+ };
+ const messages = [...history, probeMsg];
+
+ // Capture the warm send as a `provider.request` span, flagged `warm: true`
+ // so it can be diffed against the corresponding real turn's request (the
+ // prompt-cache 0%-hit debugging workflow — see notes/observability-design.md
+ // §3.1). Without this the warm body is invisible and the cache bust is
+ // undebuggable. The child-bound `warm` attribute flows into the span the
+ // provider opens (kernel logger merges child attrs into span attributes).
+ const warmLogger = deps.logger?.child({ conversationId, attrs: { warm: true } });
+ const providerOpts: ProviderStreamOptions = {
+ maxTokens: 1,
+ reasoningEffort: resolvedEffort,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(warmLogger !== undefined ? { logger: warmLogger } : {}),
+ };
+
+ let inputTokens = 0;
+ let outputTokens = 0;
+ let cacheReadTokens = 0;
+ let cacheWriteTokens = 0;
+
+ for await (const event of provider.stream(messages, assembled.tools, providerOpts)) {
+ if ((event as ProviderEvent).type === "usage") {
+ const usageEvent = event as UsageEvent;
+ inputTokens = usageEvent.usage.inputTokens;
+ outputTokens = usageEvent.usage.outputTokens;
+ cacheReadTokens = usageEvent.usage.cacheReadTokens ?? 0;
+ cacheWriteTokens = usageEvent.usage.cacheWriteTokens ?? 0;
+ }
+ }
+
+ const result: WarmResult = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
+ deps.emit(warmCompleted, { conversationId, usage: result });
+ return result;
+ },
+ };
}
const DEFAULT_KEEP_LAST_N = 10;
const DEFAULT_COMPACT_PERCENT = 85;
const COMPACTION_SYSTEM_PROMPT =
- "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " +
- "Focus on key decisions, context, file paths, and any unresolved questions. " +
- "The summary must preserve enough detail for the conversation to continue with full context.";
+ "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " +
+ "Focus on key decisions, context, file paths, and any unresolved questions. " +
+ "The summary must preserve enough detail for the conversation to continue with full context.";
function formatMessagesForSummary(messages: readonly ChatMessage[]): string {
- return messages
- .map((msg) => {
- const text = msg.chunks
- .map((c) => {
- if (c.type === "text") return c.text;
- if (c.type === "tool-call") return `[tool: ${c.toolName}]`;
- if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`;
- return "";
- })
- .join("");
- return `${msg.role}: ${text}`;
- })
- .join("\n\n");
+ return messages
+ .map((msg) => {
+ const text = msg.chunks
+ .map((c) => {
+ if (c.type === "text") return c.text;
+ if (c.type === "tool-call") return `[tool: ${c.toolName}]`;
+ if (c.type === "tool-result") return `[tool result: ${c.content.slice(0, 200)}]`;
+ return "";
+ })
+ .join("");
+ return `${msg.role}: ${text}`;
+ })
+ .join("\n\n");
}
export function createCompactionService(
- deps: SessionOrchestratorDeps & {
- readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
- },
- activeConversations: ReadonlySet<string>,
+ deps: SessionOrchestratorDeps & {
+ readonly emit: <TPayload>(hook: EventHookDescriptor<TPayload>, payload: TPayload) => void;
+ },
+ activeConversations: ReadonlySet<string>,
): CompactionService {
- return {
- async compact(conversationId, opts) {
- if (activeConversations.has(conversationId)) {
- return { error: "conversation is generating" };
- }
-
- const history = await deps.conversationStore.load(conversationId);
- const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N;
-
- if (history.length <= keepLastN) {
- return { error: "conversation too short to compact" };
- }
-
- // Auto mode: check if contextSize exceeds percent of contextWindow.
- if (opts?.auto === true) {
- const stored = await deps.conversationStore.getCompactPercent(conversationId);
- const percent = stored ?? DEFAULT_COMPACT_PERCENT;
- if (percent <= 0) return { error: "auto-compact disabled" };
- const metrics = await deps.conversationStore.loadMetrics(conversationId);
- const lastTurn = metrics[metrics.length - 1];
- if (lastTurn === undefined) return { error: "no metrics" };
- const contextSize = lastTurn.contextSize;
- if (contextSize === undefined) return { error: "no context size" };
-
- // Resolve the model's context window.
- const modelName = opts.modelName;
- if (modelName === undefined || deps.resolveModelInfo === undefined) {
- return { error: "cannot resolve model info" };
- }
- const info = await deps.resolveModelInfo(modelName);
- if (info?.contextWindow === undefined) {
- return { error: "model context window unknown" };
- }
- const threshold = Math.floor(info.contextWindow * (percent / 100));
- if (contextSize < threshold) return { error: "threshold not exceeded" };
- }
-
- // Split: old messages to summarize + recent messages to keep.
- const toSummarize = history.slice(0, history.length - keepLastN);
- const toKeep = history.slice(history.length - keepLastN);
-
- // Resolve provider
- let provider: ProviderContract;
- let modelOverride: string | undefined;
- if (opts?.modelName !== undefined && deps.resolveModel !== undefined) {
- const resolved = deps.resolveModel(opts.modelName);
- if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` };
- provider = resolved.provider;
- modelOverride = resolved.model;
- } else {
- provider = deps.resolveProvider();
- }
-
- // Build the summarization request: system prompt + conversation text + instruction
- const conversationText = formatMessagesForSummary(toSummarize);
- const summaryRequest: ChatMessage = {
- role: "user",
- chunks: [
- {
- type: "text",
- text: `Please summarize the following conversation:\n\n${conversationText}`,
- },
- ],
- };
-
- const providerOpts: ProviderStreamOptions = {
- maxTokens: 2000,
- ...(modelOverride !== undefined ? { model: modelOverride } : {}),
- ...(deps.logger !== undefined
- ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) }
- : {}),
- };
-
- // Reconstruct the system prompt on compaction (fresh variable
- // resolution — files/cwd/time may have changed since construction).
- // The construct call also persists the result for future turns. When
- // the system-prompt service is unavailable, fall back to the
- // compaction-only system prompt (current behavior, no regression).
- const systemPromptService = deps.resolveSystemPrompt?.();
- let compactionSystemPrompt: string;
- if (systemPromptService !== undefined) {
- const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
- const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
- const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
- const constructed = await systemPromptService.construct(conversationId, cwd, {
- ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
- workspaceId,
- ...(computerId !== null ? { computerId } : {}),
- });
- compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
- } else {
- compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
- }
-
- // Call the provider and accumulate the summary
- let summary = "";
- for await (const event of provider.stream([summaryRequest], [], {
- ...providerOpts,
- systemPrompt: compactionSystemPrompt,
- })) {
- if ((event as ProviderEvent).type === "text-delta") {
- summary += (event as { delta: string }).delta;
- } else if ((event as ProviderEvent).type === "error") {
- return { error: (event as { message: string }).message };
- }
- }
-
- if (summary.trim().length === 0) {
- return { error: "model produced empty summary" };
- }
-
- // Non-destructive: fork the full pre-compaction history to a new
- // archive conversation. The original conversation keeps its ID
- // (so messaging between agents still works) and gets the compacted
- // content. The archive inherits the original's compactedFrom,
- // creating a chain: A → Y → X → ...
- const archiveId = crypto.randomUUID();
- await deps.conversationStore.forkHistory(conversationId, archiveId);
-
- // Replace history: [system: summary] + recent messages
- const summaryMessage: ChatMessage = {
- role: "system",
- chunks: [
- {
- type: "text",
- text: `The following is a summary of the previous conversation:\n\n${summary}`,
- },
- ],
- };
-
- await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]);
- await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
-
- const result: CompactionResult = {
- summary,
- newConversationId: archiveId,
- messagesSummarized: toSummarize.length,
- messagesKept: toKeep.length,
- };
-
- deps.emit(conversationCompacted, {
- conversationId,
- newConversationId: archiveId,
- messagesSummarized: toSummarize.length,
- messagesKept: toKeep.length,
- });
-
- return result;
- },
- };
+ return {
+ async compact(conversationId, opts) {
+ if (activeConversations.has(conversationId)) {
+ return { error: "conversation is generating" };
+ }
+
+ const history = await deps.conversationStore.load(conversationId);
+ const keepLastN = opts?.keepLastN ?? DEFAULT_KEEP_LAST_N;
+
+ if (history.length <= keepLastN) {
+ return { error: "conversation too short to compact" };
+ }
+
+ // Auto mode: check if contextSize exceeds percent of contextWindow.
+ if (opts?.auto === true) {
+ const stored = await deps.conversationStore.getCompactPercent(conversationId);
+ const percent = stored ?? DEFAULT_COMPACT_PERCENT;
+ if (percent <= 0) return { error: "auto-compact disabled" };
+ const metrics = await deps.conversationStore.loadMetrics(conversationId);
+ const lastTurn = metrics[metrics.length - 1];
+ if (lastTurn === undefined) return { error: "no metrics" };
+ const contextSize = lastTurn.contextSize;
+ if (contextSize === undefined) return { error: "no context size" };
+
+ // Resolve the model's context window.
+ const modelName = opts.modelName;
+ if (modelName === undefined || deps.resolveModelInfo === undefined) {
+ return { error: "cannot resolve model info" };
+ }
+ const info = await deps.resolveModelInfo(modelName);
+ if (info?.contextWindow === undefined) {
+ return { error: "model context window unknown" };
+ }
+ const threshold = Math.floor(info.contextWindow * (percent / 100));
+ if (contextSize < threshold) return { error: "threshold not exceeded" };
+ }
+
+ // Split: old messages to summarize + recent messages to keep.
+ const toSummarize = history.slice(0, history.length - keepLastN);
+ const toKeep = history.slice(history.length - keepLastN);
+
+ // Resolve provider
+ let provider: ProviderContract;
+ let modelOverride: string | undefined;
+ if (opts?.modelName !== undefined && deps.resolveModel !== undefined) {
+ const resolved = deps.resolveModel(opts.modelName);
+ if (resolved === undefined) return { error: `unknown model: ${opts.modelName}` };
+ provider = resolved.provider;
+ modelOverride = resolved.model;
+ } else {
+ provider = deps.resolveProvider();
+ }
+
+ // Build the summarization request: system prompt + conversation text + instruction
+ const conversationText = formatMessagesForSummary(toSummarize);
+ const summaryRequest: ChatMessage = {
+ role: "user",
+ chunks: [
+ {
+ type: "text",
+ text: `Please summarize the following conversation:\n\n${conversationText}`,
+ },
+ ],
+ };
+
+ const providerOpts: ProviderStreamOptions = {
+ maxTokens: 2000,
+ ...(modelOverride !== undefined ? { model: modelOverride } : {}),
+ ...(deps.logger !== undefined
+ ? { logger: deps.logger.child({ conversationId, attrs: { compaction: true } }) }
+ : {}),
+ };
+
+ // Reconstruct the system prompt on compaction (fresh variable
+ // resolution — files/cwd/time may have changed since construction).
+ // The construct call also persists the result for future turns. When
+ // the system-prompt service is unavailable, fall back to the
+ // compaction-only system prompt (current behavior, no regression).
+ const systemPromptService = deps.resolveSystemPrompt?.();
+ let compactionSystemPrompt: string;
+ if (systemPromptService !== undefined) {
+ const cwd = (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? process.cwd();
+ const workspaceId = await deps.conversationStore.getWorkspaceId(conversationId);
+ const computerId = await deps.conversationStore.getEffectiveComputer(conversationId);
+ const constructed = await systemPromptService.construct(conversationId, cwd, {
+ ...(opts?.modelName !== undefined ? { model: opts.modelName } : {}),
+ workspaceId,
+ ...(computerId !== null ? { computerId } : {}),
+ });
+ compactionSystemPrompt = `${constructed}\n\n${COMPACTION_SYSTEM_PROMPT}`;
+ } else {
+ compactionSystemPrompt = COMPACTION_SYSTEM_PROMPT;
+ }
+
+ // Call the provider and accumulate the summary
+ let summary = "";
+ for await (const event of provider.stream([summaryRequest], [], {
+ ...providerOpts,
+ systemPrompt: compactionSystemPrompt,
+ })) {
+ if ((event as ProviderEvent).type === "text-delta") {
+ summary += (event as { delta: string }).delta;
+ } else if ((event as ProviderEvent).type === "error") {
+ return { error: (event as { message: string }).message };
+ }
+ }
+
+ if (summary.trim().length === 0) {
+ return { error: "model produced empty summary" };
+ }
+
+ // Non-destructive: fork the full pre-compaction history to a new
+ // archive conversation. The original conversation keeps its ID
+ // (so messaging between agents still works) and gets the compacted
+ // content. The archive inherits the original's compactedFrom,
+ // creating a chain: A → Y → X → ...
+ const archiveId = crypto.randomUUID();
+ await deps.conversationStore.forkHistory(conversationId, archiveId);
+
+ // Replace history: [system: summary] + recent messages
+ const summaryMessage: ChatMessage = {
+ role: "system",
+ chunks: [
+ {
+ type: "text",
+ text: `The following is a summary of the previous conversation:\n\n${summary}`,
+ },
+ ],
+ };
+
+ await deps.conversationStore.replaceHistory(conversationId, [summaryMessage, ...toKeep]);
+ await deps.conversationStore.setCompactedFrom(conversationId, archiveId);
+
+ const result: CompactionResult = {
+ summary,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ };
+
+ deps.emit(conversationCompacted, {
+ conversationId,
+ newConversationId: archiveId,
+ messagesSummarized: toSummarize.length,
+ messagesKept: toKeep.length,
+ });
+
+ return result;
+ },
+ };
}
diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts
index 31c55da..16591ec 100644
--- a/packages/system-prompt/src/service.test.ts
+++ b/packages/system-prompt/src/service.test.ts
@@ -5,323 +5,323 @@ import { createSystemPromptService, DEFAULT_TEMPLATE } from "./service.js";
/** In-memory StorageNamespace for tests. */
function memoryStorage(): StorageNamespace {
- const store = new Map<string, string>();
- return {
- get: async (key: string) => store.get(key) ?? null,
- set: async (key: string, value: string) => {
- store.set(key, value);
- },
- delete: async (key: string) => {
- store.delete(key);
- },
- has: async (key: string) => store.has(key),
- keys: async (prefix?: string) =>
- [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
- };
+ const store = new Map<string, string>();
+ return {
+ get: async (key: string) => store.get(key) ?? null,
+ set: async (key: string, value: string) => {
+ store.set(key, value);
+ },
+ delete: async (key: string) => {
+ store.delete(key);
+ },
+ has: async (key: string) => store.has(key),
+ keys: async (prefix?: string) =>
+ [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
+ };
}
function fakeFs(files: ReadonlyMap<string, string>): ResolverFs {
- return {
- readText: async (path: string) => files.get(path) ?? "",
- exists: async (path: string) => files.has(path),
- };
+ return {
+ readText: async (path: string) => files.get(path) ?? "",
+ exists: async (path: string) => files.has(path),
+ };
}
const failSpawn = async (): Promise<GitSpawnResult> => ({
- stdout: "",
- stderr: "",
- exitCode: 128,
+ stdout: "",
+ stderr: "",
+ exitCode: 128,
});
function adapters(files: ReadonlyMap<string, string>): ResolverAdapters {
- return {
- spawn: failSpawn,
- fs: fakeFs(files),
- now: () => new Date("2024-06-15T12:30:00.000Z"),
- platform: () => "linux",
- hostname: () => "myhost",
- };
+ return {
+ spawn: failSpawn,
+ fs: fakeFs(files),
+ now: () => new Date("2024-06-15T12:30:00.000Z"),
+ platform: () => "linux",
+ hostname: () => "myhost",
+ };
}
describe("system-prompt service", () => {
- it("construct persists and returns the resolved string", async () => {
- // 14. construct writes to storage and returns the resolved string.
- const storage = memoryStorage();
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
- });
-
- const result = await service.construct("conv-1", "/proj", { model: "gpt-4" });
-
- expect(result).toContain("You are a helpful coding assistant.");
- expect(result).toContain("RULES");
- expect(result).toContain("/proj");
- // persisted under resolved:<conversationId>
- expect(await storage.get("resolved:conv-1")).toBe(result);
- });
-
- it("get returns persisted value after construct", async () => {
- // 15. after construct, get returns the same string.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- // before construct → null
- expect(await service.get("conv-2")).toBeNull();
-
- const result = await service.construct("conv-2", "/proj");
- expect(await service.get("conv-2")).toBe(result);
- });
-
- it("get returns null before construct", async () => {
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- expect(await service.get("never-constructed")).toBeNull();
- });
-
- it("empty/no template stored → default template → non-empty", async () => {
- // 16. no template stored → default template used → resolves to non-empty.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()), // no AGENTS.md
- });
-
- const result = await service.construct("conv-3", "/proj");
-
- expect(result.length).toBeGreaterThan(0);
- expect(result).toContain("You are a helpful coding assistant.");
- expect(result).toContain("/proj");
- // no AGENTS.md file → the [if file:AGENTS.md] block is omitted
- expect(result).not.toContain("AGENTS.md");
- });
-
- it("stored template is used instead of default", async () => {
- const storage = memoryStorage();
- await storage.set("template", "cwd=[prompt:cwd] os=[system:os]");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- const result = await service.construct("conv-4", "/work");
- expect(result).toBe("cwd=/work os=linux");
- });
-
- it("empty stored template → empty string", async () => {
- const storage = memoryStorage();
- await storage.set("template", "");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- const result = await service.construct("conv-5", "/proj");
- expect(result).toBe("");
- expect(await service.get("conv-5")).toBe("");
- });
-
- it("construct is independent per conversation", async () => {
- const storage = memoryStorage();
- await storage.set("template", "[prompt:cwd]");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- const a = await service.construct("conv-a", "/dir-a");
- const b = await service.construct("conv-b", "/dir-b");
-
- expect(a).toBe("/dir-a");
- expect(b).toBe("/dir-b");
- expect(await service.get("conv-a")).toBe("/dir-a");
- expect(await service.get("conv-b")).toBe("/dir-b");
- });
-
- it("DEFAULT_TEMPLATE contains the expected structure", () => {
- expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant.");
- expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]");
- expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]");
- expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]");
- });
-
- it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => {
- // 1. never constructed → both fields null.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- const meta = await service.getWithMeta("never-constructed");
- expect(meta).toEqual({ prompt: null, cwd: null, computerId: null });
- });
-
- it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => {
- // 2. after construct → prompt + exact cwd passed to construct.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
- });
-
- const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" });
- const meta = await service.getWithMeta("conv-meta");
-
- expect(meta.prompt).toBe(result);
- expect(meta.cwd).toBe("/proj");
- });
-
- it("get still returns the same value as before (backward compat)", async () => {
- // 3. get() behavior is unchanged by the additive getWithMeta.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- // before construct → null
- expect(await service.get("conv-bc")).toBeNull();
-
- const result = await service.construct("conv-bc", "/proj");
- expect(await service.get("conv-bc")).toBe(result);
- });
-
- it("construct called twice with different cwds stores the latest cwd", async () => {
- // 4. second construct overwrites the cwd (not the first).
- const storage = memoryStorage();
- await storage.set("template", "[prompt:cwd]");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- await service.construct("conv-twice", "/first");
- expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first");
-
- const second = await service.construct("conv-twice", "/second");
- expect(second).toBe("/second");
- expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second");
- expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first");
- });
-
- it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => {
- // 5. getWithMeta reflects the latest construct, not the first.
- const storage = memoryStorage();
- await storage.set("template", "[prompt:cwd]");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- const first = await service.construct("conv-second", "/dir-a");
- const firstMeta = await service.getWithMeta("conv-second");
- expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null });
-
- const second = await service.construct("conv-second", "/dir-b");
- const secondMeta = await service.getWithMeta("conv-second");
- expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null });
- expect(secondMeta.cwd).not.toBe("/dir-a");
- });
-
- // ── resolveText: resolve an arbitrary template (no persistence) ────────────
-
- it("resolveText substitutes [type:name] variables in an arbitrary template", async () => {
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- const result = await service.resolveText(
- "os=[system:os] cwd=[prompt:cwd] ws=[prompt:workspace_id] conv=[prompt:conversation_id] model=[prompt:model]",
- "/work",
- { model: "gpt-4", conversationId: "c1", workspaceId: "ws-1" },
- );
-
- expect(result).toBe("os=linux cwd=/work ws=ws-1 conv=c1 model=gpt-4");
- });
-
- it("resolveText uses the SAME resolution as construct (same resolver + variables)", async () => {
- // A template resolved via resolveText must equal the same template
- // resolved via construct (both go through the shared resolveTemplate).
- const storage = memoryStorage();
- const template = "os=[system:os] cwd=[prompt:cwd] date=[system:date]";
- await storage.set("template", template);
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- const viaConstruct = await service.construct("conv-rt", "/work");
- const viaResolveText = await service.resolveText(template, "/work");
- expect(viaResolveText).toBe(viaConstruct);
- });
-
- it("resolveText does NOT persist (unlike construct)", async () => {
- const storage = memoryStorage();
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- await service.resolveText("[prompt:cwd]", "/work", { conversationId: "no-persist" });
-
- // No resolved:* / resolved-cwd:* / resolved-computer:* keys written.
- expect(await storage.get("resolved:no-persist")).toBeNull();
- expect(await storage.get("resolved-cwd:no-persist")).toBeNull();
- expect(await storage.get("resolved-computer:no-persist")).toBeNull();
- });
-
- it("resolveText resolves dynamic file:<path> variables referenced by the template", async () => {
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map([["/work/AGENTS.md", "RULES"]])),
- });
-
- const result = await service.resolveText("[file:AGENTS.md]", "/work");
- expect(result).toBe("RULES");
- });
-
- it("resolveText handles conditional blocks ([if]/[else]/[endif])", async () => {
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- // [prompt:model] exists (string) → then-branch renders.
- const withModel = await service.resolveText(
- "[if prompt:model]has model[else]no model[endif]",
- "/work",
- { model: "gpt-4" },
- );
- expect(withModel).toBe("has model");
-
- // model absent → else-branch renders.
- const noModel = await service.resolveText(
- "[if prompt:model]has model[else]no model[endif]",
- "/work",
- );
- expect(noModel).toBe("no model");
- });
-
- it("resolveText leaves unknown [type:name] tags substituted as empty (mirrors parser)", async () => {
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- // An unknown variable (not in the catalog) → empty string, matching
- // parseTemplate's "key absent from the map → empty string" rule.
- const result = await service.resolveText("x=[unknown:thing]", "/work");
- expect(result).toBe("x=");
- });
-
- it("resolveText on an empty template returns an empty string", async () => {
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
- expect(await service.resolveText("", "/work")).toBe("");
- });
+ it("construct persists and returns the resolved string", async () => {
+ // 14. construct writes to storage and returns the resolved string.
+ const storage = memoryStorage();
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
+ });
+
+ const result = await service.construct("conv-1", "/proj", { model: "gpt-4" });
+
+ expect(result).toContain("You are a helpful coding assistant.");
+ expect(result).toContain("RULES");
+ expect(result).toContain("/proj");
+ // persisted under resolved:<conversationId>
+ expect(await storage.get("resolved:conv-1")).toBe(result);
+ });
+
+ it("get returns persisted value after construct", async () => {
+ // 15. after construct, get returns the same string.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ // before construct → null
+ expect(await service.get("conv-2")).toBeNull();
+
+ const result = await service.construct("conv-2", "/proj");
+ expect(await service.get("conv-2")).toBe(result);
+ });
+
+ it("get returns null before construct", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ expect(await service.get("never-constructed")).toBeNull();
+ });
+
+ it("empty/no template stored → default template → non-empty", async () => {
+ // 16. no template stored → default template used → resolves to non-empty.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()), // no AGENTS.md
+ });
+
+ const result = await service.construct("conv-3", "/proj");
+
+ expect(result.length).toBeGreaterThan(0);
+ expect(result).toContain("You are a helpful coding assistant.");
+ expect(result).toContain("/proj");
+ // no AGENTS.md file → the [if file:AGENTS.md] block is omitted
+ expect(result).not.toContain("AGENTS.md");
+ });
+
+ it("stored template is used instead of default", async () => {
+ const storage = memoryStorage();
+ await storage.set("template", "cwd=[prompt:cwd] os=[system:os]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const result = await service.construct("conv-4", "/work");
+ expect(result).toBe("cwd=/work os=linux");
+ });
+
+ it("empty stored template → empty string", async () => {
+ const storage = memoryStorage();
+ await storage.set("template", "");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const result = await service.construct("conv-5", "/proj");
+ expect(result).toBe("");
+ expect(await service.get("conv-5")).toBe("");
+ });
+
+ it("construct is independent per conversation", async () => {
+ const storage = memoryStorage();
+ await storage.set("template", "[prompt:cwd]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const a = await service.construct("conv-a", "/dir-a");
+ const b = await service.construct("conv-b", "/dir-b");
+
+ expect(a).toBe("/dir-a");
+ expect(b).toBe("/dir-b");
+ expect(await service.get("conv-a")).toBe("/dir-a");
+ expect(await service.get("conv-b")).toBe("/dir-b");
+ });
+
+ it("DEFAULT_TEMPLATE contains the expected structure", () => {
+ expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant.");
+ expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]");
+ expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]");
+ expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]");
+ });
+
+ it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => {
+ // 1. never constructed → both fields null.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ const meta = await service.getWithMeta("never-constructed");
+ expect(meta).toEqual({ prompt: null, cwd: null, computerId: null });
+ });
+
+ it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => {
+ // 2. after construct → prompt + exact cwd passed to construct.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
+ });
+
+ const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" });
+ const meta = await service.getWithMeta("conv-meta");
+
+ expect(meta.prompt).toBe(result);
+ expect(meta.cwd).toBe("/proj");
+ });
+
+ it("get still returns the same value as before (backward compat)", async () => {
+ // 3. get() behavior is unchanged by the additive getWithMeta.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ // before construct → null
+ expect(await service.get("conv-bc")).toBeNull();
+
+ const result = await service.construct("conv-bc", "/proj");
+ expect(await service.get("conv-bc")).toBe(result);
+ });
+
+ it("construct called twice with different cwds stores the latest cwd", async () => {
+ // 4. second construct overwrites the cwd (not the first).
+ const storage = memoryStorage();
+ await storage.set("template", "[prompt:cwd]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ await service.construct("conv-twice", "/first");
+ expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first");
+
+ const second = await service.construct("conv-twice", "/second");
+ expect(second).toBe("/second");
+ expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second");
+ expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first");
+ });
+
+ it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => {
+ // 5. getWithMeta reflects the latest construct, not the first.
+ const storage = memoryStorage();
+ await storage.set("template", "[prompt:cwd]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const first = await service.construct("conv-second", "/dir-a");
+ const firstMeta = await service.getWithMeta("conv-second");
+ expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null });
+
+ const second = await service.construct("conv-second", "/dir-b");
+ const secondMeta = await service.getWithMeta("conv-second");
+ expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null });
+ expect(secondMeta.cwd).not.toBe("/dir-a");
+ });
+
+ // ── resolveText: resolve an arbitrary template (no persistence) ────────────
+
+ it("resolveText substitutes [type:name] variables in an arbitrary template", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ const result = await service.resolveText(
+ "os=[system:os] cwd=[prompt:cwd] ws=[prompt:workspace_id] conv=[prompt:conversation_id] model=[prompt:model]",
+ "/work",
+ { model: "gpt-4", conversationId: "c1", workspaceId: "ws-1" },
+ );
+
+ expect(result).toBe("os=linux cwd=/work ws=ws-1 conv=c1 model=gpt-4");
+ });
+
+ it("resolveText uses the SAME resolution as construct (same resolver + variables)", async () => {
+ // A template resolved via resolveText must equal the same template
+ // resolved via construct (both go through the shared resolveTemplate).
+ const storage = memoryStorage();
+ const template = "os=[system:os] cwd=[prompt:cwd] date=[system:date]";
+ await storage.set("template", template);
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const viaConstruct = await service.construct("conv-rt", "/work");
+ const viaResolveText = await service.resolveText(template, "/work");
+ expect(viaResolveText).toBe(viaConstruct);
+ });
+
+ it("resolveText does NOT persist (unlike construct)", async () => {
+ const storage = memoryStorage();
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ await service.resolveText("[prompt:cwd]", "/work", { conversationId: "no-persist" });
+
+ // No resolved:* / resolved-cwd:* / resolved-computer:* keys written.
+ expect(await storage.get("resolved:no-persist")).toBeNull();
+ expect(await storage.get("resolved-cwd:no-persist")).toBeNull();
+ expect(await storage.get("resolved-computer:no-persist")).toBeNull();
+ });
+
+ it("resolveText resolves dynamic file:<path> variables referenced by the template", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map([["/work/AGENTS.md", "RULES"]])),
+ });
+
+ const result = await service.resolveText("[file:AGENTS.md]", "/work");
+ expect(result).toBe("RULES");
+ });
+
+ it("resolveText handles conditional blocks ([if]/[else]/[endif])", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ // [prompt:model] exists (string) → then-branch renders.
+ const withModel = await service.resolveText(
+ "[if prompt:model]has model[else]no model[endif]",
+ "/work",
+ { model: "gpt-4" },
+ );
+ expect(withModel).toBe("has model");
+
+ // model absent → else-branch renders.
+ const noModel = await service.resolveText(
+ "[if prompt:model]has model[else]no model[endif]",
+ "/work",
+ );
+ expect(noModel).toBe("no model");
+ });
+
+ it("resolveText leaves unknown [type:name] tags substituted as empty (mirrors parser)", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ // An unknown variable (not in the catalog) → empty string, matching
+ // parseTemplate's "key absent from the map → empty string" rule.
+ const result = await service.resolveText("x=[unknown:thing]", "/work");
+ expect(result).toBe("x=");
+ });
+
+ it("resolveText on an empty template returns an empty string", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+ expect(await service.resolveText("", "/work")).toBe("");
+ });
});
diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts
index f695f49..2741c98 100644
--- a/packages/system-prompt/src/service.ts
+++ b/packages/system-prompt/src/service.ts
@@ -29,20 +29,20 @@ const TEMPLATE_KEY = "template";
const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`;
const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`;
const resolvedComputerIdKey = (conversationId: string): string =>
- `resolved-computer:${conversationId}`;
+ `resolved-computer:${conversationId}`;
export interface SystemPromptServiceDeps {
- /** Namespaced KV (`host.storage("system-prompt")`). */
- readonly storage: StorageNamespace;
- /** Injected effects for variable resolution (local). */
- readonly adapters: ResolverAdapters;
- /**
- * Optional: build remote-backed adapters for a given computerId. When
- * `construct` is called with a `computerId`, this is invoked to obtain
- * adapters that read/run commands on the REMOTE machine (via the
- * ExecBackend/SSH). Absent → falls back to the local `adapters`.
- */
- readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>;
+ /** Namespaced KV (`host.storage("system-prompt")`). */
+ readonly storage: StorageNamespace;
+ /** Injected effects for variable resolution (local). */
+ readonly adapters: ResolverAdapters;
+ /**
+ * Optional: build remote-backed adapters for a given computerId. When
+ * `construct` is called with a `computerId`, this is invoked to obtain
+ * adapters that read/run commands on the REMOTE machine (via the
+ * ExecBackend/SSH). Absent → falls back to the local `adapters`.
+ */
+ readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>;
}
/**
@@ -54,37 +54,37 @@ export interface SystemPromptServiceDeps {
* set, mirroring `construct`.
*/
async function resolveTemplate(
- deps: SystemPromptServiceDeps,
- template: string,
- cwd: string,
- context?: {
- readonly model?: string;
- readonly conversationId?: string;
- readonly workspaceId?: string;
- readonly computerId?: string;
- },
+ deps: SystemPromptServiceDeps,
+ template: string,
+ cwd: string,
+ context?: {
+ readonly model?: string;
+ readonly conversationId?: string;
+ readonly workspaceId?: string;
+ readonly computerId?: string;
+ },
): Promise<string> {
- const referencedKeys = extractVariables(template);
- const resolverContext: ResolverContext = {
- ...(context?.conversationId !== undefined ? { conversationId: context.conversationId } : {}),
- ...(context?.model !== undefined ? { model: context.model } : {}),
- ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
- };
-
- // Select adapters: when computerId is set, use remote-backed adapters
- // (read files / run commands on the REMOTE machine via SSH). Otherwise
- // use the local adapters.
- const computerId = context?.computerId;
- const adapters =
- computerId !== undefined && deps.resolveRemoteAdapters !== undefined
- ? await deps.resolveRemoteAdapters(computerId, cwd)
- : deps.adapters;
-
- const vars = await resolveVariables(cwd, adapters, {
- context: resolverContext,
- referencedKeys,
- });
- return parseTemplate(template, vars);
+ const referencedKeys = extractVariables(template);
+ const resolverContext: ResolverContext = {
+ ...(context?.conversationId !== undefined ? { conversationId: context.conversationId } : {}),
+ ...(context?.model !== undefined ? { model: context.model } : {}),
+ ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
+ };
+
+ // Select adapters: when computerId is set, use remote-backed adapters
+ // (read files / run commands on the REMOTE machine via SSH). Otherwise
+ // use the local adapters.
+ const computerId = context?.computerId;
+ const adapters =
+ computerId !== undefined && deps.resolveRemoteAdapters !== undefined
+ ? await deps.resolveRemoteAdapters(computerId, cwd)
+ : deps.adapters;
+
+ const vars = await resolveVariables(cwd, adapters, {
+ context: resolverContext,
+ referencedKeys,
+ });
+ return parseTemplate(template, vars);
}
/**
@@ -92,56 +92,56 @@ async function resolveTemplate(
* State is owned (not ambient): the storage reference lives in this closure.
*/
export function createSystemPromptService(deps: SystemPromptServiceDeps): SystemPromptService {
- return {
- async construct(conversationId, cwd, context) {
- let template = await deps.storage.get(TEMPLATE_KEY);
- if (template === null) template = DEFAULT_TEMPLATE;
-
- const result = await resolveTemplate(deps, template, cwd, {
- conversationId,
- ...(context?.model !== undefined ? { model: context.model } : {}),
- ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
- ...(context?.computerId !== undefined ? { computerId: context.computerId } : {}),
- });
-
- await deps.storage.set(resolvedKey(conversationId), result);
- await deps.storage.set(resolvedCwdKey(conversationId), cwd);
- // Store the computerId (or empty string for local) so the cache can be
- // invalidated when the computer changes.
- await deps.storage.set(resolvedComputerIdKey(conversationId), context?.computerId ?? "");
- return result;
- },
-
- async resolveText(template, cwd, context) {
- // An empty template has no variables to resolve; short-circuit to
- // avoid spawning git / reading files for nothing (the heartbeat's
- // default-empty prompts hit this every run).
- if (template === "") return "";
- return resolveTemplate(deps, template, cwd, context);
- },
-
- async get(conversationId) {
- return deps.storage.get(resolvedKey(conversationId));
- },
-
- async getWithMeta(conversationId) {
- const [prompt, cwd, computerIdStored] = await Promise.all([
- deps.storage.get(resolvedKey(conversationId)),
- deps.storage.get(resolvedCwdKey(conversationId)),
- deps.storage.get(resolvedComputerIdKey(conversationId)),
- ]);
- // Empty string → null (local, no computerId). Non-empty → the alias.
- const computerId = computerIdStored === null ? null : computerIdStored || null;
- return { prompt, cwd, computerId };
- },
-
- async getTemplate() {
- const stored = await deps.storage.get(TEMPLATE_KEY);
- return stored ?? DEFAULT_TEMPLATE;
- },
-
- async setTemplate(template) {
- await deps.storage.set(TEMPLATE_KEY, template);
- },
- };
+ return {
+ async construct(conversationId, cwd, context) {
+ let template = await deps.storage.get(TEMPLATE_KEY);
+ if (template === null) template = DEFAULT_TEMPLATE;
+
+ const result = await resolveTemplate(deps, template, cwd, {
+ conversationId,
+ ...(context?.model !== undefined ? { model: context.model } : {}),
+ ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
+ ...(context?.computerId !== undefined ? { computerId: context.computerId } : {}),
+ });
+
+ await deps.storage.set(resolvedKey(conversationId), result);
+ await deps.storage.set(resolvedCwdKey(conversationId), cwd);
+ // Store the computerId (or empty string for local) so the cache can be
+ // invalidated when the computer changes.
+ await deps.storage.set(resolvedComputerIdKey(conversationId), context?.computerId ?? "");
+ return result;
+ },
+
+ async resolveText(template, cwd, context) {
+ // An empty template has no variables to resolve; short-circuit to
+ // avoid spawning git / reading files for nothing (the heartbeat's
+ // default-empty prompts hit this every run).
+ if (template === "") return "";
+ return resolveTemplate(deps, template, cwd, context);
+ },
+
+ async get(conversationId) {
+ return deps.storage.get(resolvedKey(conversationId));
+ },
+
+ async getWithMeta(conversationId) {
+ const [prompt, cwd, computerIdStored] = await Promise.all([
+ deps.storage.get(resolvedKey(conversationId)),
+ deps.storage.get(resolvedCwdKey(conversationId)),
+ deps.storage.get(resolvedComputerIdKey(conversationId)),
+ ]);
+ // Empty string → null (local, no computerId). Non-empty → the alias.
+ const computerId = computerIdStored === null ? null : computerIdStored || null;
+ return { prompt, cwd, computerId };
+ },
+
+ async getTemplate() {
+ const stored = await deps.storage.get(TEMPLATE_KEY);
+ return stored ?? DEFAULT_TEMPLATE;
+ },
+
+ async setTemplate(template) {
+ await deps.storage.set(TEMPLATE_KEY, template);
+ },
+ };
}
diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts
index 670430a..ef57ee0 100644
--- a/packages/system-prompt/src/types.ts
+++ b/packages/system-prompt/src/types.ts
@@ -15,68 +15,68 @@ import { defineService, type ServiceHandle } from "@dispatch/kernel";
* no per-turn reconstruction).
*/
export interface SystemPromptService {
- /**
- * Resolve the template against the current environment and persist the
- * result under `resolved:<conversationId>`. Returns the resolved string.
- * When no template is stored, the built-in default template is used. An
- * empty template yields an empty string.
- *
- * When `context.computerId` is set, the resolver uses remote-backed adapters
- * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via
- * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine.
- */
- construct(
- conversationId: string,
- cwd: string,
- context?: {
- readonly model?: string;
- readonly workspaceId?: string;
- readonly computerId?: string;
- },
- ): Promise<string>;
+ /**
+ * Resolve the template against the current environment and persist the
+ * result under `resolved:<conversationId>`. Returns the resolved string.
+ * When no template is stored, the built-in default template is used. An
+ * empty template yields an empty string.
+ *
+ * When `context.computerId` is set, the resolver uses remote-backed adapters
+ * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via
+ * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine.
+ */
+ construct(
+ conversationId: string,
+ cwd: string,
+ context?: {
+ readonly model?: string;
+ readonly workspaceId?: string;
+ readonly computerId?: string;
+ },
+ ): Promise<string>;
- /**
- * Resolve an ARBITRARY template string against the current environment,
- * using the SAME variable resolver + adapter set as `construct` (so a
- * caller that owns its own prompt — e.g. the heartbeat extension — gets
- * `[type:name]` placeholders substituted identically to the global
- * template). Pure resolution: nothing is persisted (the result is the
- * caller's to use). An empty template yields an empty string.
- *
- * Like `construct`, when `context.computerId` is set the resolver uses
- * remote-backed adapters (reading the remote's OS/hostname/git via SSH).
- */
- resolveText(
- template: string,
- cwd: string,
- context?: {
- readonly model?: string;
- readonly conversationId?: string;
- readonly workspaceId?: string;
- readonly computerId?: string;
- },
- ): Promise<string>;
+ /**
+ * Resolve an ARBITRARY template string against the current environment,
+ * using the SAME variable resolver + adapter set as `construct` (so a
+ * caller that owns its own prompt — e.g. the heartbeat extension — gets
+ * `[type:name]` placeholders substituted identically to the global
+ * template). Pure resolution: nothing is persisted (the result is the
+ * caller's to use). An empty template yields an empty string.
+ *
+ * Like `construct`, when `context.computerId` is set the resolver uses
+ * remote-backed adapters (reading the remote's OS/hostname/git via SSH).
+ */
+ resolveText(
+ template: string,
+ cwd: string,
+ context?: {
+ readonly model?: string;
+ readonly conversationId?: string;
+ readonly workspaceId?: string;
+ readonly computerId?: string;
+ },
+ ): Promise<string>;
- /** Read the persisted resolved system prompt, or `null` if never constructed. */
- get(conversationId: string): Promise<string | null>;
+ /** Read the persisted resolved system prompt, or `null` if never constructed. */
+ get(conversationId: string): Promise<string | null>;
- /**
- * Read the persisted resolved system prompt AND the cwd + computerId it was
- * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if
- * never constructed. Consumers use this to detect whether the cached prompt
- * is stale relative to the current effective cwd or computerId.
- */
- getWithMeta(conversationId: string): Promise<{
- readonly prompt: string | null;
- readonly cwd: string | null;
- readonly computerId: string | null;
- }>;
+ /**
+ * Read the persisted resolved system prompt AND the cwd + computerId it was
+ * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if
+ * never constructed. Consumers use this to detect whether the cached prompt
+ * is stale relative to the current effective cwd or computerId.
+ */
+ getWithMeta(conversationId: string): Promise<{
+ readonly prompt: string | null;
+ readonly cwd: string | null;
+ readonly computerId: string | null;
+ }>;
- /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
- getTemplate(): Promise<string>;
+ /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
+ getTemplate(): Promise<string>;
- /** Set (upsert) the global template. An empty string means "no system prompt". */
- setTemplate(template: string): Promise<void>;
+ /** Set (upsert) the global template. An empty string means "no system prompt". */
+ setTemplate(template: string): Promise<void>;
}
/**
@@ -84,4 +84,4 @@ export interface SystemPromptService {
* session-orchestrator imports to reach the builder — no string-keyed lookup.
*/
export const systemPromptHandle: ServiceHandle<SystemPromptService> =
- defineService<SystemPromptService>("system-prompt");
+ defineService<SystemPromptService>("system-prompt");
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 7f58c63..660898a 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,12 +1,12 @@
{
- "name": "@dispatch/transport-contract",
- "version": "0.23.0",
- "type": "module",
- "private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "dependencies": {
- "@dispatch/ui-contract": "workspace:*",
- "@dispatch/wire": "workspace:*"
- }
+ "name": "@dispatch/transport-contract",
+ "version": "0.23.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/ui-contract": "workspace:*",
+ "@dispatch/wire": "workspace:*"
+ }
}
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 34f3ffb..6a9a29f 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -929,18 +929,18 @@ export interface TestComputerResponse {
* heartbeats.
*/
export interface HeartbeatConfig {
- /** Whether the heartbeat loop is active for this workspace. */
- readonly enabled: boolean;
- /** Custom system prompt for the heartbeat AI (empty = no system prompt). */
- readonly systemPrompt: string;
- /** Task prompt sent as the first user message when the heartbeat fires. */
- readonly taskPrompt: string;
- /** How often to fire, in minutes (default 30). */
- readonly intervalMinutes: number;
- /** Model name (`<credentialName>/<model>`), or empty string = server default. */
- readonly model: string;
- /** Reasoning-effort level, or `null` = inherit the workspace default. */
- readonly reasoningEffort: ReasoningEffort | null;
+ /** Whether the heartbeat loop is active for this workspace. */
+ readonly enabled: boolean;
+ /** Custom system prompt for the heartbeat AI (empty = no system prompt). */
+ readonly systemPrompt: string;
+ /** Task prompt sent as the first user message when the heartbeat fires. */
+ readonly taskPrompt: string;
+ /** How often to fire, in minutes (default 30). */
+ readonly intervalMinutes: number;
+ /** Model name (`<credentialName>/<model>`), or empty string = server default. */
+ readonly model: string;
+ /** Reasoning-effort level, or `null` = inherit the workspace default. */
+ readonly reasoningEffort: ReasoningEffort | null;
}
/**
@@ -951,12 +951,12 @@ export interface HeartbeatConfig {
* `reasoningEffort` → HTTP 400.
*/
export interface UpdateHeartbeatRequest {
- readonly enabled?: boolean;
- readonly systemPrompt?: string;
- readonly taskPrompt?: string;
- readonly intervalMinutes?: number;
- readonly model?: string;
- readonly reasoningEffort?: ReasoningEffort | null;
+ readonly enabled?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
}
/** The status of a single heartbeat run. */
@@ -970,18 +970,18 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped";
* stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`).
*/
export interface HeartbeatRun {
- readonly id: string;
- readonly conversationId: string;
- readonly triggeredAt: string;
- readonly status: HeartbeatRunStatus;
+ readonly id: string;
+ readonly conversationId: string;
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
}
/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */
export interface HeartbeatRunsResponse {
- readonly runs: readonly HeartbeatRun[];
+ readonly runs: readonly HeartbeatRun[];
}
/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
export interface StopHeartbeatRunResponse {
- readonly ok: true;
+ readonly ok: true;
}
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index 7990c2b..3c722e2 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -1,22 +1,22 @@
{
- "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/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/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 316ab67..7ae0354 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -1,229 +1,229 @@
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,
+ 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,
- HeartbeatService,
- 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,
+ 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;
+ },
+ };
}
/**
@@ -232,290 +232,290 @@ 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: [] };
+ },
+ 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: [] };
+ },
+ 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: [] };
+ },
+ 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" };
+ },
+ };
}
/**
@@ -524,3871 +524,3871 @@ function createFakeComputerService(computers: readonly ComputerEntry[] = []): Co
* 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,
- };
+ return {
+ getConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ updateConfig: async () => DEFAULT_HEARTBEAT_CONFIG,
+ listRuns: async () => [],
+ stopRun: async () => ({ ok: true }),
+ startAll: async () => {},
+ stopAll: () => {},
+ nextRunAt: async () => nextRunAt,
+ };
}
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();
+ });
});
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("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,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("GET /workspaces returns list", async () => {
+ const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listWorkspaces() {
+ return workspaceEntries;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceListResponse;
+ expect(body.workspaces).toEqual(workspaceEntries);
+ });
+
+ it("PUT /workspaces/:id creates on miss", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace(id, opts) {
+ ensured = true;
+ return { ...sampleWorkspace, id, ...opts };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(ensured).toBe(true);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.id).toBe("proj");
+ expect(body.title).toBe("Project");
+ expect(body.defaultCwd).toBe("/home/proj");
+ });
+
+ it("PUT /workspaces/:id returns existing", async () => {
+ const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ return existing;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "New Title" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Existing");
+ expect(body.defaultCwd).toBe("/old");
+ });
+
+ it("PUT /workspaces/:id rejects invalid slug", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ ensured = true;
+ return sampleWorkspace;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(ensured).toBe(false);
+ });
+
+ it("GET /workspaces/:id returns 404 for missing", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getWorkspace() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("PUT /workspaces/:id/title renames", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceTitle(id, title) {
+ return { ...sampleWorkspace, id, title };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Renamed" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Renamed");
+ });
+
+ it("PUT /workspaces/:id/default-cwd sets", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { ...sampleWorkspace, id, defaultCwd };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd: "/new/cwd" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultCwd).toBe("/new/cwd");
+ });
+
+ it("DELETE /workspaces/:id closes conversations", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 3 };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeleteWorkspaceResponse;
+ expect(body.workspaceId).toBe("proj");
+ expect(body.closedCount).toBe(3);
+ });
+
+ it("DELETE /workspaces/default returns 409", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/default", { method: "DELETE" });
+ expect(res.status).toBe(409);
+ });
});
it("POST /chat threads workspaceId", async () => {
- const cap = createCapturingOrchestrator();
- const app = createApp({
- conversationStore: createFakeConversationStore(),
- orchestrator: cap,
- credentialStore: createFakeCredentialStore([]),
- });
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
- });
- expect(res.status).toBe(200);
- expect(cap.received).toBeDefined();
- expect(cap.received?.workspaceId).toBe("proj");
+ 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();
- });
+ 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();
+ });
});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 27ae2bb..4fb295e 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -2,1624 +2,1624 @@ 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,
- HeartbeatConfig,
- HeartbeatRunsResponse,
- LastMessageResponse,
- LspServerInfo,
- LspStatusResponse,
- McpServerInfo,
- McpStatusResponse,
- ModelResponse,
- ModelsResponse,
- OpenConversationResponse,
- QueueResponse,
- ReasoningEffortResponse,
- SetCompactPercentRequest,
- SetConversationComputerRequest,
- SetSystemPromptTemplateRequest,
- SetWorkspaceDefaultComputerRequest,
- StopHeartbeatRunResponse,
- SystemPromptTemplateResponse,
- SystemPromptVariablesResponse,
- TestComputerResponse,
- ThroughputResponse,
- TitleResponse,
- UpdateHeartbeatRequest,
- WarmResponse,
- WorkspaceListResponse,
- WorkspaceResponse,
+ CloseConversationResponse,
+ CompactPercentResponse,
+ CompactResponse,
+ ComputerListResponse,
+ ComputerResponse,
+ ComputerStatusResponse,
+ ConversationComputerResponse,
+ ConversationHistoryResponse,
+ ConversationListResponse,
+ ConversationMetricsResponse,
+ ConversationStatusResponse,
+ CwdResponse,
+ DeleteWorkspaceResponse,
+ HeartbeatConfig,
+ HeartbeatRunsResponse,
+ LastMessageResponse,
+ LspServerInfo,
+ LspStatusResponse,
+ McpServerInfo,
+ McpStatusResponse,
+ ModelResponse,
+ ModelsResponse,
+ OpenConversationResponse,
+ QueueResponse,
+ ReasoningEffortResponse,
+ SetCompactPercentRequest,
+ SetConversationComputerRequest,
+ SetSystemPromptTemplateRequest,
+ SetWorkspaceDefaultComputerRequest,
+ StopHeartbeatRunResponse,
+ SystemPromptTemplateResponse,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ ThroughputResponse,
+ TitleResponse,
+ UpdateHeartbeatRequest,
+ WarmResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
import { Hono } from "hono";
import { cors } from "hono/cors";
import {
- computeCachePct,
- computeExpectedCacheRate,
- extractLastAssistantText,
- isModelParseError,
- isParseError,
- isReasoningEffortParseError,
- isSinceSeqError,
- isValidReasoningEffort,
- 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,
- type HeartbeatService,
- isValidWorkspaceSlug,
- type LspServerStatus,
- type LspService,
- type McpServerStatus,
- type McpService,
- type SessionOrchestrator,
- type SystemPromptService,
- ThroughputQueryError,
- type ThroughputStore,
- type WarmService,
+ type CompactionService,
+ type ComputerService,
+ 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 — 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;
- 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;
+ 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);
- }
- });
-
- // ─── 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;
- }
-
- 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);
- });
-
- // ─── 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 }));
+
+ 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);
+ }
+ });
+
+ // ─── 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;
+ }
+
+ 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);
+ });
+
+ // ─── 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 eb14c55..76d58b6 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -1,147 +1,147 @@
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import { createApp } from "./app.js";
import {
- type ComputerService,
- cacheWarmHandle,
- compactionHandle,
- computerServiceHandle,
- conversationStoreHandle,
- credentialStoreHandle,
- heartbeatServiceHandle,
- lspServiceHandle,
- mcpServiceHandle,
- sessionOrchestratorHandle,
- systemPromptHandle,
- throughputStoreHandle,
+ type ComputerService,
+ cacheWarmHandle,
+ compactionHandle,
+ computerServiceHandle,
+ conversationStoreHandle,
+ credentialStoreHandle,
+ heartbeatServiceHandle,
+ 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",
- "heartbeat",
- "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/default-cwd",
- "/workspaces/:id/default-computer",
- "/workspaces/:id/heartbeat",
- "/workspaces/:id/heartbeat/runs",
- "/workspaces/:id/heartbeat/runs/:runId/stop",
- "/workspaces/:id/title",
- ],
- },
- activation: "eager",
+ id: "transport-http",
+ name: "Transport HTTP",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ dependsOn: [
+ "conversation-store",
+ "credential-store",
+ "heartbeat",
+ "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/default-cwd",
+ "/workspaces/:id/default-computer",
+ "/workspaces/:id/heartbeat",
+ "/workspaces/:id/heartbeat/runs",
+ "/workspaces/:id/heartbeat/runs/:runId/stop",
+ "/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);
- 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;
- }
- 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);
+ const lspService = host.getService(lspServiceHandle);
+ 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;
+ }
+ const logger = host.logger;
- const app = createApp({
- conversationStore,
- orchestrator,
- credentialStore,
- throughputStore,
- warmService,
- compactionService,
- lspService,
- mcpService,
- systemPromptService,
- heartbeatService,
- ...(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,
+ mcpService,
+ systemPromptService,
+ heartbeatService,
+ ...(computerService !== undefined ? { computerService } : {}),
+ 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/tsconfig.json b/packages/transport-http/tsconfig.json
index 001a373..4a76434 100644
--- a/packages/transport-http/tsconfig.json
+++ b/packages/transport-http/tsconfig.json
@@ -1,16 +1,16 @@
{
- "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": "../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": "../session-orchestrator" },
+ { "path": "../system-prompt" },
+ { "path": "../throughput-store" },
+ { "path": "../transport-contract" }
+ ]
}
diff --git a/tsconfig.json b/tsconfig.json
index cde3850..e4e833d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,122 +1,122 @@
{
- "files": [],
- "references": [
- {
- "path": "./packages/wire"
- },
- {
- "path": "./packages/kernel"
- },
- {
- "path": "./packages/transport-contract"
- },
- {
- "path": "./packages/ui-contract"
- },
- {
- "path": "./packages/surface-registry"
- },
- {
- "path": "./packages/transport-ws"
- },
- {
- "path": "./packages/surface-loaded-extensions"
- },
- {
- "path": "./packages/storage-sqlite"
- },
- {
- "path": "./packages/auth-apikey"
- },
- {
- "path": "./packages/provider-openai-compat"
- },
- {
- "path": "./packages/openai-stream"
- },
- {
- "path": "./packages/provider-umans"
- },
- {
- "path": "./packages/credential-store"
- },
- {
- "path": "./packages/exec-backend"
- },
- {
- "path": "./packages/ssh"
- },
- {
- "path": "./packages/conversation-store"
- },
- {
- "path": "./packages/throughput-store"
- },
- {
- "path": "./packages/todo"
- },
- {
- "path": "./packages/session-orchestrator"
- },
- {
- "path": "./packages/transport-http"
- },
- {
- "path": "./packages/tool-read-file"
- },
- {
- "path": "./packages/tool-shell"
- },
- {
- "path": "./packages/tool-edit-file"
- },
- {
- "path": "./packages/tool-write-file"
- },
- {
- "path": "./packages/tool-web-search"
- },
- {
- "path": "./packages/tool-youtube-transcript"
- },
- {
- "path": "./packages/skills"
- },
- {
- "path": "./packages/cache-warming"
- },
- {
- "path": "./packages/message-queue"
- },
- {
- "path": "./packages/mcp"
- },
- {
- "path": "./packages/lsp"
- },
- {
- "path": "./packages/heartbeat"
- },
- {
- "path": "./packages/system-prompt"
- },
- {
- "path": "./packages/cli"
- },
- {
- "path": "./packages/journal-sink"
- },
- {
- "path": "./packages/trace-store"
- },
- {
- "path": "./packages/observability-collector"
- },
- {
- "path": "./packages/trace-replay"
- },
- {
- "path": "./packages/host-bin"
- }
- ]
+ "files": [],
+ "references": [
+ {
+ "path": "./packages/wire"
+ },
+ {
+ "path": "./packages/kernel"
+ },
+ {
+ "path": "./packages/transport-contract"
+ },
+ {
+ "path": "./packages/ui-contract"
+ },
+ {
+ "path": "./packages/surface-registry"
+ },
+ {
+ "path": "./packages/transport-ws"
+ },
+ {
+ "path": "./packages/surface-loaded-extensions"
+ },
+ {
+ "path": "./packages/storage-sqlite"
+ },
+ {
+ "path": "./packages/auth-apikey"
+ },
+ {
+ "path": "./packages/provider-openai-compat"
+ },
+ {
+ "path": "./packages/openai-stream"
+ },
+ {
+ "path": "./packages/provider-umans"
+ },
+ {
+ "path": "./packages/credential-store"
+ },
+ {
+ "path": "./packages/exec-backend"
+ },
+ {
+ "path": "./packages/ssh"
+ },
+ {
+ "path": "./packages/conversation-store"
+ },
+ {
+ "path": "./packages/throughput-store"
+ },
+ {
+ "path": "./packages/todo"
+ },
+ {
+ "path": "./packages/session-orchestrator"
+ },
+ {
+ "path": "./packages/transport-http"
+ },
+ {
+ "path": "./packages/tool-read-file"
+ },
+ {
+ "path": "./packages/tool-shell"
+ },
+ {
+ "path": "./packages/tool-edit-file"
+ },
+ {
+ "path": "./packages/tool-write-file"
+ },
+ {
+ "path": "./packages/tool-web-search"
+ },
+ {
+ "path": "./packages/tool-youtube-transcript"
+ },
+ {
+ "path": "./packages/skills"
+ },
+ {
+ "path": "./packages/cache-warming"
+ },
+ {
+ "path": "./packages/message-queue"
+ },
+ {
+ "path": "./packages/mcp"
+ },
+ {
+ "path": "./packages/lsp"
+ },
+ {
+ "path": "./packages/heartbeat"
+ },
+ {
+ "path": "./packages/system-prompt"
+ },
+ {
+ "path": "./packages/cli"
+ },
+ {
+ "path": "./packages/journal-sink"
+ },
+ {
+ "path": "./packages/trace-store"
+ },
+ {
+ "path": "./packages/observability-collector"
+ },
+ {
+ "path": "./packages/trace-replay"
+ },
+ {
+ "path": "./packages/host-bin"
+ }
+ ]
}