runner.spec.ts 53 KB

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