summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-ws
diff options
context:
space:
mode:
Diffstat (limited to 'packages/transport-ws')
-rw-r--r--packages/transport-ws/package.json26
-rw-r--r--packages/transport-ws/src/extension.ts716
-rw-r--r--packages/transport-ws/src/index.ts14
-rw-r--r--packages/transport-ws/src/manifest.ts18
-rw-r--r--packages/transport-ws/src/router.test.ts1351
-rw-r--r--packages/transport-ws/src/router.ts467
-rw-r--r--packages/transport-ws/src/server.bun.test.ts2268
-rw-r--r--packages/transport-ws/tsconfig.json20
8 files changed, 2519 insertions, 2361 deletions
diff --git a/packages/transport-ws/package.json b/packages/transport-ws/package.json
index b600a98..6593218 100644
--- a/packages/transport-ws/package.json
+++ b/packages/transport-ws/package.json
@@ -1,15 +1,15 @@
{
- "name": "@dispatch/transport-ws",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "dependencies": {
- "@dispatch/kernel": "workspace:*",
- "@dispatch/session-orchestrator": "workspace:*",
- "@dispatch/surface-registry": "workspace:*",
- "@dispatch/transport-contract": "workspace:*",
- "@dispatch/ui-contract": "workspace:*"
- }
+ "name": "@dispatch/transport-ws",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/session-orchestrator": "workspace:*",
+ "@dispatch/surface-registry": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
+ "@dispatch/ui-contract": "workspace:*"
+ }
}
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
index 56bd8e2..88f721e 100644
--- a/packages/transport-ws/src/extension.ts
+++ b/packages/transport-ws/src/extension.ts
@@ -9,10 +9,10 @@
import type { Extension, HostAPI } from "@dispatch/kernel";
import type { SessionOrchestrator } from "@dispatch/session-orchestrator";
import {
- conversationCompacted,
- conversationOpened,
- conversationStatusChanged,
- sessionOrchestratorHandle,
+ conversationCompacted,
+ conversationOpened,
+ conversationStatusChanged,
+ sessionOrchestratorHandle,
} from "@dispatch/session-orchestrator";
import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry";
import { surfaceRegistryHandle } from "@dispatch/surface-registry";
@@ -22,379 +22,397 @@ import { catalogMessage, routeClientMessage, subKey } from "./router.js";
/** Active provider subscriptions + chat subscription disposers for a single WS connection. */
interface ConnectionState {
- readonly subs: Set<string>;
- readonly providerDisposers: Map<string, () => void>;
- /** Per-conversation chat subscription disposers (orchestrator.subscribe). */
- readonly chatSubscriptions: Map<string, () => void>;
+ readonly subs: Set<string>;
+ readonly providerDisposers: Map<string, () => void>;
+ /** Per-conversation chat subscription disposers (orchestrator.subscribe). */
+ readonly chatSubscriptions: Map<string, () => void>;
}
type Ws = Bun.ServerWebSocket<ConnectionState>;
export function createTransportWsExtension(): Extension {
- let server: ReturnType<typeof Bun.serve<ConnectionState>> | undefined;
- /** Every currently-connected WS client — used for global fan-out broadcasts. */
- const connections = new Set<Ws>();
- /** Disposers for host hook subscriptions (drained on deactivate). */
- const disposers: Array<() => void> = [];
+ let server: ReturnType<typeof Bun.serve<ConnectionState>> | undefined;
+ /** Every currently-connected WS client — used for global fan-out broadcasts. */
+ const connections = new Set<Ws>();
+ /** Disposers for host hook subscriptions (drained on deactivate). */
+ const disposers: Array<() => void> = [];
- return {
- manifest,
- async activate(host: HostAPI) {
- const registry: SurfaceRegistry = host.getService(surfaceRegistryHandle);
- const orchestrator: SessionOrchestrator = host.getService(sessionOrchestratorHandle);
- const logger = host.logger;
- const port = host.config.get<number>("surfaceWsPort") ?? 24205;
+ return {
+ manifest,
+ async activate(host: HostAPI) {
+ const registry: SurfaceRegistry = host.getService(surfaceRegistryHandle);
+ const orchestrator: SessionOrchestrator = host.getService(sessionOrchestratorHandle);
+ const logger = host.logger;
+ const port = host.config.get<number>("surfaceWsPort") ?? 24205;
- function send(ws: Ws, msg: WsServerMessage): void {
- try {
- ws.send(JSON.stringify(msg));
- } catch {
- // Connection may have been dropped; swallow.
- }
- }
+ function send(ws: Ws, msg: WsServerMessage): void {
+ try {
+ ws.send(JSON.stringify(msg));
+ } catch {
+ // Connection may have been dropped; swallow.
+ }
+ }
- /** Broadcast a message to EVERY connected WS client (global fan-out). */
- function broadcast(msg: WsServerMessage): void {
- for (const ws of connections) {
- send(ws, msg);
- }
- }
+ /** Broadcast a message to EVERY connected WS client (global fan-out). */
+ function broadcast(msg: WsServerMessage): void {
+ for (const ws of connections) {
+ send(ws, msg);
+ }
+ }
- /**
- * Ensure this connection is subscribed to a conversation's chat events.
- * Idempotent — no-op if already subscribed. The orchestrator replays
- * buffered events to new subscribers (late-join), then streams live.
- */
- function ensureChatSubscribed(ws: Ws, state: ConnectionState, conversationId: string): void {
- if (state.chatSubscriptions.has(conversationId)) {
- return;
- }
- const unsubscribe = orchestrator.subscribe(conversationId, (event) => {
- send(ws, { type: "chat.delta", event });
- });
- state.chatSubscriptions.set(conversationId, unsubscribe);
- }
+ /**
+ * Ensure this connection is subscribed to a conversation's chat events.
+ * Idempotent — no-op if already subscribed. The orchestrator replays
+ * buffered events to new subscribers (late-join), then streams live.
+ */
+ function ensureChatSubscribed(ws: Ws, state: ConnectionState, conversationId: string): void {
+ if (state.chatSubscriptions.has(conversationId)) {
+ return;
+ }
+ const unsubscribe = orchestrator.subscribe(conversationId, (event) => {
+ send(ws, { type: "chat.delta", event });
+ });
+ state.chatSubscriptions.set(conversationId, unsubscribe);
+ }
- function subscribeToProvider(
- ws: Ws,
- provider: SurfaceProvider,
- surfaceId: string,
- conversationId: string | undefined,
- state: ConnectionState,
- ): void {
- const key = subKey(surfaceId, conversationId);
- if (!provider.subscribe || state.providerDisposers.has(key)) {
- return;
- }
- const context: SurfaceContext | undefined =
- conversationId !== undefined ? { conversationId } : undefined;
- const dispose = provider.subscribe(() => {
- try {
- const spec = provider.getSpec(context);
- if (spec instanceof Promise) {
- spec
- .then((s) =>
- send(ws, {
- type: "update",
- update: {
- surfaceId,
- spec: s,
- ...(conversationId !== undefined ? { conversationId } : {}),
- },
- }),
- )
- .catch(() => {});
- } else {
- send(ws, {
- type: "update",
- update: {
- surfaceId,
- spec,
- ...(conversationId !== undefined ? { conversationId } : {}),
- },
- });
- }
- } catch {
- // Provider threw — log but don't kill the connection.
- }
- });
- state.providerDisposers.set(key, dispose);
- }
+ function subscribeToProvider(
+ ws: Ws,
+ provider: SurfaceProvider,
+ surfaceId: string,
+ conversationId: string | undefined,
+ state: ConnectionState,
+ ): void {
+ const key = subKey(surfaceId, conversationId);
+ if (!provider.subscribe || state.providerDisposers.has(key)) {
+ return;
+ }
+ const context: SurfaceContext | undefined =
+ conversationId !== undefined ? { conversationId } : undefined;
+ const dispose = provider.subscribe(() => {
+ try {
+ const spec = provider.getSpec(context);
+ if (spec instanceof Promise) {
+ spec
+ .then((s) =>
+ send(ws, {
+ type: "update",
+ update: {
+ surfaceId,
+ spec: s,
+ ...(conversationId !== undefined ? { conversationId } : {}),
+ },
+ }),
+ )
+ .catch(() => {});
+ } else {
+ send(ws, {
+ type: "update",
+ update: {
+ surfaceId,
+ spec,
+ ...(conversationId !== undefined ? { conversationId } : {}),
+ },
+ });
+ }
+ } catch {
+ // Provider threw — log but don't kill the connection.
+ }
+ });
+ state.providerDisposers.set(key, dispose);
+ }
- function unsubscribeFromProvider(state: ConnectionState, key: string): void {
- const dispose = state.providerDisposers.get(key);
- if (dispose) {
- dispose();
- state.providerDisposers.delete(key);
- }
- }
+ function unsubscribeFromProvider(state: ConnectionState, key: string): void {
+ const dispose = state.providerDisposers.get(key);
+ if (dispose) {
+ dispose();
+ state.providerDisposers.delete(key);
+ }
+ }
- // Broadcast a `conversation.open` WS message to ALL connected clients
- // 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. 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, workspaceId }) => {
- broadcast({ type: "conversation.open", conversationId, workspaceId });
- }),
- );
+ // Broadcast a `conversation.open` WS message to ALL connected clients
+ // 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. 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, workspaceId }) => {
+ broadcast({ type: "conversation.open", conversationId, workspaceId });
+ }),
+ );
- // Broadcast `conversation.statusChanged` to all connected clients so
- // 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, workspaceId }) => {
- broadcast({
- type: "conversation.statusChanged",
- conversationId,
- status,
- workspaceId,
- });
- }),
- );
+ // Broadcast `conversation.statusChanged` to all connected clients so
+ // 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, workspaceId }) => {
+ broadcast({
+ type: "conversation.statusChanged",
+ conversationId,
+ status,
+ workspaceId,
+ });
+ }),
+ );
- // Broadcast `conversation.compacted` to all connected clients so
- // the FE reloads the conversation history after compaction.
- disposers.push(
- host.on(
- conversationCompacted,
- ({ conversationId, newConversationId, messagesSummarized, messagesKept }) => {
- broadcast({
- type: "conversation.compacted",
- conversationId,
- newConversationId,
- messagesSummarized,
- messagesKept,
- });
- },
- ),
- );
+ // Broadcast `conversation.compacted` to all connected clients so
+ // the FE reloads the conversation history after compaction.
+ disposers.push(
+ host.on(
+ conversationCompacted,
+ ({ conversationId, newConversationId, messagesSummarized, messagesKept }) => {
+ broadcast({
+ type: "conversation.compacted",
+ conversationId,
+ newConversationId,
+ messagesSummarized,
+ messagesKept,
+ });
+ },
+ ),
+ );
- server = Bun.serve<ConnectionState>({
- port,
- fetch(req, srv) {
- const initial: ConnectionState = {
- subs: new Set(),
- providerDisposers: new Map(),
- chatSubscriptions: new Map(),
- };
- if (srv.upgrade(req, { data: initial })) return;
- return new Response("expected websocket", { status: 426 });
- },
- websocket: {
- open(ws) {
- connections.add(ws);
- logger.debug("transport-ws: connection open");
- send(ws, catalogMessage(registry));
- },
+ server = Bun.serve<ConnectionState>({
+ port,
+ fetch(req, srv) {
+ const initial: ConnectionState = {
+ subs: new Set(),
+ providerDisposers: new Map(),
+ chatSubscriptions: new Map(),
+ };
+ if (srv.upgrade(req, { data: initial })) return;
+ return new Response("expected websocket", { status: 426 });
+ },
+ websocket: {
+ open(ws) {
+ connections.add(ws);
+ logger.debug("transport-ws: connection open");
+ send(ws, catalogMessage(registry));
+ },
- message(ws, message) {
- const state = ws.data;
- if (!state) return;
+ message(ws, message) {
+ const state = ws.data;
+ if (!state) return;
- let parsed: WsClientMessage;
- try {
- parsed = JSON.parse(String(message)) as WsClientMessage;
- } catch {
- send(ws, { type: "error", message: "Invalid JSON" });
- return;
- }
+ let parsed: WsClientMessage;
+ try {
+ parsed = JSON.parse(String(message)) as WsClientMessage;
+ } catch {
+ send(ws, { type: "error", message: "Invalid JSON" });
+ return;
+ }
- const result = routeClientMessage(registry, state.subs, parsed);
+ const result = routeClientMessage(registry, state.subs, parsed);
- switch (result.kind) {
- case "surface": {
- // Log surface-op errors (unknown surface or invoke failure).
- for (const reply of result.replies) {
- if (reply.type === "error") {
- logger.warn?.("transport-ws: surface-op error", {
- ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}),
- reason: reply.message,
- });
- }
- }
+ switch (result.kind) {
+ case "surface": {
+ // Log surface-op errors (unknown surface or invoke failure).
+ for (const reply of result.replies) {
+ if (reply.type === "error") {
+ logger.warn?.("transport-ws: surface-op error", {
+ ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}),
+ reason: reply.message,
+ });
+ }
+ }
- // Apply sub change.
- if (result.subChange) {
- const key = subKey(result.subChange.surfaceId, result.subChange.conversationId);
- if (result.subChange.op === "add") {
- state.subs.add(key);
- const provider = registry.getSurface(result.subChange.surfaceId);
- if (provider) {
- subscribeToProvider(
- ws,
- provider,
- result.subChange.surfaceId,
- result.subChange.conversationId,
- state,
- );
- }
- } else {
- state.subs.delete(key);
- unsubscribeFromProvider(state, key);
- }
- }
+ // Apply sub change.
+ if (result.subChange) {
+ const key = subKey(result.subChange.surfaceId, result.subChange.conversationId);
+ if (result.subChange.op === "add") {
+ state.subs.add(key);
+ const provider = registry.getSurface(result.subChange.surfaceId);
+ if (provider) {
+ subscribeToProvider(
+ ws,
+ provider,
+ result.subChange.surfaceId,
+ result.subChange.conversationId,
+ state,
+ );
+ }
+ } else {
+ state.subs.delete(key);
+ unsubscribeFromProvider(state, key);
+ }
+ }
- // Send replies.
- for (const reply of result.replies) {
- send(ws, reply);
- }
+ // Send replies.
+ for (const reply of result.replies) {
+ send(ws, reply);
+ }
- // Perform invoke if signalled.
- if (result.invoke) {
- const provider = registry.getSurface(result.invoke.surfaceId);
- if (provider) {
- const context: SurfaceContext | undefined =
- result.invoke.conversationId !== undefined
- ? { conversationId: result.invoke.conversationId }
- : undefined;
- try {
- const r = provider.invoke(
- result.invoke.actionId,
- result.invoke.payload,
- context,
- );
- if (r instanceof Promise) {
- r.catch(() => {});
- }
- } catch (err: unknown) {
- const reason = err instanceof Error ? err.message : "invoke failed";
- logger.warn?.("transport-ws: surface-op error", {
- surfaceId: result.invoke.surfaceId,
- actionId: result.invoke.actionId,
- reason,
- });
- }
- }
- }
- break;
- }
+ // Perform invoke if signalled.
+ if (result.invoke) {
+ const provider = registry.getSurface(result.invoke.surfaceId);
+ if (provider) {
+ const context: SurfaceContext | undefined =
+ result.invoke.conversationId !== undefined
+ ? { conversationId: result.invoke.conversationId }
+ : undefined;
+ try {
+ const r = provider.invoke(
+ result.invoke.actionId,
+ result.invoke.payload,
+ context,
+ );
+ if (r instanceof Promise) {
+ r.catch(() => {});
+ }
+ } catch (err: unknown) {
+ const reason = err instanceof Error ? err.message : "invoke failed";
+ logger.warn?.("transport-ws: surface-op error", {
+ surfaceId: result.invoke.surfaceId,
+ actionId: result.invoke.actionId,
+ reason,
+ });
+ }
+ }
+ }
+ break;
+ }
- case "chat": {
- const resolvedId = result.conversationId ?? crypto.randomUUID();
- // Auto-subscribe the sender so it sees the turn's events.
- ensureChatSubscribed(ws, state, resolvedId);
- // Start the turn detached from this connection.
- const startResult = orchestrator.startTurn({
- conversationId: resolvedId,
- text: result.message,
- ...(result.model !== undefined ? { modelName: result.model } : {}),
- ...(result.cwd !== undefined ? { cwd: result.cwd } : {}),
- ...(result.reasoningEffort !== undefined
- ? { reasoningEffort: result.reasoningEffort }
- : {}),
- ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
- ...(result.computerId !== undefined ? { computerId: result.computerId } : {}),
- });
- if (!startResult.started) {
- send(ws, {
- type: "chat.error",
- conversationId: resolvedId,
- message: "a turn is already generating for this conversation",
- });
- } else {
- logger.info?.("transport-ws: chat.send accepted", {
- conversationId: resolvedId,
- model: result.model ?? null,
- });
- }
- break;
- }
+ case "chat": {
+ const resolvedId = result.conversationId ?? crypto.randomUUID();
+ // Auto-subscribe the sender so it sees the turn's events.
+ ensureChatSubscribed(ws, state, resolvedId);
+ // Start the turn detached from this connection.
+ const startResult = orchestrator.startTurn({
+ conversationId: resolvedId,
+ text: result.message,
+ ...(result.model !== undefined ? { modelName: result.model } : {}),
+ ...(result.cwd !== undefined ? { cwd: result.cwd } : {}),
+ ...(result.reasoningEffort !== undefined
+ ? { reasoningEffort: result.reasoningEffort }
+ : {}),
+ ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
+ ...(result.computerId !== undefined ? { computerId: result.computerId } : {}),
+ ...(result.images !== undefined ? { images: result.images } : {}),
+ });
+ if (!startResult.started) {
+ send(ws, {
+ type: "chat.error",
+ conversationId: resolvedId,
+ message: "a turn is already generating for this conversation",
+ });
+ } else {
+ logger.info?.("transport-ws: chat.send accepted", {
+ conversationId: resolvedId,
+ model: result.model ?? null,
+ });
+ }
+ break;
+ }
- case "chat-subscribe": {
- ensureChatSubscribed(ws, state, result.conversationId);
- break;
- }
+ case "chat-subscribe": {
+ ensureChatSubscribed(ws, state, result.conversationId);
+ break;
+ }
- case "chat-unsubscribe": {
- const dispose = state.chatSubscriptions.get(result.conversationId);
- if (dispose) {
- dispose();
- state.chatSubscriptions.delete(result.conversationId);
- }
- break;
- }
+ case "chat-unsubscribe": {
+ const dispose = state.chatSubscriptions.get(result.conversationId);
+ if (dispose) {
+ dispose();
+ state.chatSubscriptions.delete(result.conversationId);
+ }
+ break;
+ }
- case "chat-queue": {
- // Fire-and-forget: success is confirmed by the message-queue
- // SURFACE updating (startedTurn:false) or by streaming
- // chat.deltas (startedTurn:true), NOT by a reply here. On
- // startedTurn:true the sender is auto-subscribed so the new
- // turn's events stream to it (same as chat.send); on
- // startedTurn:false (queued for steering) we emit NOTHING
- // back and do not auto-subscribe.
- const enqueueResult = orchestrator.enqueue({
- conversationId: result.conversationId,
- text: result.text,
- ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
- });
- if (enqueueResult.startedTurn) {
- ensureChatSubscribed(ws, state, result.conversationId);
- }
- logger.info?.("transport-ws: chat.queue accepted", {
- conversationId: result.conversationId,
- startedTurn: enqueueResult.startedTurn,
- });
- break;
- }
+ case "chat-queue": {
+ // Fire-and-forget: success is confirmed by the message-queue
+ // SURFACE updating (startedTurn:false) or by streaming
+ // chat.deltas (startedTurn:true), NOT by a reply here. On
+ // startedTurn:true the sender is auto-subscribed so the new
+ // turn's events stream to it (same as chat.send); on
+ // startedTurn:false (queued for steering) we emit NOTHING
+ // back and do not auto-subscribe.
+ const enqueueResult = orchestrator.enqueue({
+ conversationId: result.conversationId,
+ text: result.text,
+ ...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
+ });
+ if (enqueueResult.startedTurn) {
+ ensureChatSubscribed(ws, state, result.conversationId);
+ }
+ logger.info?.("transport-ws: chat.queue accepted", {
+ conversationId: result.conversationId,
+ startedTurn: enqueueResult.startedTurn,
+ });
+ break;
+ }
- case "chat-error": {
- logger.warn?.("transport-ws: malformed chat.send", {
- reason: result.errorMessage,
- ...(result.conversationId !== undefined
- ? { conversationId: result.conversationId }
- : {}),
- });
- send(ws, {
- type: "chat.error",
- ...(result.conversationId !== undefined
- ? { conversationId: result.conversationId }
- : {}),
- message: result.errorMessage,
- });
- break;
- }
- }
- },
+ case "chat-queue-cancel": {
+ // Fire-and-forget: success is confirmed by the message-queue
+ // SURFACE updating (the cancelled message leaves the snapshot),
+ // NOT by a reply here. Cancelling a message that is no longer
+ // queued is a silent no-op (no surface update, no error).
+ const cancelResult = orchestrator.cancelQueuedMessage({
+ conversationId: result.conversationId,
+ messageId: result.messageId,
+ });
+ logger.info?.("transport-ws: chat.queue.cancel accepted", {
+ conversationId: result.conversationId,
+ messageId: result.messageId,
+ cancelled: cancelResult.cancelled,
+ });
+ break;
+ }
- close(ws) {
- connections.delete(ws);
- const state = ws.data;
- if (state) {
- // Dispose all chat subscriptions (does NOT abort turns).
- for (const dispose of state.chatSubscriptions.values()) {
- dispose();
- }
- state.chatSubscriptions.clear();
- // Dispose surface provider subscriptions.
- for (const dispose of state.providerDisposers.values()) {
- dispose();
- }
- }
- logger.debug("transport-ws: connection close");
- },
- },
- });
+ case "chat-error": {
+ logger.warn?.("transport-ws: malformed chat.send", {
+ reason: result.errorMessage,
+ ...(result.conversationId !== undefined
+ ? { conversationId: result.conversationId }
+ : {}),
+ });
+ send(ws, {
+ type: "chat.error",
+ ...(result.conversationId !== undefined
+ ? { conversationId: result.conversationId }
+ : {}),
+ message: result.errorMessage,
+ });
+ break;
+ }
+ }
+ },
- logger.info?.("transport-ws: surface WebSocket listening", { port });
- },
+ close(ws) {
+ connections.delete(ws);
+ const state = ws.data;
+ if (state) {
+ // Dispose all chat subscriptions (does NOT abort turns).
+ for (const dispose of state.chatSubscriptions.values()) {
+ dispose();
+ }
+ state.chatSubscriptions.clear();
+ // Dispose surface provider subscriptions.
+ for (const dispose of state.providerDisposers.values()) {
+ dispose();
+ }
+ }
+ logger.debug("transport-ws: connection close");
+ },
+ },
+ });
- deactivate() {
- for (const dispose of disposers) {
- dispose();
- }
- disposers.length = 0;
- connections.clear();
- if (server) {
- server.stop();
- server = undefined;
- }
- },
- };
+ logger.info?.("transport-ws: surface WebSocket listening", { port });
+ },
+
+ deactivate() {
+ for (const dispose of disposers) {
+ dispose();
+ }
+ disposers.length = 0;
+ connections.clear();
+ if (server) {
+ server.stop();
+ server = undefined;
+ }
+ },
+ };
}
diff --git a/packages/transport-ws/src/index.ts b/packages/transport-ws/src/index.ts
index e0cc66b..5a7c3f9 100644
--- a/packages/transport-ws/src/index.ts
+++ b/packages/transport-ws/src/index.ts
@@ -1,12 +1,12 @@
export { createTransportWsExtension } from "./extension.js";
export { manifest } from "./manifest.js";
export type {
- ChatQueueRouteResult,
- ChatRouteError,
- ChatRouteResult,
- ChatSubscribeRouteResult,
- ChatUnsubscribeRouteResult,
- RouteResult,
- SurfaceRouteResult,
+ ChatQueueRouteResult,
+ ChatRouteError,
+ ChatRouteResult,
+ ChatSubscribeRouteResult,
+ ChatUnsubscribeRouteResult,
+ RouteResult,
+ SurfaceRouteResult,
} from "./router.js";
export { catalogMessage, routeClientMessage, subKey } from "./router.js";
diff --git a/packages/transport-ws/src/manifest.ts b/packages/transport-ws/src/manifest.ts
index 5058311..eef6f45 100644
--- a/packages/transport-ws/src/manifest.ts
+++ b/packages/transport-ws/src/manifest.ts
@@ -1,13 +1,13 @@
import type { Manifest } from "@dispatch/kernel";
export const manifest: Manifest = {
- id: "transport-ws",
- name: "Transport WebSocket",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- dependsOn: ["surface-registry", "session-orchestrator"],
- capabilities: { network: true },
- contributes: { routes: ["/ws/surfaces"] },
- activation: "eager",
+ id: "transport-ws",
+ name: "Transport WebSocket",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ dependsOn: ["surface-registry", "session-orchestrator"],
+ capabilities: { network: true },
+ contributes: { routes: ["/ws/surfaces"] },
+ activation: "eager",
};
diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts
index 6d01823..19b5bb5 100644
--- a/packages/transport-ws/src/router.test.ts
+++ b/packages/transport-ws/src/router.test.ts
@@ -7,677 +7,732 @@ import { catalogMessage, type RouteResult, routeClientMessage, subKey } from "./
// ── Fake in-memory registry (no mocks — just a plain implementation) ────────
interface FakeProviderOpts {
- readonly id: string;
- readonly title?: string;
- readonly actions?: readonly string[];
- /** Called with the context that getSpec receives — for test assertions. */
- readonly onGetSpec?: (context: SurfaceContext | undefined) => void;
- /** Called with the context that invoke receives — for test assertions. */
- readonly onInvoke?: (
- actionId: string,
- payload: unknown,
- context: SurfaceContext | undefined,
- ) => void;
+ readonly id: string;
+ readonly title?: string;
+ readonly actions?: readonly string[];
+ /** Called with the context that getSpec receives — for test assertions. */
+ readonly onGetSpec?: (context: SurfaceContext | undefined) => void;
+ /** Called with the context that invoke receives — for test assertions. */
+ readonly onInvoke?: (
+ actionId: string,
+ payload: unknown,
+ context: SurfaceContext | undefined,
+ ) => void;
}
function fakeProvider(
- idOrOpts: string | FakeProviderOpts,
- title?: string,
- actions?: readonly string[],
+ idOrOpts: string | FakeProviderOpts,
+ title?: string,
+ actions?: readonly string[],
): SurfaceProvider {
- const opts: FakeProviderOpts =
- typeof idOrOpts === "string"
- ? {
- id: idOrOpts,
- ...(title !== undefined ? { title } : {}),
- ...(actions !== undefined ? { actions } : {}),
- }
- : idOrOpts;
- const catalogEntry: SurfaceCatalogEntry = {
- id: opts.id,
- region: "default",
- title: opts.title ?? `Surface ${opts.id}`,
- };
- return {
- catalogEntry,
- getSpec(context?: SurfaceContext): SurfaceSpec {
- opts.onGetSpec?.(context);
- return {
- id: opts.id,
- region: "default",
- title: catalogEntry.title,
- fields:
- opts.actions?.map((a) => ({
- kind: "button" as const,
- label: a,
- action: { actionId: a },
- })) ?? [],
- };
- },
- invoke(actionId: string, _payload?: unknown, context?: SurfaceContext) {
- opts.onInvoke?.(actionId, _payload, context);
- },
- };
+ const opts: FakeProviderOpts =
+ typeof idOrOpts === "string"
+ ? {
+ id: idOrOpts,
+ ...(title !== undefined ? { title } : {}),
+ ...(actions !== undefined ? { actions } : {}),
+ }
+ : idOrOpts;
+ const catalogEntry: SurfaceCatalogEntry = {
+ id: opts.id,
+ region: "default",
+ title: opts.title ?? `Surface ${opts.id}`,
+ };
+ return {
+ catalogEntry,
+ getSpec(context?: SurfaceContext): SurfaceSpec {
+ opts.onGetSpec?.(context);
+ return {
+ id: opts.id,
+ region: "default",
+ title: catalogEntry.title,
+ fields:
+ opts.actions?.map((a) => ({
+ kind: "button" as const,
+ label: a,
+ action: { actionId: a },
+ })) ?? [],
+ };
+ },
+ invoke(actionId: string, _payload?: unknown, context?: SurfaceContext) {
+ opts.onInvoke?.(actionId, _payload, context);
+ },
+ };
}
function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry {
- const map = new Map(providers.map((p) => [p.catalogEntry.id, p]));
- return {
- register(_provider: SurfaceProvider) {
- return () => {};
- },
- getCatalog() {
- return [...map.values()].map((p) => p.catalogEntry);
- },
- getSurface(id: string) {
- return map.get(id);
- },
- };
+ const map = new Map(providers.map((p) => [p.catalogEntry.id, p]));
+ return {
+ register(_provider: SurfaceProvider) {
+ return () => {};
+ },
+ getCatalog() {
+ return [...map.values()].map((p) => p.catalogEntry);
+ },
+ getSurface(id: string) {
+ return map.get(id);
+ },
+ };
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("routeClientMessage", () => {
- describe("subscribe", () => {
- it("replies with `surface` and tracks the subscription", () => {
- const provider = fakeProvider("a", "Surface A");
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "subscribe",
- surfaceId: "a",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(1);
- expect(result.replies[0]).toEqual({
- type: "surface",
- spec: {
- id: "a",
- region: "default",
- title: "Surface A",
- fields: [],
- },
- });
- expect(result.subChange).toEqual({ op: "add", surfaceId: "a" });
- });
-
- it("is idempotent — subscribing twice does not duplicate the subChange", () => {
- const provider = fakeProvider("a");
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>([subKey("a")]); // already subscribed (global)
-
- const result = routeClientMessage(registry, connSubs, {
- type: "subscribe",
- surfaceId: "a",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(1);
- expect(result.replies[0]?.type).toBe("surface");
- expect(result.subChange).toBeUndefined();
- });
-
- it("returns `error` for an unknown surface id", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "subscribe",
- surfaceId: "nonexistent",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(1);
- expect(result.replies[0]).toEqual({
- type: "error",
- surfaceId: "nonexistent",
- message: "Unknown surface: nonexistent",
- });
- expect(result.subChange).toBeUndefined();
- });
-
- it("subscribe with conversationId fetches the provider spec for that conversation and tags the reply", () => {
- let receivedContext: SurfaceContext | undefined;
- const provider = fakeProvider({
- id: "cache-warm",
- title: "Cache Warming",
- onGetSpec(ctx) {
- receivedContext = ctx;
- },
- });
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "subscribe",
- surfaceId: "cache-warm",
- conversationId: "conv-42",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(receivedContext).toEqual({ conversationId: "conv-42" });
- expect(result.replies).toHaveLength(1);
- const reply = result.replies[0];
- if (reply?.type !== "surface") throw new Error("expected surface reply");
- expect(reply.conversationId).toBe("conv-42");
- expect(reply.spec.id).toBe("cache-warm");
- expect(result.subChange).toEqual({
- op: "add",
- surfaceId: "cache-warm",
- conversationId: "conv-42",
- });
- });
-
- it("subscribe without conversationId behaves as before (global surface unaffected)", () => {
- let receivedContext: SurfaceContext | undefined;
- const provider = fakeProvider({
- id: "global-surf",
- title: "Global Surface",
- onGetSpec(ctx) {
- receivedContext = ctx;
- },
- });
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "subscribe",
- surfaceId: "global-surf",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(receivedContext).toBeUndefined();
- const reply = result.replies[0];
- if (reply?.type !== "surface") throw new Error("expected surface reply");
- expect(reply.conversationId).toBeUndefined();
- expect(result.subChange).toEqual({ op: "add", surfaceId: "global-surf" });
- });
- });
-
- describe("unsubscribe", () => {
- it("emits a remove subChange and no replies", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>([subKey("a")]);
-
- const result = routeClientMessage(registry, connSubs, {
- type: "unsubscribe",
- surfaceId: "a",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(0);
- expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" });
- });
-
- it("emits remove even if not currently subscribed (idempotent)", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "unsubscribe",
- surfaceId: "a",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(0);
- expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" });
- });
- });
-
- describe("invoke", () => {
- it("signals the invoke effect for a known surface", () => {
- const provider = fakeProvider("a", "Surface A", ["toggle"]);
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "invoke",
- surfaceId: "a",
- actionId: "toggle",
- payload: true,
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(0);
- expect(result.invoke).toEqual({
- surfaceId: "a",
- actionId: "toggle",
- payload: true,
- });
- });
-
- it("returns `error` for an unknown surface id", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "invoke",
- surfaceId: "nonexistent",
- actionId: "toggle",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.replies).toHaveLength(1);
- expect(result.replies[0]).toEqual({
- type: "error",
- surfaceId: "nonexistent",
- message: "Unknown surface: nonexistent",
- });
- expect(result.invoke).toBeUndefined();
- });
-
- it("invoke forwards the conversationId to the provider", () => {
- let _receivedContext: SurfaceContext | undefined;
- const provider = fakeProvider({
- id: "cache-warm",
- title: "Cache Warming",
- actions: ["warm"],
- onInvoke(_actionId, _payload, ctx) {
- _receivedContext = ctx;
- },
- });
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "invoke",
- surfaceId: "cache-warm",
- actionId: "warm",
- payload: { force: true },
- conversationId: "conv-99",
- });
-
- expect(result.kind).toBe("surface");
- if (result.kind !== "surface") throw new Error("expected surface");
- expect(result.invoke).toEqual({
- surfaceId: "cache-warm",
- actionId: "warm",
- payload: { force: true },
- conversationId: "conv-99",
- });
- });
- });
-
- describe("chat.send", () => {
- it("classifies a chat.send message", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: "hello",
- });
-
- expect(result.kind).toBe("chat");
- if (result.kind !== "chat") throw new Error("expected chat");
- expect(result.message).toBe("hello");
- expect(result.conversationId).toBeUndefined();
- expect(result.model).toBeUndefined();
- expect(result.cwd).toBeUndefined();
- });
-
- it("passes through optional fields", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- conversationId: "conv-123",
- message: "follow up",
- model: "gpt-4",
- cwd: "/tmp",
- });
-
- expect(result.kind).toBe("chat");
- if (result.kind !== "chat") throw new Error("expected chat");
- expect(result.conversationId).toBe("conv-123");
- expect(result.message).toBe("follow up");
- expect(result.model).toBe("gpt-4");
- 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("chat.send threads computerId", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- conversationId: "conv-cid",
- message: "hello computer",
- computerId: "dev-box",
- });
-
- expect(result.kind).toBe("chat");
- if (result.kind !== "chat") throw new Error("expected chat");
- expect(result.computerId).toBe("dev-box");
- });
-
- it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: "hello no computer",
- });
-
- expect(result.kind).toBe("chat");
- if (result.kind !== "chat") throw new Error("expected chat");
- // computerId is absent (undefined) — the orchestrator receives no
- // computerId and resolves the inherited chain (conversation →
- // workspace defaultComputerId → local). Mirrors workspaceId.
- expect(result).not.toHaveProperty("computerId");
- });
-
- it("rejects a malformed chat.send (empty message)", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: "",
- });
-
- expect(result.kind).toBe("chat-error");
- if (result.kind !== "chat-error") throw new Error("expected chat-error");
- expect(result.errorMessage).toContain("non-empty string");
- });
-
- it("rejects a malformed chat.send (missing message)", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: undefined as unknown as string,
- });
-
- expect(result.kind).toBe("chat-error");
- if (result.kind !== "chat-error") throw new Error("expected chat-error");
- expect(result.errorMessage).toContain("non-empty string");
- });
-
- it("threads each valid reasoningEffort level through to the result", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
- const levels = ["low", "medium", "high", "xhigh", "max"] as const;
-
- for (const level of levels) {
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: "hello",
- reasoningEffort: level,
- });
-
- expect(result.kind).toBe("chat");
- if (result.kind !== "chat") throw new Error("expected chat");
- expect(result.reasoningEffort).toBe(level);
- }
- });
-
- it("omits reasoningEffort from result when not provided by client", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: "hello",
- });
-
- expect(result.kind).toBe("chat");
- if (result.kind !== "chat") throw new Error("expected chat");
- expect(result).not.toHaveProperty("reasoningEffort");
- });
-
- it("rejects an invalid reasoningEffort value with a chat-error", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.send",
- message: "hello",
- reasoningEffort: "turbo" as unknown as "low",
- });
-
- expect(result.kind).toBe("chat-error");
- if (result.kind !== "chat-error") throw new Error("expected chat-error");
- expect(result.errorMessage).toContain("invalid reasoningEffort");
- expect(result.errorMessage).toContain("turbo");
- });
- });
-
- describe("chat.subscribe", () => {
- it("routes chat.subscribe → { kind: 'chat-subscribe', conversationId }", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.subscribe",
- conversationId: "conv-abc",
- });
-
- expect(result).toEqual({ kind: "chat-subscribe", conversationId: "conv-abc" });
- });
- });
-
- describe("chat.unsubscribe", () => {
- it("routes chat.unsubscribe → { kind: 'chat-unsubscribe', conversationId }", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.unsubscribe",
- conversationId: "conv-abc",
- });
-
- expect(result).toEqual({ kind: "chat-unsubscribe", conversationId: "conv-abc" });
- });
- });
-
- describe("chat.queue", () => {
- it("routes a valid chat.queue → { kind: 'chat-queue', conversationId, text } (what the shell passes to orchestrator.enqueue)", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.queue",
- conversationId: "conv-1",
- text: "steer here",
- });
-
- expect(result).toEqual({
- kind: "chat-queue",
- conversationId: "conv-1",
- text: "steer here",
- });
- });
-
- 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>();
-
- for (const text of ["", " ", "\t\n"]) {
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.queue",
- conversationId: "conv-1",
- text,
- });
-
- expect(result.kind).toBe("chat-error");
- if (result.kind !== "chat-error") throw new Error("expected chat-error");
- expect(result.conversationId).toBe("conv-1");
- expect(result.errorMessage).toContain("non-empty string");
- expect(result.errorMessage).toContain("text");
- }
- });
-
- it("rejects missing text → chat-error (no enqueue signal)", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.queue",
- conversationId: "conv-1",
- text: undefined as unknown as string,
- });
-
- expect(result.kind).toBe("chat-error");
- if (result.kind !== "chat-error") throw new Error("expected chat-error");
- expect(result.errorMessage).toContain("non-empty string");
- });
-
- it("does not trim the stored text — passes the original through to the shell", () => {
- const registry = fakeRegistry([]);
- const connSubs = new Set<string>();
-
- // Non-empty after trim (so valid), but the value carries surrounding
- // whitespace: the router passes it through unchanged (validation uses
- // trim; the orchestrator receives the original text).
- const result = routeClientMessage(registry, connSubs, {
- type: "chat.queue",
- conversationId: "conv-1",
- text: " steer ",
- });
-
- expect(result.kind).toBe("chat-queue");
- if (result.kind !== "chat-queue") throw new Error("expected chat-queue");
- expect(result.text).toBe(" steer ");
- });
- });
-
- describe("exhaustive switch (regression guard for Wave-0 fan-out)", () => {
- // Every WsClientMessage variant must route to a defined result with a
- // known kind — no fall-through / undefined return. If the union is
- // widened again, `tsc` catches the missing case (the switch is
- // exhaustive); this test guards the runtime side of that contract.
- it("routes every WsClientMessage variant to a defined RouteResult", () => {
- const provider = fakeProvider("a", "Surface A", ["toggle"]);
- const registry = fakeRegistry([provider]);
- const connSubs = new Set<string>();
-
- const samples: WsClientMessage[] = [
- { type: "subscribe", surfaceId: "a" },
- { type: "unsubscribe", surfaceId: "a" },
- { type: "invoke", surfaceId: "a", actionId: "toggle", payload: true },
- { type: "chat.send", message: "hi" },
- { type: "chat.subscribe", conversationId: "c1" },
- { type: "chat.unsubscribe", conversationId: "c1" },
- { type: "chat.queue", conversationId: "c1", text: "steer" },
- ];
-
- const validKinds = new Set<RouteResult["kind"]>([
- "surface",
- "chat",
- "chat-error",
- "chat-subscribe",
- "chat-unsubscribe",
- "chat-queue",
- ]);
-
- for (const msg of samples) {
- const result = routeClientMessage(registry, connSubs, msg);
- expect(result).toBeDefined();
- expect(validKinds.has(result.kind)).toBe(true);
- }
- });
- });
+ describe("subscribe", () => {
+ it("replies with `surface` and tracks the subscription", () => {
+ const provider = fakeProvider("a", "Surface A");
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]).toEqual({
+ type: "surface",
+ spec: {
+ id: "a",
+ region: "default",
+ title: "Surface A",
+ fields: [],
+ },
+ });
+ expect(result.subChange).toEqual({ op: "add", surfaceId: "a" });
+ });
+
+ it("is idempotent — subscribing twice does not duplicate the subChange", () => {
+ const provider = fakeProvider("a");
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>([subKey("a")]); // already subscribed (global)
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]?.type).toBe("surface");
+ expect(result.subChange).toBeUndefined();
+ });
+
+ it("returns `error` for an unknown surface id", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "nonexistent",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]).toEqual({
+ type: "error",
+ surfaceId: "nonexistent",
+ message: "Unknown surface: nonexistent",
+ });
+ expect(result.subChange).toBeUndefined();
+ });
+
+ it("subscribe with conversationId fetches the provider spec for that conversation and tags the reply", () => {
+ let receivedContext: SurfaceContext | undefined;
+ const provider = fakeProvider({
+ id: "cache-warm",
+ title: "Cache Warming",
+ onGetSpec(ctx) {
+ receivedContext = ctx;
+ },
+ });
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "cache-warm",
+ conversationId: "conv-42",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(receivedContext).toEqual({ conversationId: "conv-42" });
+ expect(result.replies).toHaveLength(1);
+ const reply = result.replies[0];
+ if (reply?.type !== "surface") throw new Error("expected surface reply");
+ expect(reply.conversationId).toBe("conv-42");
+ expect(reply.spec.id).toBe("cache-warm");
+ expect(result.subChange).toEqual({
+ op: "add",
+ surfaceId: "cache-warm",
+ conversationId: "conv-42",
+ });
+ });
+
+ it("subscribe without conversationId behaves as before (global surface unaffected)", () => {
+ let receivedContext: SurfaceContext | undefined;
+ const provider = fakeProvider({
+ id: "global-surf",
+ title: "Global Surface",
+ onGetSpec(ctx) {
+ receivedContext = ctx;
+ },
+ });
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "subscribe",
+ surfaceId: "global-surf",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(receivedContext).toBeUndefined();
+ const reply = result.replies[0];
+ if (reply?.type !== "surface") throw new Error("expected surface reply");
+ expect(reply.conversationId).toBeUndefined();
+ expect(result.subChange).toEqual({ op: "add", surfaceId: "global-surf" });
+ });
+ });
+
+ describe("unsubscribe", () => {
+ it("emits a remove subChange and no replies", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>([subKey("a")]);
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "unsubscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(0);
+ expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" });
+ });
+
+ it("emits remove even if not currently subscribed (idempotent)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "unsubscribe",
+ surfaceId: "a",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(0);
+ expect(result.subChange).toEqual({ op: "remove", surfaceId: "a" });
+ });
+ });
+
+ describe("invoke", () => {
+ it("signals the invoke effect for a known surface", () => {
+ const provider = fakeProvider("a", "Surface A", ["toggle"]);
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "invoke",
+ surfaceId: "a",
+ actionId: "toggle",
+ payload: true,
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(0);
+ expect(result.invoke).toEqual({
+ surfaceId: "a",
+ actionId: "toggle",
+ payload: true,
+ });
+ });
+
+ it("returns `error` for an unknown surface id", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "invoke",
+ surfaceId: "nonexistent",
+ actionId: "toggle",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.replies).toHaveLength(1);
+ expect(result.replies[0]).toEqual({
+ type: "error",
+ surfaceId: "nonexistent",
+ message: "Unknown surface: nonexistent",
+ });
+ expect(result.invoke).toBeUndefined();
+ });
+
+ it("invoke forwards the conversationId to the provider", () => {
+ let _receivedContext: SurfaceContext | undefined;
+ const provider = fakeProvider({
+ id: "cache-warm",
+ title: "Cache Warming",
+ actions: ["warm"],
+ onInvoke(_actionId, _payload, ctx) {
+ _receivedContext = ctx;
+ },
+ });
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "invoke",
+ surfaceId: "cache-warm",
+ actionId: "warm",
+ payload: { force: true },
+ conversationId: "conv-99",
+ });
+
+ expect(result.kind).toBe("surface");
+ if (result.kind !== "surface") throw new Error("expected surface");
+ expect(result.invoke).toEqual({
+ surfaceId: "cache-warm",
+ actionId: "warm",
+ payload: { force: true },
+ conversationId: "conv-99",
+ });
+ });
+ });
+
+ describe("chat.send", () => {
+ it("classifies a chat.send message", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result.message).toBe("hello");
+ expect(result.conversationId).toBeUndefined();
+ expect(result.model).toBeUndefined();
+ expect(result.cwd).toBeUndefined();
+ });
+
+ it("passes through optional fields", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ conversationId: "conv-123",
+ message: "follow up",
+ model: "gpt-4",
+ cwd: "/tmp",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result.conversationId).toBe("conv-123");
+ expect(result.message).toBe("follow up");
+ expect(result.model).toBe("gpt-4");
+ 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("chat.send threads computerId", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ conversationId: "conv-cid",
+ message: "hello computer",
+ computerId: "dev-box",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result.computerId).toBe("dev-box");
+ });
+
+ it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello no computer",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ // computerId is absent (undefined) — the orchestrator receives no
+ // computerId and resolves the inherited chain (conversation →
+ // workspace defaultComputerId → local). Mirrors workspaceId.
+ expect(result).not.toHaveProperty("computerId");
+ });
+
+ it("rejects a malformed chat.send (empty message)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "",
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ });
+
+ it("rejects a malformed chat.send (missing message)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: undefined as unknown as string,
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ });
+
+ it("threads each valid reasoningEffort level through to the result", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+ const levels = ["low", "medium", "high", "xhigh", "max"] as const;
+
+ for (const level of levels) {
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello",
+ reasoningEffort: level,
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result.reasoningEffort).toBe(level);
+ }
+ });
+
+ it("omits reasoningEffort from result when not provided by client", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result).not.toHaveProperty("reasoningEffort");
+ });
+
+ it("rejects an invalid reasoningEffort value with a chat-error", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello",
+ reasoningEffort: "turbo" as unknown as "low",
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("invalid reasoningEffort");
+ expect(result.errorMessage).toContain("turbo");
+ });
+ });
+
+ describe("chat.subscribe", () => {
+ it("routes chat.subscribe → { kind: 'chat-subscribe', conversationId }", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.subscribe",
+ conversationId: "conv-abc",
+ });
+
+ expect(result).toEqual({ kind: "chat-subscribe", conversationId: "conv-abc" });
+ });
+ });
+
+ describe("chat.unsubscribe", () => {
+ it("routes chat.unsubscribe → { kind: 'chat-unsubscribe', conversationId }", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.unsubscribe",
+ conversationId: "conv-abc",
+ });
+
+ expect(result).toEqual({ kind: "chat-unsubscribe", conversationId: "conv-abc" });
+ });
+ });
+
+ describe("chat.queue", () => {
+ it("routes a valid chat.queue → { kind: 'chat-queue', conversationId, text } (what the shell passes to orchestrator.enqueue)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue",
+ conversationId: "conv-1",
+ text: "steer here",
+ });
+
+ expect(result).toEqual({
+ kind: "chat-queue",
+ conversationId: "conv-1",
+ text: "steer here",
+ });
+ });
+
+ 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>();
+
+ for (const text of ["", " ", "\t\n"]) {
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue",
+ conversationId: "conv-1",
+ text,
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.conversationId).toBe("conv-1");
+ expect(result.errorMessage).toContain("non-empty string");
+ expect(result.errorMessage).toContain("text");
+ }
+ });
+
+ it("rejects missing text → chat-error (no enqueue signal)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue",
+ conversationId: "conv-1",
+ text: undefined as unknown as string,
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ });
+
+ it("does not trim the stored text — passes the original through to the shell", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ // Non-empty after trim (so valid), but the value carries surrounding
+ // whitespace: the router passes it through unchanged (validation uses
+ // trim; the orchestrator receives the original text).
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue",
+ conversationId: "conv-1",
+ text: " steer ",
+ });
+
+ expect(result.kind).toBe("chat-queue");
+ if (result.kind !== "chat-queue") throw new Error("expected chat-queue");
+ expect(result.text).toBe(" steer ");
+ });
+ });
+
+ describe("chat.queue.cancel", () => {
+ it("routes a valid chat.queue.cancel → { kind: 'chat-queue-cancel', conversationId, messageId }", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue.cancel",
+ conversationId: "conv-1",
+ messageId: "q-42",
+ });
+
+ expect(result).toEqual({
+ kind: "chat-queue-cancel",
+ conversationId: "conv-1",
+ messageId: "q-42",
+ });
+ });
+
+ it("rejects empty conversationId → chat-error (no cancel signal)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue.cancel",
+ conversationId: "",
+ messageId: "q-42",
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ expect(result.errorMessage).toContain("conversationId");
+ });
+
+ it("rejects empty messageId → chat-error (no cancel signal)", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ for (const messageId of ["", undefined as unknown as string]) {
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.queue.cancel",
+ conversationId: "conv-1",
+ messageId,
+ });
+
+ expect(result.kind).toBe("chat-error");
+ if (result.kind !== "chat-error") throw new Error("expected chat-error");
+ expect(result.errorMessage).toContain("non-empty string");
+ expect(result.errorMessage).toContain("messageId");
+ }
+ });
+ });
+
+ describe("exhaustive switch (regression guard for Wave-0 fan-out)", () => {
+ // Every WsClientMessage variant must route to a defined result with a
+ // known kind — no fall-through / undefined return. If the union is
+ // widened again, `tsc` catches the missing case (the switch is
+ // exhaustive); this test guards the runtime side of that contract.
+ it("routes every WsClientMessage variant to a defined RouteResult", () => {
+ const provider = fakeProvider("a", "Surface A", ["toggle"]);
+ const registry = fakeRegistry([provider]);
+ const connSubs = new Set<string>();
+
+ const samples: WsClientMessage[] = [
+ { type: "subscribe", surfaceId: "a" },
+ { type: "unsubscribe", surfaceId: "a" },
+ { type: "invoke", surfaceId: "a", actionId: "toggle", payload: true },
+ { type: "chat.send", message: "hi" },
+ { type: "chat.subscribe", conversationId: "c1" },
+ { type: "chat.unsubscribe", conversationId: "c1" },
+ { type: "chat.queue", conversationId: "c1", text: "steer" },
+ { type: "chat.queue.cancel", conversationId: "c1", messageId: "m1" },
+ ];
+
+ const validKinds = new Set<RouteResult["kind"]>([
+ "surface",
+ "chat",
+ "chat-error",
+ "chat-subscribe",
+ "chat-unsubscribe",
+ "chat-queue",
+ "chat-queue-cancel",
+ ]);
+
+ for (const msg of samples) {
+ const result = routeClientMessage(registry, connSubs, msg);
+ expect(result).toBeDefined();
+ expect(validKinds.has(result.kind)).toBe(true);
+ }
+ });
+ });
});
describe("catalogMessage", () => {
- it("returns the catalog from the registry", () => {
- const providerA = fakeProvider("a", "Surface A");
- const providerB = fakeProvider("b", "Surface B");
- const registry = fakeRegistry([providerA, providerB]);
+ it("returns the catalog from the registry", () => {
+ const providerA = fakeProvider("a", "Surface A");
+ const providerB = fakeProvider("b", "Surface B");
+ const registry = fakeRegistry([providerA, providerB]);
- const msg = catalogMessage(registry);
+ const msg = catalogMessage(registry);
- expect(msg).toEqual({
- type: "catalog",
- catalog: [
- { id: "a", region: "default", title: "Surface A" },
- { id: "b", region: "default", title: "Surface B" },
- ],
- });
- });
+ expect(msg).toEqual({
+ type: "catalog",
+ catalog: [
+ { id: "a", region: "default", title: "Surface A" },
+ { id: "b", region: "default", title: "Surface B" },
+ ],
+ });
+ });
- it("returns an empty catalog when no providers are registered", () => {
- const registry = fakeRegistry([]);
+ it("returns an empty catalog when no providers are registered", () => {
+ const registry = fakeRegistry([]);
- const msg = catalogMessage(registry);
+ const msg = catalogMessage(registry);
- expect(msg).toEqual({ type: "catalog", catalog: [] });
- });
+ expect(msg).toEqual({ type: "catalog", catalog: [] });
+ });
});
describe("subKey", () => {
- it("builds a global key when conversationId is undefined", () => {
- expect(subKey("surf-a")).toBe("surf-a::");
- });
+ it("builds a global key when conversationId is undefined", () => {
+ expect(subKey("surf-a")).toBe("surf-a::");
+ });
- it("builds a conversation-scoped key when conversationId is provided", () => {
- expect(subKey("surf-a", "conv-42")).toBe("surf-a::conv-42");
- });
+ it("builds a conversation-scoped key when conversationId is provided", () => {
+ expect(subKey("surf-a", "conv-42")).toBe("surf-a::conv-42");
+ });
- it("global and conversation-scoped keys are distinct", () => {
- expect(subKey("surf-a")).not.toBe(subKey("surf-a", "conv-42"));
- });
+ it("global and conversation-scoped keys are distinct", () => {
+ expect(subKey("surf-a")).not.toBe(subKey("surf-a", "conv-42"));
+ });
});
diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts
index 7e9ba77..014db96 100644
--- a/packages/transport-ws/src/router.ts
+++ b/packages/transport-ws/src/router.ts
@@ -9,12 +9,13 @@
import type { SurfaceContext, SurfaceRegistry } from "@dispatch/surface-registry";
import type {
- ChatQueueMessage,
- ChatSendMessage,
- ChatSubscribeMessage,
- ChatUnsubscribeMessage,
- ReasoningEffort,
- WsClientMessage,
+ ChatQueueCancelMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
+ ChatSubscribeMessage,
+ ChatUnsubscribeMessage,
+ ReasoningEffort,
+ WsClientMessage,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
@@ -22,61 +23,67 @@ import type { SurfaceServerMessage } from "@dispatch/ui-contract";
/** The effect a surface client message should produce. */
export interface SurfaceRouteResult {
- readonly kind: "surface";
- /** Server messages to send back to this connection. */
- readonly replies: readonly SurfaceServerMessage[];
- /** Whether to add or remove the surface id from connSubs. */
- readonly subChange?: {
- readonly op: "add" | "remove";
- readonly surfaceId: string;
- readonly conversationId?: string;
- };
- /** If set, the shell must call `provider.invoke(actionId, payload, context)`. */
- readonly invoke?: {
- readonly surfaceId: string;
- readonly actionId: string;
- readonly payload?: unknown;
- readonly conversationId?: string;
- };
+ readonly kind: "surface";
+ /** Server messages to send back to this connection. */
+ readonly replies: readonly SurfaceServerMessage[];
+ /** Whether to add or remove the surface id from connSubs. */
+ readonly subChange?: {
+ readonly op: "add" | "remove";
+ readonly surfaceId: string;
+ readonly conversationId?: string;
+ };
+ /** If set, the shell must call `provider.invoke(actionId, payload, context)`. */
+ readonly invoke?: {
+ readonly surfaceId: string;
+ readonly actionId: string;
+ readonly payload?: unknown;
+ readonly conversationId?: string;
+ };
}
/** The effect a validated chat.send should produce. */
export interface ChatRouteResult {
- readonly kind: "chat";
- readonly conversationId: string | undefined;
- readonly message: string;
- readonly model: string | undefined;
- readonly cwd: string | undefined;
- readonly reasoningEffort?: ReasoningEffort;
- readonly workspaceId?: string;
- /**
- * The computer (SSH config alias) to run this turn's tools on — forwarded
- * verbatim to the orchestrator's `startTurn` (which resolves it via
- * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn
- * override, unvalidated here (validation happens at SSH connect time).
- * Absent when the client omits it (the orchestrator then inherits the
- * conversation → workspace → local chain).
- */
- readonly computerId?: string;
+ readonly kind: "chat";
+ readonly conversationId: string | undefined;
+ readonly message: string;
+ readonly model: string | undefined;
+ readonly cwd: string | undefined;
+ readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
+ /**
+ * The computer (SSH config alias) to run this turn's tools on — forwarded
+ * verbatim to the orchestrator's `startTurn` (which resolves it via
+ * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn
+ * override, unvalidated here (validation happens at SSH connect time).
+ * Absent when the client omits it (the orchestrator then inherits the
+ * conversation → workspace → local chain).
+ */
+ readonly computerId?: string;
+ /**
+ * Images attached to this turn (data URLs or http URLs), forwarded verbatim to
+ * the orchestrator. Absent when the client omits it. Each entry must have a
+ * non-empty string `url`; `mimeType` is optional.
+ */
+ readonly images?: readonly { readonly url: string; readonly mimeType?: string }[];
}
/** A malformed chat.send that should yield a chat.error reply. */
export interface ChatRouteError {
- readonly kind: "chat-error";
- readonly conversationId: string | undefined;
- readonly errorMessage: string;
+ readonly kind: "chat-error";
+ readonly conversationId: string | undefined;
+ readonly errorMessage: string;
}
/** The effect a chat.subscribe should produce. */
export interface ChatSubscribeRouteResult {
- readonly kind: "chat-subscribe";
- readonly conversationId: string;
+ readonly kind: "chat-subscribe";
+ readonly conversationId: string;
}
/** The effect a chat.unsubscribe should produce. */
export interface ChatUnsubscribeRouteResult {
- readonly kind: "chat-unsubscribe";
- readonly conversationId: string;
+ readonly kind: "chat-unsubscribe";
+ readonly conversationId: string;
}
/**
@@ -87,20 +94,35 @@ export interface ChatUnsubscribeRouteResult {
* (startedTurn:true — the shell auto-subscribes the sender, same as chat.send).
*/
export interface ChatQueueRouteResult {
- readonly kind: "chat-queue";
- readonly conversationId: string;
- readonly text: string;
- readonly workspaceId?: string;
+ readonly kind: "chat-queue";
+ readonly conversationId: string;
+ readonly text: string;
+ readonly workspaceId?: string;
+}
+
+/**
+ * The effect a validated chat.queue.cancel should produce. The shell calls
+ * `orchestrator.cancelQueuedMessage({ conversationId, messageId })` and emits
+ * NOTHING back (fire-and-forget): success is confirmed by the message-queue
+ * SURFACE updating (the cancelled message leaves the snapshot). Cancelling a
+ * message that is no longer queued is a silent no-op (no surface update, no
+ * error). Mirrors `ChatQueueRouteResult`'s fire-and-forget style.
+ */
+export interface ChatQueueCancelRouteResult {
+ readonly kind: "chat-queue-cancel";
+ readonly conversationId: string;
+ readonly messageId: string;
}
/** The effect any client WS message should produce. */
export type RouteResult =
- | SurfaceRouteResult
- | ChatRouteResult
- | ChatRouteError
- | ChatSubscribeRouteResult
- | ChatUnsubscribeRouteResult
- | ChatQueueRouteResult;
+ | SurfaceRouteResult
+ | ChatRouteResult
+ | ChatRouteError
+ | ChatSubscribeRouteResult
+ | ChatUnsubscribeRouteResult
+ | ChatQueueRouteResult
+ | ChatQueueCancelRouteResult;
// ── Helpers ─────────────────────────────────────────────────────────────────
@@ -109,12 +131,12 @@ export type RouteResult =
* The shell uses this same function so both layers agree on key format.
*/
export function subKey(surfaceId: string, conversationId?: string): string {
- return conversationId !== undefined ? `${surfaceId}::${conversationId}` : `${surfaceId}::`;
+ return conversationId !== undefined ? `${surfaceId}::${conversationId}` : `${surfaceId}::`;
}
/** Build the catalog `SurfaceServerMessage` from the registry. */
export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage {
- return { type: "catalog", catalog: registry.getCatalog() };
+ return { type: "catalog", catalog: registry.getCatalog() };
}
// ── Router ──────────────────────────────────────────────────────────────────
@@ -127,71 +149,104 @@ export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage
* @param msg The parsed client message (surface or chat).
*/
export function routeClientMessage(
- registry: SurfaceRegistry,
- connSubs: ReadonlySet<string>,
- msg: WsClientMessage,
+ registry: SurfaceRegistry,
+ connSubs: ReadonlySet<string>,
+ msg: WsClientMessage,
): RouteResult {
- switch (msg.type) {
- case "subscribe":
- return handleSubscribe(registry, connSubs, msg.surfaceId, msg.conversationId);
- case "unsubscribe":
- return handleUnsubscribe(msg.surfaceId, msg.conversationId);
- case "invoke":
- return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload, msg.conversationId);
- case "chat.send":
- return handleChatSend(msg);
- case "chat.subscribe":
- return handleChatSubscribe(msg);
- case "chat.unsubscribe":
- return handleChatUnsubscribe(msg);
- case "chat.queue":
- return handleChatQueue(msg);
- }
+ switch (msg.type) {
+ case "subscribe":
+ return handleSubscribe(registry, connSubs, msg.surfaceId, msg.conversationId);
+ case "unsubscribe":
+ return handleUnsubscribe(msg.surfaceId, msg.conversationId);
+ case "invoke":
+ return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload, msg.conversationId);
+ case "chat.send":
+ return handleChatSend(msg);
+ case "chat.subscribe":
+ return handleChatSubscribe(msg);
+ case "chat.unsubscribe":
+ return handleChatUnsubscribe(msg);
+ case "chat.queue":
+ return handleChatQueue(msg);
+ case "chat.queue.cancel":
+ return handleChatQueueCancel(msg);
+ }
}
// ── Chat validation ─────────────────────────────────────────────────────────
const VALID_REASONING_EFFORT: ReadonlySet<string> = new Set<ReasoningEffort>([
- "low",
- "medium",
- "high",
- "xhigh",
- "max",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
]);
function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError {
- if (typeof msg.message !== "string" || msg.message.length === 0) {
- return {
- kind: "chat-error",
- conversationId: msg.conversationId,
- errorMessage: "chat.send requires a non-empty string `message`",
- };
- }
- if (msg.reasoningEffort !== undefined && !VALID_REASONING_EFFORT.has(msg.reasoningEffort)) {
- return {
- kind: "chat-error",
- conversationId: msg.conversationId,
- errorMessage: `chat.send: invalid reasoningEffort "${msg.reasoningEffort}" — must be one of: low, medium, high, xhigh, max`,
- };
- }
- return {
- kind: "chat",
- conversationId: msg.conversationId,
- message: msg.message,
- model: msg.model,
- cwd: msg.cwd,
- ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}),
- ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
- ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}),
- };
+ if (typeof msg.message !== "string" || msg.message.length === 0) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.send requires a non-empty string `message`",
+ };
+ }
+ if (msg.reasoningEffort !== undefined && !VALID_REASONING_EFFORT.has(msg.reasoningEffort)) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: `chat.send: invalid reasoningEffort "${msg.reasoningEffort}" — must be one of: low, medium, high, xhigh, max`,
+ };
+ }
+ // Validate images (if present): each must be an object with a non-empty url.
+ let images: readonly { url: string; mimeType?: string }[] | undefined;
+ if (msg.images !== undefined) {
+ if (!Array.isArray(msg.images)) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.send: 'images' must be an array",
+ };
+ }
+ const parsed: { url: string; mimeType?: string }[] = [];
+ for (const entry of msg.images) {
+ if (
+ entry === null ||
+ typeof entry !== "object" ||
+ typeof entry.url !== "string" ||
+ entry.url.length === 0
+ ) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.send: each image must have a non-empty string 'url'",
+ };
+ }
+ const p: { url: string; mimeType?: string } = { url: entry.url };
+ if (entry.mimeType !== undefined) p.mimeType = entry.mimeType;
+ parsed.push(p);
+ }
+ if (parsed.length > 0) images = parsed;
+ }
+ return {
+ kind: "chat",
+ conversationId: msg.conversationId,
+ message: msg.message,
+ model: msg.model,
+ cwd: msg.cwd,
+ ...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}),
+ ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
+ ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}),
+ ...(images !== undefined ? { images } : {}),
+ };
}
function handleChatSubscribe(msg: ChatSubscribeMessage): ChatSubscribeRouteResult {
- return { kind: "chat-subscribe", conversationId: msg.conversationId };
+ return { kind: "chat-subscribe", conversationId: msg.conversationId };
}
function handleChatUnsubscribe(msg: ChatUnsubscribeMessage): ChatUnsubscribeRouteResult {
- return { kind: "chat-unsubscribe", conversationId: msg.conversationId };
+ return { kind: "chat-unsubscribe", conversationId: msg.conversationId };
}
/**
@@ -201,105 +256,135 @@ function handleChatUnsubscribe(msg: ChatUnsubscribeMessage): ChatUnsubscribeRout
* called). Valid → `chat-queue` (the shell calls `orchestrator.enqueue`).
*/
function handleChatQueue(msg: ChatQueueMessage): ChatQueueRouteResult | ChatRouteError {
- if (typeof msg.text !== "string" || msg.text.trim().length === 0) {
- return {
- kind: "chat-error",
- conversationId: msg.conversationId,
- errorMessage: "chat.queue requires a non-empty string `text`",
- };
- }
- return {
- kind: "chat-queue",
- conversationId: msg.conversationId,
- text: msg.text,
- ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
- };
+ if (typeof msg.text !== "string" || msg.text.trim().length === 0) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.queue requires a non-empty string `text`",
+ };
+ }
+ return {
+ kind: "chat-queue",
+ conversationId: msg.conversationId,
+ text: msg.text,
+ ...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
+ };
+}
+
+/**
+ * Validate a chat.queue.cancel: both `conversationId` and `messageId` must be
+ * non-empty strings. Invalid → `chat-error` (the shell replies with `chat.error`,
+ * same style as a malformed `chat.queue`; the orchestrator is never called).
+ * Valid → `chat-queue-cancel` (the shell calls `orchestrator.cancelQueuedMessage`).
+ */
+function handleChatQueueCancel(
+ msg: ChatQueueCancelMessage,
+): ChatQueueCancelRouteResult | ChatRouteError {
+ if (typeof msg.conversationId !== "string" || msg.conversationId.length === 0) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.queue.cancel requires a non-empty string `conversationId`",
+ };
+ }
+ if (typeof msg.messageId !== "string" || msg.messageId.length === 0) {
+ return {
+ kind: "chat-error",
+ conversationId: msg.conversationId,
+ errorMessage: "chat.queue.cancel requires a non-empty string `messageId`",
+ };
+ }
+ return {
+ kind: "chat-queue-cancel",
+ conversationId: msg.conversationId,
+ messageId: msg.messageId,
+ };
}
// ── Per-message handlers ────────────────────────────────────────────────────
function handleSubscribe(
- registry: SurfaceRegistry,
- connSubs: ReadonlySet<string>,
- surfaceId: string,
- conversationId?: string,
+ registry: SurfaceRegistry,
+ connSubs: ReadonlySet<string>,
+ surfaceId: string,
+ conversationId?: string,
): SurfaceRouteResult {
- const provider = registry.getSurface(surfaceId);
- if (!provider) {
- return {
- kind: "surface",
- replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }],
- };
- }
+ const provider = registry.getSurface(surfaceId);
+ if (!provider) {
+ return {
+ kind: "surface",
+ replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }],
+ };
+ }
- const context: SurfaceContext | undefined =
- conversationId !== undefined ? { conversationId } : undefined;
- const spec = provider.getSpec(context);
+ const context: SurfaceContext | undefined =
+ conversationId !== undefined ? { conversationId } : undefined;
+ const spec = provider.getSpec(context);
- // getSpec may be sync or async — the pure core treats it as a value the
- // shell will resolve. We return the spec directly (it's a SurfaceSpec).
- // If it's a Promise the shell awaits it; if it's sync it's already the value.
- // For the pure core we just pass it through — the shell handles the resolution.
- const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec;
+ // getSpec may be sync or async — the pure core treats it as a value the
+ // shell will resolve. We return the spec directly (it's a SurfaceSpec).
+ // If it's a Promise the shell awaits it; if it's sync it's already the value.
+ // For the pure core we just pass it through — the shell handles the resolution.
+ const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec;
- const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [
- {
- type: "surface",
- spec: specValue,
- ...(conversationId !== undefined ? { conversationId } : {}),
- },
- ];
+ const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [
+ {
+ type: "surface",
+ spec: specValue,
+ ...(conversationId !== undefined ? { conversationId } : {}),
+ },
+ ];
- // Idempotent: only emit subChange if not already subscribed.
- const key = subKey(surfaceId, conversationId);
- if (!connSubs.has(key)) {
- return {
- kind: "surface",
- replies,
- subChange: {
- op: "add",
- surfaceId,
- ...(conversationId !== undefined ? { conversationId } : {}),
- },
- };
- }
- return { kind: "surface", replies };
+ // Idempotent: only emit subChange if not already subscribed.
+ const key = subKey(surfaceId, conversationId);
+ if (!connSubs.has(key)) {
+ return {
+ kind: "surface",
+ replies,
+ subChange: {
+ op: "add",
+ surfaceId,
+ ...(conversationId !== undefined ? { conversationId } : {}),
+ },
+ };
+ }
+ return { kind: "surface", replies };
}
function handleUnsubscribe(surfaceId: string, conversationId?: string): SurfaceRouteResult {
- return {
- kind: "surface",
- replies: [],
- subChange: {
- op: "remove",
- surfaceId,
- ...(conversationId !== undefined ? { conversationId } : {}),
- },
- };
+ return {
+ kind: "surface",
+ replies: [],
+ subChange: {
+ op: "remove",
+ surfaceId,
+ ...(conversationId !== undefined ? { conversationId } : {}),
+ },
+ };
}
function handleInvoke(
- registry: SurfaceRegistry,
- surfaceId: string,
- actionId: string,
- payload?: unknown,
- conversationId?: string,
+ registry: SurfaceRegistry,
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
): SurfaceRouteResult {
- const provider = registry.getSurface(surfaceId);
- if (!provider) {
- return {
- kind: "surface",
- replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }],
- };
- }
- return {
- kind: "surface",
- replies: [],
- invoke: {
- surfaceId,
- actionId,
- payload,
- ...(conversationId !== undefined ? { conversationId } : {}),
- },
- };
+ const provider = registry.getSurface(surfaceId);
+ if (!provider) {
+ return {
+ kind: "surface",
+ replies: [{ type: "error", surfaceId, message: `Unknown surface: ${surfaceId}` }],
+ };
+ }
+ return {
+ kind: "surface",
+ replies: [],
+ invoke: {
+ surfaceId,
+ actionId,
+ payload,
+ ...(conversationId !== undefined ? { conversationId } : {}),
+ },
+ };
}
diff --git a/packages/transport-ws/src/server.bun.test.ts b/packages/transport-ws/src/server.bun.test.ts
index e24aa6b..6b64f37 100644
--- a/packages/transport-ws/src/server.bun.test.ts
+++ b/packages/transport-ws/src/server.bun.test.ts
@@ -9,1202 +9,1202 @@ import { catalogMessage, routeClientMessage, subKey } from "./router.js";
// ── Fake Logger (captures records for assertions) ───────────────────────────
interface LogEntry {
- readonly level: "debug" | "info" | "warn" | "error";
- readonly msg: string;
- readonly attrs?: Attributes | ErrorAttributes;
+ readonly level: "debug" | "info" | "warn" | "error";
+ readonly msg: string;
+ readonly attrs?: Attributes | ErrorAttributes;
}
function fakeLogger(): Logger & { readonly entries: readonly LogEntry[] } {
- const entries: LogEntry[] = [];
- return {
- entries,
- debug(msg, attrs) {
- entries.push({ level: "debug", msg, ...(attrs !== undefined ? { attrs } : {}) });
- },
- info(msg, attrs) {
- entries.push({ level: "info", msg, ...(attrs !== undefined ? { attrs } : {}) });
- },
- warn(msg, attrs) {
- entries.push({ level: "warn", msg, ...(attrs !== undefined ? { attrs } : {}) });
- },
- error(msg, attrs) {
- entries.push({ level: "error", msg, ...(attrs !== undefined ? { attrs } : {}) });
- },
- child() {
- return fakeLogger();
- },
- span() {
- return {
- id: "fake-span",
- log: fakeLogger(),
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
- };
+ const entries: LogEntry[] = [];
+ return {
+ entries,
+ debug(msg, attrs) {
+ entries.push({ level: "debug", msg, ...(attrs !== undefined ? { attrs } : {}) });
+ },
+ info(msg, attrs) {
+ entries.push({ level: "info", msg, ...(attrs !== undefined ? { attrs } : {}) });
+ },
+ warn(msg, attrs) {
+ entries.push({ level: "warn", msg, ...(attrs !== undefined ? { attrs } : {}) });
+ },
+ error(msg, attrs) {
+ entries.push({ level: "error", msg, ...(attrs !== undefined ? { attrs } : {}) });
+ },
+ child() {
+ return fakeLogger();
+ },
+ span() {
+ return {
+ id: "fake-span",
+ log: fakeLogger(),
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
+ };
}
// ── Fake registry (same pattern as router.test.ts) ──────────────────────────
function fakeProvider(id: string, title?: string): SurfaceProvider {
- const catalogEntry: SurfaceCatalogEntry = {
- id,
- region: "default",
- title: title ?? `Surface ${id}`,
- };
- return {
- catalogEntry,
- getSpec(_context?: SurfaceContext): SurfaceSpec {
- return {
- id,
- region: "default",
- title: catalogEntry.title,
- fields: [],
- };
- },
- invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext) {},
- };
+ const catalogEntry: SurfaceCatalogEntry = {
+ id,
+ region: "default",
+ title: title ?? `Surface ${id}`,
+ };
+ return {
+ catalogEntry,
+ getSpec(_context?: SurfaceContext): SurfaceSpec {
+ return {
+ id,
+ region: "default",
+ title: catalogEntry.title,
+ fields: [],
+ };
+ },
+ invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext) {},
+ };
}
function fakeRegistry(providers: readonly SurfaceProvider[]): SurfaceRegistry {
- const map = new Map(providers.map((p) => [p.catalogEntry.id, p]));
- return {
- register(_provider: SurfaceProvider) {
- return () => {};
- },
- getCatalog() {
- return [...map.values()].map((p) => p.catalogEntry);
- },
- getSurface(id: string) {
- return map.get(id);
- },
- };
+ const map = new Map(providers.map((p) => [p.catalogEntry.id, p]));
+ return {
+ register(_provider: SurfaceProvider) {
+ return () => {};
+ },
+ getCatalog() {
+ return [...map.values()].map((p) => p.catalogEntry);
+ },
+ getSurface(id: string) {
+ return map.get(id);
+ },
+ };
}
// ── Fake SessionOrchestrator (DI at the transport edge, not a vi.mock) ──────
interface FakeOrchestratorOpts {
- /** Pre-registered listeners per conversation (for test assertions). */
- readonly listeners?: Map<string, Set<TurnEventListener>>;
- /** Events to replay on subscribe (simulates buffered in-flight events). */
- readonly bufferedEvents?: Map<string, readonly AgentEvent[]>;
- /** Custom startTurn impl. */
- readonly startTurn?: SessionOrchestrator["startTurn"];
- /** If true, startTurn always returns already-active. */
- readonly alreadyActive?: boolean;
- /** Custom enqueue impl. */
- readonly enqueue?: SessionOrchestrator["enqueue"];
- /** If true, enqueue reports the conversation was active (startedTurn:false). */
- readonly queueActive?: boolean;
+ /** Pre-registered listeners per conversation (for test assertions). */
+ readonly listeners?: Map<string, Set<TurnEventListener>>;
+ /** Events to replay on subscribe (simulates buffered in-flight events). */
+ readonly bufferedEvents?: Map<string, readonly AgentEvent[]>;
+ /** Custom startTurn impl. */
+ readonly startTurn?: SessionOrchestrator["startTurn"];
+ /** If true, startTurn always returns already-active. */
+ readonly alreadyActive?: boolean;
+ /** Custom enqueue impl. */
+ readonly enqueue?: SessionOrchestrator["enqueue"];
+ /** If true, enqueue reports the conversation was active (startedTurn:false). */
+ readonly queueActive?: boolean;
}
function fakeOrchestrator(opts?: FakeOrchestratorOpts): SessionOrchestrator & {
- readonly listeners: Map<string, Set<TurnEventListener>>;
- readonly startCalls: readonly { conversationId: string; text: string; workspaceId?: string }[];
- readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[];
- readonly aborted: boolean;
+ readonly listeners: Map<string, Set<TurnEventListener>>;
+ 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>>();
- const bufferedEvents = opts?.bufferedEvents ?? new Map<string, readonly AgentEvent[]>();
- const startCalls: { conversationId: string; text: string }[] = [];
- const enqueueCalls: { conversationId: string; text: string }[] = [];
- const aborted = false;
-
- return {
- listeners,
- get startCalls() {
- return startCalls;
- },
- get enqueueCalls() {
- return enqueueCalls;
- },
- get aborted() {
- return aborted;
- },
- startTurn(input) {
- startCalls.push({
- conversationId: input.conversationId,
- text: input.text,
- ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
- });
- if (opts?.startTurn) {
- return opts.startTurn(input);
- }
- if (opts?.alreadyActive) {
- return { started: false, reason: "already-active" };
- }
- return { started: true, turnId: "fake-turn-id" };
- },
- enqueue(input) {
- enqueueCalls.push({
- conversationId: input.conversationId,
- text: input.text,
- ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
- });
- if (opts?.enqueue) {
- return opts.enqueue(input);
- }
- if (opts?.queueActive) {
- return { startedTurn: false, queue: [] };
- }
- return { startedTurn: true, queue: [] };
- },
- subscribe(conversationId, listener) {
- let set = listeners.get(conversationId);
- if (!set) {
- set = new Set();
- listeners.set(conversationId, set);
- }
- // Replay buffered events (late-join).
- const buffered = bufferedEvents.get(conversationId);
- if (buffered) {
- for (const event of buffered) {
- listener(event);
- }
- }
- set.add(listener);
- return () => {
- set.delete(listener);
- };
- },
- isActive(conversationId) {
- return listeners.has(conversationId);
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage(_input) {
- // Not used by the new transport-ws, but kept for interface compat.
- },
- };
+ const listeners = opts?.listeners ?? new Map<string, Set<TurnEventListener>>();
+ const bufferedEvents = opts?.bufferedEvents ?? new Map<string, readonly AgentEvent[]>();
+ const startCalls: { conversationId: string; text: string }[] = [];
+ const enqueueCalls: { conversationId: string; text: string }[] = [];
+ const aborted = false;
+
+ return {
+ listeners,
+ get startCalls() {
+ return startCalls;
+ },
+ get enqueueCalls() {
+ return enqueueCalls;
+ },
+ get aborted() {
+ return aborted;
+ },
+ startTurn(input) {
+ startCalls.push({
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ });
+ if (opts?.startTurn) {
+ return opts.startTurn(input);
+ }
+ if (opts?.alreadyActive) {
+ return { started: false, reason: "already-active" };
+ }
+ return { started: true, turnId: "fake-turn-id" };
+ },
+ enqueue(input) {
+ enqueueCalls.push({
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ });
+ if (opts?.enqueue) {
+ return opts.enqueue(input);
+ }
+ if (opts?.queueActive) {
+ return { startedTurn: false, queue: [] };
+ }
+ return { startedTurn: true, queue: [] };
+ },
+ subscribe(conversationId, listener) {
+ let set = listeners.get(conversationId);
+ if (!set) {
+ set = new Set();
+ listeners.set(conversationId, set);
+ }
+ // Replay buffered events (late-join).
+ const buffered = bufferedEvents.get(conversationId);
+ if (buffered) {
+ for (const event of buffered) {
+ listener(event);
+ }
+ }
+ set.add(listener);
+ return () => {
+ set.delete(listener);
+ };
+ },
+ isActive(conversationId) {
+ return listeners.has(conversationId);
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage(_input) {
+ // Not used by the new transport-ws, but kept for interface compat.
+ },
+ };
}
/** 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; workspaceId?: string }[];
- broadcast(conversationId: string, event: AgentEvent): void;
+ readonly listeners: Map<string, Set<TurnEventListener>>;
+ readonly enqueueCalls: readonly { conversationId: string; text: string; workspaceId?: string }[];
+ broadcast(conversationId: string, event: AgentEvent): void;
} {
- const listeners = new Map<string, Set<TurnEventListener>>();
- const enqueueCalls: { conversationId: string; text: string }[] = [];
-
- return {
- listeners,
- enqueueCalls,
- broadcast(conversationId, event) {
- const set = listeners.get(conversationId);
- if (set) {
- for (const listener of set) {
- listener(event);
- }
- }
- },
- startTurn(_input) {
- return { started: true, turnId: "fake-turn-id" };
- },
- enqueue(input) {
- enqueueCalls.push({
- conversationId: input.conversationId,
- text: input.text,
- ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
- });
- return { startedTurn: true, queue: [] };
- },
- subscribe(conversationId, listener) {
- let set = listeners.get(conversationId);
- if (!set) {
- set = new Set();
- listeners.set(conversationId, set);
- }
- set.add(listener);
- return () => {
- set.delete(listener);
- };
- },
- isActive(conversationId) {
- return listeners.has(conversationId);
- },
- closeConversation() {
- return { abortedTurn: false };
- },
- stopTurn() {
- return { abortedTurn: false };
- },
- async handleMessage(_input) {},
- };
+ const listeners = new Map<string, Set<TurnEventListener>>();
+ const enqueueCalls: { conversationId: string; text: string }[] = [];
+
+ return {
+ listeners,
+ enqueueCalls,
+ broadcast(conversationId, event) {
+ const set = listeners.get(conversationId);
+ if (set) {
+ for (const listener of set) {
+ listener(event);
+ }
+ }
+ },
+ startTurn(_input) {
+ return { started: true, turnId: "fake-turn-id" };
+ },
+ enqueue(input) {
+ enqueueCalls.push({
+ conversationId: input.conversationId,
+ text: input.text,
+ ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
+ });
+ return { startedTurn: true, queue: [] };
+ },
+ subscribe(conversationId, listener) {
+ let set = listeners.get(conversationId);
+ if (!set) {
+ set = new Set();
+ listeners.set(conversationId, set);
+ }
+ set.add(listener);
+ return () => {
+ set.delete(listener);
+ };
+ },
+ isActive(conversationId) {
+ return listeners.has(conversationId);
+ },
+ closeConversation() {
+ return { abortedTurn: false };
+ },
+ stopTurn() {
+ return { abortedTurn: false };
+ },
+ async handleMessage(_input) {},
+ };
}
// ── Per-connection state (mirrors extension.ts) ─────────────────────────────
interface ConnectionState {
- readonly subs: Set<string>;
- readonly providerDisposers: Map<string, () => void>;
- readonly chatSubscriptions: Map<string, () => void>;
+ readonly subs: Set<string>;
+ readonly providerDisposers: Map<string, () => void>;
+ readonly chatSubscriptions: Map<string, () => void>;
}
// ── Server helper ───────────────────────────────────────────────────────────
function startServer(
- registry: SurfaceRegistry,
- orchestrator: SessionOrchestrator,
- port = 0,
- logger?: Logger,
+ registry: SurfaceRegistry,
+ orchestrator: SessionOrchestrator,
+ port = 0,
+ logger?: Logger,
) {
- const log = logger ?? fakeLogger();
- const connections = new Set<Bun.ServerWebSocket<ConnectionState>>();
-
- /** Broadcast a message to every connected client (mirrors extension.ts). */
- function broadcast(msg: WsServerMessage): void {
- for (const ws of connections) {
- try {
- ws.send(JSON.stringify(msg));
- } catch {
- // Connection may have been dropped; swallow.
- }
- }
- }
-
- const server = Bun.serve<ConnectionState>({
- port,
- fetch(req, srv) {
- const initial: ConnectionState = {
- subs: new Set(),
- providerDisposers: new Map(),
- chatSubscriptions: new Map(),
- };
- if (srv.upgrade(req, { data: initial })) return;
- return new Response("expected websocket", { status: 426 });
- },
- websocket: {
- open(ws) {
- connections.add(ws);
- log.debug("transport-ws: connection open");
- ws.send(JSON.stringify(catalogMessage(registry)));
- },
-
- message(ws, raw) {
- const state = ws.data;
- if (!state) return;
-
- let parsed: SurfaceClientMessage;
- try {
- parsed = JSON.parse(String(raw)) as SurfaceClientMessage;
- } catch {
- ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
- return;
- }
-
- const result = routeClientMessage(registry, state.subs, parsed);
-
- switch (result.kind) {
- case "surface": {
- for (const reply of result.replies) {
- if (reply.type === "error") {
- log.warn?.("transport-ws: surface-op error", {
- ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}),
- reason: reply.message,
- });
- }
- }
-
- if (result.subChange) {
- const key = subKey(result.subChange.surfaceId, result.subChange.conversationId);
- if (result.subChange.op === "add") {
- state.subs.add(key);
- } else {
- state.subs.delete(key);
- }
- }
-
- for (const reply of result.replies) {
- ws.send(JSON.stringify(reply));
- }
- break;
- }
-
- case "chat": {
- const resolvedId = result.conversationId ?? crypto.randomUUID();
- // Auto-subscribe the sender.
- if (!state.chatSubscriptions.has(resolvedId)) {
- const unsubscribe = orchestrator.subscribe(resolvedId, (event) => {
- ws.send(JSON.stringify({ type: "chat.delta", event }));
- });
- state.chatSubscriptions.set(resolvedId, unsubscribe);
- }
- // Start the turn detached.
- const startResult = orchestrator.startTurn({
- conversationId: resolvedId,
- 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(
- JSON.stringify({
- type: "chat.error",
- conversationId: resolvedId,
- message: "a turn is already generating for this conversation",
- }),
- );
- } else {
- log.info?.("transport-ws: chat.send accepted", {
- conversationId: resolvedId,
- model: result.model ?? null,
- });
- }
- break;
- }
-
- case "chat-subscribe": {
- if (!state.chatSubscriptions.has(result.conversationId)) {
- const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => {
- ws.send(JSON.stringify({ type: "chat.delta", event }));
- });
- state.chatSubscriptions.set(result.conversationId, unsubscribe);
- }
- break;
- }
-
- case "chat-unsubscribe": {
- const dispose = state.chatSubscriptions.get(result.conversationId);
- if (dispose) {
- dispose();
- state.chatSubscriptions.delete(result.conversationId);
- }
- break;
- }
-
- case "chat-queue": {
- // Mirror extension.ts: fire-and-forget. On startedTurn:true
- // auto-subscribe the sender (deltas stream); on false emit
- // nothing back.
- 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)) {
- const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => {
- ws.send(JSON.stringify({ type: "chat.delta", event }));
- });
- state.chatSubscriptions.set(result.conversationId, unsubscribe);
- }
- }
- log.info?.("transport-ws: chat.queue accepted", {
- conversationId: result.conversationId,
- startedTurn: enqueueResult.startedTurn,
- });
- break;
- }
-
- case "chat-error": {
- log.warn?.("transport-ws: malformed chat.send", {
- reason: result.errorMessage,
- ...(result.conversationId !== undefined
- ? { conversationId: result.conversationId }
- : {}),
- });
- ws.send(
- JSON.stringify({
- type: "chat.error",
- conversationId: result.conversationId,
- message: result.errorMessage,
- }),
- );
- break;
- }
- }
- },
-
- close(ws) {
- connections.delete(ws);
- const state = ws.data;
- if (state) {
- for (const dispose of state.chatSubscriptions.values()) {
- dispose();
- }
- state.chatSubscriptions.clear();
- for (const dispose of state.providerDisposers.values()) {
- dispose();
- }
- }
- log.debug("transport-ws: connection close");
- },
- },
- });
-
- /**
- * Simulate the `conversationOpened` hook firing — mirrors the
- * `host.on(conversationOpened, ...)` subscription in extension.ts, which
- * broadcasts a `conversation.open` WS message (carrying the conversation's
- * persisted `workspaceId`) to every connected client.
- */
- return Object.assign(server, {
- 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,
- });
- },
- });
+ const log = logger ?? fakeLogger();
+ const connections = new Set<Bun.ServerWebSocket<ConnectionState>>();
+
+ /** Broadcast a message to every connected client (mirrors extension.ts). */
+ function broadcast(msg: WsServerMessage): void {
+ for (const ws of connections) {
+ try {
+ ws.send(JSON.stringify(msg));
+ } catch {
+ // Connection may have been dropped; swallow.
+ }
+ }
+ }
+
+ const server = Bun.serve<ConnectionState>({
+ port,
+ fetch(req, srv) {
+ const initial: ConnectionState = {
+ subs: new Set(),
+ providerDisposers: new Map(),
+ chatSubscriptions: new Map(),
+ };
+ if (srv.upgrade(req, { data: initial })) return;
+ return new Response("expected websocket", { status: 426 });
+ },
+ websocket: {
+ open(ws) {
+ connections.add(ws);
+ log.debug("transport-ws: connection open");
+ ws.send(JSON.stringify(catalogMessage(registry)));
+ },
+
+ message(ws, raw) {
+ const state = ws.data;
+ if (!state) return;
+
+ let parsed: SurfaceClientMessage;
+ try {
+ parsed = JSON.parse(String(raw)) as SurfaceClientMessage;
+ } catch {
+ ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
+ return;
+ }
+
+ const result = routeClientMessage(registry, state.subs, parsed);
+
+ switch (result.kind) {
+ case "surface": {
+ for (const reply of result.replies) {
+ if (reply.type === "error") {
+ log.warn?.("transport-ws: surface-op error", {
+ ...(reply.surfaceId !== undefined ? { surfaceId: reply.surfaceId } : {}),
+ reason: reply.message,
+ });
+ }
+ }
+
+ if (result.subChange) {
+ const key = subKey(result.subChange.surfaceId, result.subChange.conversationId);
+ if (result.subChange.op === "add") {
+ state.subs.add(key);
+ } else {
+ state.subs.delete(key);
+ }
+ }
+
+ for (const reply of result.replies) {
+ ws.send(JSON.stringify(reply));
+ }
+ break;
+ }
+
+ case "chat": {
+ const resolvedId = result.conversationId ?? crypto.randomUUID();
+ // Auto-subscribe the sender.
+ if (!state.chatSubscriptions.has(resolvedId)) {
+ const unsubscribe = orchestrator.subscribe(resolvedId, (event) => {
+ ws.send(JSON.stringify({ type: "chat.delta", event }));
+ });
+ state.chatSubscriptions.set(resolvedId, unsubscribe);
+ }
+ // Start the turn detached.
+ const startResult = orchestrator.startTurn({
+ conversationId: resolvedId,
+ 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(
+ JSON.stringify({
+ type: "chat.error",
+ conversationId: resolvedId,
+ message: "a turn is already generating for this conversation",
+ }),
+ );
+ } else {
+ log.info?.("transport-ws: chat.send accepted", {
+ conversationId: resolvedId,
+ model: result.model ?? null,
+ });
+ }
+ break;
+ }
+
+ case "chat-subscribe": {
+ if (!state.chatSubscriptions.has(result.conversationId)) {
+ const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => {
+ ws.send(JSON.stringify({ type: "chat.delta", event }));
+ });
+ state.chatSubscriptions.set(result.conversationId, unsubscribe);
+ }
+ break;
+ }
+
+ case "chat-unsubscribe": {
+ const dispose = state.chatSubscriptions.get(result.conversationId);
+ if (dispose) {
+ dispose();
+ state.chatSubscriptions.delete(result.conversationId);
+ }
+ break;
+ }
+
+ case "chat-queue": {
+ // Mirror extension.ts: fire-and-forget. On startedTurn:true
+ // auto-subscribe the sender (deltas stream); on false emit
+ // nothing back.
+ 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)) {
+ const unsubscribe = orchestrator.subscribe(result.conversationId, (event) => {
+ ws.send(JSON.stringify({ type: "chat.delta", event }));
+ });
+ state.chatSubscriptions.set(result.conversationId, unsubscribe);
+ }
+ }
+ log.info?.("transport-ws: chat.queue accepted", {
+ conversationId: result.conversationId,
+ startedTurn: enqueueResult.startedTurn,
+ });
+ break;
+ }
+
+ case "chat-error": {
+ log.warn?.("transport-ws: malformed chat.send", {
+ reason: result.errorMessage,
+ ...(result.conversationId !== undefined
+ ? { conversationId: result.conversationId }
+ : {}),
+ });
+ ws.send(
+ JSON.stringify({
+ type: "chat.error",
+ conversationId: result.conversationId,
+ message: result.errorMessage,
+ }),
+ );
+ break;
+ }
+ }
+ },
+
+ close(ws) {
+ connections.delete(ws);
+ const state = ws.data;
+ if (state) {
+ for (const dispose of state.chatSubscriptions.values()) {
+ dispose();
+ }
+ state.chatSubscriptions.clear();
+ for (const dispose of state.providerDisposers.values()) {
+ dispose();
+ }
+ }
+ log.debug("transport-ws: connection close");
+ },
+ },
+ });
+
+ /**
+ * Simulate the `conversationOpened` hook firing — mirrors the
+ * `host.on(conversationOpened, ...)` subscription in extension.ts, which
+ * broadcasts a `conversation.open` WS message (carrying the conversation's
+ * persisted `workspaceId`) to every connected client.
+ */
+ return Object.assign(server, {
+ 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,
+ });
+ },
+ });
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function waitForMessage(ws: WebSocket): Promise<WsServerMessage> {
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => reject(new Error("timed out waiting for message")), 5000);
- function handler(ev: MessageEvent) {
- clearTimeout(timeout);
- ws.removeEventListener("message", handler);
- resolve(JSON.parse(ev.data as string) as WsServerMessage);
- }
- ws.addEventListener("message", handler);
- });
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("timed out waiting for message")), 5000);
+ function handler(ev: MessageEvent) {
+ clearTimeout(timeout);
+ ws.removeEventListener("message", handler);
+ resolve(JSON.parse(ev.data as string) as WsServerMessage);
+ }
+ ws.addEventListener("message", handler);
+ });
}
function waitForMessages(ws: WebSocket, count: number): Promise<WsServerMessage[]> {
- return new Promise((resolve, reject) => {
- const msgs: WsServerMessage[] = [];
- const timeout = setTimeout(
- () => reject(new Error(`timed out waiting for ${count} messages (got ${msgs.length})`)),
- 5000,
- );
- function handler(ev: MessageEvent) {
- msgs.push(JSON.parse(ev.data as string) as WsServerMessage);
- if (msgs.length === count) {
- clearTimeout(timeout);
- ws.removeEventListener("message", handler);
- resolve(msgs);
- }
- }
- ws.addEventListener("message", handler);
- });
+ return new Promise((resolve, reject) => {
+ const msgs: WsServerMessage[] = [];
+ const timeout = setTimeout(
+ () => reject(new Error(`timed out waiting for ${count} messages (got ${msgs.length})`)),
+ 5000,
+ );
+ function handler(ev: MessageEvent) {
+ msgs.push(JSON.parse(ev.data as string) as WsServerMessage);
+ if (msgs.length === count) {
+ clearTimeout(timeout);
+ ws.removeEventListener("message", handler);
+ resolve(msgs);
+ }
+ }
+ ws.addEventListener("message", handler);
+ });
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("Bun.serve WebSocket server", () => {
- let server: ReturnType<typeof Bun.serve>;
- let port: number;
- const defaultOrchestrator = fakeOrchestrator();
-
- beforeEach(() => {
- const provider = fakeProvider("demo", "Demo Surface");
- const registry = fakeRegistry([provider]);
- server = startServer(registry, defaultOrchestrator);
- port = server.port as number;
- });
-
- afterEach(() => {
- server.stop();
- });
-
- test("performs WebSocket upgrade (returns 101)", async () => {
- const ws = new WebSocket(`ws://localhost:${port}`);
- const msg = await waitForMessage(ws);
- expect(msg.type).toBe("catalog");
- ws.close();
- });
-
- test("sends catalog on open", async () => {
- const ws = new WebSocket(`ws://localhost:${port}`);
- const msg = await waitForMessage(ws);
- expect(msg).toEqual({
- type: "catalog",
- catalog: [{ id: "demo", region: "default", title: "Demo Surface" }],
- });
- ws.close();
- });
-
- test("subscribe returns surface spec", async () => {
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- ws.send(JSON.stringify({ type: "subscribe", surfaceId: "demo" }));
- const msg = await waitForMessage(ws);
-
- expect(msg.type).toBe("surface");
- if (msg.type === "surface") {
- expect(msg.spec.id).toBe("demo");
- expect(msg.spec.title).toBe("Demo Surface");
- }
- ws.close();
- });
-
- test("subscribe to unknown surface returns error", async () => {
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nope" }));
- const msg = await waitForMessage(ws);
-
- expect(msg).toEqual({
- type: "error",
- surfaceId: "nope",
- message: "Unknown surface: nope",
- });
- ws.close();
- });
-
- test("non-WebSocket request returns 426", async () => {
- const res = await fetch(`http://localhost:${port}/`);
- expect(res.status).toBe(426);
- expect(await res.text()).toBe("expected websocket");
- });
+ let server: ReturnType<typeof Bun.serve>;
+ let port: number;
+ const defaultOrchestrator = fakeOrchestrator();
+
+ beforeEach(() => {
+ const provider = fakeProvider("demo", "Demo Surface");
+ const registry = fakeRegistry([provider]);
+ server = startServer(registry, defaultOrchestrator);
+ port = server.port as number;
+ });
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("performs WebSocket upgrade (returns 101)", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ const msg = await waitForMessage(ws);
+ expect(msg.type).toBe("catalog");
+ ws.close();
+ });
+
+ test("sends catalog on open", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ const msg = await waitForMessage(ws);
+ expect(msg).toEqual({
+ type: "catalog",
+ catalog: [{ id: "demo", region: "default", title: "Demo Surface" }],
+ });
+ ws.close();
+ });
+
+ test("subscribe returns surface spec", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(JSON.stringify({ type: "subscribe", surfaceId: "demo" }));
+ const msg = await waitForMessage(ws);
+
+ expect(msg.type).toBe("surface");
+ if (msg.type === "surface") {
+ expect(msg.spec.id).toBe("demo");
+ expect(msg.spec.title).toBe("Demo Surface");
+ }
+ ws.close();
+ });
+
+ test("subscribe to unknown surface returns error", async () => {
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nope" }));
+ const msg = await waitForMessage(ws);
+
+ expect(msg).toEqual({
+ type: "error",
+ surfaceId: "nope",
+ message: "Unknown surface: nope",
+ });
+ ws.close();
+ });
+
+ test("non-WebSocket request returns 426", async () => {
+ const res = await fetch(`http://localhost:${port}/`);
+ expect(res.status).toBe(426);
+ expect(await res.text()).toBe("expected websocket");
+ });
});
describe("chat ops (new orchestrator API)", () => {
- let server: ReturnType<typeof Bun.serve>;
- let port: number;
-
- afterEach(() => {
- server.stop();
- });
-
- test("chat.send auto-subscribes the sender and delivers deltas via orchestrator.subscribe broadcast", async () => {
- const orch = fakeOrchestratorWithBroadcast();
- 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: "hi" }));
-
- // Give the message handler time to run.
- await new Promise((r) => setTimeout(r, 50));
-
- // The sender should be auto-subscribed. Broadcast an event.
- const event = {
- type: "text-delta",
- conversationId: "c1",
- turnId: "t1",
- delta: "Hello",
- } as AgentEvent;
- orch.broadcast("c1", event);
-
- const msg = await waitForMessage(ws);
- expect(msg.type).toBe("chat.delta");
- if (msg.type === "chat.delta") {
- expect(msg.event).toEqual(event);
- }
-
- ws.close();
- });
-
- test("chat.send with already-active turn sends chat.error but keeps subscription", async () => {
- const orch = fakeOrchestrator({ alreadyActive: 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.send", conversationId: "c1", message: "hi" }));
- const errMsg = await waitForMessage(ws);
-
- expect(errMsg.type).toBe("chat.error");
- if (errMsg.type === "chat.error") {
- expect(errMsg.message).toBe("a turn is already generating for this conversation");
- expect(errMsg.conversationId).toBe("c1");
- }
-
- 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([]);
- 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
-
- // Both subscribe to the same conversation.
- ws1.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" }));
- ws2.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" }));
- await new Promise((r) => setTimeout(r, 50));
-
- // Broadcast an event.
- const event = {
- type: "text-delta",
- conversationId: "shared-conv",
- turnId: "t1",
- delta: "Hi both",
- } as AgentEvent;
- orch.broadcast("shared-conv", event);
-
- const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]);
-
- expect(msg1.type).toBe("chat.delta");
- expect(msg2.type).toBe("chat.delta");
- if (msg1.type === "chat.delta" && msg2.type === "chat.delta") {
- expect(msg1.event).toEqual(event);
- expect(msg2.event).toEqual(event);
- }
-
- ws1.close();
- ws2.close();
- });
-
- test("disconnect does NOT abort the turn", async () => {
- const orch = fakeOrchestratorWithBroadcast();
- const registry = fakeRegistry([]);
- server = startServer(registry, orch);
- port = server.port as number;
-
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- // Start a turn.
- ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" }));
- await new Promise((r) => setTimeout(r, 50));
-
- // Close the socket.
- ws.close();
- await new Promise((r) => setTimeout(r, 50));
-
- // The turn should still be "running" — the orchestrator was never told to abort.
- // Broadcast a post-close event; the fake still has the listener set (real orchestrator
- // would too until turn-sealed). We just verify no abort was invoked.
- // The fakeOrchestratorWithBroadcast has no abort mechanism — that's the point:
- // the transport never calls abort on disconnect.
- expect(true).toBe(true); // If we got here, no abort was attempted.
- });
-
- test("late-join replay forwarded — subscribe mid-turn receives buffered events", async () => {
- const bufferedEvents: AgentEvent[] = [
- { type: "turn-start", conversationId: "c1", turnId: "t1" } as AgentEvent,
- { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "Hel" } as AgentEvent,
- ];
- const bufferedMap = new Map<string, readonly AgentEvent[]>();
- bufferedMap.set("c1", bufferedEvents);
-
- const orch = fakeOrchestrator({ bufferedEvents: bufferedMap });
- const registry = fakeRegistry([]);
- server = startServer(registry, orch);
- port = server.port as number;
-
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- // Subscribe mid-turn — the fake orchestrator replays buffered events.
- ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: "c1" }));
-
- const msgs = await waitForMessages(ws, bufferedEvents.length);
-
- for (let i = 0; i < bufferedEvents.length; i++) {
- const msg = msgs[i];
- const expected = bufferedEvents[i];
- if (!msg || !expected) throw new Error(`missing at index ${i}`);
- expect(msg.type).toBe("chat.delta");
- if (msg.type === "chat.delta") {
- expect(msg.event).toEqual(expected);
- }
- }
-
- ws.close();
- });
-
- test("chat.send auto-subscribes the sender — deltas arrive without separate chat.subscribe", async () => {
- const orch = fakeOrchestratorWithBroadcast();
- const registry = fakeRegistry([]);
- server = startServer(registry, orch);
- port = server.port as number;
-
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- // Send without a separate chat.subscribe.
- ws.send(JSON.stringify({ type: "chat.send", conversationId: "auto-conv", message: "go" }));
- await new Promise((r) => setTimeout(r, 50));
-
- // Broadcast should reach the sender.
- const event = { type: "done", conversationId: "auto-conv", turnId: "t1" } as AgentEvent;
- orch.broadcast("auto-conv", event);
-
- const msg = await waitForMessage(ws);
- expect(msg.type).toBe("chat.delta");
- if (msg.type === "chat.delta") {
- expect(msg.event).toEqual(event);
- }
-
- ws.close();
- });
+ let server: ReturnType<typeof Bun.serve>;
+ let port: number;
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("chat.send auto-subscribes the sender and delivers deltas via orchestrator.subscribe broadcast", async () => {
+ const orch = fakeOrchestratorWithBroadcast();
+ 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: "hi" }));
+
+ // Give the message handler time to run.
+ await new Promise((r) => setTimeout(r, 50));
+
+ // The sender should be auto-subscribed. Broadcast an event.
+ const event = {
+ type: "text-delta",
+ conversationId: "c1",
+ turnId: "t1",
+ delta: "Hello",
+ } as AgentEvent;
+ orch.broadcast("c1", event);
+
+ const msg = await waitForMessage(ws);
+ expect(msg.type).toBe("chat.delta");
+ if (msg.type === "chat.delta") {
+ expect(msg.event).toEqual(event);
+ }
+
+ ws.close();
+ });
+
+ test("chat.send with already-active turn sends chat.error but keeps subscription", async () => {
+ const orch = fakeOrchestrator({ alreadyActive: 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.send", conversationId: "c1", message: "hi" }));
+ const errMsg = await waitForMessage(ws);
+
+ expect(errMsg.type).toBe("chat.error");
+ if (errMsg.type === "chat.error") {
+ expect(errMsg.message).toBe("a turn is already generating for this conversation");
+ expect(errMsg.conversationId).toBe("c1");
+ }
+
+ 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([]);
+ 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
+
+ // Both subscribe to the same conversation.
+ ws1.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" }));
+ ws2.send(JSON.stringify({ type: "chat.subscribe", conversationId: "shared-conv" }));
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Broadcast an event.
+ const event = {
+ type: "text-delta",
+ conversationId: "shared-conv",
+ turnId: "t1",
+ delta: "Hi both",
+ } as AgentEvent;
+ orch.broadcast("shared-conv", event);
+
+ const [msg1, msg2] = await Promise.all([waitForMessage(ws1), waitForMessage(ws2)]);
+
+ expect(msg1.type).toBe("chat.delta");
+ expect(msg2.type).toBe("chat.delta");
+ if (msg1.type === "chat.delta" && msg2.type === "chat.delta") {
+ expect(msg1.event).toEqual(event);
+ expect(msg2.event).toEqual(event);
+ }
+
+ ws1.close();
+ ws2.close();
+ });
+
+ test("disconnect does NOT abort the turn", async () => {
+ const orch = fakeOrchestratorWithBroadcast();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ // Start a turn.
+ ws.send(JSON.stringify({ type: "chat.send", conversationId: "c1", message: "hi" }));
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Close the socket.
+ ws.close();
+ await new Promise((r) => setTimeout(r, 50));
+
+ // The turn should still be "running" — the orchestrator was never told to abort.
+ // Broadcast a post-close event; the fake still has the listener set (real orchestrator
+ // would too until turn-sealed). We just verify no abort was invoked.
+ // The fakeOrchestratorWithBroadcast has no abort mechanism — that's the point:
+ // the transport never calls abort on disconnect.
+ expect(true).toBe(true); // If we got here, no abort was attempted.
+ });
+
+ test("late-join replay forwarded — subscribe mid-turn receives buffered events", async () => {
+ const bufferedEvents: AgentEvent[] = [
+ { type: "turn-start", conversationId: "c1", turnId: "t1" } as AgentEvent,
+ { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "Hel" } as AgentEvent,
+ ];
+ const bufferedMap = new Map<string, readonly AgentEvent[]>();
+ bufferedMap.set("c1", bufferedEvents);
+
+ const orch = fakeOrchestrator({ bufferedEvents: bufferedMap });
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ // Subscribe mid-turn — the fake orchestrator replays buffered events.
+ ws.send(JSON.stringify({ type: "chat.subscribe", conversationId: "c1" }));
+
+ const msgs = await waitForMessages(ws, bufferedEvents.length);
+
+ for (let i = 0; i < bufferedEvents.length; i++) {
+ const msg = msgs[i];
+ const expected = bufferedEvents[i];
+ if (!msg || !expected) throw new Error(`missing at index ${i}`);
+ expect(msg.type).toBe("chat.delta");
+ if (msg.type === "chat.delta") {
+ expect(msg.event).toEqual(expected);
+ }
+ }
+
+ ws.close();
+ });
+
+ test("chat.send auto-subscribes the sender — deltas arrive without separate chat.subscribe", async () => {
+ const orch = fakeOrchestratorWithBroadcast();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ // Send without a separate chat.subscribe.
+ ws.send(JSON.stringify({ type: "chat.send", conversationId: "auto-conv", message: "go" }));
+ await new Promise((r) => setTimeout(r, 50));
+
+ // Broadcast should reach the sender.
+ const event = { type: "done", conversationId: "auto-conv", turnId: "t1" } as AgentEvent;
+ orch.broadcast("auto-conv", event);
+
+ const msg = await waitForMessage(ws);
+ expect(msg.type).toBe("chat.delta");
+ if (msg.type === "chat.delta") {
+ expect(msg.event).toEqual(event);
+ }
+
+ ws.close();
+ });
});
describe("chat.queue (steering enqueue)", () => {
- let server: ReturnType<typeof Bun.serve>;
- let port: number;
-
- afterEach(() => {
- server.stop();
- });
-
- test("chat.queue with valid text → orchestrator.enqueue called with {conversationId, text}, no reply sent", 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 please" }));
- // Allow the message handler to run.
- await new Promise((r) => setTimeout(r, 50));
-
- expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer please" }]);
- // chat.send-path equivalence: enqueue NOT called via startTurn.
- expect(orch.startCalls).toHaveLength(0);
- // Fire-and-forget: no chat.error, no ack — only the catalog was sent.
- // (startedTurn:true path auto-subscribes but emits nothing itself.)
-
- 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([]);
- 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: "go" }));
- await new Promise((r) => setTimeout(r, 50));
-
- // The sender was auto-subscribed — a broadcast reaches it as a chat.delta.
- const event = {
- type: "text-delta",
- conversationId: "c1",
- turnId: "t1",
- delta: "Hi",
- } as AgentEvent;
- orch.broadcast("c1", event);
-
- const msg = await waitForMessage(ws);
- expect(msg.type).toBe("chat.delta");
- if (msg.type === "chat.delta") {
- expect(msg.event).toEqual(event);
- }
-
- ws.close();
- });
-
- test("chat.queue on startedTurn:false (queued for steering) emits NOTHING back", async () => {
- const orch = fakeOrchestrator({ queueActive: true }); // enqueue → startedTurn:false
- 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" }));
- await new Promise((r) => setTimeout(r, 50));
-
- expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer" }]);
- // No further message should arrive within a quiet window: success is
- // confirmed by the message-queue SURFACE, not a reply here. We assert
- // by NOT receiving anything (a silent socket).
- await expect(
- Promise.race([
- waitForMessage(ws).then((m) => new Error(`unexpected reply: ${JSON.stringify(m)}`)),
- new Promise((resolve) => setTimeout(() => resolve("silent"), 150)),
- ]),
- ).resolves.toBe("silent");
-
- ws.close();
- });
-
- test("chat.queue with empty text → chat.error to client, no enqueue", 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.queue", conversationId: "c1", text: " " }));
- const errMsg = await waitForMessage(ws);
-
- expect(errMsg.type).toBe("chat.error");
- if (errMsg.type === "chat.error") {
- expect(errMsg.conversationId).toBe("c1");
- expect(errMsg.message).toContain("non-empty string");
- }
- expect(orch.enqueueCalls).toHaveLength(0);
-
- ws.close();
- });
-
- test("chat.queue with missing text → chat.error to client, no enqueue", 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.queue", conversationId: "c1" }));
- const errMsg = await waitForMessage(ws);
-
- expect(errMsg.type).toBe("chat.error");
- if (errMsg.type === "chat.error") {
- expect(errMsg.message).toContain("non-empty string");
- }
- expect(orch.enqueueCalls).toHaveLength(0);
-
- ws.close();
- });
+ let server: ReturnType<typeof Bun.serve>;
+ let port: number;
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("chat.queue with valid text → orchestrator.enqueue called with {conversationId, text}, no reply sent", 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 please" }));
+ // Allow the message handler to run.
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer please" }]);
+ // chat.send-path equivalence: enqueue NOT called via startTurn.
+ expect(orch.startCalls).toHaveLength(0);
+ // Fire-and-forget: no chat.error, no ack — only the catalog was sent.
+ // (startedTurn:true path auto-subscribes but emits nothing itself.)
+
+ 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([]);
+ 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: "go" }));
+ await new Promise((r) => setTimeout(r, 50));
+
+ // The sender was auto-subscribed — a broadcast reaches it as a chat.delta.
+ const event = {
+ type: "text-delta",
+ conversationId: "c1",
+ turnId: "t1",
+ delta: "Hi",
+ } as AgentEvent;
+ orch.broadcast("c1", event);
+
+ const msg = await waitForMessage(ws);
+ expect(msg.type).toBe("chat.delta");
+ if (msg.type === "chat.delta") {
+ expect(msg.event).toEqual(event);
+ }
+
+ ws.close();
+ });
+
+ test("chat.queue on startedTurn:false (queued for steering) emits NOTHING back", async () => {
+ const orch = fakeOrchestrator({ queueActive: true }); // enqueue → startedTurn:false
+ 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" }));
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(orch.enqueueCalls).toEqual([{ conversationId: "c1", text: "steer" }]);
+ // No further message should arrive within a quiet window: success is
+ // confirmed by the message-queue SURFACE, not a reply here. We assert
+ // by NOT receiving anything (a silent socket).
+ await expect(
+ Promise.race([
+ waitForMessage(ws).then((m) => new Error(`unexpected reply: ${JSON.stringify(m)}`)),
+ new Promise((resolve) => setTimeout(() => resolve("silent"), 150)),
+ ]),
+ ).resolves.toBe("silent");
+
+ ws.close();
+ });
+
+ test("chat.queue with empty text → chat.error to client, no enqueue", 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.queue", conversationId: "c1", text: " " }));
+ const errMsg = await waitForMessage(ws);
+
+ expect(errMsg.type).toBe("chat.error");
+ if (errMsg.type === "chat.error") {
+ expect(errMsg.conversationId).toBe("c1");
+ expect(errMsg.message).toContain("non-empty string");
+ }
+ expect(orch.enqueueCalls).toHaveLength(0);
+
+ ws.close();
+ });
+
+ test("chat.queue with missing text → chat.error to client, no enqueue", 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.queue", conversationId: "c1" }));
+ const errMsg = await waitForMessage(ws);
+
+ expect(errMsg.type).toBe("chat.error");
+ if (errMsg.type === "chat.error") {
+ expect(errMsg.message).toContain("non-empty string");
+ }
+ expect(orch.enqueueCalls).toHaveLength(0);
+
+ ws.close();
+ });
});
describe("logging", () => {
- let server: ReturnType<typeof Bun.serve>;
- let port: number;
-
- afterEach(() => {
- server.stop();
- });
-
- test("logs a warn on a surface-op error", async () => {
- const logger = fakeLogger();
- const registry = fakeRegistry([]);
- server = startServer(registry, fakeOrchestrator(), 0, logger);
- port = server.port as number;
-
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nonexistent" }));
- await waitForMessage(ws); // drain error reply
- ws.close();
- // Allow close handler to run
- await new Promise((r) => setTimeout(r, 50));
-
- const surfaceErrors = logger.entries.filter(
- (e) => e.level === "warn" && e.msg === "transport-ws: surface-op error",
- );
- expect(surfaceErrors.length).toBeGreaterThanOrEqual(1);
- expect(surfaceErrors[0]?.attrs).toMatchObject({
- surfaceId: "nonexistent",
- reason: "Unknown surface: nonexistent",
- });
- });
-
- test("logs an info when a chat.send is accepted", async () => {
- const logger = fakeLogger();
- const orch = fakeOrchestrator();
- const registry = fakeRegistry([]);
- server = startServer(registry, orch, 0, logger);
- 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: "conv-42",
- message: "hello",
- model: "gpt-4",
- }),
- );
- // Wait for the message handler to run
- await new Promise((r) => setTimeout(r, 100));
- ws.close();
- await new Promise((r) => setTimeout(r, 50));
-
- const accepted = logger.entries.filter(
- (e) => e.level === "info" && e.msg === "transport-ws: chat.send accepted",
- );
- expect(accepted).toHaveLength(1);
- expect(accepted[0]?.attrs).toMatchObject({
- conversationId: "conv-42",
- model: "gpt-4",
- });
- });
-
- test("logs a warn on a malformed chat.send", async () => {
- const logger = fakeLogger();
- const registry = fakeRegistry([]);
- server = startServer(registry, fakeOrchestrator(), 0, logger);
- port = server.port as number;
-
- const ws = new WebSocket(`ws://localhost:${port}`);
- await waitForMessage(ws); // drain catalog
-
- ws.send(JSON.stringify({ type: "chat.send", message: "" }));
- await waitForMessage(ws); // drain chat.error reply
- ws.close();
- await new Promise((r) => setTimeout(r, 50));
-
- const malformed = logger.entries.filter(
- (e) => e.level === "warn" && e.msg === "transport-ws: malformed chat.send",
- );
- expect(malformed).toHaveLength(1);
- expect(malformed[0]?.attrs).toMatchObject({
- reason: "chat.send requires a non-empty string `message`",
- });
- });
-
- test("does not log 'in-flight turn aborted' on close", async () => {
- const logger = fakeLogger();
- const orch = fakeOrchestratorWithBroadcast();
- const registry = fakeRegistry([]);
- server = startServer(registry, orch, 0, logger);
- 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: "hi" }));
- await new Promise((r) => setTimeout(r, 50));
- ws.close();
- await new Promise((r) => setTimeout(r, 50));
-
- const abortLogs = logger.entries.filter(
- (e) => e.msg.includes("aborted") || e.msg.includes("abort"),
- );
- expect(abortLogs).toHaveLength(0);
- });
+ let server: ReturnType<typeof Bun.serve>;
+ let port: number;
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("logs a warn on a surface-op error", async () => {
+ const logger = fakeLogger();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, fakeOrchestrator(), 0, logger);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(JSON.stringify({ type: "subscribe", surfaceId: "nonexistent" }));
+ await waitForMessage(ws); // drain error reply
+ ws.close();
+ // Allow close handler to run
+ await new Promise((r) => setTimeout(r, 50));
+
+ const surfaceErrors = logger.entries.filter(
+ (e) => e.level === "warn" && e.msg === "transport-ws: surface-op error",
+ );
+ expect(surfaceErrors.length).toBeGreaterThanOrEqual(1);
+ expect(surfaceErrors[0]?.attrs).toMatchObject({
+ surfaceId: "nonexistent",
+ reason: "Unknown surface: nonexistent",
+ });
+ });
+
+ test("logs an info when a chat.send is accepted", async () => {
+ const logger = fakeLogger();
+ const orch = fakeOrchestrator();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch, 0, logger);
+ 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: "conv-42",
+ message: "hello",
+ model: "gpt-4",
+ }),
+ );
+ // Wait for the message handler to run
+ await new Promise((r) => setTimeout(r, 100));
+ ws.close();
+ await new Promise((r) => setTimeout(r, 50));
+
+ const accepted = logger.entries.filter(
+ (e) => e.level === "info" && e.msg === "transport-ws: chat.send accepted",
+ );
+ expect(accepted).toHaveLength(1);
+ expect(accepted[0]?.attrs).toMatchObject({
+ conversationId: "conv-42",
+ model: "gpt-4",
+ });
+ });
+
+ test("logs a warn on a malformed chat.send", async () => {
+ const logger = fakeLogger();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, fakeOrchestrator(), 0, logger);
+ port = server.port as number;
+
+ const ws = new WebSocket(`ws://localhost:${port}`);
+ await waitForMessage(ws); // drain catalog
+
+ ws.send(JSON.stringify({ type: "chat.send", message: "" }));
+ await waitForMessage(ws); // drain chat.error reply
+ ws.close();
+ await new Promise((r) => setTimeout(r, 50));
+
+ const malformed = logger.entries.filter(
+ (e) => e.level === "warn" && e.msg === "transport-ws: malformed chat.send",
+ );
+ expect(malformed).toHaveLength(1);
+ expect(malformed[0]?.attrs).toMatchObject({
+ reason: "chat.send requires a non-empty string `message`",
+ });
+ });
+
+ test("does not log 'in-flight turn aborted' on close", async () => {
+ const logger = fakeLogger();
+ const orch = fakeOrchestratorWithBroadcast();
+ const registry = fakeRegistry([]);
+ server = startServer(registry, orch, 0, logger);
+ 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: "hi" }));
+ await new Promise((r) => setTimeout(r, 50));
+ ws.close();
+ await new Promise((r) => setTimeout(r, 50));
+
+ const abortLogs = logger.entries.filter(
+ (e) => e.msg.includes("aborted") || e.msg.includes("abort"),
+ );
+ expect(abortLogs).toHaveLength(0);
+ });
});
describe("conversation.open broadcast (conversationOpened hook)", () => {
- let server: ReturnType<typeof startServer>;
- let port: number;
-
- afterEach(() => {
- server.stop();
- });
-
- test("conversation.open broadcast on conversationOpened hook", 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 conversationOpened hook firing (extension.ts's
- // `host.on(conversationOpened, ...)` handler runs and broadcasts).
- server.triggerConversationOpen("conv-42", "ws-7");
-
- const msg = await waitForMessage(ws);
- expect(msg).toEqual({
- type: "conversation.open",
- conversationId: "conv-42",
- workspaceId: "ws-7",
- });
-
- ws.close();
- });
-
- test("conversation.open sent to all connected clients", 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,
- // 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();
- });
+ let server: ReturnType<typeof startServer>;
+ let port: number;
+
+ afterEach(() => {
+ server.stop();
+ });
+
+ test("conversation.open broadcast on conversationOpened hook", 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 conversationOpened hook firing (extension.ts's
+ // `host.on(conversationOpened, ...)` handler runs and broadcasts).
+ server.triggerConversationOpen("conv-42", "ws-7");
+
+ const msg = await waitForMessage(ws);
+ expect(msg).toEqual({
+ type: "conversation.open",
+ conversationId: "conv-42",
+ workspaceId: "ws-7",
+ });
+
+ ws.close();
+ });
+
+ test("conversation.open sent to all connected clients", 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,
+ // 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.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();
- });
+ 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.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();
+ });
});
diff --git a/packages/transport-ws/tsconfig.json b/packages/transport-ws/tsconfig.json
index 2a1d7ab..c861681 100644
--- a/packages/transport-ws/tsconfig.json
+++ b/packages/transport-ws/tsconfig.json
@@ -1,12 +1,12 @@
{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
- "include": ["src/**/*.ts"],
- "references": [
- { "path": "../kernel" },
- { "path": "../session-orchestrator" },
- { "path": "../surface-registry" },
- { "path": "../transport-contract" },
- { "path": "../ui-contract" }
- ]
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [
+ { "path": "../kernel" },
+ { "path": "../session-orchestrator" },
+ { "path": "../surface-registry" },
+ { "path": "../transport-contract" },
+ { "path": "../ui-contract" }
+ ]
}