summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 03:26:11 +0900
committerAdam Malczewski <[email protected]>2026-06-24 03:26:11 +0900
commit69f89ab49be842d9826fb0b1621cc8c8dea5f14c (patch)
tree8e67fc59a74d2529c9d675cdd295d4f22642f2d6 /packages
parent41ca91a959ae245e76e50f1b55c8b21592bc6c50 (diff)
downloaddispatch-69f89ab49be842d9826fb0b1621cc8c8dea5f14c.tar.gz
dispatch-69f89ab49be842d9826fb0b1621cc8c8dea5f14c.zip
workspace: conversation.open/statusChanged carry workspaceId (1405 vitest)
- @dispatch/transport-contract 0.18.0 -> 0.19.0: add workspaceId: string to ConversationOpenMessage and ConversationStatusChangedMessage - session-orchestrator: include persisted workspaceId in conversationOpened/ conversationStatusChanged payloads - transport-ws: forward workspaceId in WS broadcasts - transport-http: POST /conversations/:id/open resolves workspaceId before emit - FE handoff to 29ae: frontend-workspace-open-handoff.md
Diffstat (limited to 'packages')
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts153
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts45
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts12
-rw-r--r--packages/transport-http/src/app.test.ts12
-rw-r--r--packages/transport-http/src/app.ts11
-rw-r--r--packages/transport-ws/src/extension.ts23
-rw-r--r--packages/transport-ws/src/server.bun.test.ts118
8 files changed, 330 insertions, 46 deletions
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 18a4a62..3b79bd5 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -19,6 +19,8 @@ import { runTurn } from "@dispatch/kernel";
import type { SystemPromptService } from "@dispatch/system-prompt";
import { describe, expect, it } from "vitest";
import {
+ type ConversationOpenedPayload,
+ type ConversationStatusChangedPayload,
createCompactionService,
createSessionOrchestrator,
createWarmService,
@@ -1228,23 +1230,48 @@ describe("lifecycle event hooks", () => {
modelName: "mymodel",
});
- expect(emitted).toHaveLength(4);
- expect(emitted[0]?.hook).toBe("session-orchestrator/turn-started");
- expect(emitted[0]?.payload.conversationId).toBe("conv-lifecycle");
- expect(emitted[0]?.payload.cwd).toBe("/work");
- expect(emitted[0]?.payload.modelName).toBe("mymodel");
- expect(emitted[0]?.order).toBe(0);
+ // The status-changed emits resolve the persisted workspace id async
+ // (getWorkspaceId) before firing, so they may land after turn-settled
+ // in microtask order. Flush all pending microtasks so every emit has
+ // landed, then assert by hook identity rather than strict index order.
+ await new Promise((resolve) => setImmediate(resolve));
- expect(emitted[1]?.hook).toBe("session-orchestrator/conversation-status-changed");
- expect((emitted[1]?.payload as unknown as { status: string }).status).toBe("active");
+ expect(emitted).toHaveLength(4);
- expect(emitted[2]?.hook).toBe("session-orchestrator/turn-settled");
- expect(emitted[2]?.payload.conversationId).toBe("conv-lifecycle");
- expect(emitted[2]?.payload.cwd).toBe("/work");
- expect(emitted[2]?.payload.modelName).toBe("mymodel");
+ const started = emitted.find((e) => e.hook === "session-orchestrator/turn-started");
+ const settled = emitted.find((e) => e.hook === "session-orchestrator/turn-settled");
+ const statusChanges = emitted.filter(
+ (e) => e.hook === "session-orchestrator/conversation-status-changed",
+ );
- expect(emitted[3]?.hook).toBe("session-orchestrator/conversation-status-changed");
- expect((emitted[3]?.payload as unknown as { status: string }).status).toBe("idle");
+ expect(started).toBeDefined();
+ expect(started?.payload.conversationId).toBe("conv-lifecycle");
+ expect(started?.payload.cwd).toBe("/work");
+ expect(started?.payload.modelName).toBe("mymodel");
+ // turn-started is the FIRST emit (synchronous, before any async deferral).
+ expect(started?.order).toBe(0);
+
+ expect(settled).toBeDefined();
+ expect(settled?.payload.conversationId).toBe("conv-lifecycle");
+ expect(settled?.payload.cwd).toBe("/work");
+ expect(settled?.payload.modelName).toBe("mymodel");
+ // turn-started precedes turn-settled.
+ expect(started?.order).toBeLessThan(settled?.order ?? Infinity);
+
+ expect(statusChanges).toHaveLength(2);
+ const activeChange = statusChanges.find(
+ (e) => (e.payload as unknown as { status: string }).status === "active",
+ );
+ const idleChange = statusChanges.find(
+ (e) => (e.payload as unknown as { status: string }).status === "idle",
+ );
+ expect(activeChange).toBeDefined();
+ expect(idleChange).toBeDefined();
+ // Both status-changed payloads now carry the persisted workspace id.
+ expect((activeChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe(
+ "default",
+ );
+ expect((idleChange?.payload as unknown as { workspaceId: string }).workspaceId).toBe("default");
});
});
@@ -2402,7 +2429,7 @@ describe("closeConversation (CR-4c)", () => {
expect(persisted[0]?.role).toBe("user");
});
- it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", () => {
+ it("is idempotent on an idle/unknown conversation: abortedTurn false, hook still emitted", async () => {
const store = createInMemoryStore();
const emitted: Array<{ hook: string; payload: unknown }> = [];
const { orchestrator } = createSessionOrchestrator({
@@ -2418,22 +2445,110 @@ describe("closeConversation (CR-4c)", () => {
const result = orchestrator.closeConversation("conv-never-seen");
expect(result.abortedTurn).toBe(false);
+ // The conversation-closed hook is emitted synchronously; the
+ // status-changed hook resolves the workspace id async before emitting.
expect(emitted).toEqual([
{
hook: "session-orchestrator/conversation-closed",
payload: { conversationId: "conv-never-seen" },
},
- {
- hook: "session-orchestrator/conversation-status-changed",
- payload: { conversationId: "conv-never-seen", status: "closed" },
- },
]);
+ // Flush the async getWorkspaceId resolution so the status-changed emit lands.
+ await new Promise((resolve) => setImmediate(resolve));
+ expect(emitted).toContainEqual({
+ hook: "session-orchestrator/conversation-status-changed",
+ payload: { conversationId: "conv-never-seen", status: "closed", workspaceId: "default" },
+ });
// Closing again is still safe.
expect(orchestrator.closeConversation("conv-never-seen").abortedTurn).toBe(false);
});
});
+// --- workspace id on conversationOpened / conversationStatusChanged payloads ---
+
+describe("workspace id broadcast payloads", () => {
+ it("conversationStatusChanged payload carries the workspace id from the store", async () => {
+ const base = createInMemoryStore();
+ // Pre-assign a non-default workspace so we can assert it's threaded
+ // through (not the per-turn start option, which differs).
+ await base.setWorkspaceId("conv-ws-broadcast", "team-workspace");
+
+ const emitted: Array<{ hook: string; payload: ConversationStatusChangedPayload }> = [];
+ const provider = createFakeProvider([
+ [
+ { type: "text-delta", delta: "ok" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: base,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook.id === "session-orchestrator/conversation-status-changed") {
+ emitted.push({
+ hook: hook.id,
+ payload: payload as ConversationStatusChangedPayload,
+ });
+ }
+ },
+ });
+
+ // Pass a DIFFERENT per-turn workspaceId to prove the payload uses the
+ // persisted store value, not the start option.
+ await orchestrator.handleMessage({
+ conversationId: "conv-ws-broadcast",
+ text: "hi",
+ onEvent: () => {},
+ workspaceId: "should-not-appear",
+ });
+ // Flush the async getWorkspaceId resolutions.
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(emitted.length).toBeGreaterThanOrEqual(2);
+ for (const e of emitted) {
+ expect(e.payload.workspaceId).toBe("team-workspace");
+ }
+
+ // closeConversation also threads the persisted workspace id.
+ const closeEmitted: ConversationStatusChangedPayload[] = [];
+ const { orchestrator: orchestrator2 } = createSessionOrchestrator({
+ conversationStore: base,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn,
+ emit: (hook, payload) => {
+ if (hook.id === "session-orchestrator/conversation-status-changed") {
+ closeEmitted.push(payload as ConversationStatusChangedPayload);
+ }
+ },
+ });
+ orchestrator2.closeConversation("conv-ws-broadcast");
+ await new Promise((resolve) => setImmediate(resolve));
+ const closed = closeEmitted.find((p) => p.status === "closed");
+ expect(closed).toBeDefined();
+ expect(closed?.workspaceId).toBe("team-workspace");
+ });
+
+ it("conversationOpened payload carries the workspace id (type-level construct)", () => {
+ // conversationOpened is emitted by a sibling transport unit, so this
+ // package only owns the payload TYPE. This regression test pins the
+ // type to require workspaceId (a missing field would fail to compile)
+ // and verifies the persisted value flows through at construction time.
+ const payload: ConversationOpenedPayload = {
+ conversationId: "conv-open",
+ workspaceId: "open-workspace",
+ };
+ expect(payload.workspaceId).toBe("open-workspace");
+ expect(payload.conversationId).toBe("conv-open");
+ });
+});
+
describe("reasoning effort resolution", () => {
it("override wins over stored → provider receives the override level", async () => {
const store = createInMemoryStore();
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 7599a0c..1403288 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -113,6 +113,12 @@ export const conversationClosed: EventHookDescriptor<ConversationClosedPayload>
/** 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;
}
/**
@@ -128,6 +134,12 @@ export const conversationOpened: EventHookDescriptor<ConversationOpenedPayload>
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;
}
/**
@@ -410,7 +422,15 @@ export function createSessionOrchestrator(
payloadPromise.then((payload) => {
deps.emit?.(turnStarted, payload);
- deps.emit?.(conversationStatusChanged, { conversationId, status: "active" });
+ // 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");
});
@@ -595,9 +615,14 @@ export function createSessionOrchestrator(
void payloadPromise.then((payload) => {
deps.emit?.(turnSettled, payload);
if (!carried) {
- deps.emit?.(conversationStatusChanged, {
- conversationId,
- status: "idle",
+ // 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
@@ -691,7 +716,17 @@ export function createSessionOrchestrator(
turn.controller.abort();
}
deps.emit?.(conversationClosed, { conversationId });
- deps.emit?.(conversationStatusChanged, { conversationId, status: "closed" });
+ // 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 };
},
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 542ed90..71baa43 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.18.0",
+ "version": "0.19.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 2a3bb9f..fcbd1b1 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -604,6 +604,12 @@ export type WsServerMessage =
export interface ConversationOpenMessage {
readonly type: "conversation.open";
readonly conversationId: string;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
}
/**
@@ -615,6 +621,12 @@ export interface ConversationStatusChangedMessage {
readonly type: "conversation.statusChanged";
readonly conversationId: string;
readonly status: ConversationStatus;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
}
/**
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 2a4b451..7887695 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -2814,8 +2814,13 @@ describe("POST /conversations/:id/open", () => {
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: createFakeConversationStore(),
+ conversationStore: store,
orchestrator: createFakeOrchestrator([]),
credentialStore: createFakeCredentialStore([]),
emit,
@@ -2825,7 +2830,10 @@ describe("POST /conversations/:id/open", () => {
expect(res.status).toBe(200);
expect(emitCalls).toHaveLength(1);
expect(emitCalls[0]?.hook).toBe(conversationOpened);
- expect(emitCalls[0]?.payload).toEqual({ conversationId: "conv1" });
+ expect(emitCalls[0]?.payload).toEqual({
+ conversationId: "conv1",
+ workspaceId: "open-workspace",
+ });
});
it("returns 500 when emit is absent", async () => {
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 41b583c..bc8b9de 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -767,7 +767,7 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json(body, 200);
});
- app.post("/conversations/:id/open", (c) => {
+ 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", {
@@ -775,8 +775,13 @@ export function createApp(opts: CreateServerOptions): Hono {
});
return c.json({ error: "not available" }, 500);
}
- opts.emit(conversationOpened, { conversationId });
- log.info("conversations: opened", { conversationId });
+ // 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);
});
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
index 899dabb..1e3da27 100644
--- a/packages/transport-ws/src/extension.ts
+++ b/packages/transport-ws/src/extension.ts
@@ -133,18 +133,29 @@ export function createTransportWsExtension(): Extension {
// whenever the orchestrator signals a conversation was opened (e.g. the
// CLI `--open` flag). The frontend decides whether to open/focus a tab —
// the backend just signals. This is a GLOBAL fan-out (like the catalog),
- // NOT a per-conversation chat broadcast.
+ // NOT a per-conversation chat broadcast. The payload's `workspaceId`
+ // is the conversation's actual persisted workspace (resolved by the
+ // orchestrator from the store), so a frontend opens/focuses the tab in
+ // the correct workspace.
disposers.push(
- host.on(conversationOpened, ({ conversationId }) => {
- broadcast({ type: "conversation.open", conversationId });
+ host.on(conversationOpened, ({ conversationId, workspaceId }) => {
+ broadcast({ type: "conversation.open", conversationId, workspaceId });
}),
);
// Broadcast `conversation.statusChanged` to all connected clients so
- // tabs sync across devices in real time.
+ // tabs sync across devices in real time. `workspaceId` is the
+ // conversation's actual persisted workspace (resolved by the
+ // orchestrator from the store), forwarded so a frontend syncs the tab
+ // in the correct workspace.
disposers.push(
- host.on(conversationStatusChanged, ({ conversationId, status }) => {
- broadcast({ type: "conversation.statusChanged", conversationId, status });
+ host.on(conversationStatusChanged, ({ conversationId, status, workspaceId }) => {
+ broadcast({
+ type: "conversation.statusChanged",
+ conversationId,
+ status,
+ workspaceId,
+ });
}),
);
diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts
index 3a1bf03..e24aa6b 100644
--- a/packages/transport-ws/src/server.bun.test.ts
+++ b/packages/transport-ws/src/server.bun.test.ts
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { AgentEvent, Attributes, ErrorAttributes, Logger } from "@dispatch/kernel";
import type { SessionOrchestrator, TurnEventListener } from "@dispatch/session-orchestrator";
import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry";
-import type { WsServerMessage } from "@dispatch/transport-contract";
+import type { ConversationStatus, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceCatalogEntry, SurfaceClientMessage, SurfaceSpec } from "@dispatch/ui-contract";
import { catalogMessage, routeClientMessage, subKey } from "./router.js";
@@ -445,11 +445,30 @@ function startServer(
/**
* Simulate the `conversationOpened` hook firing — mirrors the
* `host.on(conversationOpened, ...)` subscription in extension.ts, which
- * broadcasts a `conversation.open` WS message to every connected client.
+ * broadcasts a `conversation.open` WS message (carrying the conversation's
+ * persisted `workspaceId`) to every connected client.
*/
return Object.assign(server, {
- triggerConversationOpen(conversationId: string): void {
- broadcast({ type: "conversation.open", conversationId });
+ triggerConversationOpen(conversationId: string, workspaceId: string): void {
+ broadcast({ type: "conversation.open", conversationId, workspaceId });
+ },
+ /**
+ * Simulate the `conversationStatusChanged` hook firing — mirrors the
+ * `host.on(conversationStatusChanged, ...)` subscription in extension.ts,
+ * which broadcasts a `conversation.statusChanged` WS message (carrying the
+ * conversation's persisted `workspaceId`) to every connected client.
+ */
+ triggerConversationStatusChanged(
+ conversationId: string,
+ status: ConversationStatus,
+ workspaceId: string,
+ ): void {
+ broadcast({
+ type: "conversation.statusChanged",
+ conversationId,
+ status,
+ workspaceId,
+ });
},
});
}
@@ -1079,10 +1098,14 @@ describe("conversation.open broadcast (conversationOpened hook)", () => {
// Simulate the conversationOpened hook firing (extension.ts's
// `host.on(conversationOpened, ...)` handler runs and broadcasts).
- server.triggerConversationOpen("conv-42");
+ server.triggerConversationOpen("conv-42", "ws-7");
const msg = await waitForMessage(ws);
- expect(msg).toEqual({ type: "conversation.open", conversationId: "conv-42" });
+ expect(msg).toEqual({
+ type: "conversation.open",
+ conversationId: "conv-42",
+ workspaceId: "ws-7",
+ });
ws.close();
});
@@ -1099,12 +1122,87 @@ describe("conversation.open broadcast (conversationOpened hook)", () => {
await waitForMessage(ws2); // drain catalog
// Global fan-out: BOTH connected clients receive the broadcast,
- // regardless of any per-conversation subscription state.
- server.triggerConversationOpen("shared-conv");
+ // regardless of any per-conversation subscription state. The forwarded
+ // `workspaceId` is identical on both.
+ server.triggerConversationOpen("shared-conv", "ws-shared");
+
+ const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]);
+ expect(msg1).toEqual({
+ type: "conversation.open",
+ conversationId: "shared-conv",
+ workspaceId: "ws-shared",
+ });
+ expect(msg2).toEqual({
+ type: "conversation.open",
+ conversationId: "shared-conv",
+ workspaceId: "ws-shared",
+ });
+
+ ws1.close();
+ ws2.close();
+ });
+});
+
+describe("conversation.statusChanged broadcast (conversationStatusChanged hook)", () => {
+ let server: ReturnType<typeof startServer>;
+ let port: number;
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("conversation.statusChanged broadcast forwards workspaceId", async () => {
+ const orch = fakeOrchestrator();
+ const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ // Simulate the conversationStatusChanged hook firing (extension.ts's
+ // `host.on(conversationStatusChanged, ...)` handler runs and broadcasts).
+ server.triggerConversationStatusChanged("conv-9", "active", "ws-9");
+
+ const msg = await waitForMessage(ws);
+ expect(msg).toEqual({
+ type: "conversation.statusChanged",
+ conversationId: "conv-9",
+ status: "active",
+ workspaceId: "ws-9",
+ });
+
+ ws.close();
+ });
+
+ test("conversation.statusChanged sent to all connected clients with the same workspaceId", async () => {
+ const orch = fakeOrchestrator();
+ const registry = fakeRegistry([fakeProvider("demo", "Demo Surface")]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws1 = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws1); // drain catalog
+ const ws2 = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws2); // drain catalog
+
+ // Global fan-out: BOTH connected clients receive the broadcast with the
+ // conversation's persisted workspaceId forwarded unchanged.
+ server.triggerConversationStatusChanged("shared-conv", "idle", "ws-shared");
const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]);
- expect(msg1).toEqual({ type: "conversation.open", conversationId: "shared-conv" });
- expect(msg2).toEqual({ type: "conversation.open", conversationId: "shared-conv" });
+ expect(msg1).toEqual({
+ type: "conversation.statusChanged",
+ conversationId: "shared-conv",
+ status: "idle",
+ workspaceId: "ws-shared",
+ });
+ expect(msg2).toEqual({
+ type: "conversation.statusChanged",
+ conversationId: "shared-conv",
+ status: "idle",
+ workspaceId: "ws-shared",
+ });
ws1.close();
ws2.close();