summaryrefslogtreecommitdiffhomepage
path: root/src/adapters/ws/logic.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/adapters/ws/logic.test.ts')
-rw-r--r--src/adapters/ws/logic.test.ts626
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);
+ }
+ });
});