| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130 |
- 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<unknown>;
- 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<string, unknown>;
- 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<string, unknown>;
- 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();
- });
|