summaryrefslogtreecommitdiffhomepage
path: root/src/adapters/ws
diff options
context:
space:
mode:
Diffstat (limited to 'src/adapters/ws')
-rw-r--r--src/adapters/ws/index.test.ts729
-rw-r--r--src/adapters/ws/index.ts183
-rw-r--r--src/adapters/ws/logic.test.ts626
-rw-r--r--src/adapters/ws/logic.ts238
4 files changed, 1028 insertions, 748 deletions
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts
index 961f919..9d821e2 100644
--- a/src/adapters/ws/index.test.ts
+++ b/src/adapters/ws/index.test.ts
@@ -3,344 +3,405 @@ import type { WebSocketLike } from "./index";
import { createSurfaceSocket } from "./index";
interface FakeSocket extends WebSocketLike {
- sent: string[];
- resolveOpen(): void;
- invokeMessage(data: string): void;
- invokeClose(): void;
+ sent: string[];
+ resolveOpen(): void;
+ invokeMessage(data: string): void;
+ invokeClose(): void;
}
function fakeSocket(): FakeSocket {
- let onopen: (() => void) | null = null;
- let onmessage: ((ev: { data: string }) => void) | null = null;
- let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
- const sent: string[] = [];
-
- const ws: FakeSocket = {
- send(data: string) {
- sent.push(data);
- },
- close() {},
- get onopen() {
- return onopen;
- },
- set onopen(fn) {
- onopen = fn;
- },
- get onmessage() {
- return onmessage;
- },
- set onmessage(fn) {
- onmessage = fn;
- },
- get onclose() {
- return onclose;
- },
- set onclose(fn) {
- onclose = fn;
- },
- resolveOpen() {
- onopen?.();
- },
- invokeMessage(data: string) {
- onmessage?.({ data });
- },
- invokeClose() {
- onclose?.({ code: 1000, reason: "" });
- },
- sent,
- };
- return ws;
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
+ const sent: string[] = [];
+
+ const ws: FakeSocket = {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return onclose;
+ },
+ set onclose(fn) {
+ onclose = fn;
+ },
+ resolveOpen() {
+ onopen?.();
+ },
+ invokeMessage(data: string) {
+ onmessage?.({ data });
+ },
+ invokeClose() {
+ onclose?.({ code: 1000, reason: "" });
+ },
+ sent,
+ };
+ return ws;
}
describe("createSurfaceSocket", () => {
- it("sends queued messages once socket opens", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage,
- socketFactory: () => ws,
- });
-
- handle.send({ type: "subscribe", surfaceId: "s1" });
- handle.send({ type: "subscribe", surfaceId: "s2" });
- expect(ws.sent).toHaveLength(0);
-
- ws.resolveOpen();
- expect(ws.sent).toHaveLength(2);
- expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" });
- expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" });
- });
-
- it("sends immediately when socket is already open", () => {
- const ws = fakeSocket();
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- ws.sent.length = 0;
-
- handle.send({ type: "subscribe", surfaceId: "s1" });
- expect(ws.sent).toHaveLength(1);
- });
-
- it("routes inbound messages to onMessage via parseServerMessage", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] }));
- expect(onMessage).toHaveBeenCalledOnce();
- expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] });
- });
-
- it("drops malformed inbound messages silently", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- ws.invokeMessage("not json");
- expect(onMessage).not.toHaveBeenCalled();
- });
-
- it("auto-reconnects on close and fires onReopen after successful reconnect", () => {
- vi.useFakeTimers();
- try {
- const sockets: ReturnType<typeof fakeSocket>[] = [];
- const onMessage = vi.fn();
- const onReopen = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- onReopen,
- socketFactory: () => {
- const ws = fakeSocket();
- sockets.push(ws);
- return ws;
- },
- });
-
- expect(sockets).toHaveLength(1);
- sockets[0]?.resolveOpen();
-
- // Simulate close
- sockets[0]?.invokeClose();
-
- // Fast-forward past the backoff delay
- vi.advanceTimersByTime(600);
-
- expect(sockets).toHaveLength(2);
- // onReopen should NOT have fired yet (socket not open)
- expect(onReopen).not.toHaveBeenCalled();
-
- sockets[1]?.resolveOpen();
- expect(onReopen).toHaveBeenCalledOnce();
- } finally {
- vi.useRealTimers();
- }
- });
-
- it("does not fire onReopen on initial connect", () => {
- const ws = fakeSocket();
- const onReopen = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- onReopen,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- expect(onReopen).not.toHaveBeenCalled();
- });
-
- it("close() prevents further reconnects", () => {
- vi.useFakeTimers();
- try {
- const sockets: ReturnType<typeof fakeSocket>[] = [];
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- socketFactory: () => {
- const ws = fakeSocket();
- sockets.push(ws);
- return ws;
- },
- });
-
- sockets[0]?.resolveOpen();
- sockets[0]?.invokeClose();
- handle.close();
-
- vi.advanceTimersByTime(10_000);
- expect(sockets).toHaveLength(1);
- } finally {
- vi.useRealTimers();
- }
- });
-
- it("close() prevents further sends", () => {
- const ws = fakeSocket();
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- ws.sent.length = 0;
- handle.close();
-
- handle.send({ type: "subscribe", surfaceId: "s1" });
- expect(ws.sent).toHaveLength(0);
- });
-
- it("queues multiple sends before open and flushes in order", () => {
- const ws = fakeSocket();
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- socketFactory: () => ws,
- });
-
- handle.send({ type: "subscribe", surfaceId: "a" });
- handle.send({ type: "subscribe", surfaceId: "b" });
- handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 });
- ws.resolveOpen();
-
- expect(ws.sent).toHaveLength(3);
- expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" });
- expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" });
- expect(JSON.parse(ws.sent[2] ?? "")).toEqual({
- type: "invoke",
- surfaceId: "a",
- actionId: "x",
- payload: 1,
- });
- });
-
- it("routes chat.delta to onChat", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- const onChat = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- onChat,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" };
- ws.invokeMessage(JSON.stringify({ type: "chat.delta", event }));
- expect(onChat).toHaveBeenCalledOnce();
- expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event });
- expect(onMessage).not.toHaveBeenCalled();
- });
-
- it("routes chat.error to onChat", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- const onChat = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- onChat,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" }));
- expect(onChat).toHaveBeenCalledOnce();
- expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" });
- expect(onMessage).not.toHaveBeenCalled();
- });
-
- it("still routes surface catalog/surface to onMessage", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- const onChat = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- onChat,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] }));
- expect(onMessage).toHaveBeenCalledOnce();
- expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] });
- expect(onChat).not.toHaveBeenCalled();
-
- ws.invokeMessage(
- JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }),
- );
- expect(onMessage).toHaveBeenCalledTimes(2);
- });
-
- it("send accepts and serializes a chat.send message", () => {
- const ws = fakeSocket();
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- handle.send({ type: "chat.send", message: "hello" });
- expect(ws.sent).toHaveLength(1);
- expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" });
- });
-
- it("onChat absent is safe (surface-only usage does not throw)", () => {
- const ws = fakeSocket();
- const onMessage = vi.fn();
- createSurfaceSocket({
- url: "ws://test",
- onMessage,
- socketFactory: () => ws,
- });
-
- ws.resolveOpen();
- expect(() => {
- ws.invokeMessage(
- JSON.stringify({
- type: "chat.delta",
- event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" },
- }),
- );
- ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" }));
- }).not.toThrow();
- expect(onMessage).not.toHaveBeenCalled();
- });
-
- it("chat send is queued until open then flushed", () => {
- const ws = fakeSocket();
- const handle = createSurfaceSocket({
- url: "ws://test",
- onMessage: vi.fn(),
- socketFactory: () => ws,
- });
-
- handle.send({ type: "chat.send", message: "queued" });
- expect(ws.sent).toHaveLength(0);
-
- ws.resolveOpen();
- expect(ws.sent).toHaveLength(1);
- expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" });
- });
+ it("sends queued messages once socket opens", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ handle.send({ type: "subscribe", surfaceId: "s1" });
+ handle.send({ type: "subscribe", surfaceId: "s2" });
+ expect(ws.sent).toHaveLength(0);
+
+ ws.resolveOpen();
+ expect(ws.sent).toHaveLength(2);
+ expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" });
+ expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" });
+ });
+
+ it("sends immediately when socket is already open", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.sent.length = 0;
+
+ handle.send({ type: "subscribe", surfaceId: "s1" });
+ expect(ws.sent).toHaveLength(1);
+ });
+
+ it("routes inbound messages to onMessage via parseServerMessage", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] }));
+ expect(onMessage).toHaveBeenCalledOnce();
+ expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] });
+ });
+
+ it("drops malformed inbound messages silently", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage("not json");
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it("auto-reconnects on close and fires onReopen after successful reconnect", () => {
+ vi.useFakeTimers();
+ try {
+ const sockets: ReturnType<typeof fakeSocket>[] = [];
+ const onMessage = vi.fn();
+ const onReopen = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onReopen,
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+
+ expect(sockets).toHaveLength(1);
+ sockets[0]?.resolveOpen();
+
+ // Simulate close
+ sockets[0]?.invokeClose();
+
+ // Fast-forward past the backoff delay
+ vi.advanceTimersByTime(600);
+
+ expect(sockets).toHaveLength(2);
+ // onReopen should NOT have fired yet (socket not open)
+ expect(onReopen).not.toHaveBeenCalled();
+
+ sockets[1]?.resolveOpen();
+ expect(onReopen).toHaveBeenCalledOnce();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not fire onReopen on initial connect", () => {
+ const ws = fakeSocket();
+ const onReopen = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ onReopen,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ expect(onReopen).not.toHaveBeenCalled();
+ });
+
+ it("close() prevents further reconnects", () => {
+ vi.useFakeTimers();
+ try {
+ const sockets: ReturnType<typeof fakeSocket>[] = [];
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+
+ sockets[0]?.resolveOpen();
+ sockets[0]?.invokeClose();
+ handle.close();
+
+ vi.advanceTimersByTime(10_000);
+ expect(sockets).toHaveLength(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("close() prevents further sends", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.sent.length = 0;
+ handle.close();
+
+ handle.send({ type: "subscribe", surfaceId: "s1" });
+ expect(ws.sent).toHaveLength(0);
+ });
+
+ it("queues multiple sends before open and flushes in order", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ handle.send({ type: "subscribe", surfaceId: "a" });
+ handle.send({ type: "subscribe", surfaceId: "b" });
+ handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 });
+ ws.resolveOpen();
+
+ expect(ws.sent).toHaveLength(3);
+ expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" });
+ expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" });
+ expect(JSON.parse(ws.sent[2] ?? "")).toEqual({
+ type: "invoke",
+ surfaceId: "a",
+ actionId: "x",
+ payload: 1,
+ });
+ });
+
+ it("routes chat.delta to onChat", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const onChat = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onChat,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" };
+ ws.invokeMessage(JSON.stringify({ type: "chat.delta", event }));
+ expect(onChat).toHaveBeenCalledOnce();
+ expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event });
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it("routes chat.error to onChat", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const onChat = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onChat,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" }));
+ expect(onChat).toHaveBeenCalledOnce();
+ expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" });
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it("routes conversation.open to onConversationOpen", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const onChat = vi.fn();
+ const onConversationOpen = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onChat,
+ onConversationOpen,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ }),
+ );
+ expect(onConversationOpen).toHaveBeenCalledOnce();
+ expect(onConversationOpen).toHaveBeenCalledWith({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ });
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(onChat).not.toHaveBeenCalled();
+ });
+
+ it("routes conversation.statusChanged to onConversationStatusChanged", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const onConversationStatusChanged = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onConversationStatusChanged,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(
+ JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "active",
+ workspaceId: "w1",
+ }),
+ );
+ expect(onConversationStatusChanged).toHaveBeenCalledOnce();
+ expect(onConversationStatusChanged).toHaveBeenCalledWith({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "active",
+ workspaceId: "w1",
+ });
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it("still routes surface catalog/surface to onMessage", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ const onChat = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ onChat,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] }));
+ expect(onMessage).toHaveBeenCalledOnce();
+ expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] });
+ expect(onChat).not.toHaveBeenCalled();
+
+ ws.invokeMessage(
+ JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }),
+ );
+ expect(onMessage).toHaveBeenCalledTimes(2);
+ });
+
+ it("send accepts and serializes a chat.send message", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ handle.send({ type: "chat.send", message: "hello" });
+ expect(ws.sent).toHaveLength(1);
+ expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" });
+ });
+
+ it("onChat absent is safe (surface-only usage does not throw)", () => {
+ const ws = fakeSocket();
+ const onMessage = vi.fn();
+ createSurfaceSocket({
+ url: "ws://test",
+ onMessage,
+ socketFactory: () => ws,
+ });
+
+ ws.resolveOpen();
+ expect(() => {
+ ws.invokeMessage(
+ JSON.stringify({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" },
+ }),
+ );
+ ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" }));
+ }).not.toThrow();
+ expect(onMessage).not.toHaveBeenCalled();
+ });
+
+ it("chat send is queued until open then flushed", () => {
+ const ws = fakeSocket();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => ws,
+ });
+
+ handle.send({ type: "chat.send", message: "queued" });
+ expect(ws.sent).toHaveLength(0);
+
+ ws.resolveOpen();
+ expect(ws.sent).toHaveLength(1);
+ expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" });
+ });
});
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts
index 54a501c..1309db0 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -1,108 +1,123 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- WsClientMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ConversationCompactedMessage,
+ ConversationOpenMessage,
+ ConversationStatusChangedMessage,
+ WsClientMessage,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import { nextBackoffMs, parseServerMessage, serialize } from "./logic";
export interface WebSocketLike {
- send(data: string): void;
- close(): void;
- onopen: (() => void) | null;
- onmessage: ((ev: { data: string }) => void) | null;
- onclose: ((ev: { code: number; reason: string }) => void) | null;
+ send(data: string): void;
+ close(): void;
+ onopen: (() => void) | null;
+ onmessage: ((ev: { data: string }) => void) | null;
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
}
export interface SurfaceSocketOptions {
- url: string;
- onMessage: (msg: SurfaceServerMessage) => void;
- onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void;
- onReopen?: () => void;
- socketFactory?: (url: string) => WebSocketLike;
+ url: string;
+ onMessage: (msg: SurfaceServerMessage) => void;
+ onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void;
+ /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */
+ onConversationOpen?: (msg: ConversationOpenMessage) => void;
+ /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */
+ onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void;
+ /** Broadcast when a conversation's history has been compacted (reload needed). */
+ onConversationCompacted?: (msg: ConversationCompactedMessage) => void;
+ onReopen?: () => void;
+ socketFactory?: (url: string) => WebSocketLike;
}
export interface SurfaceSocketHandle {
- send(msg: WsClientMessage): void;
- close(): void;
+ send(msg: WsClientMessage): void;
+ close(): void;
}
export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHandle {
- const factory =
- opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike);
+ const factory =
+ opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike);
- let socket: WebSocketLike | null = null;
- let disposed = false;
- let reconnectAttempt = 0;
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
- let isOpen = false;
- const queue: string[] = [];
+ let socket: WebSocketLike | null = null;
+ let disposed = false;
+ let reconnectAttempt = 0;
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ let isOpen = false;
+ const queue: string[] = [];
- function connect(isReconnect: boolean): void {
- socket = factory(opts.url);
- isOpen = false;
+ function connect(isReconnect: boolean): void {
+ socket = factory(opts.url);
+ isOpen = false;
- socket.onopen = () => {
- if (disposed) return;
- isOpen = true;
- reconnectAttempt = 0;
- for (const raw of queue.splice(0)) {
- socket?.send(raw);
- }
- if (isReconnect) {
- opts.onReopen?.();
- }
- };
+ socket.onopen = () => {
+ if (disposed) return;
+ isOpen = true;
+ reconnectAttempt = 0;
+ for (const raw of queue.splice(0)) {
+ socket?.send(raw);
+ }
+ if (isReconnect) {
+ opts.onReopen?.();
+ }
+ };
- socket.onmessage = (ev) => {
- if (disposed) return;
- const msg = parseServerMessage(ev.data);
- if (msg !== null) {
- if (msg.type === "chat.delta" || msg.type === "chat.error") {
- opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage);
- } else {
- opts.onMessage(msg as SurfaceServerMessage);
- }
- }
- };
+ socket.onmessage = (ev) => {
+ if (disposed) return;
+ const msg = parseServerMessage(ev.data);
+ if (msg !== null) {
+ if (msg.type === "chat.delta" || msg.type === "chat.error") {
+ opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage);
+ } else if (msg.type === "conversation.open") {
+ opts.onConversationOpen?.(msg as ConversationOpenMessage);
+ } else if (msg.type === "conversation.statusChanged") {
+ opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage);
+ } else if (msg.type === "conversation.compacted") {
+ opts.onConversationCompacted?.(msg as ConversationCompactedMessage);
+ } else {
+ opts.onMessage(msg as SurfaceServerMessage);
+ }
+ }
+ };
- socket.onclose = () => {
- if (disposed) return;
- isOpen = false;
- scheduleReconnect();
- };
- }
+ socket.onclose = () => {
+ if (disposed) return;
+ isOpen = false;
+ scheduleReconnect();
+ };
+ }
- function scheduleReconnect(): void {
- const delay = nextBackoffMs(reconnectAttempt);
- reconnectAttempt++;
- reconnectTimer = setTimeout(() => {
- reconnectTimer = null;
- if (disposed) return;
- connect(true);
- }, delay);
- }
+ function scheduleReconnect(): void {
+ const delay = nextBackoffMs(reconnectAttempt);
+ reconnectAttempt++;
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ if (disposed) return;
+ connect(true);
+ }, delay);
+ }
- connect(false);
+ connect(false);
- return {
- send(msg: WsClientMessage): void {
- if (disposed) return;
- const raw = serialize(msg);
- if (isOpen) {
- socket?.send(raw);
- } else {
- queue.push(raw);
- }
- },
- close(): void {
- disposed = true;
- if (reconnectTimer !== null) {
- clearTimeout(reconnectTimer);
- reconnectTimer = null;
- }
- socket?.close();
- socket = null;
- },
- };
+ return {
+ send(msg: WsClientMessage): void {
+ if (disposed) return;
+ const raw = serialize(msg);
+ if (isOpen) {
+ socket?.send(raw);
+ } else {
+ queue.push(raw);
+ }
+ },
+ close(): void {
+ disposed = true;
+ if (reconnectTimer !== null) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ socket?.close();
+ socket = null;
+ },
+ };
}
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index 546afe1..dd2b773 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -2,253 +2,399 @@ import { describe, expect, it } from "vitest";
import { nextBackoffMs, parseServerMessage, serialize } from "./logic";
describe("serialize", () => {
- it("serializes a subscribe message", () => {
- const msg = { type: "subscribe" as const, surfaceId: "s1" };
- expect(JSON.parse(serialize(msg))).toEqual(msg);
- });
-
- it("serializes an unsubscribe message", () => {
- const msg = { type: "unsubscribe" as const, surfaceId: "s1" };
- expect(JSON.parse(serialize(msg))).toEqual(msg);
- });
-
- it("serializes an invoke message with payload", () => {
- const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true };
- expect(JSON.parse(serialize(msg))).toEqual(msg);
- });
-
- it("serializes an invoke message without payload", () => {
- const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" };
- expect(JSON.parse(serialize(msg))).toEqual(msg);
- });
-
- it("serializes a chat.send message", () => {
- const msg = { type: "chat.send" as const, message: "hello" };
- expect(JSON.parse(serialize(msg))).toEqual(msg);
- });
-
- it("serializes a chat.send message with all fields", () => {
- const msg = {
- type: "chat.send" as const,
- conversationId: "c1",
- message: "hello",
- model: "openai/gpt-4",
- cwd: "/tmp",
- };
- expect(JSON.parse(serialize(msg))).toEqual(msg);
- });
+ it("serializes a subscribe message", () => {
+ const msg = { type: "subscribe" as const, surfaceId: "s1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes an unsubscribe message", () => {
+ const msg = { type: "unsubscribe" as const, surfaceId: "s1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes an invoke message with payload", () => {
+ const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes an invoke message without payload", () => {
+ const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes a chat.send message", () => {
+ const msg = { type: "chat.send" as const, message: "hello" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+
+ it("serializes a chat.send message with all fields", () => {
+ const msg = {
+ type: "chat.send" as const,
+ conversationId: "c1",
+ message: "hello",
+ model: "openai/gpt-4",
+ cwd: "/tmp",
+ };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
});
describe("parseServerMessage", () => {
- it("parses a catalog message", () => {
- const data = JSON.stringify({
- type: "catalog",
- catalog: [{ id: "s1", region: "r", title: "S1" }],
- });
- const result = parseServerMessage(data);
- expect(result).toEqual({
- type: "catalog",
- catalog: [{ id: "s1", region: "r", title: "S1" }],
- });
- });
-
- it("parses a surface message", () => {
- const data = JSON.stringify({
- type: "surface",
- spec: { id: "s1", region: "r", title: "S1", fields: [] },
- });
- const result = parseServerMessage(data);
- expect(result).toEqual({
- type: "surface",
- spec: { id: "s1", region: "r", title: "S1", fields: [] },
- });
- });
-
- it("parses an update message", () => {
- const data = JSON.stringify({
- type: "update",
- update: {
- surfaceId: "s1",
- spec: { id: "s1", region: "r", title: "S1", fields: [] },
- },
- });
- const result = parseServerMessage(data);
- expect(result).toEqual({
- type: "update",
- update: {
- surfaceId: "s1",
- spec: { id: "s1", region: "r", title: "S1", fields: [] },
- },
- });
- });
-
- it("parses an error message with surfaceId", () => {
- const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" });
- const result = parseServerMessage(data);
- expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" });
- });
-
- it("parses an error message without surfaceId", () => {
- const data = JSON.stringify({ type: "error", message: "global boom" });
- const result = parseServerMessage(data);
- expect(result).toEqual({ type: "error", message: "global boom" });
- });
-
- it("returns null for malformed JSON", () => {
- expect(parseServerMessage("not json")).toBeNull();
- expect(parseServerMessage("{broken")).toBeNull();
- expect(parseServerMessage("")).toBeNull();
- });
-
- it("returns null for non-object JSON", () => {
- expect(parseServerMessage("42")).toBeNull();
- expect(parseServerMessage('"hello"')).toBeNull();
- expect(parseServerMessage("null")).toBeNull();
- expect(parseServerMessage("true")).toBeNull();
- expect(parseServerMessage("[1,2,3]")).toBeNull();
- });
-
- it("returns null for unknown type", () => {
- expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull();
- });
-
- it("returns null when type is missing", () => {
- expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull();
- });
-
- it("returns null when type is not a string", () => {
- expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull();
- });
-
- it("returns null for catalog with non-array catalog field", () => {
- expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull();
- });
-
- it("returns null for surface with missing spec fields", () => {
- expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull();
- });
-
- it("returns null for surface with non-object spec", () => {
- expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull();
- });
-
- it("returns null for update with missing update field", () => {
- expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull();
- });
-
- it("returns null for update with invalid spec", () => {
- expect(
- parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })),
- ).toBeNull();
- });
-
- it("returns null for error with non-string message", () => {
- expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull();
- });
-
- it("returns null for error with invalid surfaceId type", () => {
- expect(
- parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })),
- ).toBeNull();
- });
-
- it("parses a chat.delta message", () => {
- const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" };
- const data = JSON.stringify({ type: "chat.delta", event });
- const result = parseServerMessage(data);
- expect(result).toEqual({ type: "chat.delta", event });
- });
-
- it("parses a chat.error message with conversationId", () => {
- const data = JSON.stringify({
- type: "chat.error",
- conversationId: "c1",
- message: "bad request",
- });
- const result = parseServerMessage(data);
- expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" });
- });
-
- it("parses a chat.error message without conversationId", () => {
- const data = JSON.stringify({ type: "chat.error", message: "no conversation" });
- const result = parseServerMessage(data);
- expect(result).toEqual({ type: "chat.error", message: "no conversation" });
- });
-
- it("returns null for chat.delta with non-object event", () => {
- expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull();
- });
-
- it("returns null for chat.delta with missing event.type", () => {
- expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull();
- });
-
- it("returns null for chat.error with non-string message", () => {
- expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull();
- });
-
- it("returns null for chat.error with invalid conversationId type", () => {
- expect(
- parseServerMessage(
- JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }),
- ),
- ).toBeNull();
- });
+ it("parses a catalog message", () => {
+ const data = JSON.stringify({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "r", title: "S1" }],
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "r", title: "S1" }],
+ });
+ });
+
+ it("parses a surface message", () => {
+ const data = JSON.stringify({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ });
+ });
+
+ it("preserves the conversationId echo on a scoped surface message", () => {
+ const data = JSON.stringify({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ conversationId: "c1",
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ conversationId: "c1",
+ });
+ });
+
+ it("rejects a surface message with a non-string conversationId", () => {
+ const data = JSON.stringify({
+ type: "surface",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ conversationId: 42,
+ });
+ expect(parseServerMessage(data)).toBeNull();
+ });
+
+ it("parses an update message", () => {
+ const data = JSON.stringify({
+ type: "update",
+ update: {
+ surfaceId: "s1",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ },
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "update",
+ update: {
+ surfaceId: "s1",
+ spec: { id: "s1", region: "r", title: "S1", fields: [] },
+ },
+ });
+ });
+
+ it("parses an error message with surfaceId", () => {
+ const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" });
+ });
+
+ it("parses an error message without surfaceId", () => {
+ const data = JSON.stringify({ type: "error", message: "global boom" });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "error", message: "global boom" });
+ });
+
+ it("returns null for malformed JSON", () => {
+ expect(parseServerMessage("not json")).toBeNull();
+ expect(parseServerMessage("{broken")).toBeNull();
+ expect(parseServerMessage("")).toBeNull();
+ });
+
+ it("returns null for non-object JSON", () => {
+ expect(parseServerMessage("42")).toBeNull();
+ expect(parseServerMessage('"hello"')).toBeNull();
+ expect(parseServerMessage("null")).toBeNull();
+ expect(parseServerMessage("true")).toBeNull();
+ expect(parseServerMessage("[1,2,3]")).toBeNull();
+ });
+
+ it("returns null for unknown type", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull();
+ });
+
+ it("returns null when type is missing", () => {
+ expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull();
+ });
+
+ it("returns null when type is not a string", () => {
+ expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull();
+ });
+
+ it("returns null for catalog with non-array catalog field", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull();
+ });
+
+ it("returns null for surface with missing spec fields", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull();
+ });
+
+ it("returns null for surface with non-object spec", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull();
+ });
+
+ it("returns null for update with missing update field", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull();
+ });
+
+ it("returns null for update with invalid spec", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })),
+ ).toBeNull();
+ });
+
+ it("returns null for error with non-string message", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull();
+ });
+
+ it("returns null for error with invalid surfaceId type", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })),
+ ).toBeNull();
+ });
+
+ it("parses a chat.delta message", () => {
+ const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" };
+ const data = JSON.stringify({ type: "chat.delta", event });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "chat.delta", event });
+ });
+
+ it("parses a chat.error message with conversationId", () => {
+ const data = JSON.stringify({
+ type: "chat.error",
+ conversationId: "c1",
+ message: "bad request",
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" });
+ });
+
+ it("parses a chat.error message without conversationId", () => {
+ const data = JSON.stringify({ type: "chat.error", message: "no conversation" });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({ type: "chat.error", message: "no conversation" });
+ });
+
+ it("returns null for chat.delta with non-object event", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull();
+ });
+
+ it("returns null for chat.delta with missing event.type", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull();
+ });
+
+ it("returns null for chat.error with non-string message", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull();
+ });
+
+ it("returns null for chat.error with invalid conversationId type", () => {
+ expect(
+ parseServerMessage(
+ JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }),
+ ),
+ ).toBeNull();
+ });
+
+ it("parses a conversation.open message", () => {
+ const data = JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ });
+ });
+
+ it("returns null for conversation.open with missing conversationId", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })),
+ ).toBeNull();
+ });
+
+ it("returns null for conversation.open with non-string conversationId", () => {
+ expect(
+ parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: 42,
+ workspaceId: "w1",
+ }),
+ ),
+ ).toBeNull();
+ });
+
+ it("returns null for conversation.open with missing workspaceId", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })),
+ ).toBeNull();
+ });
+
+ it("returns null for conversation.open with non-string workspaceId", () => {
+ expect(
+ parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: 42,
+ }),
+ ),
+ ).toBeNull();
+ });
+
+ it("parses a conversation.statusChanged message", () => {
+ const data = JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "active",
+ workspaceId: "w1",
+ });
+ expect(parseServerMessage(data)).toEqual({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "active",
+ workspaceId: "w1",
+ });
+ });
+
+ it("accepts the `queued` status (CR-13 — waiting for a concurrency slot)", () => {
+ const data = JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "queued",
+ workspaceId: "w1",
+ });
+ expect(parseServerMessage(data)).toEqual({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "queued",
+ workspaceId: "w1",
+ });
+ });
+
+ it("returns null for conversation.statusChanged with missing workspaceId", () => {
+ expect(
+ parseServerMessage(
+ JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "active",
+ }),
+ ),
+ ).toBeNull();
+ });
+
+ it("returns null for conversation.statusChanged with non-string workspaceId", () => {
+ expect(
+ parseServerMessage(
+ JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "active",
+ workspaceId: 42,
+ }),
+ ),
+ ).toBeNull();
+ });
+ it("returns null for conversation.statusChanged with invalid status", () => {
+ expect(
+ parseServerMessage(
+ JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "done",
+ workspaceId: "w1",
+ }),
+ ),
+ ).toBeNull();
+ });
+
+ it("returns null for conversation.statusChanged with missing conversationId", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })),
+ ).toBeNull();
+ });
});
describe("round-trip: parseServerMessage(serialize(...))", () => {
- it("round-trips a subscribe message through serialize only", () => {
- const msg = { type: "subscribe" as const, surfaceId: "s1" };
- const wire = serialize(msg);
- expect(JSON.parse(wire)).toEqual(msg);
- });
-
- it("round-trips an invoke message with payload", () => {
- const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false };
- const wire = serialize(msg);
- expect(JSON.parse(wire)).toEqual(msg);
- });
+ it("round-trips a subscribe message through serialize only", () => {
+ const msg = { type: "subscribe" as const, surfaceId: "s1" };
+ const wire = serialize(msg);
+ expect(JSON.parse(wire)).toEqual(msg);
+ });
+
+ it("round-trips an invoke message with payload", () => {
+ const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false };
+ const wire = serialize(msg);
+ expect(JSON.parse(wire)).toEqual(msg);
+ });
});
describe("nextBackoffMs", () => {
- it("returns a positive number", () => {
- expect(nextBackoffMs(0)).toBeGreaterThan(0);
- });
-
- it("is capped at 30s + jitter (at most ~36s)", () => {
- for (let i = 0; i < 100; i++) {
- expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000);
- }
- });
-
- it("starts around 500ms (±20% jitter)", () => {
- for (let i = 0; i < 100; i++) {
- const ms = nextBackoffMs(0);
- expect(ms).toBeGreaterThanOrEqual(400);
- expect(ms).toBeLessThanOrEqual(600);
- }
- });
-
- it("grows exponentially with attempt", () => {
- const averages = [0, 1, 2, 3].map((attempt) => {
- let sum = 0;
- for (let i = 0; i < 200; i++) {
- sum += nextBackoffMs(attempt);
- }
- return sum / 200;
- });
- for (let i = 1; i < averages.length; i++) {
- const prev = averages[i - 1];
- if (prev === undefined) throw new Error("unreachable");
- expect(averages[i]).toBeGreaterThan(prev);
- }
- });
-
- it("treats negative attempt as 0", () => {
- for (let i = 0; i < 50; i++) {
- const ms = nextBackoffMs(-5);
- expect(ms).toBeGreaterThanOrEqual(400);
- expect(ms).toBeLessThanOrEqual(600);
- }
- });
+ it("returns a positive number", () => {
+ expect(nextBackoffMs(0)).toBeGreaterThan(0);
+ });
+
+ it("is capped at 30s + jitter (at most ~36s)", () => {
+ for (let i = 0; i < 100; i++) {
+ expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000);
+ }
+ });
+
+ it("starts around 500ms (±20% jitter)", () => {
+ for (let i = 0; i < 100; i++) {
+ const ms = nextBackoffMs(0);
+ expect(ms).toBeGreaterThanOrEqual(400);
+ expect(ms).toBeLessThanOrEqual(600);
+ }
+ });
+
+ it("grows exponentially with attempt", () => {
+ const averages = [0, 1, 2, 3].map((attempt) => {
+ let sum = 0;
+ for (let i = 0; i < 200; i++) {
+ sum += nextBackoffMs(attempt);
+ }
+ return sum / 200;
+ });
+ for (let i = 1; i < averages.length; i++) {
+ const prev = averages[i - 1];
+ if (prev === undefined) throw new Error("unreachable");
+ expect(averages[i]).toBeGreaterThan(prev);
+ }
+ });
+
+ it("treats negative attempt as 0", () => {
+ for (let i = 0; i < 50; i++) {
+ const ms = nextBackoffMs(-5);
+ expect(ms).toBeGreaterThanOrEqual(400);
+ expect(ms).toBeLessThanOrEqual(600);
+ }
+ });
});
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 6592f1b..03ef763 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -1,32 +1,38 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- WsClientMessage,
- WsServerMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ConversationCompactedMessage,
+ ConversationOpenMessage,
+ ConversationStatusChangedMessage,
+ WsClientMessage,
+ WsServerMessage,
} from "@dispatch/transport-contract";
import type {
- CatalogMessage,
- SurfaceErrorMessage,
- SurfaceMessage,
- SurfaceUpdateMessage,
+ CatalogMessage,
+ SurfaceErrorMessage,
+ SurfaceMessage,
+ SurfaceUpdateMessage,
} from "@dispatch/ui-contract";
const VALID_SERVER_TYPES = new Set([
- "catalog",
- "surface",
- "update",
- "error",
- "chat.delta",
- "chat.error",
+ "catalog",
+ "surface",
+ "update",
+ "error",
+ "chat.delta",
+ "chat.error",
+ "conversation.open",
+ "conversation.statusChanged",
+ "conversation.compacted",
]);
/** Serialize a client message to a JSON string for the wire. */
export function serialize(msg: WsClientMessage): string {
- return JSON.stringify(msg);
+ return JSON.stringify(msg);
}
function isRecord(v: unknown): v is Record<string, unknown> {
- return v !== null && typeof v === "object" && !Array.isArray(v);
+ return v !== null && typeof v === "object" && !Array.isArray(v);
}
/**
@@ -34,74 +40,126 @@ function isRecord(v: unknown): v is Record<string, unknown> {
* Returns null for malformed JSON or shapes that don't match the protocol.
*/
export function parseServerMessage(data: string): WsServerMessage | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(data);
- } catch {
- return null;
- }
- if (!isRecord(parsed)) {
- return null;
- }
- const t = parsed.type;
- if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) {
- return null;
- }
- switch (t) {
- case "catalog": {
- if (!Array.isArray(parsed.catalog)) return null;
- return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] };
- }
- case "surface": {
- const spec = parsed.spec;
- if (!isRecord(spec)) return null;
- if (typeof spec.id !== "string") return null;
- if (typeof spec.region !== "string") return null;
- if (typeof spec.title !== "string") return null;
- if (!Array.isArray(spec.fields)) return null;
- return { type: "surface", spec: spec as unknown as SurfaceMessage["spec"] };
- }
- case "update": {
- const update = parsed.update;
- if (!isRecord(update)) return null;
- if (typeof update.surfaceId !== "string") return null;
- const spec = update.spec;
- if (!isRecord(spec)) return null;
- if (typeof spec.id !== "string") return null;
- if (typeof spec.region !== "string") return null;
- if (typeof spec.title !== "string") return null;
- if (!Array.isArray(spec.fields)) return null;
- return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] };
- }
- case "error": {
- if (typeof parsed.message !== "string") return null;
- const surfaceId = parsed.surfaceId;
- if (surfaceId !== undefined && typeof surfaceId !== "string") return null;
- const msg: SurfaceErrorMessage =
- surfaceId !== undefined
- ? { type: "error", surfaceId, message: parsed.message }
- : { type: "error", message: parsed.message };
- return msg;
- }
- case "chat.delta": {
- const event = parsed.event;
- if (!isRecord(event)) return null;
- if (typeof event.type !== "string") return null;
- return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] };
- }
- case "chat.error": {
- if (typeof parsed.message !== "string") return null;
- const conversationId = parsed.conversationId;
- if (conversationId !== undefined && typeof conversationId !== "string") return null;
- const msg: ChatErrorMessage =
- conversationId !== undefined
- ? { type: "chat.error", conversationId, message: parsed.message }
- : { type: "chat.error", message: parsed.message };
- return msg;
- }
- default:
- return null;
- }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ return null;
+ }
+ if (!isRecord(parsed)) {
+ return null;
+ }
+ const t = parsed.type;
+ if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) {
+ return null;
+ }
+ switch (t) {
+ case "catalog": {
+ if (!Array.isArray(parsed.catalog)) return null;
+ return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] };
+ }
+ case "surface": {
+ const spec = parsed.spec;
+ if (!isRecord(spec)) return null;
+ if (typeof spec.id !== "string") return null;
+ if (typeof spec.region !== "string") return null;
+ if (typeof spec.title !== "string") return null;
+ if (!Array.isArray(spec.fields)) return null;
+ // Preserve the conversationId echo (a conversation-scoped surface's initial
+ // reply carries it) — dropping it would defeat the protocol reducer's
+ // stale-scope filtering on a fast conversation switch.
+ const conversationId = parsed.conversationId;
+ if (conversationId !== undefined && typeof conversationId !== "string") return null;
+ const surfaceSpec = spec as unknown as SurfaceMessage["spec"];
+ return conversationId !== undefined
+ ? { type: "surface", spec: surfaceSpec, conversationId }
+ : { type: "surface", spec: surfaceSpec };
+ }
+ case "update": {
+ const update = parsed.update;
+ if (!isRecord(update)) return null;
+ if (typeof update.surfaceId !== "string") return null;
+ const spec = update.spec;
+ if (!isRecord(spec)) return null;
+ if (typeof spec.id !== "string") return null;
+ if (typeof spec.region !== "string") return null;
+ if (typeof spec.title !== "string") return null;
+ if (!Array.isArray(spec.fields)) return null;
+ return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] };
+ }
+ case "error": {
+ if (typeof parsed.message !== "string") return null;
+ const surfaceId = parsed.surfaceId;
+ if (surfaceId !== undefined && typeof surfaceId !== "string") return null;
+ const msg: SurfaceErrorMessage =
+ surfaceId !== undefined
+ ? { type: "error", surfaceId, message: parsed.message }
+ : { type: "error", message: parsed.message };
+ return msg;
+ }
+ case "chat.delta": {
+ const event = parsed.event;
+ if (!isRecord(event)) return null;
+ if (typeof event.type !== "string") return null;
+ return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] };
+ }
+ case "chat.error": {
+ if (typeof parsed.message !== "string") return null;
+ const conversationId = parsed.conversationId;
+ if (conversationId !== undefined && typeof conversationId !== "string") return null;
+ const msg: ChatErrorMessage =
+ conversationId !== undefined
+ ? { type: "chat.error", conversationId, message: parsed.message }
+ : { type: "chat.error", message: parsed.message };
+ return msg;
+ }
+ case "conversation.open": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.workspaceId !== "string") return null;
+ const msg: ConversationOpenMessage = {
+ type: "conversation.open",
+ conversationId: parsed.conversationId,
+ workspaceId: parsed.workspaceId,
+ };
+ return msg;
+ }
+ case "conversation.statusChanged": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.status !== "string") return null;
+ if (
+ parsed.status !== "active" &&
+ parsed.status !== "queued" &&
+ parsed.status !== "idle" &&
+ parsed.status !== "closed"
+ ) {
+ return null;
+ }
+ if (typeof parsed.workspaceId !== "string") return null;
+ const msg: ConversationStatusChangedMessage = {
+ type: "conversation.statusChanged",
+ conversationId: parsed.conversationId,
+ status: parsed.status,
+ workspaceId: parsed.workspaceId,
+ };
+ return msg;
+ }
+ case "conversation.compacted": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.newConversationId !== "string") return null;
+ if (typeof parsed.messagesSummarized !== "number") return null;
+ if (typeof parsed.messagesKept !== "number") return null;
+ const msg: ConversationCompactedMessage = {
+ type: "conversation.compacted",
+ conversationId: parsed.conversationId,
+ newConversationId: parsed.newConversationId,
+ messagesSummarized: parsed.messagesSummarized,
+ messagesKept: parsed.messagesKept,
+ };
+ return msg;
+ }
+ default:
+ return null;
+ }
}
/**
@@ -109,10 +167,10 @@ export function parseServerMessage(data: string): WsServerMessage | null {
* Base: 500ms, doubles each attempt, caps at 30s, adds ±20% jitter.
*/
export function nextBackoffMs(attempt: number): number {
- const base = 500;
- const max = 30_000;
- const exponential = base * 2 ** Math.max(0, attempt);
- const capped = Math.min(exponential, max);
- const jitter = 0.8 + Math.random() * 0.4;
- return Math.round(capped * jitter);
+ const base = 500;
+ const max = 30_000;
+ const exponential = base * 2 ** Math.max(0, attempt);
+ const capped = Math.min(exponential, max);
+ const jitter = 0.8 + Math.random() * 0.4;
+ return Math.round(capped * jitter);
}