Task.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
  1. // npx jest src/core/task/__tests__/Task.test.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 { GlobalState } from "../../../schemas"
  7. import { Task } from "../Task"
  8. import { ClineProvider } from "../../webview/ClineProvider"
  9. import { ProviderSettings, ModelInfo } from "../../../shared/api"
  10. import { ApiStreamChunk } from "../../../api/transform/stream"
  11. import { ContextProxy } from "../../config/ContextProxy"
  12. import { processUserContentMentions } from "../../mentions/processUserContentMentions"
  13. jest.mock("execa", () => ({
  14. execa: jest.fn(),
  15. }))
  16. jest.mock("fs/promises", () => ({
  17. mkdir: jest.fn().mockResolvedValue(undefined),
  18. writeFile: jest.fn().mockResolvedValue(undefined),
  19. readFile: jest.fn().mockImplementation((filePath) => {
  20. if (filePath.includes("ui_messages.json")) {
  21. return Promise.resolve(JSON.stringify(mockMessages))
  22. }
  23. if (filePath.includes("api_conversation_history.json")) {
  24. return Promise.resolve(
  25. JSON.stringify([
  26. {
  27. role: "user",
  28. content: [{ type: "text", text: "historical task" }],
  29. ts: Date.now(),
  30. },
  31. {
  32. role: "assistant",
  33. content: [{ type: "text", text: "I'll help you with that task." }],
  34. ts: Date.now(),
  35. },
  36. ]),
  37. )
  38. }
  39. return Promise.resolve("[]")
  40. }),
  41. unlink: jest.fn().mockResolvedValue(undefined),
  42. rmdir: jest.fn().mockResolvedValue(undefined),
  43. }))
  44. jest.mock("p-wait-for", () => ({
  45. __esModule: true,
  46. default: jest.fn().mockImplementation(async () => Promise.resolve()),
  47. }))
  48. jest.mock("vscode", () => {
  49. const mockDisposable = { dispose: jest.fn() }
  50. const mockEventEmitter = { event: jest.fn(), fire: jest.fn() }
  51. const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
  52. const mockTextEditor = { document: mockTextDocument }
  53. const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
  54. const mockTabGroup = { tabs: [mockTab] }
  55. return {
  56. TabInputTextDiff: jest.fn(),
  57. CodeActionKind: {
  58. QuickFix: { value: "quickfix" },
  59. RefactorRewrite: { value: "refactor.rewrite" },
  60. },
  61. window: {
  62. createTextEditorDecorationType: jest.fn().mockReturnValue({
  63. dispose: jest.fn(),
  64. }),
  65. visibleTextEditors: [mockTextEditor],
  66. tabGroups: {
  67. all: [mockTabGroup],
  68. close: jest.fn(),
  69. onDidChangeTabs: jest.fn(() => ({ dispose: jest.fn() })),
  70. },
  71. showErrorMessage: jest.fn(),
  72. },
  73. workspace: {
  74. workspaceFolders: [
  75. {
  76. uri: { fsPath: "/mock/workspace/path" },
  77. name: "mock-workspace",
  78. index: 0,
  79. },
  80. ],
  81. createFileSystemWatcher: jest.fn(() => ({
  82. onDidCreate: jest.fn(() => mockDisposable),
  83. onDidDelete: jest.fn(() => mockDisposable),
  84. onDidChange: jest.fn(() => mockDisposable),
  85. dispose: jest.fn(),
  86. })),
  87. fs: {
  88. stat: jest.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1
  89. },
  90. onDidSaveTextDocument: jest.fn(() => mockDisposable),
  91. getConfiguration: jest.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
  92. },
  93. env: {
  94. uriScheme: "vscode",
  95. language: "en",
  96. },
  97. EventEmitter: jest.fn().mockImplementation(() => mockEventEmitter),
  98. Disposable: {
  99. from: jest.fn(),
  100. },
  101. TabInputText: jest.fn(),
  102. }
  103. })
  104. jest.mock("../../mentions", () => ({
  105. parseMentions: jest.fn().mockImplementation((text) => {
  106. return Promise.resolve(`processed: ${text}`)
  107. }),
  108. openMention: jest.fn(),
  109. getLatestTerminalOutput: jest.fn(),
  110. }))
  111. jest.mock("../../../integrations/misc/extract-text", () => ({
  112. extractTextFromFile: jest.fn().mockResolvedValue("Mock file content"),
  113. }))
  114. jest.mock("../../environment/getEnvironmentDetails", () => ({
  115. getEnvironmentDetails: jest.fn().mockResolvedValue(""),
  116. }))
  117. // Mock RooIgnoreController
  118. jest.mock("../../ignore/RooIgnoreController")
  119. // Mock storagePathManager to prevent dynamic import issues
  120. jest.mock("../../../utils/storage", () => ({
  121. getTaskDirectoryPath: jest
  122. .fn()
  123. .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
  124. getSettingsDirectoryPath: jest
  125. .fn()
  126. .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
  127. }))
  128. // Mock fileExistsAtPath
  129. jest.mock("../../../utils/fs", () => ({
  130. fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
  131. return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json")
  132. }),
  133. }))
  134. // Mock fs/promises
  135. const mockMessages = [
  136. {
  137. ts: Date.now(),
  138. type: "say",
  139. say: "text",
  140. text: "historical task",
  141. },
  142. ]
  143. describe("Cline", () => {
  144. let mockProvider: jest.Mocked<ClineProvider>
  145. let mockApiConfig: ProviderSettings
  146. let mockOutputChannel: any
  147. let mockExtensionContext: vscode.ExtensionContext
  148. beforeEach(() => {
  149. // Setup mock extension context
  150. const storageUri = {
  151. fsPath: path.join(os.tmpdir(), "test-storage"),
  152. }
  153. mockExtensionContext = {
  154. globalState: {
  155. get: jest.fn().mockImplementation((key: keyof GlobalState) => {
  156. if (key === "taskHistory") {
  157. return [
  158. {
  159. id: "123",
  160. number: 0,
  161. ts: Date.now(),
  162. task: "historical task",
  163. tokensIn: 100,
  164. tokensOut: 200,
  165. cacheWrites: 0,
  166. cacheReads: 0,
  167. totalCost: 0.001,
  168. },
  169. ]
  170. }
  171. return undefined
  172. }),
  173. update: jest.fn().mockImplementation((_key, _value) => Promise.resolve()),
  174. keys: jest.fn().mockReturnValue([]),
  175. },
  176. globalStorageUri: storageUri,
  177. workspaceState: {
  178. get: jest.fn().mockImplementation((_key) => undefined),
  179. update: jest.fn().mockImplementation((_key, _value) => Promise.resolve()),
  180. keys: jest.fn().mockReturnValue([]),
  181. },
  182. secrets: {
  183. get: jest.fn().mockImplementation((_key) => Promise.resolve(undefined)),
  184. store: jest.fn().mockImplementation((_key, _value) => Promise.resolve()),
  185. delete: jest.fn().mockImplementation((_key) => Promise.resolve()),
  186. },
  187. extensionUri: {
  188. fsPath: "/mock/extension/path",
  189. },
  190. extension: {
  191. packageJSON: {
  192. version: "1.0.0",
  193. },
  194. },
  195. } as unknown as vscode.ExtensionContext
  196. // Setup mock output channel
  197. mockOutputChannel = {
  198. appendLine: jest.fn(),
  199. append: jest.fn(),
  200. clear: jest.fn(),
  201. show: jest.fn(),
  202. hide: jest.fn(),
  203. dispose: jest.fn(),
  204. }
  205. // Setup mock provider with output channel
  206. mockProvider = new ClineProvider(
  207. mockExtensionContext,
  208. mockOutputChannel,
  209. "sidebar",
  210. new ContextProxy(mockExtensionContext),
  211. ) as jest.Mocked<ClineProvider>
  212. // Setup mock API configuration
  213. mockApiConfig = {
  214. apiProvider: "anthropic",
  215. apiModelId: "claude-3-5-sonnet-20241022",
  216. apiKey: "test-api-key", // Add API key to mock config
  217. }
  218. // Mock provider methods
  219. mockProvider.postMessageToWebview = jest.fn().mockResolvedValue(undefined)
  220. mockProvider.postStateToWebview = jest.fn().mockResolvedValue(undefined)
  221. mockProvider.getTaskWithId = jest.fn().mockImplementation(async (id) => ({
  222. historyItem: {
  223. id,
  224. ts: Date.now(),
  225. task: "historical task",
  226. tokensIn: 100,
  227. tokensOut: 200,
  228. cacheWrites: 0,
  229. cacheReads: 0,
  230. totalCost: 0.001,
  231. },
  232. taskDirPath: "/mock/storage/path/tasks/123",
  233. apiConversationHistoryFilePath: "/mock/storage/path/tasks/123/api_conversation_history.json",
  234. uiMessagesFilePath: "/mock/storage/path/tasks/123/ui_messages.json",
  235. apiConversationHistory: [
  236. {
  237. role: "user",
  238. content: [{ type: "text", text: "historical task" }],
  239. ts: Date.now(),
  240. },
  241. {
  242. role: "assistant",
  243. content: [{ type: "text", text: "I'll help you with that task." }],
  244. ts: Date.now(),
  245. },
  246. ],
  247. }))
  248. })
  249. describe("constructor", () => {
  250. it("should respect provided settings", async () => {
  251. const cline = new Task({
  252. provider: mockProvider,
  253. apiConfiguration: mockApiConfig,
  254. fuzzyMatchThreshold: 0.95,
  255. task: "test task",
  256. startTask: false,
  257. })
  258. expect(cline.diffEnabled).toBe(false)
  259. })
  260. it("should use default fuzzy match threshold when not provided", async () => {
  261. const cline = new Task({
  262. provider: mockProvider,
  263. apiConfiguration: mockApiConfig,
  264. enableDiff: true,
  265. fuzzyMatchThreshold: 0.95,
  266. task: "test task",
  267. startTask: false,
  268. })
  269. expect(cline.diffEnabled).toBe(true)
  270. // The diff strategy should be created with default threshold (1.0).
  271. expect(cline.diffStrategy).toBeDefined()
  272. })
  273. it("should require either task or historyItem", () => {
  274. expect(() => {
  275. new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
  276. }).toThrow("Either historyItem or task/images must be provided")
  277. })
  278. })
  279. describe("getEnvironmentDetails", () => {
  280. describe("API conversation handling", () => {
  281. it("should clean conversation history before sending to API", async () => {
  282. // Cline.create will now use our mocked getEnvironmentDetails
  283. const [cline, task] = Task.create({
  284. provider: mockProvider,
  285. apiConfiguration: mockApiConfig,
  286. task: "test task",
  287. })
  288. cline.abandoned = true
  289. await task
  290. // Set up mock stream.
  291. const mockStreamForClean = (async function* () {
  292. yield { type: "text", text: "test response" }
  293. })()
  294. // Set up spy.
  295. const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean)
  296. jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy)
  297. // Add test message to conversation history.
  298. cline.apiConversationHistory = [
  299. {
  300. role: "user" as const,
  301. content: [{ type: "text" as const, text: "test message" }],
  302. ts: Date.now(),
  303. },
  304. ]
  305. // Mock abort state
  306. Object.defineProperty(cline, "abort", {
  307. get: () => false,
  308. set: () => {},
  309. configurable: true,
  310. })
  311. // Add a message with extra properties to the conversation history
  312. const messageWithExtra = {
  313. role: "user" as const,
  314. content: [{ type: "text" as const, text: "test message" }],
  315. ts: Date.now(),
  316. extraProp: "should be removed",
  317. }
  318. cline.apiConversationHistory = [messageWithExtra]
  319. // Trigger an API request
  320. await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false)
  321. // Get the conversation history from the first API call
  322. const history = cleanMessageSpy.mock.calls[0][1]
  323. expect(history).toBeDefined()
  324. expect(history.length).toBeGreaterThan(0)
  325. // Find our test message
  326. const cleanedMessage = history.find((msg: { content?: Array<{ text: string }> }) =>
  327. msg.content?.some((content) => content.text === "test message"),
  328. )
  329. expect(cleanedMessage).toBeDefined()
  330. expect(cleanedMessage).toEqual({
  331. role: "user",
  332. content: [{ type: "text", text: "test message" }],
  333. })
  334. // Verify extra properties were removed
  335. expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"])
  336. })
  337. it("should handle image blocks based on model capabilities", async () => {
  338. // Create two configurations - one with image support, one without
  339. const configWithImages = {
  340. ...mockApiConfig,
  341. apiModelId: "claude-3-sonnet",
  342. }
  343. const configWithoutImages = {
  344. ...mockApiConfig,
  345. apiModelId: "gpt-3.5-turbo",
  346. }
  347. // Create test conversation history with mixed content
  348. const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
  349. {
  350. role: "user" as const,
  351. content: [
  352. {
  353. type: "text" as const,
  354. text: "Here is an image",
  355. } satisfies Anthropic.TextBlockParam,
  356. {
  357. type: "image" as const,
  358. source: {
  359. type: "base64" as const,
  360. media_type: "image/jpeg",
  361. data: "base64data",
  362. },
  363. } satisfies Anthropic.ImageBlockParam,
  364. ],
  365. },
  366. {
  367. role: "assistant" as const,
  368. content: [
  369. {
  370. type: "text" as const,
  371. text: "I see the image",
  372. } satisfies Anthropic.TextBlockParam,
  373. ],
  374. },
  375. ]
  376. // Test with model that supports images
  377. const [clineWithImages, taskWithImages] = Task.create({
  378. provider: mockProvider,
  379. apiConfiguration: configWithImages,
  380. task: "test task",
  381. })
  382. // Mock the model info to indicate image support
  383. jest.spyOn(clineWithImages.api, "getModel").mockReturnValue({
  384. id: "claude-3-sonnet",
  385. info: {
  386. supportsImages: true,
  387. supportsPromptCache: true,
  388. supportsComputerUse: true,
  389. contextWindow: 200000,
  390. maxTokens: 4096,
  391. inputPrice: 0.25,
  392. outputPrice: 0.75,
  393. } as ModelInfo,
  394. })
  395. clineWithImages.apiConversationHistory = conversationHistory
  396. // Test with model that doesn't support images
  397. const [clineWithoutImages, taskWithoutImages] = Task.create({
  398. provider: mockProvider,
  399. apiConfiguration: configWithoutImages,
  400. task: "test task",
  401. })
  402. // Mock the model info to indicate no image support
  403. jest.spyOn(clineWithoutImages.api, "getModel").mockReturnValue({
  404. id: "gpt-3.5-turbo",
  405. info: {
  406. supportsImages: false,
  407. supportsPromptCache: false,
  408. supportsComputerUse: false,
  409. contextWindow: 16000,
  410. maxTokens: 2048,
  411. inputPrice: 0.1,
  412. outputPrice: 0.2,
  413. } as ModelInfo,
  414. })
  415. clineWithoutImages.apiConversationHistory = conversationHistory
  416. // Mock abort state for both instances
  417. Object.defineProperty(clineWithImages, "abort", {
  418. get: () => false,
  419. set: () => {},
  420. configurable: true,
  421. })
  422. Object.defineProperty(clineWithoutImages, "abort", {
  423. get: () => false,
  424. set: () => {},
  425. configurable: true,
  426. })
  427. // Set up mock streams
  428. const mockStreamWithImages = (async function* () {
  429. yield { type: "text", text: "test response" }
  430. })()
  431. const mockStreamWithoutImages = (async function* () {
  432. yield { type: "text", text: "test response" }
  433. })()
  434. // Set up spies
  435. const imagesSpy = jest.fn().mockReturnValue(mockStreamWithImages)
  436. const noImagesSpy = jest.fn().mockReturnValue(mockStreamWithoutImages)
  437. jest.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy)
  438. jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy)
  439. // Set up conversation history with images
  440. clineWithImages.apiConversationHistory = [
  441. {
  442. role: "user",
  443. content: [
  444. { type: "text", text: "Here is an image" },
  445. { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } },
  446. ],
  447. },
  448. ]
  449. clineWithImages.abandoned = true
  450. await taskWithImages.catch(() => {})
  451. clineWithoutImages.abandoned = true
  452. await taskWithoutImages.catch(() => {})
  453. // Trigger API requests
  454. await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
  455. await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
  456. // Get the calls
  457. const imagesCalls = imagesSpy.mock.calls
  458. const noImagesCalls = noImagesSpy.mock.calls
  459. // Verify model with image support preserves image blocks
  460. expect(imagesCalls[0][1][0].content).toHaveLength(2)
  461. expect(imagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
  462. expect(imagesCalls[0][1][0].content[1]).toHaveProperty("type", "image")
  463. // Verify model without image support converts image blocks to text
  464. expect(noImagesCalls[0][1][0].content).toHaveLength(2)
  465. expect(noImagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
  466. expect(noImagesCalls[0][1][0].content[1]).toEqual({
  467. type: "text",
  468. text: "[Referenced image in conversation]",
  469. })
  470. })
  471. it.skip("should handle API retry with countdown", async () => {
  472. const [cline, task] = Task.create({
  473. provider: mockProvider,
  474. apiConfiguration: mockApiConfig,
  475. task: "test task",
  476. })
  477. // Mock delay to track countdown timing
  478. const mockDelay = jest.fn().mockResolvedValue(undefined)
  479. jest.spyOn(require("delay"), "default").mockImplementation(mockDelay)
  480. // Mock say to track messages
  481. const saySpy = jest.spyOn(cline, "say")
  482. // Create a stream that fails on first chunk
  483. const mockError = new Error("API Error")
  484. const mockFailedStream = {
  485. // eslint-disable-next-line require-yield
  486. async *[Symbol.asyncIterator]() {
  487. throw mockError
  488. },
  489. async next() {
  490. throw mockError
  491. },
  492. async return() {
  493. return { done: true, value: undefined }
  494. },
  495. async throw(e: any) {
  496. throw e
  497. },
  498. async [Symbol.asyncDispose]() {
  499. // Cleanup
  500. },
  501. } as AsyncGenerator<ApiStreamChunk>
  502. // Create a successful stream for retry
  503. const mockSuccessStream = {
  504. async *[Symbol.asyncIterator]() {
  505. yield { type: "text", text: "Success" }
  506. },
  507. async next() {
  508. return { done: true, value: { type: "text", text: "Success" } }
  509. },
  510. async return() {
  511. return { done: true, value: undefined }
  512. },
  513. async throw(e: any) {
  514. throw e
  515. },
  516. async [Symbol.asyncDispose]() {
  517. // Cleanup
  518. },
  519. } as AsyncGenerator<ApiStreamChunk>
  520. // Mock createMessage to fail first then succeed
  521. let firstAttempt = true
  522. jest.spyOn(cline.api, "createMessage").mockImplementation(() => {
  523. if (firstAttempt) {
  524. firstAttempt = false
  525. return mockFailedStream
  526. }
  527. return mockSuccessStream
  528. })
  529. // Set alwaysApproveResubmit and requestDelaySeconds
  530. mockProvider.getState = jest.fn().mockResolvedValue({
  531. alwaysApproveResubmit: true,
  532. requestDelaySeconds: 3,
  533. })
  534. // Mock previous API request message
  535. cline.clineMessages = [
  536. {
  537. ts: Date.now(),
  538. type: "say",
  539. say: "api_req_started",
  540. text: JSON.stringify({
  541. tokensIn: 100,
  542. tokensOut: 50,
  543. cacheWrites: 0,
  544. cacheReads: 0,
  545. request: "test request",
  546. }),
  547. },
  548. ]
  549. // Trigger API request
  550. const iterator = cline.attemptApiRequest(0)
  551. await iterator.next()
  552. // Calculate expected delay for first retry
  553. const baseDelay = 3 // from requestDelaySeconds
  554. // Verify countdown messages
  555. for (let i = baseDelay; i > 0; i--) {
  556. expect(saySpy).toHaveBeenCalledWith(
  557. "api_req_retry_delayed",
  558. expect.stringContaining(`Retrying in ${i} seconds`),
  559. undefined,
  560. true,
  561. )
  562. }
  563. expect(saySpy).toHaveBeenCalledWith(
  564. "api_req_retry_delayed",
  565. expect.stringContaining("Retrying now"),
  566. undefined,
  567. false,
  568. )
  569. // Calculate expected delay calls for countdown
  570. const totalExpectedDelays = baseDelay // One delay per second for countdown
  571. expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays)
  572. expect(mockDelay).toHaveBeenCalledWith(1000)
  573. // Verify error message content
  574. const errorMessage = saySpy.mock.calls.find((call) => call[1]?.includes(mockError.message))?.[1]
  575. expect(errorMessage).toBe(
  576. `${mockError.message}\n\nRetry attempt 1\nRetrying in ${baseDelay} seconds...`,
  577. )
  578. await cline.abortTask(true)
  579. await task.catch(() => {})
  580. })
  581. it.skip("should not apply retry delay twice", async () => {
  582. const [cline, task] = Task.create({
  583. provider: mockProvider,
  584. apiConfiguration: mockApiConfig,
  585. task: "test task",
  586. })
  587. // Mock delay to track countdown timing
  588. const mockDelay = jest.fn().mockResolvedValue(undefined)
  589. jest.spyOn(require("delay"), "default").mockImplementation(mockDelay)
  590. // Mock say to track messages
  591. const saySpy = jest.spyOn(cline, "say")
  592. // Create a stream that fails on first chunk
  593. const mockError = new Error("API Error")
  594. const mockFailedStream = {
  595. // eslint-disable-next-line require-yield
  596. async *[Symbol.asyncIterator]() {
  597. throw mockError
  598. },
  599. async next() {
  600. throw mockError
  601. },
  602. async return() {
  603. return { done: true, value: undefined }
  604. },
  605. async throw(e: any) {
  606. throw e
  607. },
  608. async [Symbol.asyncDispose]() {
  609. // Cleanup
  610. },
  611. } as AsyncGenerator<ApiStreamChunk>
  612. // Create a successful stream for retry
  613. const mockSuccessStream = {
  614. async *[Symbol.asyncIterator]() {
  615. yield { type: "text", text: "Success" }
  616. },
  617. async next() {
  618. return { done: true, value: { type: "text", text: "Success" } }
  619. },
  620. async return() {
  621. return { done: true, value: undefined }
  622. },
  623. async throw(e: any) {
  624. throw e
  625. },
  626. async [Symbol.asyncDispose]() {
  627. // Cleanup
  628. },
  629. } as AsyncGenerator<ApiStreamChunk>
  630. // Mock createMessage to fail first then succeed
  631. let firstAttempt = true
  632. jest.spyOn(cline.api, "createMessage").mockImplementation(() => {
  633. if (firstAttempt) {
  634. firstAttempt = false
  635. return mockFailedStream
  636. }
  637. return mockSuccessStream
  638. })
  639. // Set alwaysApproveResubmit and requestDelaySeconds
  640. mockProvider.getState = jest.fn().mockResolvedValue({
  641. alwaysApproveResubmit: true,
  642. requestDelaySeconds: 3,
  643. })
  644. // Mock previous API request message
  645. cline.clineMessages = [
  646. {
  647. ts: Date.now(),
  648. type: "say",
  649. say: "api_req_started",
  650. text: JSON.stringify({
  651. tokensIn: 100,
  652. tokensOut: 50,
  653. cacheWrites: 0,
  654. cacheReads: 0,
  655. request: "test request",
  656. }),
  657. },
  658. ]
  659. // Trigger API request
  660. const iterator = cline.attemptApiRequest(0)
  661. await iterator.next()
  662. // Verify delay is only applied for the countdown
  663. const baseDelay = 3 // from requestDelaySeconds
  664. const expectedDelayCount = baseDelay // One delay per second for countdown
  665. expect(mockDelay).toHaveBeenCalledTimes(expectedDelayCount)
  666. expect(mockDelay).toHaveBeenCalledWith(1000) // Each delay should be 1 second
  667. // Verify countdown messages were only shown once
  668. const retryMessages = saySpy.mock.calls.filter(
  669. (call) => call[0] === "api_req_retry_delayed" && call[1]?.includes("Retrying in"),
  670. )
  671. expect(retryMessages).toHaveLength(baseDelay)
  672. // Verify the retry message sequence
  673. for (let i = baseDelay; i > 0; i--) {
  674. expect(saySpy).toHaveBeenCalledWith(
  675. "api_req_retry_delayed",
  676. expect.stringContaining(`Retrying in ${i} seconds`),
  677. undefined,
  678. true,
  679. )
  680. }
  681. // Verify final retry message
  682. expect(saySpy).toHaveBeenCalledWith(
  683. "api_req_retry_delayed",
  684. expect.stringContaining("Retrying now"),
  685. undefined,
  686. false,
  687. )
  688. await cline.abortTask(true)
  689. await task.catch(() => {})
  690. })
  691. describe("processUserContentMentions", () => {
  692. it("should process mentions in task and feedback tags", async () => {
  693. const [cline, task] = Task.create({
  694. provider: mockProvider,
  695. apiConfiguration: mockApiConfig,
  696. task: "test task",
  697. })
  698. const userContent = [
  699. {
  700. type: "text",
  701. text: "Regular text with @/some/path",
  702. } as const,
  703. {
  704. type: "text",
  705. text: "<task>Text with @/some/path in task tags</task>",
  706. } as const,
  707. {
  708. type: "tool_result",
  709. tool_use_id: "test-id",
  710. content: [
  711. {
  712. type: "text",
  713. text: "<feedback>Check @/some/path</feedback>",
  714. },
  715. ],
  716. } as Anthropic.ToolResultBlockParam,
  717. {
  718. type: "tool_result",
  719. tool_use_id: "test-id-2",
  720. content: [
  721. {
  722. type: "text",
  723. text: "Regular tool result with @/path",
  724. },
  725. ],
  726. } as Anthropic.ToolResultBlockParam,
  727. ]
  728. const processedContent = await processUserContentMentions({
  729. userContent,
  730. cwd: cline.cwd,
  731. urlContentFetcher: cline.urlContentFetcher,
  732. fileContextTracker: cline.fileContextTracker,
  733. })
  734. // Regular text should not be processed
  735. expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe("Regular text with @/some/path")
  736. // Text within task tags should be processed
  737. expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:")
  738. expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain(
  739. "<task>Text with @/some/path in task tags</task>",
  740. )
  741. // Feedback tag content should be processed
  742. const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam
  743. const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content
  744. expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:")
  745. expect((content1 as Anthropic.TextBlockParam).text).toContain(
  746. "<feedback>Check @/some/path</feedback>",
  747. )
  748. // Regular tool result should not be processed
  749. const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam
  750. const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content
  751. expect((content2 as Anthropic.TextBlockParam).text).toBe("Regular tool result with @/path")
  752. await cline.abortTask(true)
  753. await task.catch(() => {})
  754. })
  755. })
  756. })
  757. })
  758. })