runner.spec.ts 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403
  1. import { describe, expect, it, vi, beforeEach } from "vitest"
  2. import { AgentRunner, ModelDoesNotSupportToolsError } from "./runner"
  3. import { ToolRegistry } from "./registry"
  4. import type { AgentConfig, AgentMessage } from "./types"
  5. import type { Tool } from "./types"
  6. import { TOOL_EXECUTE_TIMEOUT_MS } from "./types"
  7. import type { StreamCallbacks } from "../llm-client"
  8. import type { LlmConfig } from "@/stores/wiki-store"
  9. const breakpointMocks = vi.hoisted(() => ({
  10. createTaskBreakpoint: vi.fn(() => ({
  11. taskId: "test",
  12. taskGoal: "test",
  13. completedStages: [],
  14. currentStage: "agent_round_1",
  15. usedSkills: [],
  16. usedTools: [],
  17. searches: [],
  18. mcpCalls: [],
  19. createdAt: 0,
  20. updatedAt: 0,
  21. })),
  22. updateBreakpointStage: vi.fn((bp, newStage, completedStage) => ({
  23. ...bp,
  24. currentStage: newStage,
  25. completedStages: completedStage ? [...bp.completedStages, completedStage] : bp.completedStages,
  26. updatedAt: 1,
  27. })),
  28. saveTaskBreakpoint: vi.fn(async () => {}),
  29. clearTaskBreakpoint: vi.fn(async () => {}),
  30. buildBreakpointResumePrompt: vi.fn(() => ""),
  31. }))
  32. vi.mock("./task-breakpoint", () => breakpointMocks)
  33. const mockLlmConfig: LlmConfig = {
  34. provider: "openai",
  35. apiKey: "",
  36. model: "test",
  37. ollamaUrl: "",
  38. customEndpoint: "",
  39. maxContextSize: 8192,
  40. }
  41. // Mock streamChat
  42. const mockStreamChat = vi.fn()
  43. vi.mock("../llm-client", () => ({
  44. streamChat: (...args: unknown[]) => mockStreamChat(...args),
  45. }))
  46. describe("AgentRunner", () => {
  47. let runner: AgentRunner
  48. let registry: ToolRegistry
  49. const systemMsg: AgentMessage = { role: "system", content: "You are helpful" }
  50. const userMsg: AgentMessage = { role: "user", content: "Hello" }
  51. beforeEach(() => {
  52. runner = new AgentRunner()
  53. registry = new ToolRegistry()
  54. mockStreamChat.mockReset()
  55. breakpointMocks.createTaskBreakpoint.mockClear()
  56. breakpointMocks.updateBreakpointStage.mockClear()
  57. breakpointMocks.saveTaskBreakpoint.mockClear()
  58. breakpointMocks.clearTaskBreakpoint.mockClear()
  59. })
  60. it("returns final text when LLM responds without tool calls", async () => {
  61. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  62. for (const char of "Hello user!") {
  63. cb.onToken(char)
  64. }
  65. cb.onDone()
  66. })
  67. const callbacks = {
  68. onText: vi.fn(),
  69. onToolCall: vi.fn(),
  70. onToolResult: vi.fn(),
  71. onToolError: vi.fn(),
  72. onDone: vi.fn(),
  73. onError: vi.fn(),
  74. }
  75. const config: AgentConfig = { maxRounds: 3, tools: [], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
  76. const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
  77. expect(result.finalText).toBe("Hello user!")
  78. expect(result.roundsUsed).toBe(1)
  79. expect(callbacks.onDone).toHaveBeenCalledOnce()
  80. expect(callbacks.onError).not.toHaveBeenCalled()
  81. })
  82. it("nudges and continues when requiredToolsOnce is missing on a no-tool final", async () => {
  83. const tool: Tool = {
  84. name: "run_chapter_workflow",
  85. description: "workflow",
  86. category: "action",
  87. parameters: {
  88. userRequest: { type: "string", description: "request", required: true },
  89. },
  90. execute: vi.fn().mockResolvedValue("最终正文:\n章节内容"),
  91. }
  92. registry.register(tool)
  93. mockStreamChat
  94. .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  95. cb.onToken("我直接写完了")
  96. cb.onDone()
  97. })
  98. .mockImplementationOnce(async (_config: unknown, msgs: unknown[], cb: StreamCallbacks) => {
  99. const messages = msgs as AgentMessage[]
  100. expect(messages.some((message) =>
  101. message.role === "system" &&
  102. typeof message.content === "string" &&
  103. message.content.includes("禁止直接输出章节终稿"),
  104. )).toBe(true)
  105. cb.onToolCallDelta?.({ index: 0, id: "call_workflow_1", name: "run_chapter_workflow" })
  106. cb.onToolCallDelta?.({ index: 0, arguments: '{"userRequest":"写第1章"}' })
  107. cb.onDone()
  108. })
  109. .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  110. cb.onToken("最终交付正文")
  111. cb.onDone()
  112. })
  113. const callbacks = {
  114. onText: vi.fn(),
  115. onToolCall: vi.fn(),
  116. onToolResult: vi.fn(),
  117. onToolError: vi.fn(),
  118. onDone: vi.fn(),
  119. onError: vi.fn(),
  120. }
  121. const result = await runner.run(
  122. {
  123. maxRounds: 4,
  124. tools: [tool],
  125. systemPrompt: "You are helpful",
  126. llmConfig: mockLlmConfig,
  127. requiredToolsOnce: ["run_chapter_workflow"],
  128. },
  129. registry,
  130. [systemMsg, userMsg],
  131. callbacks,
  132. undefined,
  133. )
  134. expect(tool.execute).toHaveBeenCalledOnce()
  135. expect(callbacks.onDone).toHaveBeenCalledOnce()
  136. expect(callbacks.onError).not.toHaveBeenCalled()
  137. expect(result.finalText).toBe("最终交付正文")
  138. expect(result.roundsUsed).toBe(3)
  139. // blocked draft must not stream as final
  140. expect(callbacks.onText.mock.calls.flat()).not.toContain("我直接写完了")
  141. })
  142. it("errors on last round when requiredToolsOnce is never called", async () => {
  143. const tool: Tool = {
  144. name: "run_chapter_workflow",
  145. description: "workflow",
  146. category: "action",
  147. parameters: {
  148. userRequest: { type: "string", description: "request", required: true },
  149. },
  150. execute: vi.fn().mockResolvedValue("ok"),
  151. }
  152. registry.register(tool)
  153. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  154. cb.onToken("直出正文")
  155. cb.onDone()
  156. })
  157. const callbacks = {
  158. onText: vi.fn(),
  159. onToolCall: vi.fn(),
  160. onToolResult: vi.fn(),
  161. onToolError: vi.fn(),
  162. onDone: vi.fn(),
  163. onError: vi.fn(),
  164. }
  165. const result = await runner.run(
  166. {
  167. maxRounds: 2,
  168. tools: [tool],
  169. systemPrompt: "You are helpful",
  170. llmConfig: mockLlmConfig,
  171. requiredToolsOnce: ["run_chapter_workflow"],
  172. },
  173. registry,
  174. [systemMsg, userMsg],
  175. callbacks,
  176. undefined,
  177. )
  178. expect(callbacks.onDone).not.toHaveBeenCalled()
  179. expect(callbacks.onError).toHaveBeenCalledOnce()
  180. expect(callbacks.onError.mock.calls[0][0].name).toBe("RequiredToolsNotCalledError")
  181. expect(result.finalText).toBe("")
  182. expect(tool.execute).not.toHaveBeenCalled()
  183. })
  184. it("does not block no-tool finals when requiredToolsOnce is unset", async () => {
  185. const tool: Tool = {
  186. name: "run_chapter_workflow",
  187. description: "workflow",
  188. category: "action",
  189. parameters: {},
  190. execute: vi.fn().mockResolvedValue("ok"),
  191. }
  192. registry.register(tool)
  193. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  194. cb.onToken("普通回复")
  195. cb.onDone()
  196. })
  197. const callbacks = {
  198. onText: vi.fn(),
  199. onToolCall: vi.fn(),
  200. onToolResult: vi.fn(),
  201. onToolError: vi.fn(),
  202. onDone: vi.fn(),
  203. onError: vi.fn(),
  204. }
  205. const result = await runner.run(
  206. {
  207. maxRounds: 2,
  208. tools: [tool],
  209. systemPrompt: "You are helpful",
  210. llmConfig: mockLlmConfig,
  211. },
  212. registry,
  213. [systemMsg, userMsg],
  214. callbacks,
  215. undefined,
  216. )
  217. expect(result.finalText).toBe("普通回复")
  218. expect(callbacks.onDone).toHaveBeenCalledOnce()
  219. expect(callbacks.onError).not.toHaveBeenCalled()
  220. })
  221. it("skips required-tools gate after tools fallback disables tools", async () => {
  222. const tool: Tool = {
  223. name: "run_chapter_workflow",
  224. description: "workflow",
  225. category: "action",
  226. parameters: {},
  227. execute: vi.fn().mockResolvedValue("ok"),
  228. }
  229. registry.register(tool)
  230. let callCount = 0
  231. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks, _signal?: unknown, overrides?: { tools?: unknown }) => {
  232. callCount += 1
  233. if (callCount === 1 && overrides?.tools) {
  234. cb.onError?.(new Error("tools are not supported"))
  235. return
  236. }
  237. cb.onToken("无工具回退正文")
  238. cb.onDone()
  239. })
  240. const callbacks = {
  241. onText: vi.fn(),
  242. onToolCall: vi.fn(),
  243. onToolResult: vi.fn(),
  244. onToolError: vi.fn(),
  245. onDone: vi.fn(),
  246. onError: vi.fn(),
  247. }
  248. const result = await runner.run(
  249. {
  250. maxRounds: 2,
  251. tools: [tool],
  252. systemPrompt: "You are helpful",
  253. llmConfig: mockLlmConfig,
  254. requiredToolsOnce: ["run_chapter_workflow"],
  255. },
  256. registry,
  257. [systemMsg, userMsg],
  258. callbacks,
  259. undefined,
  260. )
  261. expect(result.finalText).toBe("无工具回退正文")
  262. expect(callbacks.onDone).toHaveBeenCalledOnce()
  263. expect(callbacks.onError).not.toHaveBeenCalled()
  264. })
  265. it("replays reasoning_content on tool-call assistant messages in the next round", async () => {
  266. const tool: Tool = {
  267. name: "read_chapter",
  268. description: "read",
  269. category: "read",
  270. parameters: { name: { type: "string", description: "name" } },
  271. execute: vi.fn().mockResolvedValue("Chapter content"),
  272. }
  273. registry.register(tool)
  274. let callCount = 0
  275. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  276. callCount += 1
  277. if (callCount === 1) {
  278. cb.onReasoningToken?.("先读章节")
  279. cb.onToolCallDelta?.({ index: 0, id: "call_reason_1", name: "read_chapter" })
  280. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  281. cb.onDone()
  282. return
  283. }
  284. cb.onToken("写完了")
  285. cb.onDone()
  286. })
  287. const config: AgentConfig = {
  288. maxRounds: 3,
  289. tools: [tool],
  290. systemPrompt: "You are helpful",
  291. llmConfig: mockLlmConfig,
  292. }
  293. const result = await runner.run(
  294. config,
  295. registry,
  296. [systemMsg, userMsg],
  297. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  298. undefined,
  299. )
  300. const round2Messages = mockStreamChat.mock.calls[1][1] as AgentMessage[]
  301. const toolAssistant = round2Messages.find((message) => message.role === "assistant" && message.tool_calls?.length)
  302. expect(toolAssistant?.reasoning_content).toBe("先读章节")
  303. expect(result.finalText).toBe("写完了")
  304. })
  305. it("always attaches reasoning_content for tool-call assistants even when empty", async () => {
  306. const tool: Tool = {
  307. name: "read_chapter",
  308. description: "read",
  309. category: "read",
  310. parameters: { name: { type: "string", description: "name" } },
  311. execute: vi.fn().mockResolvedValue("Chapter content"),
  312. }
  313. registry.register(tool)
  314. let callCount = 0
  315. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  316. callCount += 1
  317. if (callCount === 1) {
  318. cb.onToolCallDelta?.({ index: 0, id: "call_empty_reason", name: "read_chapter" })
  319. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  320. cb.onDone()
  321. return
  322. }
  323. cb.onToken("完成")
  324. cb.onDone()
  325. })
  326. await runner.run(
  327. {
  328. maxRounds: 3,
  329. tools: [tool],
  330. systemPrompt: "You are helpful",
  331. llmConfig: mockLlmConfig,
  332. },
  333. registry,
  334. [systemMsg, userMsg],
  335. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  336. undefined,
  337. )
  338. const round2Messages = mockStreamChat.mock.calls[1][1] as AgentMessage[]
  339. const toolAssistant = round2Messages.find((message) => message.role === "assistant" && message.tool_calls?.length)
  340. expect(toolAssistant).toHaveProperty("reasoning_content", "")
  341. })
  342. it("passes cacheable system content blocks through to the provider layer", async () => {
  343. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  344. cb.onToken("完成")
  345. cb.onDone()
  346. })
  347. const cacheableSystem: AgentMessage = {
  348. role: "system",
  349. content: [
  350. { type: "text", text: "稳定核心", cacheControl: true },
  351. { type: "text", text: "动态上下文" },
  352. ],
  353. }
  354. await runner.run(
  355. { maxRounds: 1, tools: [], systemPrompt: "", llmConfig: mockLlmConfig },
  356. registry,
  357. [cacheableSystem, userMsg],
  358. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  359. )
  360. expect(mockStreamChat.mock.calls[0][1][0]).toEqual(cacheableSystem)
  361. })
  362. it("aggregates provider usage across agent rounds", async () => {
  363. let round = 0
  364. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  365. round += 1
  366. if (round === 1) {
  367. cb.onUsage?.({ inputTokens: 1000, outputTokens: 20, cachedInputTokens: 600 })
  368. cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "missing_tool", arguments: "{}" })
  369. } else {
  370. cb.onUsage?.({ inputTokens: 700, outputTokens: 80, cachedInputTokens: 500 })
  371. cb.onToken("完成")
  372. }
  373. cb.onDone()
  374. })
  375. const result = await runner.run(
  376. { maxRounds: 2, tools: [], systemPrompt: "", llmConfig: mockLlmConfig },
  377. registry,
  378. [systemMsg, userMsg],
  379. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  380. )
  381. expect(result.usage).toEqual({
  382. inputTokens: 1700,
  383. outputTokens: 100,
  384. cachedInputTokens: 1100,
  385. })
  386. })
  387. it("executes tool calls and continues the loop", async () => {
  388. const tool: Tool = {
  389. name: "read_chapter",
  390. description: "read",
  391. category: "read",
  392. parameters: { name: { type: "string", description: "name" } },
  393. execute: vi.fn().mockResolvedValue("Chapter content"),
  394. }
  395. registry.register(tool)
  396. // Round 1: tool call
  397. // Round 2: final text
  398. let callCount = 0
  399. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  400. callCount++
  401. if (callCount === 1) {
  402. cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
  403. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  404. cb.onDone()
  405. } else {
  406. cb.onToken("G")
  407. cb.onToken("ot it!")
  408. cb.onDone()
  409. }
  410. })
  411. const callbacks = {
  412. onText: vi.fn(),
  413. onToolCall: vi.fn(),
  414. onToolResult: vi.fn(),
  415. onToolError: vi.fn(),
  416. onDone: vi.fn(),
  417. onError: vi.fn(),
  418. }
  419. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
  420. const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
  421. expect(tool.execute).toHaveBeenCalledWith(
  422. { name: "ch1" },
  423. undefined,
  424. expect.objectContaining({ callId: "call_1", toolName: "read_chapter" }),
  425. )
  426. expect(callbacks.onToolCall).toHaveBeenCalledOnce()
  427. expect(callbacks.onToolResult).toHaveBeenCalledOnce()
  428. expect(result.finalText).toBe("Got it!")
  429. expect(result.roundsUsed).toBe(2)
  430. })
  431. it("parses text JSON tool calls for cursor-cli providers", async () => {
  432. const tool: Tool = {
  433. name: "read_chapter",
  434. description: "read",
  435. category: "read",
  436. parameters: { name: { type: "string", description: "name" } },
  437. execute: vi.fn().mockResolvedValue("Chapter content"),
  438. }
  439. registry.register(tool)
  440. let callCount = 0
  441. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  442. callCount++
  443. if (callCount === 1) {
  444. cb.onToken('{"name":"read_chapter","arguments":{"name":"ch1"}}')
  445. cb.onDone()
  446. } else {
  447. cb.onToken("完成")
  448. cb.onDone()
  449. }
  450. })
  451. const callbacks = {
  452. onText: vi.fn(),
  453. onToolCall: vi.fn(),
  454. onToolResult: vi.fn(),
  455. onToolError: vi.fn(),
  456. onDone: vi.fn(),
  457. onError: vi.fn(),
  458. }
  459. const config: AgentConfig = {
  460. maxRounds: 3,
  461. tools: [tool],
  462. systemPrompt: "You are helpful",
  463. llmConfig: { ...mockLlmConfig, provider: "cursor-cli" },
  464. }
  465. const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
  466. expect(tool.execute).toHaveBeenCalledWith(
  467. { name: "ch1" },
  468. undefined,
  469. expect.objectContaining({ toolName: "read_chapter" }),
  470. )
  471. expect(callbacks.onToolCall).toHaveBeenCalledOnce()
  472. expect(result.finalText).toBe("完成")
  473. expect(result.roundsUsed).toBe(2)
  474. })
  475. it("passes the real tool call id into tool execution context", async () => {
  476. const tool: Tool = {
  477. name: "read_chapter",
  478. description: "read",
  479. category: "read",
  480. parameters: { name: { type: "string", description: "name" } },
  481. execute: vi.fn().mockResolvedValue("Chapter content"),
  482. }
  483. registry.register(tool)
  484. let callCount = 0
  485. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  486. callCount++
  487. if (callCount === 1) {
  488. cb.onToolCallDelta?.({ index: 0, id: "real_call_1", name: "read_chapter" })
  489. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  490. cb.onDone()
  491. } else {
  492. cb.onToken("done")
  493. cb.onDone()
  494. }
  495. })
  496. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  497. await runner.run(
  498. config,
  499. registry,
  500. [systemMsg, userMsg],
  501. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  502. undefined,
  503. )
  504. expect(tool.execute).toHaveBeenCalledWith(
  505. { name: "ch1" },
  506. undefined,
  507. expect.objectContaining({
  508. callId: "real_call_1",
  509. toolName: "read_chapter",
  510. }),
  511. )
  512. })
  513. it("passes the tool event emitter into tool execution context", async () => {
  514. const tool: Tool = {
  515. name: "read_chapter",
  516. description: "read",
  517. category: "read",
  518. parameters: { name: { type: "string", description: "name" } },
  519. execute: vi.fn().mockResolvedValue("Chapter content"),
  520. }
  521. registry.register(tool)
  522. let callCount = 0
  523. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  524. callCount++
  525. if (callCount === 1) {
  526. cb.onToolCallDelta?.({ index: 0, id: "real_call_2", name: "read_chapter" })
  527. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  528. cb.onDone()
  529. } else {
  530. cb.onToken("done")
  531. cb.onDone()
  532. }
  533. })
  534. const onToolEvent = vi.fn()
  535. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  536. await runner.run(
  537. config,
  538. registry,
  539. [systemMsg, userMsg],
  540. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn(), onToolEvent },
  541. undefined,
  542. )
  543. expect(tool.execute).toHaveBeenCalledWith(
  544. { name: "ch1" },
  545. undefined,
  546. expect.objectContaining({
  547. callId: "real_call_2",
  548. toolName: "read_chapter",
  549. onToolEvent,
  550. }),
  551. )
  552. })
  553. it("passes the activity event emitter into tool execution context", async () => {
  554. const tool: Tool = {
  555. name: "read_chapter",
  556. description: "read",
  557. category: "read",
  558. parameters: { name: { type: "string", description: "name" } },
  559. execute: vi.fn(async (_params, _signal, context) => {
  560. context?.onActivityEvent?.({
  561. id: "tool-activity",
  562. stageId: "read_context",
  563. kind: "read_source",
  564. title: "读取章节",
  565. content: "读取章节《第1章》",
  566. toolCallId: context.callId,
  567. timestamp: 100,
  568. })
  569. return "Chapter content"
  570. }),
  571. }
  572. registry.register(tool)
  573. let callCount = 0
  574. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  575. callCount++
  576. if (callCount === 1) {
  577. cb.onToolCallDelta?.({ index: 0, id: "real_call_3", name: "read_chapter" })
  578. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  579. cb.onDone()
  580. } else {
  581. cb.onToken("done")
  582. cb.onDone()
  583. }
  584. })
  585. const onActivityEvent = vi.fn()
  586. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  587. await runner.run(
  588. config,
  589. registry,
  590. [systemMsg, userMsg],
  591. {
  592. onText: vi.fn(),
  593. onToolCall: vi.fn(),
  594. onToolResult: vi.fn(),
  595. onToolError: vi.fn(),
  596. onDone: vi.fn(),
  597. onError: vi.fn(),
  598. onActivityEvent,
  599. },
  600. undefined,
  601. )
  602. expect(tool.execute).toHaveBeenCalledWith(
  603. { name: "ch1" },
  604. undefined,
  605. expect.objectContaining({
  606. callId: "real_call_3",
  607. toolName: "read_chapter",
  608. onActivityEvent,
  609. }),
  610. )
  611. expect(onActivityEvent).toHaveBeenCalledWith(expect.objectContaining({
  612. stageId: "read_context",
  613. kind: "read_source",
  614. title: "读取章节",
  615. }))
  616. })
  617. it("does not stream assistant narration from tool-call rounds", async () => {
  618. const tool: Tool = {
  619. name: "read_chapter",
  620. description: "read",
  621. category: "read",
  622. parameters: { name: { type: "string", description: "name" } },
  623. execute: vi.fn().mockResolvedValue("Chapter content"),
  624. }
  625. registry.register(tool)
  626. let callCount = 0
  627. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  628. callCount++
  629. if (callCount === 1) {
  630. cb.onToken("我先读取上一章。")
  631. cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
  632. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"ch1"}' })
  633. cb.onDone()
  634. } else {
  635. cb.onToken("章节正文")
  636. cb.onDone()
  637. }
  638. })
  639. const callbacks = {
  640. onText: vi.fn(),
  641. onToolCall: vi.fn(),
  642. onToolResult: vi.fn(),
  643. onToolError: vi.fn(),
  644. onDone: vi.fn(),
  645. onError: vi.fn(),
  646. }
  647. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "You are helpful", llmConfig: mockLlmConfig }
  648. const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
  649. expect(callbacks.onText).toHaveBeenCalledTimes(1)
  650. expect(callbacks.onText).toHaveBeenCalledWith("章节正文")
  651. expect(result.finalText).toBe("章节正文")
  652. })
  653. it("stops after maxRounds exceeded", async () => {
  654. // Always return tool calls
  655. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  656. cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
  657. cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
  658. cb.onDone()
  659. })
  660. const tool: Tool = {
  661. name: "read_chapter",
  662. description: "",
  663. category: "read",
  664. parameters: {},
  665. execute: vi.fn().mockResolvedValue("ok"),
  666. }
  667. registry.register(tool)
  668. const onError = vi.fn()
  669. const config: AgentConfig = { maxRounds: 2, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  670. await runner.run(config, registry, [systemMsg, userMsg], { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError }, undefined)
  671. expect(onError).toHaveBeenCalled()
  672. expect(onError.mock.calls[0][0].message).toContain("轮次")
  673. })
  674. it("reports tool execution errors via onToolError", async () => {
  675. const tool: Tool = {
  676. name: "bad_tool",
  677. description: "",
  678. category: "read",
  679. parameters: {},
  680. execute: vi.fn().mockRejectedValue(new Error("execution failed")),
  681. }
  682. registry.register(tool)
  683. let callCount = 0
  684. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  685. callCount++
  686. if (callCount === 1) {
  687. cb.onToolCallDelta?.({ index: 0, id: "c1", name: "bad_tool" })
  688. cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
  689. cb.onDone()
  690. } else {
  691. cb.onToken("ok")
  692. cb.onDone()
  693. }
  694. })
  695. const onToolError = vi.fn()
  696. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  697. await runner.run(config, registry, [systemMsg, userMsg], { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError, onDone: vi.fn(), onError: vi.fn() }, undefined)
  698. expect(onToolError).toHaveBeenCalledOnce()
  699. })
  700. it("allows long-running workflow tools to opt out of the generic 30 second timeout", async () => {
  701. vi.useFakeTimers()
  702. try {
  703. const tool: Tool = {
  704. name: "run_chapter_workflow",
  705. description: "workflow",
  706. category: "action",
  707. permission: "auto",
  708. executeTimeoutMs: 0,
  709. parameters: {},
  710. execute: vi.fn(() => new Promise<string>((resolve) => {
  711. setTimeout(() => resolve("章节工作流完成"), TOOL_EXECUTE_TIMEOUT_MS + 1000)
  712. })),
  713. }
  714. registry.register(tool)
  715. let callCount = 0
  716. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  717. callCount++
  718. if (callCount === 1) {
  719. cb.onToolCallDelta?.({ index: 0, id: "workflow_1", name: "run_chapter_workflow" })
  720. cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
  721. cb.onDone()
  722. } else {
  723. cb.onToken("完成")
  724. cb.onDone()
  725. }
  726. })
  727. const onToolError = vi.fn()
  728. const onToolResult = vi.fn()
  729. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  730. const runPromise = runner.run(
  731. config,
  732. registry,
  733. [systemMsg, userMsg],
  734. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult, onToolError, onDone: vi.fn(), onError: vi.fn() },
  735. undefined,
  736. )
  737. await vi.advanceTimersByTimeAsync(TOOL_EXECUTE_TIMEOUT_MS + 1000)
  738. const result = await runPromise
  739. expect(onToolError).not.toHaveBeenCalled()
  740. expect(onToolResult).toHaveBeenCalledWith("workflow_1", "章节工作流完成")
  741. expect(result.toolCalls[0].status).toBe("done")
  742. } finally {
  743. vi.useRealTimers()
  744. }
  745. })
  746. it("executes confirm-required write tools for preview and sets approval_required status", async () => {
  747. const tool: Tool = {
  748. name: "write_chapter",
  749. description: "",
  750. category: "write",
  751. permission: "confirm",
  752. parameters: {},
  753. execute: vi.fn().mockResolvedValue("written preview content"),
  754. }
  755. registry.register(tool)
  756. let callCount = 0
  757. mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
  758. callCount++
  759. if (callCount === 1) {
  760. cb.onToolCallDelta?.({ index: 0, id: "write_1", name: "write_chapter" })
  761. cb.onToolCallDelta?.({ index: 0, arguments: '{"name":"第1章","content":"正文"}' })
  762. cb.onDone()
  763. } else {
  764. expect(messages[messages.length - 1].content).toContain("written preview content")
  765. cb.onToken("已生成写入预览,等待确认。")
  766. cb.onDone()
  767. }
  768. })
  769. const onToolEvent = vi.fn()
  770. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  771. const result = await runner.run(
  772. config,
  773. registry,
  774. [systemMsg, userMsg],
  775. {
  776. onText: vi.fn(),
  777. onToolCall: vi.fn(),
  778. onToolResult: vi.fn(),
  779. onToolError: vi.fn(),
  780. onToolEvent,
  781. onDone: vi.fn(),
  782. onError: vi.fn(),
  783. },
  784. undefined,
  785. )
  786. expect(tool.execute).toHaveBeenCalledTimes(1)
  787. expect(result.toolCalls[0].status).toBe("approval_required")
  788. expect((result.toolCalls[0] as any).preview).toBe("written preview content")
  789. expect(result.toolCalls[0].result).toBe("written preview content")
  790. expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "approval_required", callId: "write_1", preview: "written preview content" }))
  791. })
  792. it("marks confirm-required tools as error when preview generation throws", async () => {
  793. const tool: Tool = {
  794. name: "write_outline_node",
  795. description: "",
  796. category: "write",
  797. permission: "confirm",
  798. parameters: {},
  799. generatePreview: vi.fn().mockRejectedValue(new Error("无法写入大纲:大纲文件名称必须是 Markdown 文件。")),
  800. execute: vi.fn(),
  801. }
  802. registry.register(tool)
  803. let callCount = 0
  804. mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
  805. callCount++
  806. if (callCount === 1) {
  807. cb.onToolCallDelta?.({ index: 0, id: "write_outline_1", name: "write_outline_node" })
  808. cb.onToolCallDelta?.({ index: 0, arguments: '{"outlineName":"坏目标.txt","nodeTitle":"节点","nodeContent":"正文"}' })
  809. cb.onDone()
  810. } else {
  811. expect(messages[messages.length - 1].content).toContain("预览生成失败")
  812. cb.onToken("写入目标无效,已跳过确认。")
  813. cb.onDone()
  814. }
  815. })
  816. const onToolError = vi.fn()
  817. const onToolEvent = vi.fn()
  818. const config: AgentConfig = { maxRounds: 3, tools: [tool], systemPrompt: "", llmConfig: mockLlmConfig }
  819. const result = await runner.run(
  820. config,
  821. registry,
  822. [systemMsg, userMsg],
  823. {
  824. onText: vi.fn(),
  825. onToolCall: vi.fn(),
  826. onToolResult: vi.fn(),
  827. onToolError,
  828. onToolEvent,
  829. onDone: vi.fn(),
  830. onError: vi.fn(),
  831. },
  832. undefined,
  833. )
  834. expect(tool.generatePreview).toHaveBeenCalledTimes(1)
  835. expect(tool.execute).not.toHaveBeenCalled()
  836. expect(result.toolCalls[0].status).toBe("error")
  837. expect(result.toolCalls[0].result).toContain("预览生成失败")
  838. expect(onToolError).toHaveBeenCalledWith("write_outline_1", expect.stringContaining("预览生成失败"))
  839. expect(onToolEvent).toHaveBeenCalledWith(expect.objectContaining({ type: "error", callId: "write_outline_1" }))
  840. })
  841. it("keeps full tool result in record but sends compressed result back to the model", async () => {
  842. const longResult = `${"开头".repeat(2000)}\n中间内容\n${"结尾".repeat(2000)}`
  843. const tool: Tool = {
  844. name: "read_chapter",
  845. description: "read",
  846. category: "read",
  847. parameters: {},
  848. execute: vi.fn().mockResolvedValue(longResult),
  849. }
  850. registry.register(tool)
  851. let compressedToolMessage = ""
  852. let callCount = 0
  853. mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
  854. callCount++
  855. if (callCount === 1) {
  856. cb.onToolCallDelta?.({ index: 0, id: "read_1", name: "read_chapter" })
  857. cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
  858. cb.onDone()
  859. } else {
  860. compressedToolMessage = String(messages[messages.length - 1].content)
  861. cb.onToken("已分析")
  862. cb.onDone()
  863. }
  864. })
  865. const config: AgentConfig = {
  866. maxRounds: 3,
  867. tools: [tool],
  868. systemPrompt: "",
  869. llmConfig: mockLlmConfig,
  870. toolResultContextLimit: 1200,
  871. }
  872. const result = await runner.run(
  873. config,
  874. registry,
  875. [systemMsg, userMsg],
  876. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  877. undefined,
  878. )
  879. expect(result.toolCalls[0].result).toBe(longResult)
  880. expect(compressedToolMessage.length).toBeLessThan(longResult.length)
  881. expect(compressedToolMessage).toContain("已压缩给模型使用")
  882. expect(compressedToolMessage).toContain("开头")
  883. expect(compressedToolMessage).toContain("结尾")
  884. })
  885. it("每轮模型请求保留任务契约并压缩内部工作消息", async () => {
  886. mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
  887. const total = messages.reduce((sum, message) => sum + (typeof message.content === "string" ? message.content.length : 0), 0)
  888. expect(total).toBeLessThanOrEqual(750)
  889. expect(messages.some((message) => String(message.content).includes("任务契约"))).toBe(true)
  890. expect(messages.some((message) => String(message.content).includes("完成整本小说"))).toBe(true)
  891. cb.onToken("完成")
  892. cb.onDone()
  893. })
  894. const callbacks = { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() }
  895. await runner.run(
  896. {
  897. maxRounds: 2,
  898. tools: [],
  899. systemPrompt: "",
  900. taskGoal: "完成整本小说,不能改变主角身份。",
  901. llmConfig: { ...mockLlmConfig, maxContextSize: 1_000 },
  902. },
  903. registry,
  904. [
  905. { role: "system", content: "系统规则".repeat(120) },
  906. { role: "assistant", content: "旧结果".repeat(180) },
  907. { role: "user", content: "继续执行当前任务" },
  908. ],
  909. callbacks,
  910. )
  911. expect(callbacks.onError).not.toHaveBeenCalled()
  912. })
  913. it("15 轮重复工具调用不会让内部上下文无限增长", async () => {
  914. const tool: Tool = {
  915. name: "read_memory",
  916. description: "read",
  917. category: "read",
  918. parameters: {},
  919. execute: vi.fn(async () => "记忆原文".repeat(3000)),
  920. }
  921. registry.register(tool)
  922. let round = 0
  923. const injectedToolContents: string[] = []
  924. mockStreamChat.mockImplementation(async (_config: unknown, messages: AgentMessage[], cb: StreamCallbacks) => {
  925. round += 1
  926. const total = messages.reduce((sum, message) => sum + (typeof message.content === "string" ? message.content.length : 0), 0)
  927. expect(total).toBeLessThanOrEqual(2250)
  928. const latestTool = [...messages].reverse().find((message) => message.role === "tool")
  929. if (latestTool) injectedToolContents.push(String(latestTool.content))
  930. if (round <= 15) {
  931. cb.onToolCallDelta?.({ index: 0, id: `call-${round}`, name: "read_memory", arguments: '{"name":"核心目标"}' })
  932. } else {
  933. cb.onToken("全部完成")
  934. }
  935. cb.onDone()
  936. })
  937. const callbacks = { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() }
  938. const result = await runner.run({
  939. maxRounds: 16,
  940. tools: [tool],
  941. systemPrompt: "",
  942. taskGoal: "持续读取并完成长期任务",
  943. llmConfig: { ...mockLlmConfig, maxContextSize: 3000 },
  944. toolResultContextLimit: 1200,
  945. }, registry, [systemMsg, userMsg], callbacks)
  946. expect(result.toolCalls).toHaveLength(15)
  947. expect(tool.execute).toHaveBeenCalledTimes(15)
  948. expect(injectedToolContents.some((content) => content.includes("工具证据引用"))).toBe(true)
  949. expect(callbacks.onError).not.toHaveBeenCalled()
  950. })
  951. it("merges caller request overrides with tool calling options", async () => {
  952. const tool: Tool = {
  953. name: "read_chapter",
  954. description: "read",
  955. category: "read",
  956. parameters: {},
  957. execute: vi.fn().mockResolvedValue("Chapter content"),
  958. }
  959. registry.register(tool)
  960. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  961. cb.onToken("章节正文")
  962. cb.onDone()
  963. })
  964. const config: AgentConfig = {
  965. maxRounds: 3,
  966. tools: [tool],
  967. systemPrompt: "",
  968. llmConfig: mockLlmConfig,
  969. requestOverrides: {
  970. max_tokens: 8000,
  971. reasoning: { mode: "off" },
  972. },
  973. }
  974. await runner.run(
  975. config,
  976. registry,
  977. [systemMsg, userMsg],
  978. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  979. undefined,
  980. )
  981. expect(mockStreamChat).toHaveBeenCalledWith(
  982. mockLlmConfig,
  983. expect.any(Array),
  984. expect.any(Object),
  985. undefined,
  986. expect.objectContaining({
  987. max_tokens: 8000,
  988. reasoning: { mode: "off" },
  989. tools: expect.any(Array),
  990. toolChoice: "auto",
  991. }),
  992. )
  993. })
  994. it("omits tools when functionCallingEnabled is false on llmConfig", async () => {
  995. const tool: Tool = {
  996. name: "read_chapter",
  997. description: "read",
  998. category: "read",
  999. parameters: {},
  1000. execute: vi.fn().mockResolvedValue("Chapter content"),
  1001. }
  1002. registry.register(tool)
  1003. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1004. cb.onToken("普通回复")
  1005. cb.onDone()
  1006. })
  1007. await runner.run(
  1008. {
  1009. maxRounds: 3,
  1010. tools: [tool],
  1011. systemPrompt: "",
  1012. llmConfig: { ...mockLlmConfig, functionCallingEnabled: false },
  1013. },
  1014. registry,
  1015. [systemMsg, userMsg],
  1016. { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  1017. undefined,
  1018. )
  1019. expect(mockStreamChat).toHaveBeenCalledWith(
  1020. expect.objectContaining({ functionCallingEnabled: false }),
  1021. expect.any(Array),
  1022. expect.any(Object),
  1023. undefined,
  1024. undefined,
  1025. )
  1026. })
  1027. it("retries once without tools when the API rejects function calling", async () => {
  1028. const tool: Tool = {
  1029. name: "read_chapter",
  1030. description: "read",
  1031. category: "read",
  1032. parameters: {},
  1033. execute: vi.fn().mockResolvedValue("Chapter content"),
  1034. }
  1035. registry.register(tool)
  1036. mockStreamChat
  1037. .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1038. cb.onError(new Error("This model does not support function calling / tools"))
  1039. })
  1040. .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1041. cb.onToken("降级正文")
  1042. cb.onDone()
  1043. })
  1044. const callbacks = {
  1045. onText: vi.fn(),
  1046. onToolCall: vi.fn(),
  1047. onToolResult: vi.fn(),
  1048. onToolError: vi.fn(),
  1049. onDone: vi.fn(),
  1050. onError: vi.fn(),
  1051. }
  1052. const result = await runner.run(
  1053. {
  1054. maxRounds: 3,
  1055. tools: [tool],
  1056. systemPrompt: "",
  1057. llmConfig: mockLlmConfig,
  1058. },
  1059. registry,
  1060. [systemMsg, userMsg],
  1061. callbacks,
  1062. undefined,
  1063. )
  1064. expect(mockStreamChat).toHaveBeenCalledTimes(2)
  1065. expect(mockStreamChat.mock.calls[0][4]).toEqual(expect.objectContaining({
  1066. tools: expect.any(Array),
  1067. toolChoice: "auto",
  1068. }))
  1069. expect(mockStreamChat.mock.calls[1][4]).toBeUndefined()
  1070. expect(result.finalText).toBe("降级正文")
  1071. expect(callbacks.onError).not.toHaveBeenCalled()
  1072. })
  1073. it("emits ModelDoesNotSupportToolsError when tool-less retry still fails", async () => {
  1074. const tool: Tool = {
  1075. name: "read_chapter",
  1076. description: "read",
  1077. category: "read",
  1078. parameters: {},
  1079. execute: vi.fn().mockResolvedValue("Chapter content"),
  1080. }
  1081. registry.register(tool)
  1082. mockStreamChat
  1083. .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1084. cb.onError(new Error("does not support function calling"))
  1085. })
  1086. .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1087. cb.onError(new Error("upstream 500"))
  1088. })
  1089. const callbacks = {
  1090. onText: vi.fn(),
  1091. onToolCall: vi.fn(),
  1092. onToolResult: vi.fn(),
  1093. onToolError: vi.fn(),
  1094. onDone: vi.fn(),
  1095. onError: vi.fn(),
  1096. }
  1097. await runner.run(
  1098. {
  1099. maxRounds: 3,
  1100. tools: [tool],
  1101. systemPrompt: "",
  1102. llmConfig: mockLlmConfig,
  1103. },
  1104. registry,
  1105. [systemMsg, userMsg],
  1106. callbacks,
  1107. undefined,
  1108. )
  1109. expect(mockStreamChat).toHaveBeenCalledTimes(2)
  1110. expect(callbacks.onError).toHaveBeenCalledWith(expect.any(ModelDoesNotSupportToolsError))
  1111. })
  1112. it("does not treat unrelated unsupported errors as missing function calling", async () => {
  1113. const tool: Tool = {
  1114. name: "read_chapter",
  1115. description: "read",
  1116. category: "read",
  1117. parameters: {},
  1118. execute: vi.fn().mockResolvedValue("Chapter content"),
  1119. }
  1120. registry.register(tool)
  1121. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1122. cb.onError(new Error("Unsupported parameter: temperature"))
  1123. })
  1124. const callbacks = {
  1125. onText: vi.fn(),
  1126. onToolCall: vi.fn(),
  1127. onToolResult: vi.fn(),
  1128. onToolError: vi.fn(),
  1129. onDone: vi.fn(),
  1130. onError: vi.fn(),
  1131. }
  1132. await runner.run(
  1133. {
  1134. maxRounds: 3,
  1135. tools: [tool],
  1136. systemPrompt: "",
  1137. llmConfig: mockLlmConfig,
  1138. },
  1139. registry,
  1140. [systemMsg, userMsg],
  1141. callbacks,
  1142. undefined,
  1143. )
  1144. expect(mockStreamChat).toHaveBeenCalledTimes(1)
  1145. expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
  1146. message: "Unsupported parameter: temperature",
  1147. }))
  1148. })
  1149. it("retries a reasoning-only model round once with reasoning disabled", async () => {
  1150. const reasoningOnlyError = new Error("模型只输出了 543 字符的思考内容,但没有输出正文。")
  1151. mockStreamChat.mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1152. cb.onError(reasoningOnlyError)
  1153. })
  1154. mockStreamChat.mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1155. cb.onToken("章节正文")
  1156. cb.onDone()
  1157. })
  1158. const callbacks = {
  1159. onText: vi.fn(),
  1160. onToolCall: vi.fn(),
  1161. onToolResult: vi.fn(),
  1162. onToolError: vi.fn(),
  1163. onDone: vi.fn(),
  1164. onError: vi.fn(),
  1165. }
  1166. const config: AgentConfig = {
  1167. maxRounds: 3,
  1168. tools: [],
  1169. systemPrompt: "",
  1170. llmConfig: { ...mockLlmConfig, reasoning: { mode: "high" } },
  1171. }
  1172. const result = await runner.run(config, registry, [systemMsg, userMsg], callbacks, undefined)
  1173. expect(mockStreamChat).toHaveBeenCalledTimes(2)
  1174. expect(mockStreamChat.mock.calls[0][4]).toBeUndefined()
  1175. expect(mockStreamChat.mock.calls[1][4]).toEqual({ reasoning: { mode: "off" } })
  1176. expect(result.finalText).toBe("章节正文")
  1177. expect(callbacks.onText).toHaveBeenCalledWith("章节正文")
  1178. expect(callbacks.onDone).toHaveBeenCalledOnce()
  1179. expect(callbacks.onError).not.toHaveBeenCalled()
  1180. })
  1181. describe("Stage F 断点保存与清理", () => {
  1182. it("config.projectPath 存在时创建并保存断点", async () => {
  1183. const tool: Tool = {
  1184. name: "read_chapter",
  1185. description: "read",
  1186. category: "read",
  1187. parameters: {},
  1188. execute: vi.fn().mockResolvedValue("章节内容"),
  1189. }
  1190. registry.register(tool)
  1191. let callCount = 0
  1192. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1193. callCount++
  1194. if (callCount === 1) {
  1195. cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "read_chapter" })
  1196. cb.onToolCallDelta?.({ index: 0, arguments: "{}" })
  1197. cb.onDone()
  1198. } else {
  1199. cb.onToken("完成")
  1200. cb.onDone()
  1201. }
  1202. })
  1203. const config: AgentConfig = {
  1204. maxRounds: 3,
  1205. tools: [tool],
  1206. systemPrompt: "",
  1207. llmConfig: mockLlmConfig,
  1208. projectPath: "/test",
  1209. taskGoal: "写第一章",
  1210. }
  1211. await runner.run(config, registry, [systemMsg, userMsg], {
  1212. onText: vi.fn(),
  1213. onToolCall: vi.fn(),
  1214. onToolResult: vi.fn(),
  1215. onToolError: vi.fn(),
  1216. onDone: vi.fn(),
  1217. onError: vi.fn(),
  1218. })
  1219. expect(breakpointMocks.createTaskBreakpoint).toHaveBeenCalledWith(
  1220. expect.objectContaining({ taskGoal: "写第一章", currentStage: "agent_round_1" })
  1221. )
  1222. expect(breakpointMocks.saveTaskBreakpoint).toHaveBeenCalledWith("/test", expect.any(Object))
  1223. expect(breakpointMocks.updateBreakpointStage).toHaveBeenCalled()
  1224. })
  1225. it("成功完成时清理断点", async () => {
  1226. mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
  1227. cb.onToken("完成")
  1228. cb.onDone()
  1229. })
  1230. const config: AgentConfig = {
  1231. maxRounds: 3,
  1232. tools: [],
  1233. systemPrompt: "",
  1234. llmConfig: mockLlmConfig,
  1235. projectPath: "/test",
  1236. taskGoal: "写第一章",
  1237. }
  1238. await runner.run(config, registry, [systemMsg, userMsg], {
  1239. onText: vi.fn(),
  1240. onToolCall: vi.fn(),
  1241. onToolResult: vi.fn(),
  1242. onToolError: vi.fn(),
  1243. onDone: vi.fn(),
  1244. onError: vi.fn(),
  1245. })
  1246. expect(breakpointMocks.clearTaskBreakpoint).toHaveBeenCalledWith("/test")
  1247. })
  1248. it("失败时保留断点", async () => {
  1249. mockStreamChat.mockRejectedValueOnce(new Error("模型失败"))
  1250. const config: AgentConfig = {
  1251. maxRounds: 3,
  1252. tools: [],
  1253. systemPrompt: "",
  1254. llmConfig: mockLlmConfig,
  1255. projectPath: "/test",
  1256. taskGoal: "写第一章",
  1257. }
  1258. await runner.run(config, registry, [systemMsg, userMsg], {
  1259. onText: vi.fn(),
  1260. onToolCall: vi.fn(),
  1261. onToolResult: vi.fn(),
  1262. onToolError: vi.fn(),
  1263. onDone: vi.fn(),
  1264. onError: vi.fn(),
  1265. })
  1266. expect(breakpointMocks.saveTaskBreakpoint).toHaveBeenCalled()
  1267. expect(breakpointMocks.clearTaskBreakpoint).not.toHaveBeenCalled()
  1268. })
  1269. })
  1270. })