import assert from "node:assert/strict"; import test from "node:test"; import { AppServerClient } from "../src/app-server-client.js"; import type { ReadMethod, StatusChangedEvent } from "../src/domain.js"; import { FakeAppServer, rawThread, successfulHandshake } from "./fake-app-server.js"; function clientFor(server: FakeAppServer, requestTimeoutMs = 100): AppServerClient { return new AppServerClient({ url: "ws://127.0.0.1:4500", requestTimeoutMs, connectTimeoutMs: requestTimeoutMs, webSocketFactory: server.factory, }); } test("connection completes initialize request and initialized notification", async () => { const server = new FakeAppServer(successfulHandshake()); const client = clientFor(server); await client.connect(); assert.deepEqual(server.frames.map((frame) => frame.method), ["initialize", "initialized"]); assert.equal(client.connected, true); client.close(); }); test("initialization exposes upstream protocol errors", async () => { const server = new FakeAppServer((request, fake) => { if (request.method === "initialize") fake.fail(request, -32000, "incompatible client"); }); await assert.rejects(clientFor(server).connect(), /incompatible client/); }); test("initialization times out when the server does not respond", async () => { const server = new FakeAppServer(() => undefined); await assert.rejects(clientFor(server, 20).connect(), /request timed out: initialize/); }); test("malformed initialization response is rejected", async () => { const server = new FakeAppServer((request, fake) => { if (request.method === "initialize") fake.socket?.deliver({ id: request.id }); }); await assert.rejects(clientFor(server).connect(), /Malformed response for initialize/); }); test("runtime allowlist rejects mutating method before sending a frame", async () => { const server = new FakeAppServer(successfulHandshake()); const client = clientFor(server); await client.connect(); const before = server.frames.length; const callWithString = client.request.bind(client) as unknown as ( method: string, params: unknown, ) => Promise; assert.throws(() => callWithString("thread/archive", {}), /not allowlisted/); assert.equal(server.frames.length, before); const compileTimeMethod: ReadMethod = "thread/list"; assert.equal(compileTimeMethod, "thread/list"); client.close(); }); test("thread and loaded inventories follow every cursor page", async () => { const server = new FakeAppServer(successfulHandshake((request, fake) => { const params = request.params as Record; if (request.method === "thread/list") { fake.respond(request, params.cursor === null ? { data: [rawThread("a")], nextCursor: "next", backwardsCursor: null } : { data: [rawThread("b")], nextCursor: null, backwardsCursor: null }); } else if (request.method === "thread/loaded/list") { fake.respond(request, params.cursor === null ? { data: ["a"], nextCursor: "loaded-next" } : { data: ["b"], nextCursor: null }); } })); const client = clientFor(server); await client.connect(); assert.deepEqual((await client.listAllThreads()).map((thread) => thread.id), ["a", "b"]); assert.deepEqual([...await client.listLoadedThreadIds()], ["a", "b"]); client.close(); }); test("explicit inspection covers known, unknown, oversized, and malformed responses", async () => { const server = new FakeAppServer(successfulHandshake((request, fake) => { const params = request.params as Record; if (request.method !== "thread/read") return; if (params.threadId === "missing") { fake.fail(request, -32602, "thread not found"); return; } if (params.threadId === "malformed") { fake.respond(request, { thread: { id: "malformed" } }); return; } fake.respond(request, { thread: rawThread("known", { turns: [{ id: "turn", items: [ { id: "user", type: "userMessage", content: [{ type: "text", text: "old" }] }, { id: "agent", type: "agentMessage", text: "0123456789" }, ], }], }), }); })); const client = clientFor(server); await client.connect(); const known = await client.inspectThread("known", { maxItems: 1, maxCharacters: 4 }); assert.deepEqual(known.context.items.map((item) => item.text), ["6789"]); assert.equal(known.context.truncated, true); await assert.rejects(client.inspectThread("missing", { maxItems: 1, maxCharacters: 4 }), /not found/); await assert.rejects(client.inspectThread("malformed", { maxItems: 1, maxCharacters: 4 }), /Malformed/); client.close(); }); test("status notifications preserve active, idle, notLoaded, and active flags", async () => { const server = new FakeAppServer(successfulHandshake()); const client = clientFor(server); const events: StatusChangedEvent[] = []; client.onStatusChanged((event) => events.push(event)); await client.connect(); server.notify("thread/status/changed", { threadId: "a", status: { type: "active", activeFlags: ["waitingOnUserInput"] }, }); server.notify("thread/status/changed", { threadId: "a", status: { type: "idle" } }); server.notify("thread/status/changed", { threadId: "a", status: { type: "notLoaded" } }); assert.deepEqual(events.map((event) => event.status.type), ["active", "idle", "notLoaded"]); assert.deepEqual(events[0]?.status.activeFlags, ["waitingOnUserInput"]); client.close(); });