Task.test.ts 25 KB

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