summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/cli/src/args.test.ts33
-rw-r--r--packages/cli/src/args.ts17
-rw-r--r--packages/cli/src/http.test.ts12
-rw-r--r--packages/cli/src/main.ts5
-rw-r--r--packages/cli/src/message.test.ts50
-rw-r--r--packages/cli/src/message.ts2
-rw-r--r--packages/cli/src/render.test.ts1
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts378
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts55
-rw-r--r--packages/session-orchestrator/src/queue.test.ts25
-rw-r--r--packages/transport-http/src/app.test.ts508
-rw-r--r--packages/transport-http/src/app.ts186
-rw-r--r--packages/transport-http/src/extension.ts4
-rw-r--r--packages/transport-http/src/index.ts1
-rw-r--r--packages/transport-http/src/logic.ts20
-rw-r--r--packages/transport-http/src/seam.ts2
-rw-r--r--packages/transport-http/src/server.bun.test.ts25
-rw-r--r--packages/transport-ws/src/extension.ts2
-rw-r--r--packages/transport-ws/src/router.test.ts48
-rw-r--r--packages/transport-ws/src/router.ts4
-rw-r--r--packages/transport-ws/src/server.bun.test.ts101
21 files changed, 1413 insertions, 66 deletions
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts
index 992d09f..3d07c96 100644
--- a/packages/cli/src/args.test.ts
+++ b/packages/cli/src/args.test.ts
@@ -52,6 +52,7 @@ describe("parseArgs", () => {
reasoningEffort: undefined,
showReasoning: false,
open: false,
+ workspaceId: undefined,
});
});
@@ -68,6 +69,7 @@ describe("parseArgs", () => {
reasoningEffort: undefined,
showReasoning: false,
open: false,
+ workspaceId: undefined,
});
});
@@ -103,6 +105,7 @@ describe("parseArgs", () => {
reasoningEffort: undefined,
showReasoning: true,
open: false,
+ workspaceId: undefined,
});
});
@@ -119,6 +122,7 @@ describe("parseArgs", () => {
reasoningEffort: "high",
showReasoning: false,
open: false,
+ workspaceId: undefined,
});
});
@@ -183,6 +187,35 @@ describe("parseArgs", () => {
const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer });
expect(result.kind).toBe("error");
});
+
+ it("parses --workspace flag", () => {
+ const result = parseArgs(["m", "--text", "x", "--workspace", "my-work"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "m",
+ text: "x",
+ file: undefined,
+ cwd: undefined,
+ conversationId: undefined,
+ reasoningEffort: undefined,
+ showReasoning: false,
+ open: false,
+ workspaceId: "my-work",
+ });
+ });
+
+ it("parses -w shorthand", () => {
+ const result = parseArgs(["m", "--text", "x", "-w", "ws"], { defaultServer });
+ expect(result.kind).toBe("chat");
+ if (result.kind === "chat") expect(result.workspaceId).toBe("ws");
+ });
+
+ it("errors when --workspace has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--workspace"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--workspace requires a value");
+ });
});
describe("list", () => {
diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts
index ac5dd4a..8a63777 100644
--- a/packages/cli/src/args.ts
+++ b/packages/cli/src/args.ts
@@ -26,6 +26,7 @@ export type ParsedCommand =
readonly reasoningEffort?: ReasoningEffort | undefined;
readonly showReasoning: boolean;
readonly open: boolean;
+ readonly workspaceId?: string | undefined;
}
| {
readonly kind: "list";
@@ -46,6 +47,7 @@ export type ParsedCommand =
readonly open: boolean;
readonly cwd?: string;
readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
}
| { readonly kind: "stop"; readonly server: string; readonly conversationId: string }
| { readonly kind: "help" }
@@ -206,6 +208,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
let open = false;
let cwd: string | undefined;
let reasoningEffort: ReasoningEffort | undefined;
+ let workspaceId: string | undefined;
for (let i = 1; i < argv.length; i++) {
const arg = argv[i] as string;
@@ -243,6 +246,12 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
reasoningEffort = val;
break;
}
+ case "--workspace":
+ case "-w":
+ if (i + 1 >= argv.length)
+ return { kind: "error", message: "--workspace requires a value" };
+ workspaceId = argv[++i];
+ break;
default:
if (arg.startsWith("--")) return { kind: "error", message: `Unknown flag: ${arg}` };
if (conversationId !== undefined)
@@ -267,6 +276,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
open,
...(cwd !== undefined && { cwd }),
...(reasoningEffort !== undefined && { reasoningEffort }),
+ ...(workspaceId !== undefined && { workspaceId }),
};
}
@@ -280,6 +290,7 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
let showReasoning = false;
let open = false;
let server = opts.defaultServer;
+ let workspaceId: string | undefined;
for (let i = 1; i < argv.length; i++) {
const arg = argv[i] as string;
@@ -327,6 +338,11 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
reasoningEffort = val;
}
break;
+ case "--workspace":
+ case "-w":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" };
+ workspaceId = argv[++i];
+ break;
default:
return { kind: "error", message: `Unknown flag: ${arg}` };
}
@@ -350,5 +366,6 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma
reasoningEffort,
showReasoning,
open,
+ ...(workspaceId !== undefined && { workspaceId }),
};
}
diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts
index 3e7befe..2aa61e9 100644
--- a/packages/cli/src/http.test.ts
+++ b/packages/cli/src/http.test.ts
@@ -252,7 +252,14 @@ describe("fetchConversations", () => {
let calledUrl: string | undefined;
const list: ConversationListResponse = {
conversations: [
- { id: "abcdef1234567890", title: "first", createdAt: 1, lastActivityAt: 2, status: "idle" },
+ {
+ id: "abcdef1234567890",
+ title: "first",
+ createdAt: 1,
+ lastActivityAt: 2,
+ status: "idle",
+ workspaceId: "default",
+ },
],
};
const fakeFetch = (async (url: string | URL | Request): Promise<Response> => {
@@ -408,6 +415,7 @@ describe("resolveConversationId", () => {
createdAt: 1,
lastActivityAt: 2,
status: "idle",
+ workspaceId: "default",
},
],
});
@@ -440,6 +448,7 @@ describe("resolveConversationId", () => {
createdAt: 1,
lastActivityAt: 2,
status: "idle",
+ workspaceId: "default",
},
{
id: "abcdef1234567890bbbbbbbbbbbbbbbb",
@@ -447,6 +456,7 @@ describe("resolveConversationId", () => {
createdAt: 1,
lastActivityAt: 3,
status: "idle",
+ workspaceId: "default",
},
],
});
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index 5935bab..9dfc317 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -29,8 +29,8 @@ const USAGE = `Usage:
dispatch compact <conversationId> [--server <url>]
dispatch read <conversationId> [--server <url>]
dispatch open <conversationId> [--server <url>]
- dispatch send <conversationId> --text "..." [--queue] [--open] [--cwd <dir>] [--effort <level>] [--server <url>]
- dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--server <url>] [--show-reasoning] [--open]
+ dispatch send <conversationId> --text "..." [--queue] [--open] [--cwd <dir>] [--effort <level>] [--workspace <id>] [--server <url>]
+ dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--workspace <id>] [--server <url>] [--show-reasoning] [--open]
dispatch --help
Effort levels: low, medium, high (default), xhigh, max`;
@@ -171,6 +171,7 @@ async function main(): Promise<void> {
message: parsed.text,
...(parsed.cwd !== undefined && { cwd: parsed.cwd }),
...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }),
+ ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }),
};
const { events } = await streamChat(
{ fetchImpl: globalThis.fetch },
diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts
index a3f1e0b..440ec85 100644
--- a/packages/cli/src/message.test.ts
+++ b/packages/cli/src/message.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { parseArgs } from "./args.js";
import { buildChatRequest, composeMessage } from "./message.js";
describe("composeMessage", () => {
@@ -94,4 +95,53 @@ describe("buildChatRequest", () => {
);
expect(req).not.toHaveProperty("reasoningEffort");
});
+
+ it("includes workspaceId when provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", workspaceId: "my-work", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req.workspaceId).toBe("my-work");
+ });
+
+ it("omits workspaceId when not provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req).not.toHaveProperty("workspaceId");
+ });
+});
+
+describe("workspace flag → ChatRequest", () => {
+ const defaultServer = "http://localhost:24203";
+
+ it("--workspace flag sets workspaceId on request", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi", "--workspace", "my-work"], {
+ defaultServer,
+ });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.workspaceId).toBe("my-work");
+ });
+
+ it("--workspace flag omitted sends no workspaceId", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.workspaceId).toBeUndefined();
+ expect(req).not.toHaveProperty("workspaceId");
+ });
+
+ it("-w shorthand sets workspaceId on request", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi", "-w", "shorthand"], {
+ defaultServer,
+ });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.workspaceId).toBe("shorthand");
+ });
});
diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts
index 80befec..ec4d6d1 100644
--- a/packages/cli/src/message.ts
+++ b/packages/cli/src/message.ts
@@ -38,6 +38,7 @@ interface ChatCmd {
readonly cwd?: string | undefined;
readonly conversationId?: string | undefined;
readonly reasoningEffort?: ReasoningEffort | undefined;
+ readonly workspaceId?: string | undefined;
readonly showReasoning: boolean;
}
@@ -53,5 +54,6 @@ export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest {
...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }),
...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }),
...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }),
+ ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }),
};
}
diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts
index eb89300..1c92733 100644
--- a/packages/cli/src/render.test.ts
+++ b/packages/cli/src/render.test.ts
@@ -230,6 +230,7 @@ describe("formatConversationList", () => {
createdAt: now - ageMs - 1000,
lastActivityAt: now - ageMs,
status: "idle",
+ workspaceId: "default",
});
it("returns empty string for an empty list", () => {
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index ce134f4..e778705 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -97,6 +97,31 @@ function createInMemoryStore(): ConversationStore & {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace(id) {
+ return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle(id, title) {
+ return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd(conversationId) {
+ return cwdData.get(conversationId) ?? null;
+ },
};
}
@@ -507,53 +532,15 @@ describe("turn-sealed event", () => {
const ordering: string[] = [];
const wrappedStore: ConversationStore = {
+ ...store,
async append(conversationId, messages) {
await store.append(conversationId, messages);
ordering.push("append");
},
- async load(conversationId) {
- return store.load(conversationId);
- },
- async loadSince(conversationId, sinceSeq) {
- return store.loadSince(conversationId, sinceSeq);
- },
async appendMetrics(conversationId, metrics) {
await store.appendMetrics(conversationId, metrics);
ordering.push("appendMetrics");
},
- async loadMetrics(conversationId) {
- return store.loadMetrics(conversationId);
- },
- async getCwd(conversationId) {
- return store.getCwd(conversationId);
- },
- async setCwd(conversationId, cwd) {
- await store.setCwd(conversationId, cwd);
- },
- async getReasoningEffort(conversationId) {
- return store.getReasoningEffort(conversationId);
- },
- async setReasoningEffort(conversationId, effort) {
- await store.setReasoningEffort(conversationId, effort);
- },
- 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() {},
};
const { orchestrator } = createSessionOrchestrator({
@@ -627,6 +614,31 @@ describe("turn-sealed event", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace(id) {
+ return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle(id, title) {
+ return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const { orchestrator } = createSessionOrchestrator({
@@ -989,6 +1001,31 @@ describe("turn metrics persistence", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace(id) {
+ return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle(id, title) {
+ return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const { orchestrator } = createSessionOrchestrator({
@@ -2486,3 +2523,266 @@ describe("reasoning effort resolution", () => {
expect(warmOpts?.reasoningEffort).toBe(captured[0]?.providerOpts?.reasoningEffort);
});
});
+
+// --- Workspace integration (workspaceId threading + effective cwd) ---
+
+describe("workspace integration", () => {
+ function waitForSealed(
+ orchestrator: ReturnType<typeof createSessionOrchestrator>["orchestrator"],
+ conversationId: string,
+ ): Promise<void> {
+ return new Promise((resolve) => {
+ const unsub = orchestrator.subscribe(conversationId, (e) => {
+ if (e.type === "turn-sealed") {
+ unsub();
+ resolve();
+ }
+ });
+ });
+ }
+
+ it("startTurn stamps workspaceId on new conversation", async () => {
+ const base = createInMemoryStore();
+ const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = [];
+ const store: ConversationStore = {
+ ...base,
+ async setWorkspaceId(conversationId, workspaceId) {
+ setWorkspaceIdCalls.push({ conversationId, workspaceId });
+ },
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: createCapturingRunTurn().captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-stamp",
+ text: "hi",
+ onEvent: () => {},
+ workspaceId: "my-workspace",
+ });
+
+ expect(setWorkspaceIdCalls).toContainEqual({
+ conversationId: "conv-ws-stamp",
+ workspaceId: "my-workspace",
+ });
+ });
+
+ it("startTurn defaults workspaceId to default", async () => {
+ const base = createInMemoryStore();
+ const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = [];
+ const store: ConversationStore = {
+ ...base,
+ async setWorkspaceId(conversationId, workspaceId) {
+ setWorkspaceIdCalls.push({ conversationId, workspaceId });
+ },
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: createCapturingRunTurn().captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-default",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(setWorkspaceIdCalls).toContainEqual({
+ conversationId: "conv-ws-default",
+ workspaceId: "default",
+ });
+ });
+
+ it("startTurn auto-creates workspace if missing", async () => {
+ const base = createInMemoryStore();
+ const ensureWorkspaceCalls: string[] = [];
+ const store: ConversationStore = {
+ ...base,
+ async ensureWorkspace(id) {
+ ensureWorkspaceCalls.push(id);
+ return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: createCapturingRunTurn().captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-autocreate",
+ text: "hi",
+ onEvent: () => {},
+ workspaceId: "brand-new-workspace",
+ });
+
+ expect(ensureWorkspaceCalls).toContain("brand-new-workspace");
+ });
+
+ it("startTurn uses effective cwd when no explicit cwd", async () => {
+ const base = createInMemoryStore();
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd() {
+ return "/workspace/default/cwd";
+ },
+ };
+
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-effcwd",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/workspace/default/cwd");
+ });
+
+ it("startTurn explicit cwd overrides workspace default", async () => {
+ const base = createInMemoryStore();
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd() {
+ return "/workspace/default/cwd";
+ },
+ };
+
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-override",
+ text: "hi",
+ onEvent: () => {},
+ cwd: "/explicit/cwd",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBe("/explicit/cwd");
+ });
+
+ it("startTurn effective cwd null when nothing set", async () => {
+ const store = createInMemoryStore();
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-null-cwd",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.cwd).toBeUndefined();
+ });
+
+ it("warm uses effective cwd", async () => {
+ const base = createInMemoryStore();
+ await base.append("conv-warm-effcwd", [
+ { role: "user", chunks: [{ type: "text", text: "hi" }] },
+ ]);
+ const store: ConversationStore = {
+ ...base,
+ async getEffectiveCwd() {
+ return "/workspace/warm/cwd";
+ },
+ };
+
+ let assemblyCwd: string | undefined = "UNSET";
+ const provider: ProviderContract = {
+ id: "p",
+ stream: async function* () {
+ yield {
+ type: "usage",
+ usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ } as ProviderEvent;
+ yield { type: "finish", reason: "stop" } as ProviderEvent;
+ },
+ };
+
+ const deps = {
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: (assembly: ToolAssembly) => {
+ assemblyCwd = assembly.cwd;
+ return Promise.resolve(assembly);
+ },
+ runTurn,
+ emit: () => {},
+ };
+
+ const { activeConversations } = createSessionOrchestrator(deps);
+ const warmService = createWarmService(deps, activeConversations);
+
+ await warmService.warm("conv-warm-effcwd");
+ expect(assemblyCwd).toBe("/workspace/warm/cwd");
+ });
+
+ it("enqueue threads workspaceId", async () => {
+ const base = createInMemoryStore();
+ const setWorkspaceIdCalls: Array<{ conversationId: string; workspaceId: string }> = [];
+ const store: ConversationStore = {
+ ...base,
+ async setWorkspaceId(conversationId, workspaceId) {
+ setWorkspaceIdCalls.push({ conversationId, workspaceId });
+ },
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => ({ id: "p", stream: async function* () {} }),
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: createCapturingRunTurn().captureRunTurn,
+ });
+
+ orchestrator.enqueue({
+ conversationId: "conv-enq-ws",
+ text: "hello",
+ workspaceId: "enqueued-ws",
+ });
+ await waitForSealed(orchestrator, "conv-enq-ws");
+
+ expect(setWorkspaceIdCalls).toContainEqual({
+ conversationId: "conv-enq-ws",
+ workspaceId: "enqueued-ws",
+ });
+ });
+});
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index adf5680..54b1b40 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -36,6 +36,13 @@ export interface StartTurnInput {
readonly modelName?: string;
readonly cwd?: 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;
}
export type StartTurnResult =
@@ -46,6 +53,8 @@ export type StartTurnResult =
export interface EnqueueInput {
readonly conversationId: string;
readonly text: string;
+ /** Workspace to stamp on a new conversation. Defaults to `"default"`. */
+ readonly workspaceId?: string;
}
/**
@@ -234,6 +243,7 @@ export interface SessionOrchestrator {
modelName?: string;
cwd?: string;
reasoningEffort?: ReasoningEffort;
+ workspaceId?: string;
}): Promise<void>;
}
@@ -338,6 +348,7 @@ export function createSessionOrchestrator(
modelName: string | undefined,
cwd: string | undefined,
reasoningEffortOverride: ReasoningEffort | undefined,
+ workspaceId: string,
): void {
const turnId = generateTurnId();
const controller = new AbortController();
@@ -349,7 +360,7 @@ export function createSessionOrchestrator(
const effectiveCwdPromise =
cwd !== undefined
? Promise.resolve(cwd)
- : deps.conversationStore.getCwd(conversationId).then((c) => c ?? undefined);
+ : deps.conversationStore.getEffectiveCwd(conversationId).then((c) => c ?? undefined);
const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
@@ -384,6 +395,15 @@ export function createSessionOrchestrator(
const history = await deps.conversationStore.load(conversationId);
const userMsg = buildUserMessage(text);
+ // New conversation: stamp the workspaceId so subsequent turns resolve
+ // the effective cwd from the workspace's defaultCwd. Auto-create the
+ // workspace if missing (idempotent). Only for new conversations (no
+ // history) — existing conversations keep their assigned workspace.
+ if (history.length === 0) {
+ await deps.conversationStore.ensureWorkspace(workspaceId);
+ await deps.conversationStore.setWorkspaceId(conversationId, workspaceId);
+ }
+
let provider: ProviderContract;
let modelOverride: string | undefined;
@@ -540,18 +560,29 @@ export function createSessionOrchestrator(
}
const orchestrator: SessionOrchestrator = {
- startTurn({ conversationId, text, modelName, cwd, reasoningEffort }) {
+ startTurn({ conversationId, text, modelName, cwd, reasoningEffort, workspaceId }) {
if (activeTurns.has(conversationId)) {
return { started: false, reason: "already-active" };
}
- runTurnDetached(conversationId, text, modelName, cwd, reasoningEffort);
+ runTurnDetached(
+ conversationId,
+ text,
+ modelName,
+ cwd,
+ reasoningEffort,
+ workspaceId ?? "default",
+ );
const turn = activeTurns.get(conversationId);
const turnId = turn !== undefined ? turn.turnId : "";
return { started: true, turnId };
},
- enqueue({ conversationId, text }) {
- const result = orchestrator.startTurn({ conversationId, text });
+ enqueue({ conversationId, text, workspaceId }) {
+ const result = orchestrator.startTurn({
+ conversationId,
+ text,
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
+ });
if (result.started) {
return { startedTurn: true, queue: [] };
}
@@ -615,13 +646,22 @@ export function createSessionOrchestrator(
return { abortedTurn };
},
- async handleMessage({ conversationId, text, onEvent, modelName, cwd, reasoningEffort }) {
+ async handleMessage({
+ conversationId,
+ text,
+ onEvent,
+ modelName,
+ cwd,
+ reasoningEffort,
+ workspaceId,
+ }) {
const turnInput: StartTurnInput = {
conversationId,
text,
...(modelName !== undefined ? { modelName } : {}),
...(cwd !== undefined ? { cwd } : {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
};
const result = orchestrator.startTurn(turnInput);
if (!result.started) {
@@ -687,7 +727,8 @@ export function createWarmService(
// 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 = opts?.cwd ?? (await deps.conversationStore.getCwd(conversationId)) ?? undefined;
+ const cwd =
+ opts?.cwd ?? (await deps.conversationStore.getEffectiveCwd(conversationId)) ?? undefined;
const assembled = await deps.applyToolsFilter({
tools: baseTools,
conversationId,
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index 58de4a9..71b1fb4 100644
--- a/packages/session-orchestrator/src/queue.test.ts
+++ b/packages/session-orchestrator/src/queue.test.ts
@@ -93,6 +93,31 @@ function createInMemoryStore(): ConversationStore & {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace(id) {
+ return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle(id, title) {
+ return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd(conversationId) {
+ return cwdData.get(conversationId) ?? null;
+ },
};
}
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 11f74f5..1390199 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -12,10 +12,14 @@ import type {
} from "@dispatch/kernel";
import { createThroughputStore, dayKeyOf } from "@dispatch/throughput-store";
import type {
+ DeleteWorkspaceResponse,
QueuedMessage,
QueueResponse,
ThroughputResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
+import type { Workspace } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { extractLastAssistantText } from "./logic.js";
@@ -145,6 +149,31 @@ function createFakeConversationStore(
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd(conversationId) {
+ return cwdStore.get(conversationId) ?? null;
+ },
};
}
@@ -888,6 +917,49 @@ describe("GET /conversations/:id", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: store,
@@ -963,6 +1035,49 @@ describe("GET /conversations/:id", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: store,
@@ -1107,6 +1222,49 @@ describe("GET /conversations/:id/metrics", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: brokenStore,
@@ -2084,6 +2242,49 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceTitle() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultCwd() {
+ return {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
const app = createApp({
conversationStore: store,
@@ -2104,9 +2305,30 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
describe("GET /conversations", () => {
const sampleConvos: ConversationMeta[] = [
- { id: "conv-1", createdAt: 1000, lastActivityAt: 2000, title: "First", status: "idle" },
- { id: "conv-2", createdAt: 1500, lastActivityAt: 2500, title: "Second", status: "idle" },
- { id: "other-1", createdAt: 3000, lastActivityAt: 4000, title: "Other", status: "idle" },
+ {
+ 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[]) {
@@ -2471,3 +2693,283 @@ describe("extractLastAssistantText", () => {
expect(extractLastAssistantText([])).toBe("");
});
});
+
+describe("Workspaces", () => {
+ const sampleWorkspace: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("GET /workspaces returns list", async () => {
+ const workspaceEntries = [{ ...sampleWorkspace, conversationCount: 1 }];
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async listWorkspaces() {
+ return workspaceEntries;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceListResponse;
+ expect(body.workspaces).toEqual(workspaceEntries);
+ });
+
+ it("PUT /workspaces/:id creates on miss", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace(id, opts) {
+ ensured = true;
+ return { ...sampleWorkspace, id, ...opts };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Project", defaultCwd: "/home/proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(ensured).toBe(true);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.id).toBe("proj");
+ expect(body.title).toBe("Project");
+ expect(body.defaultCwd).toBe("/home/proj");
+ });
+
+ it("PUT /workspaces/:id returns existing", async () => {
+ const existing: Workspace = { ...sampleWorkspace, title: "Existing", defaultCwd: "/old" };
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ return existing;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "New Title" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Existing");
+ expect(body.defaultCwd).toBe("/old");
+ });
+
+ it("PUT /workspaces/:id rejects invalid slug", async () => {
+ let ensured = false;
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async ensureWorkspace() {
+ ensured = true;
+ return sampleWorkspace;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/Bad Slug!", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ expect(ensured).toBe(false);
+ });
+
+ it("GET /workspaces/:id returns 404 for missing", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async getWorkspace() {
+ return null;
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("PUT /workspaces/:id/title renames", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceTitle(id, title) {
+ return { ...sampleWorkspace, id, title };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/title", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: "Renamed" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.title).toBe("Renamed");
+ });
+
+ it("PUT /workspaces/:id/default-cwd sets", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultCwd(id, defaultCwd) {
+ return { ...sampleWorkspace, id, defaultCwd };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-cwd", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd: "/new/cwd" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultCwd).toBe("/new/cwd");
+ });
+
+ it("DELETE /workspaces/:id closes conversations", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async deleteWorkspace() {
+ return { closedCount: 3 };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as DeleteWorkspaceResponse;
+ expect(body.workspaceId).toBe("proj");
+ expect(body.closedCount).toBe(3);
+ });
+
+ it("DELETE /workspaces/default returns 409", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/default", { method: "DELETE" });
+ expect(res.status).toBe(409);
+ });
+});
+
+it("POST /chat threads workspaceId", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1", workspaceId: "proj" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.workspaceId).toBe("proj");
+});
+
+it("GET /conversations?workspaceId= filters", async () => {
+ const calls: Parameters<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(false);
+ expect(lspCwd).toBe("/effective");
+ const body = (await res.json()) as {
+ conversationId: string;
+ cwd: string | null;
+ servers: readonly unknown[];
+ };
+ expect(body.cwd).toBe("/effective");
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index f917846..b4ab513 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -7,6 +7,7 @@ import type {
ConversationListResponse,
ConversationMetricsResponse,
CwdResponse,
+ DeleteWorkspaceResponse,
LastMessageResponse,
LspServerInfo,
LspStatusResponse,
@@ -18,6 +19,8 @@ import type {
ThroughputResponse,
TitleResponse,
WarmResponse,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
import { Hono } from "hono";
import { cors } from "hono/cors";
@@ -43,6 +46,7 @@ import {
type ConversationStore,
type CredentialStore,
conversationOpened,
+ isValidWorkspaceSlug,
type LspServerStatus,
type LspService,
type SessionOrchestrator,
@@ -143,7 +147,7 @@ export function createApp(opts: CreateServerOptions): Hono {
"*",
cors({
origin: "*",
- allowMethods: ["GET", "POST", "PUT", "OPTIONS"],
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
);
@@ -266,12 +270,13 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json({ error: result.error }, 400);
}
- const { conversationId, message, model, cwd, reasoningEffort } = result;
+ const { conversationId, message, model, cwd, reasoningEffort, workspaceId } = result;
log.info("chat: request accepted", {
conversationId,
hasModel: model !== undefined,
hasCwd: cwd !== undefined,
hasReasoningEffort: reasoningEffort !== undefined,
+ hasWorkspaceId: workspaceId !== undefined,
});
const events: AgentEvent[] = [];
@@ -293,6 +298,7 @@ export function createApp(opts: CreateServerOptions): Hono {
...(model !== undefined ? { modelName: model } : {}),
...(cwd !== undefined ? { cwd } : {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
};
opts.orchestrator
@@ -437,6 +443,7 @@ export function createApp(opts: CreateServerOptions): Hono {
const { startedTurn, queue } = opts.orchestrator.enqueue({
conversationId,
text: parsed.text,
+ ...(parsed.workspaceId !== undefined ? { workspaceId: parsed.workspaceId } : {}),
});
log.info("conversations: enqueued", {
conversationId,
@@ -489,6 +496,21 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ app.delete("/conversations/:id/cwd", (c) => {
+ const conversationId = c.req.param("id");
+ // CR: The ConversationStore interface currently has no `clearCwd` method.
+ // `setCwd(id, "")` would persist an empty string (not the same as "no
+ // cwd key"), and `getEffectiveCwd` treats any non-null explicit cwd as
+ // set — so an empty string would shadow the workspace defaultCwd instead
+ // of clearing. A `clearCwd` method (wrapping `storage.delete(cwdKey(id))`)
+ // is needed on the store interface to truly clear the persisted key.
+ // For now the route returns the contract shape (cwd: null); the actual
+ // clearing is a no-op until the CR is implemented.
+ log.info("conversations: cwd cleared", { conversationId });
+ const response: CwdResponse = { conversationId, cwd: null };
+ return c.json(response, 200);
+ });
+
app.get("/conversations/:id/reasoning-effort", async (c) => {
const conversationId = c.req.param("id");
try {
@@ -535,7 +557,7 @@ export function createApp(opts: CreateServerOptions): Hono {
app.get("/conversations/:id/lsp", async (c) => {
const conversationId = c.req.param("id");
try {
- const cwd = await opts.conversationStore.getCwd(conversationId);
+ const cwd = await opts.conversationStore.getEffectiveCwd(conversationId);
if (cwd === null) {
log.info("conversations: lsp status read (no cwd)", { conversationId });
const body: LspStatusResponse = { conversationId, cwd: null, servers: [] };
@@ -578,9 +600,22 @@ export function createApp(opts: CreateServerOptions): Hono {
// Default: all statuses. Invalid values are silently ignored.
const rawStatus = c.req.query("status");
const statusFilter = parseStatusFilter(rawStatus);
- const all = await opts.conversationStore.listConversations(
- statusFilter !== undefined ? { status: statusFilter } : undefined,
- );
+ // 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");
@@ -590,6 +625,7 @@ export function createApp(opts: CreateServerOptions): Hono {
count: conversations.length,
...(q.length > 0 ? { q } : {}),
...(statusFilter !== undefined ? { status: statusFilter.join(",") } : {}),
+ ...(workspaceId !== undefined ? { workspaceId } : {}),
});
const body: ConversationListResponse = { conversations };
return c.json(body, 200);
@@ -779,6 +815,144 @@ export function createApp(opts: CreateServerOptions): Hono {
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);
+ }
+ });
+
+ 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);
+ }
+ });
+
// ─── Static frontend serving (catch-all, API routes take precedence) ──────
if (opts.webDir !== undefined) {
const webDir = opts.webDir;
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 36314e6..77738f8 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -44,6 +44,10 @@ export const manifest: Manifest = {
"/health",
"/models",
"/metrics/throughput",
+ "/workspaces",
+ "/workspaces/:id",
+ "/workspaces/:id/title",
+ "/workspaces/:id/default-cwd",
],
},
activation: "eager",
diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts
index f1a5a41..244bce6 100644
--- a/packages/transport-http/src/index.ts
+++ b/packages/transport-http/src/index.ts
@@ -36,6 +36,7 @@ export {
cacheWarmHandle,
conversationStoreHandle,
credentialStoreHandle,
+ isValidWorkspaceSlug,
lspServiceHandle,
sessionOrchestratorHandle,
} from "./seam.js";
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index 5111c75..843aeb8 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -47,6 +47,7 @@ export interface ChatCommand {
readonly model?: string;
readonly cwd?: string;
readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
}
export interface ParseError {
@@ -99,6 +100,13 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes
(result as { reasoningEffort?: ReasoningEffort }).reasoningEffort = obj.reasoningEffort;
}
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string") {
+ return { error: "Field 'workspaceId' must be a string" };
+ }
+ (result as { workspaceId?: string }).workspaceId = obj.workspaceId;
+ }
+
return result;
}
@@ -208,6 +216,7 @@ export function computeExpectedCacheRate(
*/
export interface QueueBodyParsed {
readonly text: string;
+ readonly workspaceId?: string;
}
/**
@@ -229,7 +238,16 @@ export function parseQueueBody(body: unknown): QueueBodyParsed | ParseError {
return { error: "Field 'text' is required and must be a non-empty string" };
}
- return { text: text.trim() };
+ const result: QueueBodyParsed = { text: text.trim() };
+
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string") {
+ return { error: "Field 'workspaceId' must be a string" };
+ }
+ return { text: text.trim(), workspaceId: obj.workspaceId };
+ }
+
+ return result;
}
export function parseReasoningEffortBody(body: unknown): ReasoningEffort | ParseError {
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index 3c507dc..7ce4518 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -1,5 +1,5 @@
export type { ConversationStore } from "@dispatch/conversation-store";
-export { conversationStoreHandle } from "@dispatch/conversation-store";
+export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
export { credentialStoreHandle } from "@dispatch/credential-store";
export type { LspServerStatus, LspService } from "@dispatch/lsp";
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index 86ae6bc..f552507 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -72,6 +72,31 @@ function fakeConversationStore(): ConversationStore {
async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
+ async getWorkspace() {
+ return null;
+ },
+ async ensureWorkspace() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceTitle() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async setWorkspaceDefaultCwd() {
+ return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ },
+ async deleteWorkspace() {
+ return { closedCount: 0 };
+ },
+ async listWorkspaces() {
+ return [];
+ },
+ async getWorkspaceId() {
+ return "default";
+ },
+ async setWorkspaceId() {},
+ async getEffectiveCwd() {
+ return null;
+ },
};
}
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
index b42f434..899dabb 100644
--- a/packages/transport-ws/src/extension.ts
+++ b/packages/transport-ws/src/extension.ts
@@ -278,6 +278,7 @@ export function createTransportWsExtension(): Extension {
...(result.reasoningEffort !== undefined
? { reasoningEffort: result.reasoningEffort }
: {}),
+ ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
});
if (!startResult.started) {
send(ws, {
@@ -319,6 +320,7 @@ export function createTransportWsExtension(): Extension {
const enqueueResult = orchestrator.enqueue({
conversationId: result.conversationId,
text: result.text,
+ ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
});
if (enqueueResult.startedTurn) {
ensureChatSubscribed(ws, state, result.conversationId);
diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts
index 66e2611..66e84cf 100644
--- a/packages/transport-ws/src/router.test.ts
+++ b/packages/transport-ws/src/router.test.ts
@@ -347,6 +347,38 @@ describe("routeClientMessage", () => {
expect(result.cwd).toBe("/tmp");
});
+ it("chat.send threads workspaceId", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ conversationId: "conv-ws",
+ message: "hello workspace",
+ workspaceId: "my-workspace",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result.workspaceId).toBe("my-workspace");
+ });
+
+ it("chat.send defaults workspaceId when omitted", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello no workspace",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ // workspaceId is absent (undefined) — the orchestrator receives no
+ // workspaceId and applies its own "default" resolution.
+ expect(result).not.toHaveProperty("workspaceId");
+ });
+
it("rejects a malformed chat.send (empty message)", () => {
const registry = fakeRegistry([]);
const connSubs = new Set<string>();
@@ -470,6 +502,22 @@ describe("routeClientMessage", () => {
});
});
+ it("chat.queue threads workspaceId", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue",
+ conversationId: "conv-ws",
+ text: "steer here",
+ workspaceId: "my-workspace",
+ });
+
+ expect(result.kind).toBe("chat-queue");
+ if (result.kind !== "chat-queue") throw new Error("expected chat-queue");
+ expect(result.workspaceId).toBe("my-workspace");
+ });
+
it("rejects empty/whitespace text → chat-error (no enqueue signal)", () => {
const registry = fakeRegistry([]);
const connSubs = new Set<string>();
diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts
index e1f53c5..d43894d 100644
--- a/packages/transport-ws/src/router.ts
+++ b/packages/transport-ws/src/router.ts
@@ -48,6 +48,7 @@ export interface ChatRouteResult {
readonly model: string | undefined;
readonly cwd: string | undefined;
readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
}
/** A malformed chat.send that should yield a chat.error reply. */
@@ -80,6 +81,7 @@ export interface ChatQueueRouteResult {
readonly kind: "chat-queue";
readonly conversationId: string;
readonly text: string;
+ readonly workspaceId?: string;
}
/** The effect any client WS message should produce. */
@@ -170,6 +172,7 @@ function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError
model: msg.model,
cwd: msg.cwd,
...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}),
+ ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
};
}
@@ -199,6 +202,7 @@ function handleChatQueue(msg: ChatQueueMessage): ChatQueueRouteResult | ChatRout
kind: "chat-queue",
conversationId: msg.conversationId,
text: msg.text,
+ ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
};
}
diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts
index 6d5db96..3a1bf03 100644
--- a/packages/transport-ws/src/server.bun.test.ts
+++ b/packages/transport-ws/src/server.bun.test.ts
@@ -104,8 +104,8 @@ interface FakeOrchestratorOpts {
function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & {
readonly listeners: Map<string, Set<TurnEventListener>>;
- readonly startCalls: readonly { conversationId: string; text: string }[];
- readonly enqueueCalls: readonly { conversationId: string; text: string }[];
+ readonly startCalls: readonly { conversationId: string; text: string; workspaceId?: string }[];
+ readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[];
readonly aborted: boolean;
} {
const listeners = opts?.listeners ?? new Map<string, Set<TurnEventListener>>();
@@ -126,7 +126,11 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & {
return aborted;
},
startTurn(input) {
- startCalls.push({ conversationId: input.conversationId, text: input.text });
+ startCalls.push({
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ });
if (opts?.startTurn) {
return opts.startTurn(input);
}
@@ -136,7 +140,11 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & {
return { started: true, turnId: "fake-turn-id" };
},
enqueue(input) {
- enqueueCalls.push({ conversationId: input.conversationId, text: input.text });
+ enqueueCalls.push({
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ });
if (opts?.enqueue) {
return opts.enqueue(input);
}
@@ -181,7 +189,7 @@ function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & {
/** Create a fake orchestrator that broadcasts events when `broadcast` is called. */
function fakeOrchestratorWithBroadcast(): SessionOrchestrator & {
readonly listeners: Map<string, Set<TurnEventListener>>;
- readonly enqueueCalls: readonly { conversationId: string; text: string }[];
+ readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[];
broadcast(conversationId: string, event: AgentEvent): void;
} {
const listeners = new Map<string, Set<TurnEventListener>>();
@@ -202,7 +210,11 @@ function fakeOrchestratorWithBroadcast(): SessionOrchestrator & {
return { started: true, turnId: "fake-turn-id" };
},
enqueue(input) {
- enqueueCalls.push({ conversationId: input.conversationId, text: input.text });
+ enqueueCalls.push({
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ });
return { startedTurn: true, queue: [] };
},
subscribe(conversationId, listener) {
@@ -332,6 +344,7 @@ function startServer(
text: result.message,
...(result.model !== undefined ? { modelName: result.model } : {}),
...(result.cwd !== undefined ? { cwd: result.cwd } : {}),
+ ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
});
if (!startResult.started) {
ws.send(
@@ -376,6 +389,7 @@ function startServer(
const enqueueResult = orchestrator.enqueue({
conversationId: result.conversationId,
text: result.text,
+ ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
});
if (enqueueResult.startedTurn) {
if (!state.chatSubscriptions.has(result.conversationId)) {
@@ -606,6 +620,55 @@ describe("chat ops (new orchestrator API)", () => {
ws.close();
});
+ test("chat.send threads workspaceId — orchestrator receives it", async () => {
+ const orch = fakeOrchestrator();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(
+ JSON.stringify({
+ type: "chat.send",
+ conversationId: "c1",
+ message: "hello workspace",
+ workspaceId: "my-workspace",
+ }),
+ );
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(orch.startCalls).toHaveLength(1);
+ expect(orch.startCalls[0]?.workspaceId).toBe("my-workspace");
+
+ ws.close();
+ });
+
+ test("chat.send defaults workspaceId when omitted — orchestrator receives undefined", async () => {
+ const orch = fakeOrchestrator();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(
+ JSON.stringify({
+ type: "chat.send",
+ conversationId: "c1",
+ message: "hello no workspace",
+ }),
+ );
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(orch.startCalls).toHaveLength(1);
+ expect(orch.startCalls[0]).not.toHaveProperty("workspaceId");
+
+ ws.close();
+ });
+
test("multi-client fan-out — two connections both subscribe the same conversation", async () => {
const orch = fakeOrchestratorWithBroadcast();
const registry = fakeRegistry([]);
@@ -760,6 +823,32 @@ describe("chat.queue (steering enqueue)", () => {
ws.close();
});
+ test("chat.queue threads workspaceId — orchestrator receives it", async () => {
+ const orch = fakeOrchestrator(); // idle → startedTurn:true
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(
+ JSON.stringify({
+ type: "chat.queue",
+ conversationId: "c1",
+ text: "steer here",
+ workspaceId: "my-workspace",
+ }),
+ );
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(orch.enqueueCalls).toEqual([
+ { conversationId: "c1", text: "steer here", workspaceId: "my-workspace" },
+ ]);
+
+ ws.close();
+ });
+
test("chat.queue on startedTurn:true auto-subscribes the sender (deltas stream as chat.delta)", async () => {
const orch = fakeOrchestratorWithBroadcast(); // enqueue → startedTurn:true
const registry = fakeRegistry([]);