Task.spec.ts 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189
  1. // npx vitest core/task/__tests__/Task.spec.ts
  2. import * as os from "os"
  3. import * as path from "path"
  4. import * as vscode from "vscode"
  5. import { Anthropic } from "@anthropic-ai/sdk"
  6. import type { GlobalState, ProviderSettings, ModelInfo } from "@roo-code/types"
  7. import { TelemetryService } from "@roo-code/telemetry"
  8. import { Task } from "../Task"
  9. import { ClineProvider } from "../../webview/ClineProvider"
  10. import { ApiStreamChunk } from "../../../api/transform/stream"
  11. import { ContextProxy } from "../../config/ContextProxy"
  12. import { processUserContentMentions } from "../../mentions/processUserContentMentions"
  13. import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace"
  14. import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace"
  15. import { EXPERIMENT_IDS } from "../../../shared/experiments"
  16. // Mock delay before any imports that might use it
  17. vi.mock("delay", () => ({
  18. __esModule: true,
  19. default: vi.fn().mockResolvedValue(undefined),
  20. }))
  21. import delay from "delay"
  22. vi.mock("uuid", async (importOriginal) => {
  23. const actual = await importOriginal<typeof import("uuid")>()
  24. return {
  25. ...actual,
  26. v7: vi.fn(() => "00000000-0000-7000-8000-000000000000"),
  27. }
  28. })
  29. vi.mock("execa", () => ({
  30. execa: vi.fn(),
  31. }))
  32. vi.mock("fs/promises", async (importOriginal) => {
  33. const actual = (await importOriginal()) as Record<string, any>
  34. const mockFunctions = {
  35. mkdir: vi.fn().mockResolvedValue(undefined),
  36. writeFile: vi.fn().mockResolvedValue(undefined),
  37. readFile: vi.fn().mockImplementation((filePath) => {
  38. if (filePath.includes("ui_messages.json")) {
  39. return Promise.resolve(JSON.stringify(mockMessages))
  40. }
  41. if (filePath.includes("api_conversation_history.json")) {
  42. return Promise.resolve(
  43. JSON.stringify([
  44. {
  45. role: "user",
  46. content: [{ type: "text", text: "historical task" }],
  47. ts: Date.now(),
  48. },
  49. {
  50. role: "assistant",
  51. content: [{ type: "text", text: "I'll help you with that task." }],
  52. ts: Date.now(),
  53. },
  54. ]),
  55. )
  56. }
  57. return Promise.resolve("[]")
  58. }),
  59. unlink: vi.fn().mockResolvedValue(undefined),
  60. rmdir: vi.fn().mockResolvedValue(undefined),
  61. }
  62. return {
  63. ...actual,
  64. ...mockFunctions,
  65. default: mockFunctions,
  66. }
  67. })
  68. vi.mock("p-wait-for", () => ({
  69. default: vi.fn().mockImplementation(async () => Promise.resolve()),
  70. }))
  71. vi.mock("vscode", () => {
  72. const mockDisposable = { dispose: vi.fn() }
  73. const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
  74. const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
  75. const mockTextEditor = { document: mockTextDocument }
  76. const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
  77. const mockTabGroup = { tabs: [mockTab] }
  78. return {
  79. TabInputTextDiff: vi.fn(),
  80. CodeActionKind: {
  81. QuickFix: { value: "quickfix" },
  82. RefactorRewrite: { value: "refactor.rewrite" },
  83. },
  84. window: {
  85. createTextEditorDecorationType: vi.fn().mockReturnValue({
  86. dispose: vi.fn(),
  87. }),
  88. visibleTextEditors: [mockTextEditor],
  89. tabGroups: {
  90. all: [mockTabGroup],
  91. close: vi.fn(),
  92. onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
  93. },
  94. showErrorMessage: vi.fn(),
  95. },
  96. workspace: {
  97. workspaceFolders: [
  98. {
  99. uri: { fsPath: "/mock/workspace/path" },
  100. name: "mock-workspace",
  101. index: 0,
  102. },
  103. ],
  104. createFileSystemWatcher: vi.fn(() => ({
  105. onDidCreate: vi.fn(() => mockDisposable),
  106. onDidDelete: vi.fn(() => mockDisposable),
  107. onDidChange: vi.fn(() => mockDisposable),
  108. dispose: vi.fn(),
  109. })),
  110. fs: {
  111. stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1
  112. },
  113. onDidSaveTextDocument: vi.fn(() => mockDisposable),
  114. getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
  115. },
  116. env: {
  117. uriScheme: "vscode",
  118. language: "en",
  119. },
  120. EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
  121. Disposable: {
  122. from: vi.fn(),
  123. },
  124. TabInputText: vi.fn(),
  125. }
  126. })
  127. vi.mock("../../mentions", () => ({
  128. parseMentions: vi.fn().mockImplementation((text) => {
  129. return Promise.resolve({ text: `processed: ${text}`, mode: undefined })
  130. }),
  131. openMention: vi.fn(),
  132. getLatestTerminalOutput: vi.fn(),
  133. }))
  134. vi.mock("../../../integrations/misc/extract-text", () => ({
  135. extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
  136. }))
  137. vi.mock("../../environment/getEnvironmentDetails", () => ({
  138. getEnvironmentDetails: vi.fn().mockResolvedValue(""),
  139. }))
  140. vi.mock("../../ignore/RooIgnoreController")
  141. vi.mock("../../condense", async (importOriginal) => {
  142. const actual = (await importOriginal()) as any
  143. return {
  144. ...actual,
  145. summarizeConversation: vi.fn().mockResolvedValue({
  146. messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }],
  147. summary: "summary",
  148. cost: 0,
  149. newContextTokens: 1,
  150. }),
  151. }
  152. })
  153. // Mock storagePathManager to prevent dynamic import issues.
  154. vi.mock("../../../utils/storage", () => ({
  155. getTaskDirectoryPath: vi
  156. .fn()
  157. .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
  158. getSettingsDirectoryPath: vi
  159. .fn()
  160. .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
  161. }))
  162. vi.mock("../../../utils/fs", () => ({
  163. fileExistsAtPath: vi.fn().mockImplementation((filePath) => {
  164. return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json")
  165. }),
  166. }))
  167. const mockMessages = [
  168. {
  169. ts: Date.now(),
  170. type: "say",
  171. say: "text",
  172. text: "historical task",
  173. },
  174. ]
  175. describe("Cline", () => {
  176. let mockProvider: any
  177. let mockApiConfig: ProviderSettings
  178. let mockOutputChannel: any
  179. let mockExtensionContext: vscode.ExtensionContext
  180. beforeEach(() => {
  181. if (!TelemetryService.hasInstance()) {
  182. TelemetryService.createInstance([])
  183. }
  184. // Setup mock extension context
  185. const storageUri = {
  186. fsPath: path.join(os.tmpdir(), "test-storage"),
  187. }
  188. mockExtensionContext = {
  189. globalState: {
  190. get: vi.fn().mockImplementation((key: keyof GlobalState) => {
  191. if (key === "taskHistory") {
  192. return [
  193. {
  194. id: "123",
  195. number: 0,
  196. ts: Date.now(),
  197. task: "historical task",
  198. tokensIn: 100,
  199. tokensOut: 200,
  200. cacheWrites: 0,
  201. cacheReads: 0,
  202. totalCost: 0.001,
  203. },
  204. ]
  205. }
  206. return undefined
  207. }),
  208. update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
  209. keys: vi.fn().mockReturnValue([]),
  210. },
  211. globalStorageUri: storageUri,
  212. workspaceState: {
  213. get: vi.fn().mockImplementation((_key) => undefined),
  214. update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
  215. keys: vi.fn().mockReturnValue([]),
  216. },
  217. secrets: {
  218. get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)),
  219. store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
  220. delete: vi.fn().mockImplementation((_key) => Promise.resolve()),
  221. },
  222. extensionUri: {
  223. fsPath: "/mock/extension/path",
  224. },
  225. extension: {
  226. packageJSON: {
  227. version: "1.0.0",
  228. },
  229. },
  230. } as unknown as vscode.ExtensionContext
  231. // Setup mock output channel
  232. mockOutputChannel = {
  233. appendLine: vi.fn(),
  234. append: vi.fn(),
  235. clear: vi.fn(),
  236. show: vi.fn(),
  237. hide: vi.fn(),
  238. dispose: vi.fn(),
  239. }
  240. // Setup mock provider with output channel
  241. mockProvider = new ClineProvider(
  242. mockExtensionContext,
  243. mockOutputChannel,
  244. "sidebar",
  245. new ContextProxy(mockExtensionContext),
  246. ) as any
  247. // Setup mock API configuration
  248. mockApiConfig = {
  249. apiProvider: "anthropic",
  250. apiModelId: "claude-3-5-sonnet-20241022",
  251. apiKey: "test-api-key", // Add API key to mock config
  252. }
  253. // Mock provider methods
  254. mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
  255. mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
  256. mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({
  257. historyItem: {
  258. id,
  259. ts: Date.now(),
  260. task: "historical task",
  261. tokensIn: 100,
  262. tokensOut: 200,
  263. cacheWrites: 0,
  264. cacheReads: 0,
  265. totalCost: 0.001,
  266. },
  267. taskDirPath: "/mock/storage/path/tasks/123",
  268. apiConversationHistoryFilePath: "/mock/storage/path/tasks/123/api_conversation_history.json",
  269. uiMessagesFilePath: "/mock/storage/path/tasks/123/ui_messages.json",
  270. apiConversationHistory: [
  271. {
  272. role: "user",
  273. content: [{ type: "text", text: "historical task" }],
  274. ts: Date.now(),
  275. },
  276. {
  277. role: "assistant",
  278. content: [{ type: "text", text: "I'll help you with that task." }],
  279. ts: Date.now(),
  280. },
  281. ],
  282. }))
  283. })
  284. describe("constructor", () => {
  285. it("should respect provided settings", async () => {
  286. const cline = new Task({
  287. provider: mockProvider,
  288. apiConfiguration: mockApiConfig,
  289. fuzzyMatchThreshold: 0.95,
  290. task: "test task",
  291. startTask: false,
  292. })
  293. expect(cline.diffEnabled).toBe(false)
  294. })
  295. it("should use default fuzzy match threshold when not provided", async () => {
  296. const cline = new Task({
  297. provider: mockProvider,
  298. apiConfiguration: mockApiConfig,
  299. enableDiff: true,
  300. fuzzyMatchThreshold: 0.95,
  301. task: "test task",
  302. startTask: false,
  303. })
  304. expect(cline.diffEnabled).toBe(true)
  305. // The diff strategy should be created with default threshold (1.0).
  306. expect(cline.diffStrategy).toBeDefined()
  307. })
  308. it("should use default consecutiveMistakeLimit when not provided", () => {
  309. const cline = new Task({
  310. provider: mockProvider,
  311. apiConfiguration: mockApiConfig,
  312. task: "test task",
  313. startTask: false,
  314. })
  315. expect(cline.consecutiveMistakeLimit).toBe(3)
  316. })
  317. it("should respect provided consecutiveMistakeLimit", () => {
  318. const cline = new Task({
  319. provider: mockProvider,
  320. apiConfiguration: mockApiConfig,
  321. consecutiveMistakeLimit: 5,
  322. task: "test task",
  323. startTask: false,
  324. })
  325. expect(cline.consecutiveMistakeLimit).toBe(5)
  326. })
  327. it("should keep consecutiveMistakeLimit of 0 as 0 for unlimited", () => {
  328. const cline = new Task({
  329. provider: mockProvider,
  330. apiConfiguration: mockApiConfig,
  331. consecutiveMistakeLimit: 0,
  332. task: "test task",
  333. startTask: false,
  334. })
  335. expect(cline.consecutiveMistakeLimit).toBe(0)
  336. })
  337. it("should pass 0 to ToolRepetitionDetector for unlimited mode", () => {
  338. const cline = new Task({
  339. provider: mockProvider,
  340. apiConfiguration: mockApiConfig,
  341. consecutiveMistakeLimit: 0,
  342. task: "test task",
  343. startTask: false,
  344. })
  345. // The toolRepetitionDetector should be initialized with 0 for unlimited mode
  346. expect(cline.toolRepetitionDetector).toBeDefined()
  347. // Verify the limit remains as 0
  348. expect(cline.consecutiveMistakeLimit).toBe(0)
  349. })
  350. it("should pass consecutiveMistakeLimit to ToolRepetitionDetector", () => {
  351. const cline = new Task({
  352. provider: mockProvider,
  353. apiConfiguration: mockApiConfig,
  354. consecutiveMistakeLimit: 5,
  355. task: "test task",
  356. startTask: false,
  357. })
  358. // The toolRepetitionDetector should be initialized with the same limit
  359. expect(cline.toolRepetitionDetector).toBeDefined()
  360. expect(cline.consecutiveMistakeLimit).toBe(5)
  361. })
  362. it("should require either task or historyItem", () => {
  363. expect(() => {
  364. new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
  365. }).toThrow("Either historyItem or task/images must be provided")
  366. })
  367. })
  368. describe("getEnvironmentDetails", () => {
  369. describe("API conversation handling", () => {
  370. it.skip("should clean conversation history before sending to API", async () => {
  371. // Cline.create will now use our mocked getEnvironmentDetails
  372. const [cline, task] = Task.create({
  373. provider: mockProvider,
  374. apiConfiguration: mockApiConfig,
  375. task: "test task",
  376. })
  377. cline.abandoned = true
  378. await task
  379. // Set up mock stream.
  380. const mockStreamForClean = (async function* () {
  381. yield { type: "text", text: "test response" }
  382. })()
  383. // Set up spy.
  384. const cleanMessageSpy = vi.fn().mockReturnValue(mockStreamForClean)
  385. vi.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy)
  386. // Add test message to conversation history.
  387. cline.apiConversationHistory = [
  388. {
  389. role: "user" as const,
  390. content: [{ type: "text" as const, text: "test message" }],
  391. ts: Date.now(),
  392. },
  393. ]
  394. // Mock abort state
  395. Object.defineProperty(cline, "abort", {
  396. get: () => false,
  397. set: () => {},
  398. configurable: true,
  399. })
  400. // Add a message with extra properties to the conversation history
  401. const messageWithExtra = {
  402. role: "user" as const,
  403. content: [{ type: "text" as const, text: "test message" }],
  404. ts: Date.now(),
  405. extraProp: "should be removed",
  406. }
  407. cline.apiConversationHistory = [messageWithExtra]
  408. // Trigger an API request
  409. await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false)
  410. // Get the conversation history from the first API call
  411. expect(cleanMessageSpy.mock.calls.length).toBeGreaterThan(0)
  412. const history = cleanMessageSpy.mock.calls[0]?.[1]
  413. expect(history).toBeDefined()
  414. expect(history.length).toBeGreaterThan(0)
  415. // Find our test message
  416. const cleanedMessage = history.find((msg: { content?: Array<{ text: string }> }) =>
  417. msg.content?.some((content) => content.text === "test message"),
  418. )
  419. expect(cleanedMessage).toBeDefined()
  420. expect(cleanedMessage).toEqual({
  421. role: "user",
  422. content: [{ type: "text", text: "test message" }],
  423. })
  424. // Verify extra properties were removed
  425. expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"])
  426. })
  427. it.skip("should handle image blocks based on model capabilities", async () => {
  428. // Create two configurations - one with image support, one without
  429. const configWithImages = {
  430. ...mockApiConfig,
  431. apiModelId: "claude-3-sonnet",
  432. }
  433. const configWithoutImages = {
  434. ...mockApiConfig,
  435. apiModelId: "gpt-3.5-turbo",
  436. }
  437. // Create test conversation history with mixed content
  438. const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
  439. {
  440. role: "user" as const,
  441. content: [
  442. {
  443. type: "text" as const,
  444. text: "Here is an image",
  445. } satisfies Anthropic.TextBlockParam,
  446. {
  447. type: "image" as const,
  448. source: {
  449. type: "base64" as const,
  450. media_type: "image/jpeg",
  451. data: "base64data",
  452. },
  453. } satisfies Anthropic.ImageBlockParam,
  454. ],
  455. },
  456. {
  457. role: "assistant" as const,
  458. content: [
  459. {
  460. type: "text" as const,
  461. text: "I see the image",
  462. } satisfies Anthropic.TextBlockParam,
  463. ],
  464. },
  465. ]
  466. // Test with model that supports images
  467. const [clineWithImages, taskWithImages] = Task.create({
  468. provider: mockProvider,
  469. apiConfiguration: configWithImages,
  470. task: "test task",
  471. })
  472. // Mock the model info to indicate image support
  473. vi.spyOn(clineWithImages.api, "getModel").mockReturnValue({
  474. id: "claude-3-sonnet",
  475. info: {
  476. supportsImages: true,
  477. supportsPromptCache: true,
  478. contextWindow: 200000,
  479. maxTokens: 4096,
  480. inputPrice: 0.25,
  481. outputPrice: 0.75,
  482. } as ModelInfo,
  483. })
  484. clineWithImages.apiConversationHistory = conversationHistory
  485. // Test with model that doesn't support images
  486. const [clineWithoutImages, taskWithoutImages] = Task.create({
  487. provider: mockProvider,
  488. apiConfiguration: configWithoutImages,
  489. task: "test task",
  490. })
  491. // Mock the model info to indicate no image support
  492. vi.spyOn(clineWithoutImages.api, "getModel").mockReturnValue({
  493. id: "gpt-3.5-turbo",
  494. info: {
  495. supportsImages: false,
  496. supportsPromptCache: false,
  497. contextWindow: 16000,
  498. maxTokens: 2048,
  499. inputPrice: 0.1,
  500. outputPrice: 0.2,
  501. } as ModelInfo,
  502. })
  503. clineWithoutImages.apiConversationHistory = conversationHistory
  504. // Mock abort state for both instances
  505. Object.defineProperty(clineWithImages, "abort", {
  506. get: () => false,
  507. set: () => {},
  508. configurable: true,
  509. })
  510. Object.defineProperty(clineWithoutImages, "abort", {
  511. get: () => false,
  512. set: () => {},
  513. configurable: true,
  514. })
  515. // Set up mock streams
  516. const mockStreamWithImages = (async function* () {
  517. yield { type: "text", text: "test response" }
  518. })()
  519. const mockStreamWithoutImages = (async function* () {
  520. yield { type: "text", text: "test response" }
  521. })()
  522. // Set up spies
  523. const imagesSpy = vi.fn().mockReturnValue(mockStreamWithImages)
  524. const noImagesSpy = vi.fn().mockReturnValue(mockStreamWithoutImages)
  525. vi.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy)
  526. vi.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy)
  527. // Set up conversation history with images
  528. clineWithImages.apiConversationHistory = [
  529. {
  530. role: "user",
  531. content: [
  532. { type: "text", text: "Here is an image" },
  533. { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } },
  534. ],
  535. },
  536. ]
  537. clineWithImages.abandoned = true
  538. await taskWithImages.catch(() => {})
  539. clineWithoutImages.abandoned = true
  540. await taskWithoutImages.catch(() => {})
  541. // Trigger API requests
  542. await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
  543. await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
  544. // Get the calls
  545. const imagesCalls = imagesSpy.mock.calls
  546. const noImagesCalls = noImagesSpy.mock.calls
  547. // Verify model with image support preserves image blocks
  548. expect(imagesCalls.length).toBeGreaterThan(0)
  549. if (imagesCalls[0]?.[1]?.[0]?.content) {
  550. expect(imagesCalls[0][1][0].content).toHaveLength(2)
  551. expect(imagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
  552. expect(imagesCalls[0][1][0].content[1]).toHaveProperty("type", "image")
  553. }
  554. // Verify model without image support converts image blocks to text
  555. expect(noImagesCalls.length).toBeGreaterThan(0)
  556. if (noImagesCalls[0]?.[1]?.[0]?.content) {
  557. expect(noImagesCalls[0][1][0].content).toHaveLength(2)
  558. expect(noImagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
  559. expect(noImagesCalls[0][1][0].content[1]).toEqual({
  560. type: "text",
  561. text: "[Referenced image in conversation]",
  562. })
  563. }
  564. })
  565. it.skip("should handle API retry with countdown", async () => {
  566. const [cline, task] = Task.create({
  567. provider: mockProvider,
  568. apiConfiguration: mockApiConfig,
  569. task: "test task",
  570. })
  571. // Mock delay to track countdown timing
  572. const mockDelay = vi.fn().mockResolvedValue(undefined)
  573. vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)
  574. // Mock say to track messages
  575. const saySpy = vi.spyOn(cline, "say")
  576. // Create a stream that fails on first chunk
  577. const mockError = new Error("API Error")
  578. const mockFailedStream = {
  579. // eslint-disable-next-line require-yield
  580. async *[Symbol.asyncIterator]() {
  581. throw mockError
  582. },
  583. async next() {
  584. throw mockError
  585. },
  586. async return() {
  587. return { done: true, value: undefined }
  588. },
  589. async throw(e: any) {
  590. throw e
  591. },
  592. async [Symbol.asyncDispose]() {
  593. // Cleanup
  594. },
  595. } as AsyncGenerator<ApiStreamChunk>
  596. // Create a successful stream for retry
  597. const mockSuccessStream = {
  598. async *[Symbol.asyncIterator]() {
  599. yield { type: "text", text: "Success" }
  600. },
  601. async next() {
  602. return { done: true, value: { type: "text", text: "Success" } }
  603. },
  604. async return() {
  605. return { done: true, value: undefined }
  606. },
  607. async throw(e: any) {
  608. throw e
  609. },
  610. async [Symbol.asyncDispose]() {
  611. // Cleanup
  612. },
  613. } as AsyncGenerator<ApiStreamChunk>
  614. // Mock createMessage to fail first then succeed
  615. let firstAttempt = true
  616. vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
  617. if (firstAttempt) {
  618. firstAttempt = false
  619. return mockFailedStream
  620. }
  621. return mockSuccessStream
  622. })
  623. // Set up mock state
  624. mockProvider.getState = vi.fn().mockResolvedValue({})
  625. // Mock previous API request message
  626. cline.clineMessages = [
  627. {
  628. ts: Date.now(),
  629. type: "say",
  630. say: "api_req_started",
  631. text: JSON.stringify({
  632. tokensIn: 100,
  633. tokensOut: 50,
  634. cacheWrites: 0,
  635. cacheReads: 0,
  636. }),
  637. },
  638. ]
  639. // Trigger API request
  640. const iterator = cline.attemptApiRequest(0)
  641. await iterator.next()
  642. // Calculate expected delay for first retry
  643. const baseDelay = 3 // test retry delay
  644. // Verify countdown messages
  645. for (let i = baseDelay; i > 0; i--) {
  646. expect(saySpy).toHaveBeenCalledWith(
  647. "api_req_retry_delayed",
  648. expect.stringContaining(`Retrying in ${i} seconds`),
  649. undefined,
  650. true,
  651. )
  652. }
  653. expect(saySpy).toHaveBeenCalledWith(
  654. "api_req_retry_delayed",
  655. expect.stringContaining("Retrying now"),
  656. undefined,
  657. false,
  658. )
  659. // Calculate expected delay calls for countdown
  660. const totalExpectedDelays = baseDelay // One delay per second for countdown
  661. expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays)
  662. expect(mockDelay).toHaveBeenCalledWith(1000)
  663. // Verify error message content
  664. const errorMessage = saySpy.mock.calls.find((call) => call[1]?.includes(mockError.message))?.[1]
  665. expect(errorMessage).toBe(
  666. `${mockError.message}\n\nRetry attempt 1\nRetrying in ${baseDelay} seconds...`,
  667. )
  668. await cline.abortTask(true)
  669. await task.catch(() => {})
  670. })
  671. it.skip("should not apply retry delay twice", async () => {
  672. const [cline, task] = Task.create({
  673. provider: mockProvider,
  674. apiConfiguration: mockApiConfig,
  675. task: "test task",
  676. })
  677. // Mock delay to track countdown timing
  678. const mockDelay = vi.fn().mockResolvedValue(undefined)
  679. vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)
  680. // Mock say to track messages
  681. const saySpy = vi.spyOn(cline, "say")
  682. // Create a stream that fails on first chunk
  683. const mockError = new Error("API Error")
  684. const mockFailedStream = {
  685. // eslint-disable-next-line require-yield
  686. async *[Symbol.asyncIterator]() {
  687. throw mockError
  688. },
  689. async next() {
  690. throw mockError
  691. },
  692. async return() {
  693. return { done: true, value: undefined }
  694. },
  695. async throw(e: any) {
  696. throw e
  697. },
  698. async [Symbol.asyncDispose]() {
  699. // Cleanup
  700. },
  701. } as AsyncGenerator<ApiStreamChunk>
  702. // Create a successful stream for retry
  703. const mockSuccessStream = {
  704. async *[Symbol.asyncIterator]() {
  705. yield { type: "text", text: "Success" }
  706. },
  707. async next() {
  708. return { done: true, value: { type: "text", text: "Success" } }
  709. },
  710. async return() {
  711. return { done: true, value: undefined }
  712. },
  713. async throw(e: any) {
  714. throw e
  715. },
  716. async [Symbol.asyncDispose]() {
  717. // Cleanup
  718. },
  719. } as AsyncGenerator<ApiStreamChunk>
  720. // Mock createMessage to fail first then succeed
  721. let firstAttempt = true
  722. vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
  723. if (firstAttempt) {
  724. firstAttempt = false
  725. return mockFailedStream
  726. }
  727. return mockSuccessStream
  728. })
  729. // Set up mock state
  730. mockProvider.getState = vi.fn().mockResolvedValue({})
  731. // Mock previous API request message
  732. cline.clineMessages = [
  733. {
  734. ts: Date.now(),
  735. type: "say",
  736. say: "api_req_started",
  737. text: JSON.stringify({
  738. tokensIn: 100,
  739. tokensOut: 50,
  740. cacheWrites: 0,
  741. cacheReads: 0,
  742. }),
  743. },
  744. ]
  745. // Trigger API request
  746. const iterator = cline.attemptApiRequest(0)
  747. await iterator.next()
  748. // Verify delay is only applied for the countdown
  749. const baseDelay = 3 // test retry delay
  750. const expectedDelayCount = baseDelay // One delay per second for countdown
  751. expect(mockDelay).toHaveBeenCalledTimes(expectedDelayCount)
  752. expect(mockDelay).toHaveBeenCalledWith(1000) // Each delay should be 1 second
  753. // Verify countdown messages were only shown once
  754. const retryMessages = saySpy.mock.calls.filter(
  755. (call) => call[0] === "api_req_retry_delayed" && call[1]?.includes("Retrying in"),
  756. )
  757. expect(retryMessages).toHaveLength(baseDelay)
  758. // Verify the retry message sequence
  759. for (let i = baseDelay; i > 0; i--) {
  760. expect(saySpy).toHaveBeenCalledWith(
  761. "api_req_retry_delayed",
  762. expect.stringContaining(`Retrying in ${i} seconds`),
  763. undefined,
  764. true,
  765. )
  766. }
  767. // Verify final retry message
  768. expect(saySpy).toHaveBeenCalledWith(
  769. "api_req_retry_delayed",
  770. expect.stringContaining("Retrying now"),
  771. undefined,
  772. false,
  773. )
  774. await cline.abortTask(true)
  775. await task.catch(() => {})
  776. })
  777. describe("processUserContentMentions", () => {
  778. it("should process mentions in task and feedback tags", async () => {
  779. const [cline, task] = Task.create({
  780. provider: mockProvider,
  781. apiConfiguration: mockApiConfig,
  782. task: "test task",
  783. })
  784. const userContent = [
  785. {
  786. type: "text",
  787. text: "Regular text with 'some/path' (see below for file content)",
  788. } as const,
  789. {
  790. type: "text",
  791. text: "<task>Text with 'some/path' (see below for file content) in task tags</task>",
  792. } as const,
  793. {
  794. type: "tool_result",
  795. tool_use_id: "test-id",
  796. content: [
  797. {
  798. type: "text",
  799. text: "<feedback>Check 'some/path' (see below for file content)</feedback>",
  800. },
  801. ],
  802. } as Anthropic.ToolResultBlockParam,
  803. {
  804. type: "tool_result",
  805. tool_use_id: "test-id-2",
  806. content: [
  807. {
  808. type: "text",
  809. text: "Regular tool result with 'path' (see below for file content)",
  810. },
  811. ],
  812. } as Anthropic.ToolResultBlockParam,
  813. ]
  814. const { content: processedContent } = await processUserContentMentions({
  815. userContent,
  816. cwd: cline.cwd,
  817. urlContentFetcher: cline.urlContentFetcher,
  818. fileContextTracker: cline.fileContextTracker,
  819. })
  820. // Regular text should not be processed
  821. expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe(
  822. "Regular text with 'some/path' (see below for file content)",
  823. )
  824. // Text within task tags should be processed
  825. expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:")
  826. expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain(
  827. "<task>Text with 'some/path' (see below for file content) in task tags</task>",
  828. )
  829. // Feedback tag content should be processed
  830. const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam
  831. const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content
  832. expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:")
  833. expect((content1 as Anthropic.TextBlockParam).text).toContain(
  834. "<feedback>Check 'some/path' (see below for file content)</feedback>",
  835. )
  836. // Regular tool result should not be processed
  837. const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam
  838. const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content
  839. expect((content2 as Anthropic.TextBlockParam).text).toBe(
  840. "Regular tool result with 'path' (see below for file content)",
  841. )
  842. await cline.abortTask(true)
  843. await task.catch(() => {})
  844. })
  845. })
  846. })
  847. describe("Subtask Rate Limiting", () => {
  848. let mockProvider: any
  849. let mockApiConfig: any
  850. let mockDelay: ReturnType<typeof vi.fn>
  851. beforeEach(() => {
  852. vi.clearAllMocks()
  853. // Reset the global timestamp before each test
  854. Task.resetGlobalApiRequestTime()
  855. mockApiConfig = {
  856. apiProvider: "anthropic",
  857. apiKey: "test-key",
  858. rateLimitSeconds: 5,
  859. }
  860. mockProvider = {
  861. context: {
  862. globalStorageUri: { fsPath: "/test/storage" },
  863. },
  864. getState: vi.fn().mockResolvedValue({
  865. apiConfiguration: mockApiConfig,
  866. }),
  867. getMcpHub: vi.fn().mockReturnValue(undefined),
  868. getSkillsManager: vi.fn().mockReturnValue(undefined),
  869. say: vi.fn(),
  870. postStateToWebview: vi.fn().mockResolvedValue(undefined),
  871. postMessageToWebview: vi.fn().mockResolvedValue(undefined),
  872. updateTaskHistory: vi.fn().mockResolvedValue(undefined),
  873. }
  874. // Get the mocked delay function
  875. mockDelay = delay as ReturnType<typeof vi.fn>
  876. mockDelay.mockClear()
  877. })
  878. afterEach(() => {
  879. // Clean up the global state after each test
  880. Task.resetGlobalApiRequestTime()
  881. })
  882. it("should enforce rate limiting across parent and subtask", async () => {
  883. // Add a spy to track getState calls
  884. const getStateSpy = vi.spyOn(mockProvider, "getState")
  885. // Create parent task
  886. const parent = new Task({
  887. provider: mockProvider,
  888. apiConfiguration: mockApiConfig,
  889. task: "parent task",
  890. startTask: false,
  891. })
  892. // Mock the API stream response
  893. const mockStream = {
  894. async *[Symbol.asyncIterator]() {
  895. yield { type: "text", text: "parent response" }
  896. },
  897. async next() {
  898. return { done: true, value: { type: "text", text: "parent response" } }
  899. },
  900. async return() {
  901. return { done: true, value: undefined }
  902. },
  903. async throw(e: any) {
  904. throw e
  905. },
  906. [Symbol.asyncDispose]: async () => {},
  907. } as AsyncGenerator<ApiStreamChunk>
  908. vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
  909. // Make an API request with the parent task
  910. const parentIterator = parent.attemptApiRequest(0)
  911. await parentIterator.next()
  912. // Verify no delay was applied for the first request
  913. expect(mockDelay).not.toHaveBeenCalled()
  914. // Create a subtask immediately after
  915. const child = new Task({
  916. provider: mockProvider,
  917. apiConfiguration: mockApiConfig,
  918. task: "child task",
  919. parentTask: parent,
  920. rootTask: parent,
  921. startTask: false,
  922. })
  923. // Spy on child.say to verify the emitted message type
  924. const saySpy = vi.spyOn(child, "say")
  925. // Mock the child's API stream
  926. const childMockStream = {
  927. async *[Symbol.asyncIterator]() {
  928. yield { type: "text", text: "child response" }
  929. },
  930. async next() {
  931. return { done: true, value: { type: "text", text: "child response" } }
  932. },
  933. async return() {
  934. return { done: true, value: undefined }
  935. },
  936. async throw(e: any) {
  937. throw e
  938. },
  939. [Symbol.asyncDispose]: async () => {},
  940. } as AsyncGenerator<ApiStreamChunk>
  941. vi.spyOn(child.api, "createMessage").mockReturnValue(childMockStream)
  942. // Make an API request with the child task
  943. const childIterator = child.attemptApiRequest(0)
  944. await childIterator.next()
  945. // Verify rate limiting was applied
  946. expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
  947. expect(mockDelay).toHaveBeenCalledWith(1000)
  948. // Verify we used the non-error rate-limit wait message type (JSON format)
  949. expect(saySpy).toHaveBeenCalledWith(
  950. "api_req_rate_limit_wait",
  951. expect.stringMatching(/\{"seconds":\d+\}/),
  952. undefined,
  953. true,
  954. )
  955. // Verify the wait message was finalized
  956. expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false)
  957. }, 10000) // Increase timeout to 10 seconds
  958. it("should not apply rate limiting if enough time has passed", async () => {
  959. // Create parent task
  960. const parent = new Task({
  961. provider: mockProvider,
  962. apiConfiguration: mockApiConfig,
  963. task: "parent task",
  964. startTask: false,
  965. })
  966. // Mock the API stream response
  967. const mockStream = {
  968. async *[Symbol.asyncIterator]() {
  969. yield { type: "text", text: "response" }
  970. },
  971. async next() {
  972. return { done: true, value: { type: "text", text: "response" } }
  973. },
  974. async return() {
  975. return { done: true, value: undefined }
  976. },
  977. async throw(e: any) {
  978. throw e
  979. },
  980. [Symbol.asyncDispose]: async () => {},
  981. } as AsyncGenerator<ApiStreamChunk>
  982. vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
  983. // Make an API request with the parent task
  984. const parentIterator = parent.attemptApiRequest(0)
  985. await parentIterator.next()
  986. // Simulate time passing (more than rate limit)
  987. const originalPerformanceNow = performance.now
  988. const mockTime = performance.now() + (mockApiConfig.rateLimitSeconds + 1) * 1000
  989. performance.now = vi.fn(() => mockTime)
  990. // Create a subtask after time has passed
  991. const child = new Task({
  992. provider: mockProvider,
  993. apiConfiguration: mockApiConfig,
  994. task: "child task",
  995. parentTask: parent,
  996. rootTask: parent,
  997. startTask: false,
  998. })
  999. vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream)
  1000. // Make an API request with the child task
  1001. const childIterator = child.attemptApiRequest(0)
  1002. await childIterator.next()
  1003. // Verify no rate limiting was applied
  1004. expect(mockDelay).not.toHaveBeenCalled()
  1005. // Restore performance.now
  1006. performance.now = originalPerformanceNow
  1007. })
  1008. it("should share rate limiting across multiple subtasks", async () => {
  1009. // Create parent task
  1010. const parent = new Task({
  1011. provider: mockProvider,
  1012. apiConfiguration: mockApiConfig,
  1013. task: "parent task",
  1014. startTask: false,
  1015. })
  1016. // Mock the API stream response
  1017. const mockStream = {
  1018. async *[Symbol.asyncIterator]() {
  1019. yield { type: "text", text: "response" }
  1020. },
  1021. async next() {
  1022. return { done: true, value: { type: "text", text: "response" } }
  1023. },
  1024. async return() {
  1025. return { done: true, value: undefined }
  1026. },
  1027. async throw(e: any) {
  1028. throw e
  1029. },
  1030. [Symbol.asyncDispose]: async () => {},
  1031. } as AsyncGenerator<ApiStreamChunk>
  1032. vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
  1033. // Make an API request with the parent task
  1034. const parentIterator = parent.attemptApiRequest(0)
  1035. await parentIterator.next()
  1036. // Create first subtask
  1037. const child1 = new Task({
  1038. provider: mockProvider,
  1039. apiConfiguration: mockApiConfig,
  1040. task: "child task 1",
  1041. parentTask: parent,
  1042. rootTask: parent,
  1043. startTask: false,
  1044. })
  1045. vi.spyOn(child1.api, "createMessage").mockReturnValue(mockStream)
  1046. // Make an API request with the first child task
  1047. const child1Iterator = child1.attemptApiRequest(0)
  1048. await child1Iterator.next()
  1049. // Verify rate limiting was applied
  1050. const firstDelayCount = mockDelay.mock.calls.length
  1051. expect(firstDelayCount).toBe(mockApiConfig.rateLimitSeconds)
  1052. // Clear the mock to count new delays
  1053. mockDelay.mockClear()
  1054. // Create second subtask immediately after
  1055. const child2 = new Task({
  1056. provider: mockProvider,
  1057. apiConfiguration: mockApiConfig,
  1058. task: "child task 2",
  1059. parentTask: parent,
  1060. rootTask: parent,
  1061. startTask: false,
  1062. })
  1063. vi.spyOn(child2.api, "createMessage").mockReturnValue(mockStream)
  1064. // Make an API request with the second child task
  1065. const child2Iterator = child2.attemptApiRequest(0)
  1066. await child2Iterator.next()
  1067. // Verify rate limiting was applied again
  1068. expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
  1069. }, 15000) // Increase timeout to 15 seconds
  1070. it("should handle rate limiting with zero rate limit", async () => {
  1071. // Update config to have zero rate limit
  1072. mockApiConfig.rateLimitSeconds = 0
  1073. mockProvider.getState.mockResolvedValue({
  1074. apiConfiguration: mockApiConfig,
  1075. })
  1076. // Create parent task
  1077. const parent = new Task({
  1078. provider: mockProvider,
  1079. apiConfiguration: mockApiConfig,
  1080. task: "parent task",
  1081. startTask: false,
  1082. })
  1083. // Mock the API stream response
  1084. const mockStream = {
  1085. async *[Symbol.asyncIterator]() {
  1086. yield { type: "text", text: "response" }
  1087. },
  1088. async next() {
  1089. return { done: true, value: { type: "text", text: "response" } }
  1090. },
  1091. async return() {
  1092. return { done: true, value: undefined }
  1093. },
  1094. async throw(e: any) {
  1095. throw e
  1096. },
  1097. [Symbol.asyncDispose]: async () => {},
  1098. } as AsyncGenerator<ApiStreamChunk>
  1099. vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
  1100. // Make an API request with the parent task
  1101. const parentIterator = parent.attemptApiRequest(0)
  1102. await parentIterator.next()
  1103. // Create a subtask
  1104. const child = new Task({
  1105. provider: mockProvider,
  1106. apiConfiguration: mockApiConfig,
  1107. task: "child task",
  1108. parentTask: parent,
  1109. rootTask: parent,
  1110. startTask: false,
  1111. })
  1112. vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream)
  1113. // Make an API request with the child task
  1114. const childIterator = child.attemptApiRequest(0)
  1115. await childIterator.next()
  1116. // Verify no delay was applied
  1117. expect(mockDelay).not.toHaveBeenCalled()
  1118. })
  1119. it("should update global timestamp even when no rate limiting is needed", async () => {
  1120. // Create task
  1121. const task = new Task({
  1122. provider: mockProvider,
  1123. apiConfiguration: mockApiConfig,
  1124. task: "test task",
  1125. startTask: false,
  1126. })
  1127. // Mock the API stream response
  1128. const mockStream = {
  1129. async *[Symbol.asyncIterator]() {
  1130. yield { type: "text", text: "response" }
  1131. },
  1132. async next() {
  1133. return { done: true, value: { type: "text", text: "response" } }
  1134. },
  1135. async return() {
  1136. return { done: true, value: undefined }
  1137. },
  1138. async throw(e: any) {
  1139. throw e
  1140. },
  1141. [Symbol.asyncDispose]: async () => {},
  1142. } as AsyncGenerator<ApiStreamChunk>
  1143. vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
  1144. // Make an API request
  1145. const iterator = task.attemptApiRequest(0)
  1146. await iterator.next()
  1147. // Access the private static property via reflection for testing
  1148. const globalTimestamp = (Task as any).lastGlobalApiRequestTime
  1149. expect(globalTimestamp).toBeDefined()
  1150. expect(globalTimestamp).toBeGreaterThan(0)
  1151. })
  1152. })
  1153. describe("Dynamic Strategy Selection", () => {
  1154. let mockProvider: any
  1155. let mockApiConfig: any
  1156. beforeEach(() => {
  1157. vi.clearAllMocks()
  1158. mockApiConfig = {
  1159. apiProvider: "anthropic",
  1160. apiKey: "test-key",
  1161. }
  1162. mockProvider = {
  1163. context: {
  1164. globalStorageUri: { fsPath: "/test/storage" },
  1165. },
  1166. getState: vi.fn(),
  1167. }
  1168. })
  1169. it("should use MultiSearchReplaceDiffStrategy by default", async () => {
  1170. mockProvider.getState.mockResolvedValue({
  1171. experiments: {
  1172. [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false,
  1173. },
  1174. })
  1175. const task = new Task({
  1176. provider: mockProvider,
  1177. apiConfiguration: mockApiConfig,
  1178. enableDiff: true,
  1179. task: "test task",
  1180. startTask: false,
  1181. })
  1182. // Initially should be MultiSearchReplaceDiffStrategy
  1183. expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
  1184. expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace")
  1185. })
  1186. it("should switch to MultiFileSearchReplaceDiffStrategy when experiment is enabled", async () => {
  1187. mockProvider.getState.mockResolvedValue({
  1188. experiments: {
  1189. [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true,
  1190. },
  1191. })
  1192. const task = new Task({
  1193. provider: mockProvider,
  1194. apiConfiguration: mockApiConfig,
  1195. enableDiff: true,
  1196. task: "test task",
  1197. startTask: false,
  1198. })
  1199. // Initially should be MultiSearchReplaceDiffStrategy
  1200. expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
  1201. // Wait for async strategy update
  1202. await new Promise((resolve) => setTimeout(resolve, 10))
  1203. // Should have switched to MultiFileSearchReplaceDiffStrategy
  1204. expect(task.diffStrategy).toBeInstanceOf(MultiFileSearchReplaceDiffStrategy)
  1205. expect(task.diffStrategy?.getName()).toBe("MultiFileSearchReplace")
  1206. })
  1207. it("should keep MultiSearchReplaceDiffStrategy when experiments are undefined", async () => {
  1208. mockProvider.getState.mockResolvedValue({})
  1209. const task = new Task({
  1210. provider: mockProvider,
  1211. apiConfiguration: mockApiConfig,
  1212. enableDiff: true,
  1213. task: "test task",
  1214. startTask: false,
  1215. })
  1216. // Initially should be MultiSearchReplaceDiffStrategy
  1217. expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
  1218. // Wait for async strategy update
  1219. await new Promise((resolve) => setTimeout(resolve, 10))
  1220. // Should still be MultiSearchReplaceDiffStrategy
  1221. expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
  1222. expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace")
  1223. })
  1224. it("should not create diff strategy when enableDiff is false", async () => {
  1225. const task = new Task({
  1226. provider: mockProvider,
  1227. apiConfiguration: mockApiConfig,
  1228. enableDiff: false,
  1229. task: "test task",
  1230. startTask: false,
  1231. })
  1232. expect(task.diffEnabled).toBe(false)
  1233. expect(task.diffStrategy).toBeUndefined()
  1234. })
  1235. })
  1236. describe("getApiProtocol", () => {
  1237. it("should determine API protocol based on provider and model", async () => {
  1238. // Test with Anthropic provider
  1239. const anthropicConfig = {
  1240. ...mockApiConfig,
  1241. apiProvider: "anthropic" as const,
  1242. apiModelId: "gpt-4",
  1243. }
  1244. const anthropicTask = new Task({
  1245. provider: mockProvider,
  1246. apiConfiguration: anthropicConfig,
  1247. task: "test task",
  1248. startTask: false,
  1249. })
  1250. // Should use anthropic protocol even with non-claude model
  1251. expect(anthropicTask.apiConfiguration.apiProvider).toBe("anthropic")
  1252. // Test with OpenRouter provider and Claude model
  1253. const openrouterClaudeConfig = {
  1254. apiProvider: "openrouter" as const,
  1255. openRouterModelId: "anthropic/claude-3-opus",
  1256. }
  1257. const openrouterClaudeTask = new Task({
  1258. provider: mockProvider,
  1259. apiConfiguration: openrouterClaudeConfig,
  1260. task: "test task",
  1261. startTask: false,
  1262. })
  1263. expect(openrouterClaudeTask.apiConfiguration.apiProvider).toBe("openrouter")
  1264. // Test with OpenRouter provider and non-Claude model
  1265. const openrouterGptConfig = {
  1266. apiProvider: "openrouter" as const,
  1267. openRouterModelId: "openai/gpt-4",
  1268. }
  1269. const openrouterGptTask = new Task({
  1270. provider: mockProvider,
  1271. apiConfiguration: openrouterGptConfig,
  1272. task: "test task",
  1273. startTask: false,
  1274. })
  1275. expect(openrouterGptTask.apiConfiguration.apiProvider).toBe("openrouter")
  1276. // Test with various Claude model formats
  1277. const claudeModelFormats = [
  1278. "claude-3-opus",
  1279. "Claude-3-Sonnet",
  1280. "CLAUDE-instant",
  1281. "anthropic/claude-3-haiku",
  1282. "some-provider/claude-model",
  1283. ]
  1284. for (const modelId of claudeModelFormats) {
  1285. const config = {
  1286. apiProvider: "openai" as const,
  1287. openAiModelId: modelId,
  1288. }
  1289. const task = new Task({
  1290. provider: mockProvider,
  1291. apiConfiguration: config,
  1292. task: "test task",
  1293. startTask: false,
  1294. })
  1295. // Verify the model ID contains claude (case-insensitive)
  1296. expect(modelId.toLowerCase()).toContain("claude")
  1297. }
  1298. })
  1299. it("should handle edge cases for API protocol detection", async () => {
  1300. // Test with undefined provider
  1301. const undefinedProviderConfig = {
  1302. apiModelId: "claude-3-opus",
  1303. }
  1304. const undefinedProviderTask = new Task({
  1305. provider: mockProvider,
  1306. apiConfiguration: undefinedProviderConfig,
  1307. task: "test task",
  1308. startTask: false,
  1309. })
  1310. expect(undefinedProviderTask.apiConfiguration.apiProvider).toBeUndefined()
  1311. // Test with no model ID
  1312. const noModelConfig = {
  1313. apiProvider: "openai" as const,
  1314. }
  1315. const noModelTask = new Task({
  1316. provider: mockProvider,
  1317. apiConfiguration: noModelConfig,
  1318. task: "test task",
  1319. startTask: false,
  1320. })
  1321. expect(noModelTask.apiConfiguration.apiProvider).toBe("openai")
  1322. })
  1323. })
  1324. describe("submitUserMessage", () => {
  1325. it("should always route through webview sendMessage invoke", async () => {
  1326. const task = new Task({
  1327. provider: mockProvider,
  1328. apiConfiguration: mockApiConfig,
  1329. task: "initial task",
  1330. startTask: false,
  1331. })
  1332. // Set up some existing messages to simulate an ongoing conversation
  1333. task.clineMessages = [
  1334. {
  1335. ts: Date.now(),
  1336. type: "say",
  1337. say: "text",
  1338. text: "Initial message",
  1339. },
  1340. ]
  1341. // Call submitUserMessage
  1342. task.submitUserMessage("test message", ["image1.png"])
  1343. // Verify postMessageToWebview was called with sendMessage invoke
  1344. expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
  1345. type: "invoke",
  1346. invoke: "sendMessage",
  1347. text: "test message",
  1348. images: ["image1.png"],
  1349. })
  1350. })
  1351. it("should handle empty messages gracefully", async () => {
  1352. const task = new Task({
  1353. provider: mockProvider,
  1354. apiConfiguration: mockApiConfig,
  1355. task: "initial task",
  1356. startTask: false,
  1357. })
  1358. // Call with empty text and no images
  1359. task.submitUserMessage("", [])
  1360. // Should not call postMessageToWebview for empty messages
  1361. expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
  1362. // Call with whitespace only
  1363. task.submitUserMessage(" ", [])
  1364. expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
  1365. })
  1366. it("should route through webview for both new and existing tasks", async () => {
  1367. const task = new Task({
  1368. provider: mockProvider,
  1369. apiConfiguration: mockApiConfig,
  1370. task: "initial task",
  1371. startTask: false,
  1372. })
  1373. // Test with no messages (new task scenario)
  1374. task.clineMessages = []
  1375. task.submitUserMessage("new task", ["image1.png"])
  1376. expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
  1377. type: "invoke",
  1378. invoke: "sendMessage",
  1379. text: "new task",
  1380. images: ["image1.png"],
  1381. })
  1382. // Clear mock
  1383. mockProvider.postMessageToWebview.mockClear()
  1384. // Test with existing messages (ongoing task scenario)
  1385. task.clineMessages = [
  1386. {
  1387. ts: Date.now(),
  1388. type: "say",
  1389. say: "text",
  1390. text: "Initial message",
  1391. },
  1392. ]
  1393. task.submitUserMessage("follow-up message", ["image2.png"])
  1394. expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
  1395. type: "invoke",
  1396. invoke: "sendMessage",
  1397. text: "follow-up message",
  1398. images: ["image2.png"],
  1399. })
  1400. })
  1401. it("should handle undefined provider gracefully", async () => {
  1402. const task = new Task({
  1403. provider: mockProvider,
  1404. apiConfiguration: mockApiConfig,
  1405. task: "initial task",
  1406. startTask: false,
  1407. })
  1408. // Simulate weakref returning undefined
  1409. Object.defineProperty(task, "providerRef", {
  1410. value: { deref: () => undefined },
  1411. writable: false,
  1412. configurable: true,
  1413. })
  1414. // Spy on console.error to verify error is logged
  1415. const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
  1416. // Should log error but not throw
  1417. task.submitUserMessage("test message")
  1418. expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#submitUserMessage] Provider reference lost")
  1419. expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
  1420. // Restore console.error
  1421. consoleErrorSpy.mockRestore()
  1422. })
  1423. })
  1424. })
  1425. describe("abortTask", () => {
  1426. it("should set abort flag and emit TaskAborted event", async () => {
  1427. const task = new Task({
  1428. provider: mockProvider,
  1429. apiConfiguration: mockApiConfig,
  1430. task: "test task",
  1431. startTask: false,
  1432. })
  1433. // Spy on emit method
  1434. const emitSpy = vi.spyOn(task, "emit")
  1435. // Mock the dispose method to avoid actual cleanup
  1436. vi.spyOn(task, "dispose").mockImplementation(() => {})
  1437. // Call abortTask
  1438. await task.abortTask()
  1439. // Verify abort flag is set
  1440. expect(task.abort).toBe(true)
  1441. // Verify TaskAborted event was emitted
  1442. expect(emitSpy).toHaveBeenCalledWith("taskAborted")
  1443. })
  1444. it("should be equivalent to clicking Cancel button functionality", async () => {
  1445. const task = new Task({
  1446. provider: mockProvider,
  1447. apiConfiguration: mockApiConfig,
  1448. task: "test task",
  1449. startTask: false,
  1450. })
  1451. // Mock the dispose method to track cleanup
  1452. const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {})
  1453. // Call abortTask
  1454. await task.abortTask()
  1455. // Verify the same behavior as Cancel button
  1456. expect(task.abort).toBe(true)
  1457. expect(disposeSpy).toHaveBeenCalled()
  1458. })
  1459. it("should work with TaskLike interface", async () => {
  1460. const task = new Task({
  1461. provider: mockProvider,
  1462. apiConfiguration: mockApiConfig,
  1463. task: "test task",
  1464. startTask: false,
  1465. })
  1466. // Cast to TaskLike to ensure interface compliance
  1467. const taskLike = task as any // TaskLike interface from types package
  1468. // Verify abortTask method exists and is callable
  1469. expect(typeof taskLike.abortTask).toBe("function")
  1470. // Mock the dispose method to avoid actual cleanup
  1471. vi.spyOn(task, "dispose").mockImplementation(() => {})
  1472. // Call abortTask through interface
  1473. await taskLike.abortTask()
  1474. // Verify it works
  1475. expect(task.abort).toBe(true)
  1476. })
  1477. it("should handle errors during disposal gracefully", async () => {
  1478. const task = new Task({
  1479. provider: mockProvider,
  1480. apiConfiguration: mockApiConfig,
  1481. task: "test task",
  1482. startTask: false,
  1483. })
  1484. // Mock dispose to throw an error
  1485. const mockError = new Error("Disposal failed")
  1486. vi.spyOn(task, "dispose").mockImplementation(() => {
  1487. throw mockError
  1488. })
  1489. // Spy on console.error to verify error is logged
  1490. const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
  1491. // abortTask should not throw even if dispose fails
  1492. await expect(task.abortTask()).resolves.not.toThrow()
  1493. // Verify error was logged
  1494. expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Error during task"), mockError)
  1495. // Verify abort flag is still set
  1496. expect(task.abort).toBe(true)
  1497. // Restore console.error
  1498. consoleErrorSpy.mockRestore()
  1499. })
  1500. describe("Stream Failure Retry", () => {
  1501. it("should not abort task on stream failure, only on user cancellation", async () => {
  1502. const task = new Task({
  1503. provider: mockProvider,
  1504. apiConfiguration: mockApiConfig,
  1505. task: "test task",
  1506. startTask: false,
  1507. })
  1508. // Spy on console.error to verify error logging
  1509. const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
  1510. // Spy on abortTask to verify it's NOT called for stream failures
  1511. const abortTaskSpy = vi.spyOn(task, "abortTask").mockResolvedValue(undefined)
  1512. // Test Case 1: Stream failure should NOT abort task
  1513. task.abort = false
  1514. task.abandoned = false
  1515. // Simulate the catch block behavior for stream failure
  1516. const streamFailureError = new Error("Stream failed mid-execution")
  1517. // The key assertion: verify that when abort=false, abortTask is NOT called
  1518. // This would normally happen in the catch block around line 2184
  1519. const shouldAbort = task.abort
  1520. expect(shouldAbort).toBe(false)
  1521. // Verify error would be logged (this is what the new code does)
  1522. console.error(
  1523. `[Task#${task.taskId}.${task.instanceId}] Stream failed, will retry: ${streamFailureError.message}`,
  1524. )
  1525. expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Stream failed, will retry"))
  1526. // Verify abortTask was NOT called
  1527. expect(abortTaskSpy).not.toHaveBeenCalled()
  1528. // Test Case 2: User cancellation SHOULD abort task
  1529. task.abort = true
  1530. // For user cancellation, abortTask SHOULD be called
  1531. if (task.abort) {
  1532. await task.abortTask()
  1533. }
  1534. expect(abortTaskSpy).toHaveBeenCalled()
  1535. // Restore mocks
  1536. consoleErrorSpy.mockRestore()
  1537. })
  1538. })
  1539. describe("cancelCurrentRequest", () => {
  1540. it("should cancel the current HTTP request via AbortController", () => {
  1541. const task = new Task({
  1542. provider: mockProvider,
  1543. apiConfiguration: mockApiConfig,
  1544. task: "test task",
  1545. startTask: false,
  1546. })
  1547. // Create a real AbortController and spy on its abort method
  1548. const mockAbortController = new AbortController()
  1549. const abortSpy = vi.spyOn(mockAbortController, "abort")
  1550. task.currentRequestAbortController = mockAbortController
  1551. // Spy on console.log
  1552. const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
  1553. // Call cancelCurrentRequest
  1554. task.cancelCurrentRequest()
  1555. // Verify abort was called on the controller
  1556. expect(abortSpy).toHaveBeenCalled()
  1557. // Verify the controller was cleared
  1558. expect(task.currentRequestAbortController).toBeUndefined()
  1559. // Verify logging
  1560. expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Aborting current HTTP request"))
  1561. // Restore console.log
  1562. consoleLogSpy.mockRestore()
  1563. })
  1564. it("should handle missing AbortController gracefully", () => {
  1565. const task = new Task({
  1566. provider: mockProvider,
  1567. apiConfiguration: mockApiConfig,
  1568. task: "test task",
  1569. startTask: false,
  1570. })
  1571. // Ensure no controller exists
  1572. task.currentRequestAbortController = undefined
  1573. // Should not throw when called with no controller
  1574. expect(() => task.cancelCurrentRequest()).not.toThrow()
  1575. })
  1576. it("should be called during dispose", () => {
  1577. const task = new Task({
  1578. provider: mockProvider,
  1579. apiConfiguration: mockApiConfig,
  1580. task: "test task",
  1581. startTask: false,
  1582. })
  1583. // Spy on cancelCurrentRequest
  1584. const cancelSpy = vi.spyOn(task, "cancelCurrentRequest")
  1585. // Mock other dispose operations
  1586. vi.spyOn(task.messageQueueService, "removeListener").mockImplementation(
  1587. () => task.messageQueueService as any,
  1588. )
  1589. vi.spyOn(task.messageQueueService, "dispose").mockImplementation(() => {})
  1590. vi.spyOn(task, "removeAllListeners").mockImplementation(() => task as any)
  1591. // Call dispose
  1592. task.dispose()
  1593. // Verify cancelCurrentRequest was called
  1594. expect(cancelSpy).toHaveBeenCalled()
  1595. })
  1596. })
  1597. })
  1598. })
  1599. describe("Queued message processing after condense", () => {
  1600. function createProvider(): any {
  1601. const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
  1602. const ctx = {
  1603. globalState: {
  1604. get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
  1605. update: vi.fn().mockResolvedValue(undefined),
  1606. keys: vi.fn().mockReturnValue([]),
  1607. },
  1608. globalStorageUri: storageUri,
  1609. workspaceState: {
  1610. get: vi.fn().mockImplementation((_key) => undefined),
  1611. update: vi.fn().mockResolvedValue(undefined),
  1612. keys: vi.fn().mockReturnValue([]),
  1613. },
  1614. secrets: {
  1615. get: vi.fn().mockResolvedValue(undefined),
  1616. store: vi.fn().mockResolvedValue(undefined),
  1617. delete: vi.fn().mockResolvedValue(undefined),
  1618. },
  1619. extensionUri: { fsPath: "/mock/extension/path" },
  1620. extension: { packageJSON: { version: "1.0.0" } },
  1621. } as unknown as vscode.ExtensionContext
  1622. const output = {
  1623. appendLine: vi.fn(),
  1624. append: vi.fn(),
  1625. clear: vi.fn(),
  1626. show: vi.fn(),
  1627. hide: vi.fn(),
  1628. dispose: vi.fn(),
  1629. }
  1630. const provider = new ClineProvider(ctx, output as any, "sidebar", new ContextProxy(ctx)) as any
  1631. provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
  1632. provider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
  1633. provider.getState = vi.fn().mockResolvedValue({})
  1634. return provider
  1635. }
  1636. const apiConfig: ProviderSettings = {
  1637. apiProvider: "anthropic",
  1638. apiModelId: "claude-3-5-sonnet-20241022",
  1639. apiKey: "test-api-key",
  1640. } as any
  1641. it("processes queued message after condense completes", async () => {
  1642. const provider = createProvider()
  1643. const task = new Task({
  1644. provider,
  1645. apiConfiguration: apiConfig,
  1646. task: "initial task",
  1647. startTask: false,
  1648. })
  1649. // Make condense fast + deterministic
  1650. vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("system")
  1651. const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined)
  1652. // Queue a message during condensing
  1653. task.messageQueueService.addMessage("queued text", ["img1.png"])
  1654. // Use fake timers to capture setTimeout(0) in processQueuedMessages
  1655. vi.useFakeTimers()
  1656. await task.condenseContext()
  1657. // Flush the microtask that submits the queued message
  1658. vi.runAllTimers()
  1659. vi.useRealTimers()
  1660. expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"])
  1661. expect(task.messageQueueService.isEmpty()).toBe(true)
  1662. })
  1663. it("does not cross-drain queues between separate tasks", async () => {
  1664. const providerA = createProvider()
  1665. const providerB = createProvider()
  1666. const taskA = new Task({
  1667. provider: providerA,
  1668. apiConfiguration: apiConfig,
  1669. task: "task A",
  1670. startTask: false,
  1671. })
  1672. const taskB = new Task({
  1673. provider: providerB,
  1674. apiConfiguration: apiConfig,
  1675. task: "task B",
  1676. startTask: false,
  1677. })
  1678. vi.spyOn(taskA as any, "getSystemPrompt").mockResolvedValue("system")
  1679. vi.spyOn(taskB as any, "getSystemPrompt").mockResolvedValue("system")
  1680. const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined)
  1681. const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined)
  1682. taskA.messageQueueService.addMessage("A message")
  1683. taskB.messageQueueService.addMessage("B message")
  1684. // Condense in task A should only drain A's queue
  1685. vi.useFakeTimers()
  1686. await taskA.condenseContext()
  1687. vi.runAllTimers()
  1688. vi.useRealTimers()
  1689. expect(spyA).toHaveBeenCalledWith("A message", undefined)
  1690. expect(spyB).not.toHaveBeenCalled()
  1691. expect(taskB.messageQueueService.isEmpty()).toBe(false)
  1692. // Now condense in task B should drain B's queue
  1693. vi.useFakeTimers()
  1694. await taskB.condenseContext()
  1695. vi.runAllTimers()
  1696. vi.useRealTimers()
  1697. expect(spyB).toHaveBeenCalledWith("B message", undefined)
  1698. expect(taskB.messageQueueService.isEmpty()).toBe(true)
  1699. })
  1700. })
  1701. describe("pushToolResultToUserContent", () => {
  1702. let mockProvider: any
  1703. let mockApiConfig: ProviderSettings
  1704. beforeEach(() => {
  1705. mockApiConfig = {
  1706. apiProvider: "anthropic",
  1707. apiModelId: "claude-3-5-sonnet-20241022",
  1708. apiKey: "test-api-key",
  1709. }
  1710. const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
  1711. const mockExtensionContext = {
  1712. globalState: {
  1713. get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
  1714. update: vi.fn().mockResolvedValue(undefined),
  1715. keys: vi.fn().mockReturnValue([]),
  1716. },
  1717. globalStorageUri: storageUri,
  1718. workspaceState: {
  1719. get: vi.fn().mockImplementation((_key) => undefined),
  1720. update: vi.fn().mockResolvedValue(undefined),
  1721. keys: vi.fn().mockReturnValue([]),
  1722. },
  1723. secrets: {
  1724. get: vi.fn().mockResolvedValue(undefined),
  1725. store: vi.fn().mockResolvedValue(undefined),
  1726. delete: vi.fn().mockResolvedValue(undefined),
  1727. },
  1728. extensionUri: { fsPath: "/mock/extension/path" },
  1729. extension: { packageJSON: { version: "1.0.0" } },
  1730. } as unknown as vscode.ExtensionContext
  1731. const mockOutputChannel = {
  1732. name: "test-output",
  1733. appendLine: vi.fn(),
  1734. append: vi.fn(),
  1735. replace: vi.fn(),
  1736. clear: vi.fn(),
  1737. show: vi.fn(),
  1738. hide: vi.fn(),
  1739. dispose: vi.fn(),
  1740. }
  1741. mockProvider = new ClineProvider(
  1742. mockExtensionContext,
  1743. mockOutputChannel,
  1744. "sidebar",
  1745. new ContextProxy(mockExtensionContext),
  1746. ) as any
  1747. mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
  1748. mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
  1749. })
  1750. it("should add tool_result when not a duplicate", () => {
  1751. const task = new Task({
  1752. provider: mockProvider,
  1753. apiConfiguration: mockApiConfig,
  1754. task: "test task",
  1755. startTask: false,
  1756. })
  1757. const toolResult: Anthropic.ToolResultBlockParam = {
  1758. type: "tool_result",
  1759. tool_use_id: "test-id-1",
  1760. content: "Test result",
  1761. }
  1762. const added = task.pushToolResultToUserContent(toolResult)
  1763. expect(added).toBe(true)
  1764. expect(task.userMessageContent).toHaveLength(1)
  1765. expect(task.userMessageContent[0]).toEqual(toolResult)
  1766. })
  1767. it("should prevent duplicate tool_result with same tool_use_id", () => {
  1768. const task = new Task({
  1769. provider: mockProvider,
  1770. apiConfiguration: mockApiConfig,
  1771. task: "test task",
  1772. startTask: false,
  1773. })
  1774. const toolResult1: Anthropic.ToolResultBlockParam = {
  1775. type: "tool_result",
  1776. tool_use_id: "duplicate-id",
  1777. content: "First result",
  1778. }
  1779. const toolResult2: Anthropic.ToolResultBlockParam = {
  1780. type: "tool_result",
  1781. tool_use_id: "duplicate-id",
  1782. content: "Second result (should be skipped)",
  1783. }
  1784. // Spy on console.warn to verify warning is logged
  1785. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
  1786. // Add first result - should succeed
  1787. const added1 = task.pushToolResultToUserContent(toolResult1)
  1788. expect(added1).toBe(true)
  1789. expect(task.userMessageContent).toHaveLength(1)
  1790. // Add second result with same ID - should be skipped
  1791. const added2 = task.pushToolResultToUserContent(toolResult2)
  1792. expect(added2).toBe(false)
  1793. expect(task.userMessageContent).toHaveLength(1)
  1794. // Verify only the first result is in the array
  1795. expect(task.userMessageContent[0]).toEqual(toolResult1)
  1796. // Verify warning was logged
  1797. expect(warnSpy).toHaveBeenCalledWith(
  1798. expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"),
  1799. )
  1800. warnSpy.mockRestore()
  1801. })
  1802. it("should allow different tool_use_ids to be added", () => {
  1803. const task = new Task({
  1804. provider: mockProvider,
  1805. apiConfiguration: mockApiConfig,
  1806. task: "test task",
  1807. startTask: false,
  1808. })
  1809. const toolResult1: Anthropic.ToolResultBlockParam = {
  1810. type: "tool_result",
  1811. tool_use_id: "id-1",
  1812. content: "Result 1",
  1813. }
  1814. const toolResult2: Anthropic.ToolResultBlockParam = {
  1815. type: "tool_result",
  1816. tool_use_id: "id-2",
  1817. content: "Result 2",
  1818. }
  1819. const added1 = task.pushToolResultToUserContent(toolResult1)
  1820. const added2 = task.pushToolResultToUserContent(toolResult2)
  1821. expect(added1).toBe(true)
  1822. expect(added2).toBe(true)
  1823. expect(task.userMessageContent).toHaveLength(2)
  1824. expect(task.userMessageContent[0]).toEqual(toolResult1)
  1825. expect(task.userMessageContent[1]).toEqual(toolResult2)
  1826. })
  1827. it("should handle tool_result with is_error flag", () => {
  1828. const task = new Task({
  1829. provider: mockProvider,
  1830. apiConfiguration: mockApiConfig,
  1831. task: "test task",
  1832. startTask: false,
  1833. })
  1834. const errorResult: Anthropic.ToolResultBlockParam = {
  1835. type: "tool_result",
  1836. tool_use_id: "error-id",
  1837. content: "Error message",
  1838. is_error: true,
  1839. }
  1840. const added = task.pushToolResultToUserContent(errorResult)
  1841. expect(added).toBe(true)
  1842. expect(task.userMessageContent).toHaveLength(1)
  1843. expect(task.userMessageContent[0]).toEqual(errorResult)
  1844. })
  1845. it("should not interfere with other content types in userMessageContent", () => {
  1846. const task = new Task({
  1847. provider: mockProvider,
  1848. apiConfiguration: mockApiConfig,
  1849. task: "test task",
  1850. startTask: false,
  1851. })
  1852. // Add text and image blocks manually
  1853. task.userMessageContent.push(
  1854. { type: "text", text: "Some text" },
  1855. { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
  1856. )
  1857. const toolResult: Anthropic.ToolResultBlockParam = {
  1858. type: "tool_result",
  1859. tool_use_id: "test-id",
  1860. content: "Result",
  1861. }
  1862. const added = task.pushToolResultToUserContent(toolResult)
  1863. expect(added).toBe(true)
  1864. expect(task.userMessageContent).toHaveLength(3)
  1865. expect(task.userMessageContent[0].type).toBe("text")
  1866. expect(task.userMessageContent[1].type).toBe("image")
  1867. expect(task.userMessageContent[2]).toEqual(toolResult)
  1868. })
  1869. })