diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 13:08:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 13:08:38 +0900 |
| commit | ffbbcf692a97ec8648af39353b49f32896367207 (patch) | |
| tree | 2e2ddfd03d4a868f4a4ba12e20586cc03c37f90a /packages/transport-ws/src | |
| parent | 27fd0be36b2f6395249de5aacc86e41fe4e0207f (diff) | |
| download | dispatch-ffbbcf692a97ec8648af39353b49f32896367207.tar.gz dispatch-ffbbcf692a97ec8648af39353b49f32896367207.zip | |
feat(surfaces): NumberField + per-conversation surface scoping; cache-warming controls
Extend the surface framework so cache-warming exposes per-conversation controls:
- ui-contract: add NumberField (settable free-value numeric) to SurfaceField;
add optional conversationId to subscribe/unsubscribe/invoke + surface/update
- surface-registry: SurfaceContext { conversationId? } on getSpec/invoke (backward-compatible)
- transport-ws: thread conversationId; key subscriptions by (surfaceId, conversationId);
tag surface/update replies with conversationId
- cache-warming: per-conversation surface — Toggle(enabled) + Number(interval seconds,
cache-warming/set-interval) + Stat(last cache %); drop the currentConversationId closure
Global surfaces (surface-loaded-extensions) unchanged. 784 vitest + 109 bun = 893 tests;
tsc -b EXIT 0; biome clean.
Diffstat (limited to 'packages/transport-ws/src')
| -rw-r--r-- | packages/transport-ws/src/extension.ts | 65 | ||||
| -rw-r--r-- | packages/transport-ws/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/transport-ws/src/router.test.ts | 155 | ||||
| -rw-r--r-- | packages/transport-ws/src/router.ts | 67 | ||||
| -rw-r--r-- | packages/transport-ws/src/server.bun.test.ts | 13 |
5 files changed, 255 insertions, 47 deletions
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts index 0f1a397..10981a5 100644 --- a/packages/transport-ws/src/extension.ts +++ b/packages/transport-ws/src/extension.ts @@ -9,11 +9,11 @@ import type { Extension, HostAPI } from "@dispatch/kernel"; import type { SessionOrchestrator } from "@dispatch/session-orchestrator"; import { sessionOrchestratorHandle } from "@dispatch/session-orchestrator"; -import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; +import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; import { surfaceRegistryHandle } from "@dispatch/surface-registry"; import type { WsClientMessage, WsServerMessage } from "@dispatch/transport-contract"; import { manifest } from "./manifest.js"; -import { catalogMessage, routeClientMessage } from "./router.js"; +import { catalogMessage, routeClientMessage, subKey } from "./router.js"; /** Active provider subscriptions + chat abort controller for a single WS connection. */ interface ConnectionState { @@ -48,33 +48,53 @@ export function createTransportWsExtension(): Extension { ws: Ws, provider: SurfaceProvider, surfaceId: string, + conversationId: string | undefined, state: ConnectionState, ): void { - if (!provider.subscribe || state.providerDisposers.has(surfaceId)) { + 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(); + const spec = provider.getSpec(context); if (spec instanceof Promise) { spec - .then((s) => send(ws, { type: "update", update: { surfaceId, spec: s } })) + .then((s) => + send(ws, { + type: "update", + update: { + surfaceId, + spec: s, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }), + ) .catch(() => {}); } else { - send(ws, { type: "update", update: { surfaceId, spec } }); + send(ws, { + type: "update", + update: { + surfaceId, + spec, + ...(conversationId !== undefined ? { conversationId } : {}), + }, + }); } } catch { // Provider threw — log but don't kill the connection. } }); - state.providerDisposers.set(surfaceId, dispose); + state.providerDisposers.set(key, dispose); } - function unsubscribeFromProvider(state: ConnectionState, surfaceId: string): void { - const dispose = state.providerDisposers.get(surfaceId); + function unsubscribeFromProvider(state: ConnectionState, key: string): void { + const dispose = state.providerDisposers.get(key); if (dispose) { dispose(); - state.providerDisposers.delete(surfaceId); + state.providerDisposers.delete(key); } } @@ -158,15 +178,22 @@ export function createTransportWsExtension(): Extension { // Apply sub change. if (result.subChange) { + const key = subKey(result.subChange.surfaceId, result.subChange.conversationId); if (result.subChange.op === "add") { - state.subs.add(result.subChange.surfaceId); + state.subs.add(key); const provider = registry.getSurface(result.subChange.surfaceId); if (provider) { - subscribeToProvider(ws, provider, result.subChange.surfaceId, state); + subscribeToProvider( + ws, + provider, + result.subChange.surfaceId, + result.subChange.conversationId, + state, + ); } } else { - state.subs.delete(result.subChange.surfaceId); - unsubscribeFromProvider(state, result.subChange.surfaceId); + state.subs.delete(key); + unsubscribeFromProvider(state, key); } } @@ -179,8 +206,16 @@ export function createTransportWsExtension(): Extension { 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); + const r = provider.invoke( + result.invoke.actionId, + result.invoke.payload, + context, + ); if (r instanceof Promise) { r.catch(() => {}); } diff --git a/packages/transport-ws/src/index.ts b/packages/transport-ws/src/index.ts index f4355c0..600519a 100644 --- a/packages/transport-ws/src/index.ts +++ b/packages/transport-ws/src/index.ts @@ -6,4 +6,4 @@ export type { RouteResult, SurfaceRouteResult, } from "./router.js"; -export { catalogMessage, routeClientMessage } from "./router.js"; +export { catalogMessage, routeClientMessage, subKey } from "./router.js"; diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts index ae76c5d..afd7b2f 100644 --- a/packages/transport-ws/src/router.test.ts +++ b/packages/transport-ws/src/router.test.ts @@ -1,32 +1,61 @@ -import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; +import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; import type { SurfaceCatalogEntry, SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; -import { catalogMessage, routeClientMessage } from "./router.js"; +import { catalogMessage, routeClientMessage, subKey } from "./router.js"; // ── Fake in-memory registry (no mocks — just a plain implementation) ──────── -function fakeProvider(id: string, title?: string, actions?: readonly string[]): SurfaceProvider { +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, + id: opts.id, region: "default", - title: title ?? `Surface ${id}`, + title: opts.title ?? `Surface ${opts.id}`, }; return { catalogEntry, - getSpec(): SurfaceSpec { + getSpec(context?: SurfaceContext): SurfaceSpec { + opts.onGetSpec?.(context); return { - id, + id: opts.id, region: "default", title: catalogEntry.title, fields: - actions?.map((a) => ({ + opts.actions?.map((a) => ({ kind: "button" as const, label: a, action: { actionId: a }, })) ?? [], }; }, - invoke(_actionId: string, _payload?: unknown) {}, + invoke(actionId: string, _payload?: unknown, context?: SurfaceContext) { + opts.onInvoke?.(actionId, _payload, context); + }, }; } @@ -77,7 +106,7 @@ describe("routeClientMessage", () => { it("is idempotent — subscribing twice does not duplicate the subChange", () => { const provider = fakeProvider("a"); const registry = fakeRegistry([provider]); - const connSubs = new Set<string>(["a"]); // already subscribed + const connSubs = new Set<string>([subKey("a")]); // already subscribed (global) const result = routeClientMessage(registry, connSubs, { type: "subscribe", @@ -110,12 +139,71 @@ describe("routeClientMessage", () => { }); 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>(["a"]); + const connSubs = new Set<string>([subKey("a")]); const result = routeClientMessage(registry, connSubs, { type: "unsubscribe", @@ -187,6 +275,37 @@ describe("routeClientMessage", () => { }); 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", () => { @@ -282,3 +401,17 @@ describe("catalogMessage", () => { 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")); + }); +}); diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts index 1a90e86..d1b03ac 100644 --- a/packages/transport-ws/src/router.ts +++ b/packages/transport-ws/src/router.ts @@ -7,7 +7,7 @@ * provider.invoke, drives the orchestrator. */ -import type { SurfaceRegistry } from "@dispatch/surface-registry"; +import type { SurfaceContext, SurfaceRegistry } from "@dispatch/surface-registry"; import type { ChatSendMessage, WsClientMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; @@ -19,12 +19,17 @@ export interface SurfaceRouteResult { /** 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 }; - /** If set, the shell must call `provider.invoke(actionId, payload)`. */ + 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; }; } @@ -49,6 +54,14 @@ export type RouteResult = SurfaceRouteResult | ChatRouteResult | ChatRouteError; // ── Helpers ───────────────────────────────────────────────────────────────── +/** + * Build a subscription key from a surface id and optional conversation id. + * 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}::`; +} + /** Build the catalog `SurfaceServerMessage` from the registry. */ export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage { return { type: "catalog", catalog: registry.getCatalog() }; @@ -60,7 +73,7 @@ export function catalogMessage(registry: SurfaceRegistry): SurfaceServerMessage * Route a single client message into a pure effect description. * * @param registry The surface registry (looked up once, injected). - * @param connSubs This connection's current subscribed surface ids. + * @param connSubs This connection's current subscription keys (via `subKey`). * @param msg The parsed client message (surface or chat). */ export function routeClientMessage( @@ -70,11 +83,11 @@ export function routeClientMessage( ): RouteResult { switch (msg.type) { case "subscribe": - return handleSubscribe(registry, connSubs, msg.surfaceId); + return handleSubscribe(registry, connSubs, msg.surfaceId, msg.conversationId); case "unsubscribe": - return handleUnsubscribe(msg.surfaceId); + return handleUnsubscribe(msg.surfaceId, msg.conversationId); case "invoke": - return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload); + return handleInvoke(registry, msg.surfaceId, msg.actionId, msg.payload, msg.conversationId); case "chat.send": return handleChatSend(msg); } @@ -105,6 +118,7 @@ function handleSubscribe( registry: SurfaceRegistry, connSubs: ReadonlySet<string>, surfaceId: string, + conversationId?: string, ): SurfaceRouteResult { const provider = registry.getSurface(surfaceId); if (!provider) { @@ -114,7 +128,9 @@ function handleSubscribe( }; } - const spec = provider.getSpec(); + 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). @@ -123,21 +139,38 @@ function handleSubscribe( const specValue = spec as import("@dispatch/ui-contract").SurfaceSpec; const replies: import("@dispatch/ui-contract").SurfaceServerMessage[] = [ - { type: "surface", spec: specValue }, + { + type: "surface", + spec: specValue, + ...(conversationId !== undefined ? { conversationId } : {}), + }, ]; // Idempotent: only emit subChange if not already subscribed. - if (!connSubs.has(surfaceId)) { - return { kind: "surface", replies, subChange: { op: "add", surfaceId } }; + 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): SurfaceRouteResult { +function handleUnsubscribe(surfaceId: string, conversationId?: string): SurfaceRouteResult { return { kind: "surface", replies: [], - subChange: { op: "remove", surfaceId }, + subChange: { + op: "remove", + surfaceId, + ...(conversationId !== undefined ? { conversationId } : {}), + }, }; } @@ -146,6 +179,7 @@ function handleInvoke( surfaceId: string, actionId: string, payload?: unknown, + conversationId?: string, ): SurfaceRouteResult { const provider = registry.getSurface(surfaceId); if (!provider) { @@ -157,6 +191,11 @@ function handleInvoke( return { kind: "surface", replies: [], - invoke: { surfaceId, actionId, payload }, + 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 ea9d80c..8d6f0b8 100644 --- a/packages/transport-ws/src/server.bun.test.ts +++ b/packages/transport-ws/src/server.bun.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { AgentEvent, Attributes, ErrorAttributes, Logger } from "@dispatch/kernel"; import type { SessionOrchestrator } from "@dispatch/session-orchestrator"; -import type { SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; +import type { SurfaceContext, SurfaceProvider, SurfaceRegistry } from "@dispatch/surface-registry"; import type { WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceCatalogEntry, SurfaceClientMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import { catalogMessage, routeClientMessage } from "./router.js"; +import { catalogMessage, routeClientMessage, subKey } from "./router.js"; // ── Fake Logger (captures records for assertions) ─────────────────────────── @@ -58,7 +58,7 @@ function fakeProvider(id: string, title?: string): SurfaceProvider { }; return { catalogEntry, - getSpec(): SurfaceSpec { + getSpec(_context?: SurfaceContext): SurfaceSpec { return { id, region: "default", @@ -66,7 +66,7 @@ function fakeProvider(id: string, title?: string): SurfaceProvider { fields: [], }; }, - invoke(_actionId: string, _payload?: unknown) {}, + invoke(_actionId: string, _payload?: unknown, _context?: SurfaceContext) {}, }; } @@ -151,10 +151,11 @@ function startServer( } if (result.subChange) { + const key = subKey(result.subChange.surfaceId, result.subChange.conversationId); if (result.subChange.op === "add") { - state.subs.add(result.subChange.surfaceId); + state.subs.add(key); } else { - state.subs.delete(result.subChange.surfaceId); + state.subs.delete(key); } } |
