app-server-client.test.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import assert from "node:assert/strict";
  2. import test from "node:test";
  3. import { AppServerClient } from "../src/app-server-client.js";
  4. import type { ReadMethod, StatusChangedEvent } from "../src/domain.js";
  5. import { FakeAppServer, rawThread, successfulHandshake } from "./fake-app-server.js";
  6. function clientFor(server: FakeAppServer, requestTimeoutMs = 100): AppServerClient {
  7. return new AppServerClient({
  8. url: "ws://127.0.0.1:4500",
  9. requestTimeoutMs,
  10. connectTimeoutMs: requestTimeoutMs,
  11. webSocketFactory: server.factory,
  12. });
  13. }
  14. test("connection completes initialize request and initialized notification", async () => {
  15. const server = new FakeAppServer(successfulHandshake());
  16. const client = clientFor(server);
  17. await client.connect();
  18. assert.deepEqual(server.frames.map((frame) => frame.method), ["initialize", "initialized"]);
  19. assert.equal(client.connected, true);
  20. client.close();
  21. });
  22. test("initialization exposes upstream protocol errors", async () => {
  23. const server = new FakeAppServer((request, fake) => {
  24. if (request.method === "initialize") fake.fail(request, -32000, "incompatible client");
  25. });
  26. await assert.rejects(clientFor(server).connect(), /incompatible client/);
  27. });
  28. test("initialization times out when the server does not respond", async () => {
  29. const server = new FakeAppServer(() => undefined);
  30. await assert.rejects(clientFor(server, 20).connect(), /request timed out: initialize/);
  31. });
  32. test("malformed initialization response is rejected", async () => {
  33. const server = new FakeAppServer((request, fake) => {
  34. if (request.method === "initialize") fake.socket?.deliver({ id: request.id });
  35. });
  36. await assert.rejects(clientFor(server).connect(), /Malformed response for initialize/);
  37. });
  38. test("runtime allowlist rejects mutating method before sending a frame", async () => {
  39. const server = new FakeAppServer(successfulHandshake());
  40. const client = clientFor(server);
  41. await client.connect();
  42. const before = server.frames.length;
  43. const callWithString = client.request.bind(client) as unknown as (
  44. method: string,
  45. params: unknown,
  46. ) => Promise<unknown>;
  47. assert.throws(() => callWithString("thread/archive", {}), /not allowlisted/);
  48. assert.equal(server.frames.length, before);
  49. const compileTimeMethod: ReadMethod = "thread/list";
  50. assert.equal(compileTimeMethod, "thread/list");
  51. client.close();
  52. });
  53. test("thread and loaded inventories follow every cursor page", async () => {
  54. const server = new FakeAppServer(successfulHandshake((request, fake) => {
  55. const params = request.params as Record<string, unknown>;
  56. if (request.method === "thread/list") {
  57. fake.respond(request, params.cursor === null
  58. ? { data: [rawThread("a")], nextCursor: "next", backwardsCursor: null }
  59. : { data: [rawThread("b")], nextCursor: null, backwardsCursor: null });
  60. } else if (request.method === "thread/loaded/list") {
  61. fake.respond(request, params.cursor === null
  62. ? { data: ["a"], nextCursor: "loaded-next" }
  63. : { data: ["b"], nextCursor: null });
  64. }
  65. }));
  66. const client = clientFor(server);
  67. await client.connect();
  68. assert.deepEqual((await client.listAllThreads()).map((thread) => thread.id), ["a", "b"]);
  69. assert.deepEqual([...await client.listLoadedThreadIds()], ["a", "b"]);
  70. client.close();
  71. });
  72. test("explicit inspection covers known, unknown, oversized, and malformed responses", async () => {
  73. const server = new FakeAppServer(successfulHandshake((request, fake) => {
  74. const params = request.params as Record<string, unknown>;
  75. if (request.method !== "thread/read") return;
  76. if (params.threadId === "missing") {
  77. fake.fail(request, -32602, "thread not found");
  78. return;
  79. }
  80. if (params.threadId === "malformed") {
  81. fake.respond(request, { thread: { id: "malformed" } });
  82. return;
  83. }
  84. fake.respond(request, {
  85. thread: rawThread("known", {
  86. turns: [{
  87. id: "turn",
  88. items: [
  89. { id: "user", type: "userMessage", content: [{ type: "text", text: "old" }] },
  90. { id: "agent", type: "agentMessage", text: "0123456789" },
  91. ],
  92. }],
  93. }),
  94. });
  95. }));
  96. const client = clientFor(server);
  97. await client.connect();
  98. const known = await client.inspectThread("known", { maxItems: 1, maxCharacters: 4 });
  99. assert.deepEqual(known.context.items.map((item) => item.text), ["6789"]);
  100. assert.equal(known.context.truncated, true);
  101. await assert.rejects(client.inspectThread("missing", { maxItems: 1, maxCharacters: 4 }), /not found/);
  102. await assert.rejects(client.inspectThread("malformed", { maxItems: 1, maxCharacters: 4 }), /Malformed/);
  103. client.close();
  104. });
  105. test("status notifications preserve active, idle, notLoaded, and active flags", async () => {
  106. const server = new FakeAppServer(successfulHandshake());
  107. const client = clientFor(server);
  108. const events: StatusChangedEvent[] = [];
  109. client.onStatusChanged((event) => events.push(event));
  110. await client.connect();
  111. server.notify("thread/status/changed", {
  112. threadId: "a",
  113. status: { type: "active", activeFlags: ["waitingOnUserInput"] },
  114. });
  115. server.notify("thread/status/changed", { threadId: "a", status: { type: "idle" } });
  116. server.notify("thread/status/changed", { threadId: "a", status: { type: "notLoaded" } });
  117. assert.deepEqual(events.map((event) => event.status.type), ["active", "idle", "notLoaded"]);
  118. assert.deepEqual(events[0]?.status.activeFlags, ["waitingOnUserInput"]);
  119. client.close();
  120. });