Task.spec.ts 56 KB

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