summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-ws/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 13:08:38 +0900
committerAdam Malczewski <[email protected]>2026-06-11 13:08:38 +0900
commitffbbcf692a97ec8648af39353b49f32896367207 (patch)
tree2e2ddfd03d4a868f4a4ba12e20586cc03c37f90a /packages/transport-ws/src
parent27fd0be36b2f6395249de5aacc86e41fe4e0207f (diff)
downloaddispatch-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.ts65
-rw-r--r--packages/transport-ws/src/index.ts2
-rw-r--r--packages/transport-ws/src/router.test.ts155
-rw-r--r--packages/transport-ws/src/router.ts67
-rw-r--r--packages/transport-ws/src/server.bun.test.ts13
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);
}
}