diff options
Diffstat (limited to 'src/adapters/ws')
| -rw-r--r-- | src/adapters/ws/index.test.ts | 729 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 183 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 626 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 238 |
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); } |
