diff options
Diffstat (limited to 'src/adapters/ws/logic.test.ts')
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 626 |
1 files changed, 386 insertions, 240 deletions
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); + } + }); }); |
