From 89742db3c9eb9d89fc80ec3eebc57b860b90498a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 10:25:36 +0000 Subject: [PATCH] test: cover resume tool pairing and plugin note fan-out A missing middle tool result, a stray tool id, and a second assistant group must still resume as a valid transcript. A throwing before_llm_call must not drop a later note, and frozen settings arrays must stay frozen. Co-authored-by: moikapy --- test/plugin_host.test.ts | 36 +++++++++++- test/session_persist.test.ts | 110 +++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/test/plugin_host.test.ts b/test/plugin_host.test.ts index e772093..8c81294 100644 --- a/test/plugin_host.test.ts +++ b/test/plugin_host.test.ts @@ -120,10 +120,17 @@ describe("plugin entries in config", () => { it("loader deep-freezes a copy of object-entry settings", async () => { const nested = { level: 1 }; - const { plugins } = await load_plugins([{ path: "named.plugin.ts", settings: { nested } }], FIXTURES); - const settings = plugins[0]?.settings as { nested: { level: number } }; + const list = [{ id: "a" }]; + const { plugins } = await load_plugins([{ path: "named.plugin.ts", settings: { nested, list } }], FIXTURES); + const settings = plugins[0]?.settings as { nested: { level: number }; list: { id: string }[] }; expect(Object.isFrozen(settings.nested)).toBe(true); + expect(Object.isFrozen(settings.list)).toBe(true); + expect(Object.isFrozen(settings.list[0])).toBe(true); expect(Object.isFrozen(nested)).toBe(false); + expect(Object.isFrozen(list)).toBe(false); + expect(() => { + settings.list.push({ id: "b" }); + }).toThrow(TypeError); }); }); @@ -274,4 +281,29 @@ describe("before_llm_call", () => { expect(run.outcome.final?.content).toBe("done"); expect(requests[0]?.at(-1)?.role).toBe("user"); }); + + it("keeps a later note when an earlier hook throws, and drops empty or non-string notes", async () => { + const plugins: Plugin[] = [ + { + name: "broken", + hooks: { + before_llm_call: () => { + throw new Error("decision model down"); + }, + }, + }, + { name: "blank", hooks: { before_llm_call: () => ({ note: "" }) } }, + { name: "weird", hooks: { before_llm_call: () => ({ note: 4 }) as unknown as { note: string } } }, + { name: "lane", hooks: { before_llm_call: () => ({ note: "hold the gate" }) } }, + ]; + const { fetch_fn, requests } = recording_fetch(() => text("done")); + const agent = await make_agent( + fetch_fn, + plugins.map((plugin) => ({ plugin, entry: plugin.name })), + ); + const run = await agent.run({ input: "go" }); + expect(run.outcome.final?.content).toBe("done"); + expect(requests[0]?.at(-1)).toEqual({ role: "system", content: "[plugin lane] hold the gate" }); + expect(run.messages.some((message) => message.content?.includes("hold the gate") === true)).toBe(false); + }); }); diff --git a/test/session_persist.test.ts b/test/session_persist.test.ts index f923d5d..b55f330 100644 --- a/test/session_persist.test.ts +++ b/test/session_persist.test.ts @@ -6,6 +6,7 @@ import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import path from "node:path"; import { afterAll, describe, expect, it } from "vitest"; import { create_agent } from "../src/agent/agent.js"; +import type { Message } from "../src/providers/types.js"; import { open_session, read_session_messages } from "../src/session/store.js"; import { TMP_BASE } from "./helpers/tmp_base.js"; @@ -363,6 +364,115 @@ describe("read_session_messages resume hygiene", () => { ]); }); + it("closes a missing middle call and does not let an unrelated tool row satisfy it", async () => { + const work_dir = await make_temp_dir(); + const handle = await open_session(path.join(work_dir, "sessions"), "middle"); + const rows: Array<{ ts: string; message: Message }> = [ + { + ts: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: "run three" }, + }, + { + ts: "2026-01-01T00:00:01.000Z", + message: { + role: "assistant", + content: "", + tool_calls: [ + { id: "c1", name: "read_file", args: { path: "a.txt" } }, + { id: "c2", name: "read_file", args: { path: "b.txt" } }, + { id: "c3", name: "read_file", args: { path: "c.txt" } }, + ], + }, + }, + { + ts: "2026-01-01T00:00:02.000Z", + message: { role: "tool", tool_call_id: "c1", name: "read_file", content: "denied", is_error: true }, + }, + { + ts: "2026-01-01T00:00:03.000Z", + message: { role: "tool", tool_call_id: "stray", name: "read_file", content: "not-this-call" }, + }, + { + ts: "2026-01-01T00:00:04.000Z", + message: { role: "tool", tool_call_id: "c3", name: "read_file", content: "c-body" }, + }, + ]; + for (const row of rows) { + await handle.append({ ts: row.ts, kind: "message", message: row.message }); + } + const messages = await read_session_messages(handle.path); + const tool_ids = messages.map((message) => (message.role === "tool" ? message.tool_call_id : "")); + expect(tool_ids.filter((id) => id.length > 0)).toEqual(["c1", "stray", "c3", "c2"]); + expect(messages[2]).toMatchObject({ tool_call_id: "c1", content: "denied", is_error: true }); + expect(messages[3]).toMatchObject({ tool_call_id: "stray", content: "not-this-call" }); + expect(messages[4]).toMatchObject({ tool_call_id: "c3", content: "c-body" }); + expect(messages[5]).toMatchObject({ + role: "tool", + tool_call_id: "c2", + name: "read_file", + is_error: true, + content: JSON.stringify({ ok: false, output: "", error: "cancelled" }), + }); + }); + + it("closes a later assistant's open calls after an earlier exchange and drops trailing users", async () => { + const work_dir = await make_temp_dir(); + const handle = await open_session(path.join(work_dir, "sessions"), "two-groups"); + const rows: Array<{ ts: string; message: Message }> = [ + { ts: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "go" } }, + { + ts: "2026-01-01T00:00:01.000Z", + message: { + role: "assistant", + content: "", + tool_calls: [{ id: "c1", name: "read_file", args: { path: "a.txt" } }], + }, + }, + { + ts: "2026-01-01T00:00:02.000Z", + message: { role: "tool", tool_call_id: "c1", name: "read_file", content: "a-body" }, + }, + { + ts: "2026-01-01T00:00:03.000Z", + message: { + role: "assistant", + content: "", + tool_calls: [ + { id: "c2", name: "terminal", args: { command: "echo b" } }, + { id: "c3", name: "terminal", args: { command: "echo c" } }, + ], + }, + }, + { + ts: "2026-01-01T00:00:04.000Z", + message: { role: "tool", tool_call_id: "c2", name: "terminal", content: "b-body" }, + }, + { ts: "2026-01-01T00:00:05.000Z", message: { role: "user", content: "unanswered" } }, + ]; + for (const row of rows) { + await handle.append({ ts: row.ts, kind: "message", message: row.message }); + } + const messages = await read_session_messages(handle.path); + expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "assistant", "tool", "tool"]); + expect(messages.map((message) => (message.role === "tool" ? message.tool_call_id : ""))).toEqual([ + "", + "", + "c1", + "", + "c2", + "c3", + ]); + expect(messages[2]).toMatchObject({ tool_call_id: "c1", content: "a-body" }); + expect(messages[4]).toMatchObject({ tool_call_id: "c2", content: "b-body" }); + expect(messages[5]).toMatchObject({ + tool_call_id: "c3", + name: "terminal", + is_error: true, + content: JSON.stringify({ ok: false, output: "", error: "cancelled" }), + }); + expect(JSON.stringify(messages)).not.toContain("unanswered"); + }); + it("drops every trailing user message left by repeated failures", async () => { const work_dir = await make_temp_dir(); const handle = await open_session(path.join(work_dir, "sessions"), "dangling-many");