agent-session.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
  2. import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
  3. import { join } from "node:path";
  4. import { tmpdir } from "node:os";
  5. const EMPTY_USAGE = {
  6. input: 0,
  7. output: 0,
  8. cacheRead: 0,
  9. cacheWrite: 0,
  10. totalTokens: 0,
  11. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
  12. };
  13. const { agentInstances, streamCalls } = vi.hoisted(() => ({
  14. agentInstances: [] as any[],
  15. streamCalls: [] as Array<{ model: any; context: any }>,
  16. }));
  17. vi.mock("@mariozechner/pi-agent-core", async () => {
  18. const actual = await vi.importActual<any>("@mariozechner/pi-agent-core");
  19. class SpyAgent extends actual.Agent {
  20. constructor(options: any) {
  21. super(options);
  22. agentInstances.push(this);
  23. }
  24. }
  25. return { ...actual, Agent: SpyAgent };
  26. });
  27. vi.mock("@mariozechner/pi-ai", async () => {
  28. const actual = await vi.importActual<any>("@mariozechner/pi-ai");
  29. function clone(value: unknown): unknown {
  30. return JSON.parse(JSON.stringify(value));
  31. }
  32. function textFromContent(content: unknown): string {
  33. if (typeof content === "string") return content;
  34. if (!Array.isArray(content)) return "";
  35. return content
  36. .filter((block: any) => block?.type === "text" && typeof block.text === "string")
  37. .map((block: any) => block.text)
  38. .join("");
  39. }
  40. function lastVisibleUserText(messages: any[]): string {
  41. for (let index = messages.length - 1; index >= 0; index -= 1) {
  42. const message = messages[index];
  43. if (message?.role === "user") return textFromContent(message.content);
  44. }
  45. return "";
  46. }
  47. function assistant(content: any[], timestamp = Date.now()) {
  48. return {
  49. role: "assistant",
  50. content,
  51. api: "anthropic-messages",
  52. provider: "anthropic",
  53. model: "fake",
  54. usage: EMPTY_USAGE,
  55. stopReason: content.some((block) => block.type === "toolCall") ? "toolUse" : "stop",
  56. timestamp,
  57. };
  58. }
  59. const streamSimple = vi.fn((model: any, context: any) => {
  60. streamCalls.push({ model: clone(model), context: clone(context) });
  61. const stream = actual.createAssistantMessageEventStream();
  62. const last = context.messages.at(-1);
  63. const prompt = lastVisibleUserText(context.messages);
  64. const timestamp = Date.now();
  65. const message = last?.role === "toolResult"
  66. ? assistant([{ type: "text", text: "ok" }], timestamp)
  67. : prompt === "think"
  68. ? assistant([
  69. { type: "thinking", thinking: "raw thought", thinkingSignature: "sig-1" },
  70. { type: "text", text: "ok" },
  71. ], timestamp)
  72. : prompt === "use tool"
  73. ? assistant([
  74. {
  75. type: "toolCall",
  76. id: "tool-1",
  77. name: "read",
  78. arguments: { path: "book-a/story/story_bible.md" },
  79. },
  80. ], timestamp)
  81. : assistant([{ type: "text", text: "ok" }], timestamp);
  82. stream.push({
  83. type: "done",
  84. reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
  85. message,
  86. });
  87. return stream;
  88. });
  89. return {
  90. ...actual,
  91. streamSimple,
  92. getEnvApiKey: vi.fn(() => "fake-key"),
  93. getModel: vi.fn((provider: string, id: string) => ({
  94. provider,
  95. id,
  96. name: id,
  97. api: "anthropic-messages",
  98. baseUrl: "",
  99. reasoning: false,
  100. input: ["text"],
  101. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  102. contextWindow: 200_000,
  103. maxTokens: 4096,
  104. })),
  105. };
  106. });
  107. import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
  108. import { readTranscriptEvents } from "../interaction/session-transcript.js";
  109. describe("runAgentSession cache — bookId switch", () => {
  110. let projectRoot: string;
  111. beforeEach(async () => {
  112. projectRoot = await mkdtemp(join(tmpdir(), "inkos-agent-cache-"));
  113. await mkdir(join(projectRoot, "books", "book-a", "story"), { recursive: true });
  114. await writeFile(
  115. join(projectRoot, "books", "book-a", "story", "story_bible.md"),
  116. "书A 的真相",
  117. );
  118. await mkdir(join(projectRoot, "books", "book-b", "story"), { recursive: true });
  119. await writeFile(
  120. join(projectRoot, "books", "book-b", "story", "story_bible.md"),
  121. "书B 的真相",
  122. );
  123. agentInstances.length = 0;
  124. streamCalls.length = 0;
  125. });
  126. afterEach(async () => {
  127. evictAgentCache("s1");
  128. await rm(projectRoot, { recursive: true, force: true });
  129. });
  130. it("rebuilds Agent when bookId changes for same sessionId", async () => {
  131. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  132. const pipeline = {} as any;
  133. await runAgentSession(
  134. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  135. "earlier question about book A",
  136. );
  137. expect(agentInstances).toHaveLength(1);
  138. await runAgentSession(
  139. { sessionId: "s1", bookId: "book-b", language: "zh", pipeline, projectRoot, model },
  140. "new question",
  141. );
  142. expect(agentInstances).toHaveLength(2);
  143. const body = JSON.stringify(streamCalls.at(-1)?.context.messages);
  144. expect(body).toContain("书B 的真相");
  145. expect(body).not.toContain("书A 的真相");
  146. expect(body).toContain("earlier question about book A");
  147. });
  148. it("rebuilds Agent when bookId goes from null to a real book", async () => {
  149. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  150. const pipeline = {} as any;
  151. await runAgentSession(
  152. { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
  153. "hi",
  154. );
  155. expect(agentInstances).toHaveLength(1);
  156. await runAgentSession(
  157. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  158. "hi",
  159. );
  160. expect(agentInstances).toHaveLength(2);
  161. expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).toContain("书A 的真相");
  162. });
  163. it("treats undefined bookId as null (no spurious rebuild)", async () => {
  164. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  165. const pipeline = {} as any;
  166. await runAgentSession(
  167. { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
  168. "hi",
  169. );
  170. expect(agentInstances).toHaveLength(1);
  171. await runAgentSession(
  172. { sessionId: "s1", bookId: undefined as any, language: "zh", pipeline, projectRoot, model },
  173. "hi",
  174. );
  175. expect(agentInstances).toHaveLength(1);
  176. });
  177. it("reuses Agent when bookId unchanged on same sessionId", async () => {
  178. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  179. const pipeline = {} as any;
  180. await runAgentSession(
  181. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  182. "hi",
  183. );
  184. await runAgentSession(
  185. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  186. "hi2",
  187. );
  188. expect(agentInstances).toHaveLength(1);
  189. });
  190. it("enables system file read by default for the session read tool", async () => {
  191. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  192. const pipeline = {} as any;
  193. const outsidePath = join(projectRoot, "outside.md");
  194. await writeFile(outsidePath, "outside content", "utf-8");
  195. await runAgentSession(
  196. { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
  197. "hi",
  198. );
  199. const readTool = agentInstances[0].state.tools.find((tool: any) => tool.name === "read");
  200. const result = await readTool.execute("tool-read-default-session", { path: outsidePath });
  201. expect(result.content[0]?.type).toBe("text");
  202. if (result.content[0]?.type === "text") {
  203. expect(result.content[0].text).toContain("outside content");
  204. }
  205. });
  206. it("can explicitly disable system file read for the session read tool", async () => {
  207. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  208. const pipeline = {} as any;
  209. const outsidePath = join(projectRoot, "outside.md");
  210. await writeFile(outsidePath, "outside content", "utf-8");
  211. await runAgentSession(
  212. {
  213. sessionId: "s1",
  214. bookId: null,
  215. language: "zh",
  216. pipeline,
  217. projectRoot,
  218. model,
  219. allowSystemFileRead: false,
  220. },
  221. "hi",
  222. );
  223. const readTool = agentInstances[0].state.tools.find((tool: any) => tool.name === "read");
  224. const result = await readTool.execute("tool-read-disabled-session", { path: outsidePath });
  225. expect(result.content[0]?.type).toBe("text");
  226. if (result.content[0]?.type === "text") {
  227. expect(result.content[0].text).toContain("Path traversal blocked");
  228. expect(result.content[0].text).not.toContain("outside content");
  229. }
  230. });
  231. it("把真实 Agent 的 message_end 写入 JSONL,并在 cache 失效后恢复 raw AgentMessage", async () => {
  232. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  233. const pipeline = {} as any;
  234. await runAgentSession(
  235. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  236. "think",
  237. );
  238. const events = await readTranscriptEvents(projectRoot, "s1");
  239. expect(events.map((event) => event.type)).toContain("request_committed");
  240. evictAgentCache("s1");
  241. await runAgentSession(
  242. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  243. "again",
  244. );
  245. expect(agentInstances).toHaveLength(2);
  246. expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).toContain("raw thought");
  247. expect(streamCalls.at(-1)?.context.messages).toEqual(
  248. expect.arrayContaining([
  249. expect.objectContaining({ role: "assistant" }),
  250. ]),
  251. );
  252. });
  253. it("恢复 transcript 中的 toolResult message", async () => {
  254. const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
  255. const pipeline = {} as any;
  256. await runAgentSession(
  257. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  258. "use tool",
  259. );
  260. evictAgentCache("s1");
  261. await runAgentSession(
  262. { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
  263. "again",
  264. );
  265. expect(agentInstances).toHaveLength(2);
  266. expect(streamCalls.at(-1)?.context.messages.some(
  267. (message: any) => message.role === "toolResult" && message.toolCallId === "tool-1",
  268. )).toBe(true);
  269. const messageEvents = (await readTranscriptEvents(projectRoot, "s1"))
  270. .filter((event) => event.type === "message");
  271. const toolAssistant = messageEvents.find(
  272. (event: any) => event.toolCallId === "tool-1" && event.role === "assistant",
  273. ) as any;
  274. const toolResult = messageEvents.find(
  275. (event: any) => event.toolCallId === "tool-1" && event.role === "toolResult",
  276. ) as any;
  277. expect(toolResult.sourceToolAssistantUuid).toBe(toolAssistant.uuid);
  278. });
  279. });