272 lines
9.5 KiB
TypeScript
272 lines
9.5 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
CursorSessionManager,
|
|
type CursorSession,
|
|
} from "../../open-sse/services/cursorSessionManager";
|
|
import { flattenMessages } from "../../open-sse/utils/cursorAgentProtobuf";
|
|
|
|
// ─── Test doubles for h2 ───────────────────────────────────────────────────
|
|
//
|
|
// We don't open real h2 connections in unit tests. Sessions hold opaque
|
|
// references that the manager only ever .close()s or .write()s through
|
|
// encodeExecMcpResult. A pair of stubs is enough.
|
|
|
|
type WriteCall = { kind: "write"; data: Buffer } | { kind: "close" };
|
|
|
|
function mockReq() {
|
|
const calls: WriteCall[] = [];
|
|
return {
|
|
req: {
|
|
write: (data: Buffer) => {
|
|
calls.push({ kind: "write", data });
|
|
return true;
|
|
},
|
|
close: () => {
|
|
calls.push({ kind: "close" });
|
|
},
|
|
} as unknown as import("node:http2").ClientHttp2Stream,
|
|
calls,
|
|
};
|
|
}
|
|
|
|
function mockClient() {
|
|
const closed = { value: false };
|
|
return {
|
|
client: {
|
|
close: () => {
|
|
closed.value = true;
|
|
},
|
|
} as unknown as import("node:http2").ClientHttp2Session,
|
|
closed,
|
|
};
|
|
}
|
|
|
|
// ─── flattenMessages: Phase 6 cold-resume support ──────────────────────────
|
|
|
|
test("flattenMessages handles role:'tool' messages", () => {
|
|
const out = flattenMessages([
|
|
{ role: "user", content: "what's the weather?" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: "call_xyz",
|
|
type: "function",
|
|
function: { name: "get_weather", arguments: '{"city":"Paris"}' },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: "call_xyz", content: "sunny, 22C" },
|
|
]);
|
|
assert.match(out, /User: what's the weather\?/);
|
|
assert.match(
|
|
out,
|
|
/Assistant called tool get_weather \(call_xyz\) with arguments: \{"city":"Paris"\}/
|
|
);
|
|
assert.match(out, /Tool result \(call_xyz\): sunny, 22C/);
|
|
});
|
|
|
|
test("flattenMessages handles assistant with text + tool_calls in same message", () => {
|
|
const out = flattenMessages([
|
|
{ role: "user", content: "do x" },
|
|
{
|
|
role: "assistant",
|
|
content: "Let me check.",
|
|
tool_calls: [
|
|
{
|
|
id: "c1",
|
|
type: "function",
|
|
function: { name: "check", arguments: "{}" },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: "c1", content: "result" },
|
|
]);
|
|
assert.match(out, /Assistant: Let me check\./);
|
|
assert.match(out, /Assistant called tool check \(c1\) with arguments: \{\}/);
|
|
assert.match(out, /Tool result \(c1\): result/);
|
|
});
|
|
|
|
test("flattenMessages handles parallel tool_calls", () => {
|
|
const out = flattenMessages([
|
|
{ role: "user", content: "check both" },
|
|
{
|
|
role: "assistant",
|
|
content: null,
|
|
tool_calls: [
|
|
{ id: "c1", type: "function", function: { name: "tool_a", arguments: "{}" } },
|
|
{ id: "c2", type: "function", function: { name: "tool_b", arguments: "{}" } },
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: "c1", content: "result_a" },
|
|
{ role: "tool", tool_call_id: "c2", content: "result_b" },
|
|
]);
|
|
assert.match(out, /tool_a \(c1\)/);
|
|
assert.match(out, /tool_b \(c2\)/);
|
|
assert.match(out, /Tool result \(c1\): result_a/);
|
|
assert.match(out, /Tool result \(c2\): result_b/);
|
|
});
|
|
|
|
test("flattenMessages keeps single-user fast path unchanged when no tool_calls", () => {
|
|
const out = flattenMessages([{ role: "user", content: "hi" }]);
|
|
assert.equal(out, "hi");
|
|
});
|
|
|
|
// ─── CursorSessionManager lifecycle ────────────────────────────────────────
|
|
|
|
test("CursorSessionManager.open registers a session under conversation_id", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req } = mockReq();
|
|
const { client } = mockClient();
|
|
const session = m.open("conv-1", client, req, new Map());
|
|
assert.equal(m.size(), 1);
|
|
assert.ok(m.has("conv-1"));
|
|
assert.equal(session.conversationId, "conv-1");
|
|
assert.equal(session.state, "running");
|
|
});
|
|
|
|
test("CursorSessionManager.acquire returns undefined when no session", () => {
|
|
const m = new CursorSessionManager();
|
|
assert.equal(m.acquire("nope"), undefined);
|
|
});
|
|
|
|
test("CursorSessionManager.acquire returns undefined when session is still running", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req } = mockReq();
|
|
const { client } = mockClient();
|
|
m.open("conv-2", client, req, new Map());
|
|
// open() leaves state="running"; acquire requires "awaiting_tool_result"
|
|
assert.equal(m.acquire("conv-2"), undefined);
|
|
});
|
|
|
|
test("CursorSessionManager.acquire returns the session after release(awaiting_tool_result)", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req } = mockReq();
|
|
const { client } = mockClient();
|
|
const opened = m.open("conv-3", client, req, new Map());
|
|
m.release(opened, "awaiting_tool_result");
|
|
const acquired = m.acquire("conv-3");
|
|
assert.equal(acquired, opened);
|
|
assert.equal(acquired?.state, "running");
|
|
});
|
|
|
|
test("CursorSessionManager release(awaiting_tool_result) actively evicts after TTL", async () => {
|
|
const m = new CursorSessionManager({ idleTtlMs: 20 });
|
|
const { req } = mockReq();
|
|
const { client, closed } = mockClient();
|
|
const session = m.open("conv-ttl", client, req, new Map());
|
|
m.release(session, "awaiting_tool_result");
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
assert.equal(m.size(), 0);
|
|
assert.ok(closed.value);
|
|
});
|
|
|
|
test("CursorSessionManager enforces a maximum retained session count", () => {
|
|
const m = new CursorSessionManager({ maxSessions: 1 });
|
|
const firstReq = mockReq();
|
|
const firstClient = mockClient();
|
|
m.open("conv-max-1", firstClient.client, firstReq.req, new Map());
|
|
const secondReq = mockReq();
|
|
const secondClient = mockClient();
|
|
m.open("conv-max-2", secondClient.client, secondReq.req, new Map());
|
|
|
|
assert.equal(m.size(), 1);
|
|
assert.equal(m.has("conv-max-1"), false);
|
|
assert.equal(m.has("conv-max-2"), true);
|
|
assert.ok(firstClient.closed.value);
|
|
});
|
|
|
|
test("CursorSessionManager.release(idle) closes the session", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req, calls } = mockReq();
|
|
const { client, closed } = mockClient();
|
|
const session = m.open("conv-4", client, req, new Map());
|
|
m.release(session, "idle");
|
|
assert.equal(m.size(), 0);
|
|
assert.ok(closed.value);
|
|
assert.ok(calls.some((c) => c.kind === "close"));
|
|
});
|
|
|
|
test("CursorSessionManager.acquire evicts expired sessions", () => {
|
|
const m = new CursorSessionManager({ idleTtlMs: 10 });
|
|
const { req } = mockReq();
|
|
const { client, closed } = mockClient();
|
|
const session = m.open("conv-5", client, req, new Map());
|
|
m.release(session, "awaiting_tool_result");
|
|
// Manually backdate lastActivityTs to simulate idle.
|
|
session.lastActivityTs = Date.now() - 1000;
|
|
const acquired = m.acquire("conv-5");
|
|
assert.equal(acquired, undefined);
|
|
assert.equal(m.size(), 0);
|
|
assert.ok(closed.value);
|
|
});
|
|
|
|
test("CursorSessionManager.sendToolResult writes ExecMcpResult on the session's req", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req, calls } = mockReq();
|
|
const { client } = mockClient();
|
|
const session = m.open("conv-6", client, req, new Map());
|
|
session.pendingToolCalls.set("call_x", {
|
|
execMsgId: 1,
|
|
execId: "exec-1",
|
|
toolName: "get_weather",
|
|
});
|
|
const ok = m.sendToolResult(session, "call_x", "sunny", false);
|
|
assert.equal(ok, true);
|
|
// Verify a write happened
|
|
const writes = calls.filter((c) => c.kind === "write");
|
|
assert.equal(writes.length, 1);
|
|
// The write should be a Connect-RPC frame containing "sunny" and "exec-1"
|
|
const data = (writes[0] as { kind: "write"; data: Buffer }).data;
|
|
assert.ok(data.includes(Buffer.from("sunny", "utf8")));
|
|
assert.ok(data.includes(Buffer.from("exec-1", "utf8")));
|
|
// Pending tool call was consumed
|
|
assert.equal(session.pendingToolCalls.has("call_x"), false);
|
|
});
|
|
|
|
test("CursorSessionManager.sendToolResult returns false when openAIToolCallId not pending", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req } = mockReq();
|
|
const { client } = mockClient();
|
|
const session = m.open("conv-7", client, req, new Map());
|
|
const ok = m.sendToolResult(session, "unknown_id", "x", false);
|
|
assert.equal(ok, false);
|
|
});
|
|
|
|
test("CursorSessionManager.close clears unanswered pendingToolCalls", () => {
|
|
const m = new CursorSessionManager();
|
|
const { req } = mockReq();
|
|
const { client } = mockClient();
|
|
const session = m.open("conv-clear", client, req, new Map());
|
|
session.pendingToolCalls.set("call_unanswered", {
|
|
execMsgId: 1,
|
|
execId: "exec-1",
|
|
toolName: "get_weather",
|
|
});
|
|
m.close(session);
|
|
// close() drops the unanswered mapping so it isn't pinned on the dead session.
|
|
assert.equal(session.pendingToolCalls.size, 0);
|
|
assert.equal(m.size(), 0);
|
|
});
|
|
|
|
test("CursorSessionManager.open replaces an existing session for the same conversation", () => {
|
|
const m = new CursorSessionManager();
|
|
const r1 = mockReq();
|
|
const c1 = mockClient();
|
|
const session1 = m.open("conv-8", c1.client, r1.req, new Map());
|
|
m.release(session1, "awaiting_tool_result");
|
|
const r2 = mockReq();
|
|
const c2 = mockClient();
|
|
const session2 = m.open("conv-8", c2.client, r2.req, new Map());
|
|
// First session's client should be closed
|
|
assert.ok(c1.closed.value);
|
|
// Map only has one session; the new one
|
|
assert.equal(m.size(), 1);
|
|
assert.equal(m.acquire("conv-8"), undefined); // session2 is "running"
|
|
void session2;
|
|
});
|