import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; import type { WsClientMessage } from "@dispatch/transport-contract"; import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; import { catalogMessage, type RouteResult, routeClientMessage, subKey } from "./router.js"; // ── 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; } function fakeProvider( 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); }, }; } 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); }, }; } // ── 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(); 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([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(); 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(); 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(); 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([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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); // 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(); 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([ "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("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]); const msg = catalogMessage(registry); 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([]); const msg = catalogMessage(registry); 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 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")); }); });