diff options
Diffstat (limited to 'packages/transport-ws/src/router.test.ts')
| -rw-r--r-- | packages/transport-ws/src/router.test.ts | 1351 |
1 files changed, 703 insertions, 648 deletions
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")); + }); }); |
