| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189 |
- // npx vitest core/task/__tests__/Task.spec.ts
- import * as os from "os"
- import * as path from "path"
- import * as vscode from "vscode"
- import { Anthropic } from "@anthropic-ai/sdk"
- import type { GlobalState, ProviderSettings, ModelInfo } from "@roo-code/types"
- import { TelemetryService } from "@roo-code/telemetry"
- import { Task } from "../Task"
- import { ClineProvider } from "../../webview/ClineProvider"
- import { ApiStreamChunk } from "../../../api/transform/stream"
- import { ContextProxy } from "../../config/ContextProxy"
- import { processUserContentMentions } from "../../mentions/processUserContentMentions"
- import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace"
- import { MultiFileSearchReplaceDiffStrategy } from "../../diff/strategies/multi-file-search-replace"
- import { EXPERIMENT_IDS } from "../../../shared/experiments"
- // Mock delay before any imports that might use it
- vi.mock("delay", () => ({
- __esModule: true,
- default: vi.fn().mockResolvedValue(undefined),
- }))
- import delay from "delay"
- vi.mock("uuid", async (importOriginal) => {
- const actual = await importOriginal<typeof import("uuid")>()
- return {
- ...actual,
- v7: vi.fn(() => "00000000-0000-7000-8000-000000000000"),
- }
- })
- vi.mock("execa", () => ({
- execa: vi.fn(),
- }))
- vi.mock("fs/promises", async (importOriginal) => {
- const actual = (await importOriginal()) as Record<string, any>
- const mockFunctions = {
- mkdir: vi.fn().mockResolvedValue(undefined),
- writeFile: vi.fn().mockResolvedValue(undefined),
- readFile: vi.fn().mockImplementation((filePath) => {
- if (filePath.includes("ui_messages.json")) {
- return Promise.resolve(JSON.stringify(mockMessages))
- }
- if (filePath.includes("api_conversation_history.json")) {
- return Promise.resolve(
- JSON.stringify([
- {
- role: "user",
- content: [{ type: "text", text: "historical task" }],
- ts: Date.now(),
- },
- {
- role: "assistant",
- content: [{ type: "text", text: "I'll help you with that task." }],
- ts: Date.now(),
- },
- ]),
- )
- }
- return Promise.resolve("[]")
- }),
- unlink: vi.fn().mockResolvedValue(undefined),
- rmdir: vi.fn().mockResolvedValue(undefined),
- }
- return {
- ...actual,
- ...mockFunctions,
- default: mockFunctions,
- }
- })
- vi.mock("p-wait-for", () => ({
- default: vi.fn().mockImplementation(async () => Promise.resolve()),
- }))
- vi.mock("vscode", () => {
- const mockDisposable = { dispose: vi.fn() }
- const mockEventEmitter = { event: vi.fn(), fire: vi.fn() }
- const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } }
- const mockTextEditor = { document: mockTextDocument }
- const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } }
- const mockTabGroup = { tabs: [mockTab] }
- return {
- TabInputTextDiff: vi.fn(),
- CodeActionKind: {
- QuickFix: { value: "quickfix" },
- RefactorRewrite: { value: "refactor.rewrite" },
- },
- window: {
- createTextEditorDecorationType: vi.fn().mockReturnValue({
- dispose: vi.fn(),
- }),
- visibleTextEditors: [mockTextEditor],
- tabGroups: {
- all: [mockTabGroup],
- close: vi.fn(),
- onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })),
- },
- showErrorMessage: vi.fn(),
- },
- workspace: {
- workspaceFolders: [
- {
- uri: { fsPath: "/mock/workspace/path" },
- name: "mock-workspace",
- index: 0,
- },
- ],
- createFileSystemWatcher: vi.fn(() => ({
- onDidCreate: vi.fn(() => mockDisposable),
- onDidDelete: vi.fn(() => mockDisposable),
- onDidChange: vi.fn(() => mockDisposable),
- dispose: vi.fn(),
- })),
- fs: {
- stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1
- },
- onDidSaveTextDocument: vi.fn(() => mockDisposable),
- getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })),
- },
- env: {
- uriScheme: "vscode",
- language: "en",
- },
- EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter),
- Disposable: {
- from: vi.fn(),
- },
- TabInputText: vi.fn(),
- }
- })
- vi.mock("../../mentions", () => ({
- parseMentions: vi.fn().mockImplementation((text) => {
- return Promise.resolve({ text: `processed: ${text}`, mode: undefined })
- }),
- openMention: vi.fn(),
- getLatestTerminalOutput: vi.fn(),
- }))
- vi.mock("../../../integrations/misc/extract-text", () => ({
- extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
- }))
- vi.mock("../../environment/getEnvironmentDetails", () => ({
- getEnvironmentDetails: vi.fn().mockResolvedValue(""),
- }))
- vi.mock("../../ignore/RooIgnoreController")
- vi.mock("../../condense", async (importOriginal) => {
- const actual = (await importOriginal()) as any
- return {
- ...actual,
- summarizeConversation: vi.fn().mockResolvedValue({
- messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }],
- summary: "summary",
- cost: 0,
- newContextTokens: 1,
- }),
- }
- })
- // Mock storagePathManager to prevent dynamic import issues.
- vi.mock("../../../utils/storage", () => ({
- getTaskDirectoryPath: vi
- .fn()
- .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
- getSettingsDirectoryPath: vi
- .fn()
- .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
- }))
- vi.mock("../../../utils/fs", () => ({
- fileExistsAtPath: vi.fn().mockImplementation((filePath) => {
- return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json")
- }),
- }))
- const mockMessages = [
- {
- ts: Date.now(),
- type: "say",
- say: "text",
- text: "historical task",
- },
- ]
- describe("Cline", () => {
- let mockProvider: any
- let mockApiConfig: ProviderSettings
- let mockOutputChannel: any
- let mockExtensionContext: vscode.ExtensionContext
- beforeEach(() => {
- if (!TelemetryService.hasInstance()) {
- TelemetryService.createInstance([])
- }
- // Setup mock extension context
- const storageUri = {
- fsPath: path.join(os.tmpdir(), "test-storage"),
- }
- mockExtensionContext = {
- globalState: {
- get: vi.fn().mockImplementation((key: keyof GlobalState) => {
- if (key === "taskHistory") {
- return [
- {
- id: "123",
- number: 0,
- ts: Date.now(),
- task: "historical task",
- tokensIn: 100,
- tokensOut: 200,
- cacheWrites: 0,
- cacheReads: 0,
- totalCost: 0.001,
- },
- ]
- }
- return undefined
- }),
- update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
- keys: vi.fn().mockReturnValue([]),
- },
- globalStorageUri: storageUri,
- workspaceState: {
- get: vi.fn().mockImplementation((_key) => undefined),
- update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
- keys: vi.fn().mockReturnValue([]),
- },
- secrets: {
- get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)),
- store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()),
- delete: vi.fn().mockImplementation((_key) => Promise.resolve()),
- },
- extensionUri: {
- fsPath: "/mock/extension/path",
- },
- extension: {
- packageJSON: {
- version: "1.0.0",
- },
- },
- } as unknown as vscode.ExtensionContext
- // Setup mock output channel
- mockOutputChannel = {
- appendLine: vi.fn(),
- append: vi.fn(),
- clear: vi.fn(),
- show: vi.fn(),
- hide: vi.fn(),
- dispose: vi.fn(),
- }
- // Setup mock provider with output channel
- mockProvider = new ClineProvider(
- mockExtensionContext,
- mockOutputChannel,
- "sidebar",
- new ContextProxy(mockExtensionContext),
- ) as any
- // Setup mock API configuration
- mockApiConfig = {
- apiProvider: "anthropic",
- apiModelId: "claude-3-5-sonnet-20241022",
- apiKey: "test-api-key", // Add API key to mock config
- }
- // Mock provider methods
- mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
- mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
- mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({
- historyItem: {
- id,
- ts: Date.now(),
- task: "historical task",
- tokensIn: 100,
- tokensOut: 200,
- cacheWrites: 0,
- cacheReads: 0,
- totalCost: 0.001,
- },
- taskDirPath: "/mock/storage/path/tasks/123",
- apiConversationHistoryFilePath: "/mock/storage/path/tasks/123/api_conversation_history.json",
- uiMessagesFilePath: "/mock/storage/path/tasks/123/ui_messages.json",
- apiConversationHistory: [
- {
- role: "user",
- content: [{ type: "text", text: "historical task" }],
- ts: Date.now(),
- },
- {
- role: "assistant",
- content: [{ type: "text", text: "I'll help you with that task." }],
- ts: Date.now(),
- },
- ],
- }))
- })
- describe("constructor", () => {
- it("should respect provided settings", async () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- fuzzyMatchThreshold: 0.95,
- task: "test task",
- startTask: false,
- })
- expect(cline.diffEnabled).toBe(false)
- })
- it("should use default fuzzy match threshold when not provided", async () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- enableDiff: true,
- fuzzyMatchThreshold: 0.95,
- task: "test task",
- startTask: false,
- })
- expect(cline.diffEnabled).toBe(true)
- // The diff strategy should be created with default threshold (1.0).
- expect(cline.diffStrategy).toBeDefined()
- })
- it("should use default consecutiveMistakeLimit when not provided", () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- expect(cline.consecutiveMistakeLimit).toBe(3)
- })
- it("should respect provided consecutiveMistakeLimit", () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- consecutiveMistakeLimit: 5,
- task: "test task",
- startTask: false,
- })
- expect(cline.consecutiveMistakeLimit).toBe(5)
- })
- it("should keep consecutiveMistakeLimit of 0 as 0 for unlimited", () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- consecutiveMistakeLimit: 0,
- task: "test task",
- startTask: false,
- })
- expect(cline.consecutiveMistakeLimit).toBe(0)
- })
- it("should pass 0 to ToolRepetitionDetector for unlimited mode", () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- consecutiveMistakeLimit: 0,
- task: "test task",
- startTask: false,
- })
- // The toolRepetitionDetector should be initialized with 0 for unlimited mode
- expect(cline.toolRepetitionDetector).toBeDefined()
- // Verify the limit remains as 0
- expect(cline.consecutiveMistakeLimit).toBe(0)
- })
- it("should pass consecutiveMistakeLimit to ToolRepetitionDetector", () => {
- const cline = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- consecutiveMistakeLimit: 5,
- task: "test task",
- startTask: false,
- })
- // The toolRepetitionDetector should be initialized with the same limit
- expect(cline.toolRepetitionDetector).toBeDefined()
- expect(cline.consecutiveMistakeLimit).toBe(5)
- })
- it("should require either task or historyItem", () => {
- expect(() => {
- new Task({ provider: mockProvider, apiConfiguration: mockApiConfig })
- }).toThrow("Either historyItem or task/images must be provided")
- })
- })
- describe("getEnvironmentDetails", () => {
- describe("API conversation handling", () => {
- it.skip("should clean conversation history before sending to API", async () => {
- // Cline.create will now use our mocked getEnvironmentDetails
- const [cline, task] = Task.create({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- })
- cline.abandoned = true
- await task
- // Set up mock stream.
- const mockStreamForClean = (async function* () {
- yield { type: "text", text: "test response" }
- })()
- // Set up spy.
- const cleanMessageSpy = vi.fn().mockReturnValue(mockStreamForClean)
- vi.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy)
- // Add test message to conversation history.
- cline.apiConversationHistory = [
- {
- role: "user" as const,
- content: [{ type: "text" as const, text: "test message" }],
- ts: Date.now(),
- },
- ]
- // Mock abort state
- Object.defineProperty(cline, "abort", {
- get: () => false,
- set: () => {},
- configurable: true,
- })
- // Add a message with extra properties to the conversation history
- const messageWithExtra = {
- role: "user" as const,
- content: [{ type: "text" as const, text: "test message" }],
- ts: Date.now(),
- extraProp: "should be removed",
- }
- cline.apiConversationHistory = [messageWithExtra]
- // Trigger an API request
- await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false)
- // Get the conversation history from the first API call
- expect(cleanMessageSpy.mock.calls.length).toBeGreaterThan(0)
- const history = cleanMessageSpy.mock.calls[0]?.[1]
- expect(history).toBeDefined()
- expect(history.length).toBeGreaterThan(0)
- // Find our test message
- const cleanedMessage = history.find((msg: { content?: Array<{ text: string }> }) =>
- msg.content?.some((content) => content.text === "test message"),
- )
- expect(cleanedMessage).toBeDefined()
- expect(cleanedMessage).toEqual({
- role: "user",
- content: [{ type: "text", text: "test message" }],
- })
- // Verify extra properties were removed
- expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"])
- })
- it.skip("should handle image blocks based on model capabilities", async () => {
- // Create two configurations - one with image support, one without
- const configWithImages = {
- ...mockApiConfig,
- apiModelId: "claude-3-sonnet",
- }
- const configWithoutImages = {
- ...mockApiConfig,
- apiModelId: "gpt-3.5-turbo",
- }
- // Create test conversation history with mixed content
- const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
- {
- role: "user" as const,
- content: [
- {
- type: "text" as const,
- text: "Here is an image",
- } satisfies Anthropic.TextBlockParam,
- {
- type: "image" as const,
- source: {
- type: "base64" as const,
- media_type: "image/jpeg",
- data: "base64data",
- },
- } satisfies Anthropic.ImageBlockParam,
- ],
- },
- {
- role: "assistant" as const,
- content: [
- {
- type: "text" as const,
- text: "I see the image",
- } satisfies Anthropic.TextBlockParam,
- ],
- },
- ]
- // Test with model that supports images
- const [clineWithImages, taskWithImages] = Task.create({
- provider: mockProvider,
- apiConfiguration: configWithImages,
- task: "test task",
- })
- // Mock the model info to indicate image support
- vi.spyOn(clineWithImages.api, "getModel").mockReturnValue({
- id: "claude-3-sonnet",
- info: {
- supportsImages: true,
- supportsPromptCache: true,
- contextWindow: 200000,
- maxTokens: 4096,
- inputPrice: 0.25,
- outputPrice: 0.75,
- } as ModelInfo,
- })
- clineWithImages.apiConversationHistory = conversationHistory
- // Test with model that doesn't support images
- const [clineWithoutImages, taskWithoutImages] = Task.create({
- provider: mockProvider,
- apiConfiguration: configWithoutImages,
- task: "test task",
- })
- // Mock the model info to indicate no image support
- vi.spyOn(clineWithoutImages.api, "getModel").mockReturnValue({
- id: "gpt-3.5-turbo",
- info: {
- supportsImages: false,
- supportsPromptCache: false,
- contextWindow: 16000,
- maxTokens: 2048,
- inputPrice: 0.1,
- outputPrice: 0.2,
- } as ModelInfo,
- })
- clineWithoutImages.apiConversationHistory = conversationHistory
- // Mock abort state for both instances
- Object.defineProperty(clineWithImages, "abort", {
- get: () => false,
- set: () => {},
- configurable: true,
- })
- Object.defineProperty(clineWithoutImages, "abort", {
- get: () => false,
- set: () => {},
- configurable: true,
- })
- // Set up mock streams
- const mockStreamWithImages = (async function* () {
- yield { type: "text", text: "test response" }
- })()
- const mockStreamWithoutImages = (async function* () {
- yield { type: "text", text: "test response" }
- })()
- // Set up spies
- const imagesSpy = vi.fn().mockReturnValue(mockStreamWithImages)
- const noImagesSpy = vi.fn().mockReturnValue(mockStreamWithoutImages)
- vi.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy)
- vi.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy)
- // Set up conversation history with images
- clineWithImages.apiConversationHistory = [
- {
- role: "user",
- content: [
- { type: "text", text: "Here is an image" },
- { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data" } },
- ],
- },
- ]
- clineWithImages.abandoned = true
- await taskWithImages.catch(() => {})
- clineWithoutImages.abandoned = true
- await taskWithoutImages.catch(() => {})
- // Trigger API requests
- await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
- await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }])
- // Get the calls
- const imagesCalls = imagesSpy.mock.calls
- const noImagesCalls = noImagesSpy.mock.calls
- // Verify model with image support preserves image blocks
- expect(imagesCalls.length).toBeGreaterThan(0)
- if (imagesCalls[0]?.[1]?.[0]?.content) {
- expect(imagesCalls[0][1][0].content).toHaveLength(2)
- expect(imagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
- expect(imagesCalls[0][1][0].content[1]).toHaveProperty("type", "image")
- }
- // Verify model without image support converts image blocks to text
- expect(noImagesCalls.length).toBeGreaterThan(0)
- if (noImagesCalls[0]?.[1]?.[0]?.content) {
- expect(noImagesCalls[0][1][0].content).toHaveLength(2)
- expect(noImagesCalls[0][1][0].content[0]).toEqual({ type: "text", text: "Here is an image" })
- expect(noImagesCalls[0][1][0].content[1]).toEqual({
- type: "text",
- text: "[Referenced image in conversation]",
- })
- }
- })
- it.skip("should handle API retry with countdown", async () => {
- const [cline, task] = Task.create({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- })
- // Mock delay to track countdown timing
- const mockDelay = vi.fn().mockResolvedValue(undefined)
- vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)
- // Mock say to track messages
- const saySpy = vi.spyOn(cline, "say")
- // Create a stream that fails on first chunk
- const mockError = new Error("API Error")
- const mockFailedStream = {
- // eslint-disable-next-line require-yield
- async *[Symbol.asyncIterator]() {
- throw mockError
- },
- async next() {
- throw mockError
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- async [Symbol.asyncDispose]() {
- // Cleanup
- },
- } as AsyncGenerator<ApiStreamChunk>
- // Create a successful stream for retry
- const mockSuccessStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "Success" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "Success" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- async [Symbol.asyncDispose]() {
- // Cleanup
- },
- } as AsyncGenerator<ApiStreamChunk>
- // Mock createMessage to fail first then succeed
- let firstAttempt = true
- vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
- if (firstAttempt) {
- firstAttempt = false
- return mockFailedStream
- }
- return mockSuccessStream
- })
- // Set up mock state
- mockProvider.getState = vi.fn().mockResolvedValue({})
- // Mock previous API request message
- cline.clineMessages = [
- {
- ts: Date.now(),
- type: "say",
- say: "api_req_started",
- text: JSON.stringify({
- tokensIn: 100,
- tokensOut: 50,
- cacheWrites: 0,
- cacheReads: 0,
- }),
- },
- ]
- // Trigger API request
- const iterator = cline.attemptApiRequest(0)
- await iterator.next()
- // Calculate expected delay for first retry
- const baseDelay = 3 // test retry delay
- // Verify countdown messages
- for (let i = baseDelay; i > 0; i--) {
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining(`Retrying in ${i} seconds`),
- undefined,
- true,
- )
- }
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining("Retrying now"),
- undefined,
- false,
- )
- // Calculate expected delay calls for countdown
- const totalExpectedDelays = baseDelay // One delay per second for countdown
- expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays)
- expect(mockDelay).toHaveBeenCalledWith(1000)
- // Verify error message content
- const errorMessage = saySpy.mock.calls.find((call) => call[1]?.includes(mockError.message))?.[1]
- expect(errorMessage).toBe(
- `${mockError.message}\n\nRetry attempt 1\nRetrying in ${baseDelay} seconds...`,
- )
- await cline.abortTask(true)
- await task.catch(() => {})
- })
- it.skip("should not apply retry delay twice", async () => {
- const [cline, task] = Task.create({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- })
- // Mock delay to track countdown timing
- const mockDelay = vi.fn().mockResolvedValue(undefined)
- vi.spyOn(await import("delay"), "default").mockImplementation(mockDelay)
- // Mock say to track messages
- const saySpy = vi.spyOn(cline, "say")
- // Create a stream that fails on first chunk
- const mockError = new Error("API Error")
- const mockFailedStream = {
- // eslint-disable-next-line require-yield
- async *[Symbol.asyncIterator]() {
- throw mockError
- },
- async next() {
- throw mockError
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- async [Symbol.asyncDispose]() {
- // Cleanup
- },
- } as AsyncGenerator<ApiStreamChunk>
- // Create a successful stream for retry
- const mockSuccessStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "Success" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "Success" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- async [Symbol.asyncDispose]() {
- // Cleanup
- },
- } as AsyncGenerator<ApiStreamChunk>
- // Mock createMessage to fail first then succeed
- let firstAttempt = true
- vi.spyOn(cline.api, "createMessage").mockImplementation(() => {
- if (firstAttempt) {
- firstAttempt = false
- return mockFailedStream
- }
- return mockSuccessStream
- })
- // Set up mock state
- mockProvider.getState = vi.fn().mockResolvedValue({})
- // Mock previous API request message
- cline.clineMessages = [
- {
- ts: Date.now(),
- type: "say",
- say: "api_req_started",
- text: JSON.stringify({
- tokensIn: 100,
- tokensOut: 50,
- cacheWrites: 0,
- cacheReads: 0,
- }),
- },
- ]
- // Trigger API request
- const iterator = cline.attemptApiRequest(0)
- await iterator.next()
- // Verify delay is only applied for the countdown
- const baseDelay = 3 // test retry delay
- const expectedDelayCount = baseDelay // One delay per second for countdown
- expect(mockDelay).toHaveBeenCalledTimes(expectedDelayCount)
- expect(mockDelay).toHaveBeenCalledWith(1000) // Each delay should be 1 second
- // Verify countdown messages were only shown once
- const retryMessages = saySpy.mock.calls.filter(
- (call) => call[0] === "api_req_retry_delayed" && call[1]?.includes("Retrying in"),
- )
- expect(retryMessages).toHaveLength(baseDelay)
- // Verify the retry message sequence
- for (let i = baseDelay; i > 0; i--) {
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining(`Retrying in ${i} seconds`),
- undefined,
- true,
- )
- }
- // Verify final retry message
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_retry_delayed",
- expect.stringContaining("Retrying now"),
- undefined,
- false,
- )
- await cline.abortTask(true)
- await task.catch(() => {})
- })
- describe("processUserContentMentions", () => {
- it("should process mentions in task and feedback tags", async () => {
- const [cline, task] = Task.create({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- })
- const userContent = [
- {
- type: "text",
- text: "Regular text with 'some/path' (see below for file content)",
- } as const,
- {
- type: "text",
- text: "<task>Text with 'some/path' (see below for file content) in task tags</task>",
- } as const,
- {
- type: "tool_result",
- tool_use_id: "test-id",
- content: [
- {
- type: "text",
- text: "<feedback>Check 'some/path' (see below for file content)</feedback>",
- },
- ],
- } as Anthropic.ToolResultBlockParam,
- {
- type: "tool_result",
- tool_use_id: "test-id-2",
- content: [
- {
- type: "text",
- text: "Regular tool result with 'path' (see below for file content)",
- },
- ],
- } as Anthropic.ToolResultBlockParam,
- ]
- const { content: processedContent } = await processUserContentMentions({
- userContent,
- cwd: cline.cwd,
- urlContentFetcher: cline.urlContentFetcher,
- fileContextTracker: cline.fileContextTracker,
- })
- // Regular text should not be processed
- expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe(
- "Regular text with 'some/path' (see below for file content)",
- )
- // Text within task tags should be processed
- expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:")
- expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain(
- "<task>Text with 'some/path' (see below for file content) in task tags</task>",
- )
- // Feedback tag content should be processed
- const toolResult1 = processedContent[2] as Anthropic.ToolResultBlockParam
- const content1 = Array.isArray(toolResult1.content) ? toolResult1.content[0] : toolResult1.content
- expect((content1 as Anthropic.TextBlockParam).text).toContain("processed:")
- expect((content1 as Anthropic.TextBlockParam).text).toContain(
- "<feedback>Check 'some/path' (see below for file content)</feedback>",
- )
- // Regular tool result should not be processed
- const toolResult2 = processedContent[3] as Anthropic.ToolResultBlockParam
- const content2 = Array.isArray(toolResult2.content) ? toolResult2.content[0] : toolResult2.content
- expect((content2 as Anthropic.TextBlockParam).text).toBe(
- "Regular tool result with 'path' (see below for file content)",
- )
- await cline.abortTask(true)
- await task.catch(() => {})
- })
- })
- })
- describe("Subtask Rate Limiting", () => {
- let mockProvider: any
- let mockApiConfig: any
- let mockDelay: ReturnType<typeof vi.fn>
- beforeEach(() => {
- vi.clearAllMocks()
- // Reset the global timestamp before each test
- Task.resetGlobalApiRequestTime()
- mockApiConfig = {
- apiProvider: "anthropic",
- apiKey: "test-key",
- rateLimitSeconds: 5,
- }
- mockProvider = {
- context: {
- globalStorageUri: { fsPath: "/test/storage" },
- },
- getState: vi.fn().mockResolvedValue({
- apiConfiguration: mockApiConfig,
- }),
- getMcpHub: vi.fn().mockReturnValue(undefined),
- getSkillsManager: vi.fn().mockReturnValue(undefined),
- say: vi.fn(),
- postStateToWebview: vi.fn().mockResolvedValue(undefined),
- postMessageToWebview: vi.fn().mockResolvedValue(undefined),
- updateTaskHistory: vi.fn().mockResolvedValue(undefined),
- }
- // Get the mocked delay function
- mockDelay = delay as ReturnType<typeof vi.fn>
- mockDelay.mockClear()
- })
- afterEach(() => {
- // Clean up the global state after each test
- Task.resetGlobalApiRequestTime()
- })
- it("should enforce rate limiting across parent and subtask", async () => {
- // Add a spy to track getState calls
- const getStateSpy = vi.spyOn(mockProvider, "getState")
- // Create parent task
- const parent = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "parent task",
- startTask: false,
- })
- // Mock the API stream response
- const mockStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "parent response" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "parent response" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- [Symbol.asyncDispose]: async () => {},
- } as AsyncGenerator<ApiStreamChunk>
- vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the parent task
- const parentIterator = parent.attemptApiRequest(0)
- await parentIterator.next()
- // Verify no delay was applied for the first request
- expect(mockDelay).not.toHaveBeenCalled()
- // Create a subtask immediately after
- const child = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "child task",
- parentTask: parent,
- rootTask: parent,
- startTask: false,
- })
- // Spy on child.say to verify the emitted message type
- const saySpy = vi.spyOn(child, "say")
- // Mock the child's API stream
- const childMockStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "child response" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "child response" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- [Symbol.asyncDispose]: async () => {},
- } as AsyncGenerator<ApiStreamChunk>
- vi.spyOn(child.api, "createMessage").mockReturnValue(childMockStream)
- // Make an API request with the child task
- const childIterator = child.attemptApiRequest(0)
- await childIterator.next()
- // Verify rate limiting was applied
- expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
- expect(mockDelay).toHaveBeenCalledWith(1000)
- // Verify we used the non-error rate-limit wait message type (JSON format)
- expect(saySpy).toHaveBeenCalledWith(
- "api_req_rate_limit_wait",
- expect.stringMatching(/\{"seconds":\d+\}/),
- undefined,
- true,
- )
- // Verify the wait message was finalized
- expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false)
- }, 10000) // Increase timeout to 10 seconds
- it("should not apply rate limiting if enough time has passed", async () => {
- // Create parent task
- const parent = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "parent task",
- startTask: false,
- })
- // Mock the API stream response
- const mockStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "response" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "response" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- [Symbol.asyncDispose]: async () => {},
- } as AsyncGenerator<ApiStreamChunk>
- vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the parent task
- const parentIterator = parent.attemptApiRequest(0)
- await parentIterator.next()
- // Simulate time passing (more than rate limit)
- const originalPerformanceNow = performance.now
- const mockTime = performance.now() + (mockApiConfig.rateLimitSeconds + 1) * 1000
- performance.now = vi.fn(() => mockTime)
- // Create a subtask after time has passed
- const child = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "child task",
- parentTask: parent,
- rootTask: parent,
- startTask: false,
- })
- vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the child task
- const childIterator = child.attemptApiRequest(0)
- await childIterator.next()
- // Verify no rate limiting was applied
- expect(mockDelay).not.toHaveBeenCalled()
- // Restore performance.now
- performance.now = originalPerformanceNow
- })
- it("should share rate limiting across multiple subtasks", async () => {
- // Create parent task
- const parent = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "parent task",
- startTask: false,
- })
- // Mock the API stream response
- const mockStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "response" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "response" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- [Symbol.asyncDispose]: async () => {},
- } as AsyncGenerator<ApiStreamChunk>
- vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the parent task
- const parentIterator = parent.attemptApiRequest(0)
- await parentIterator.next()
- // Create first subtask
- const child1 = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "child task 1",
- parentTask: parent,
- rootTask: parent,
- startTask: false,
- })
- vi.spyOn(child1.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the first child task
- const child1Iterator = child1.attemptApiRequest(0)
- await child1Iterator.next()
- // Verify rate limiting was applied
- const firstDelayCount = mockDelay.mock.calls.length
- expect(firstDelayCount).toBe(mockApiConfig.rateLimitSeconds)
- // Clear the mock to count new delays
- mockDelay.mockClear()
- // Create second subtask immediately after
- const child2 = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "child task 2",
- parentTask: parent,
- rootTask: parent,
- startTask: false,
- })
- vi.spyOn(child2.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the second child task
- const child2Iterator = child2.attemptApiRequest(0)
- await child2Iterator.next()
- // Verify rate limiting was applied again
- expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
- }, 15000) // Increase timeout to 15 seconds
- it("should handle rate limiting with zero rate limit", async () => {
- // Update config to have zero rate limit
- mockApiConfig.rateLimitSeconds = 0
- mockProvider.getState.mockResolvedValue({
- apiConfiguration: mockApiConfig,
- })
- // Create parent task
- const parent = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "parent task",
- startTask: false,
- })
- // Mock the API stream response
- const mockStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "response" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "response" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- [Symbol.asyncDispose]: async () => {},
- } as AsyncGenerator<ApiStreamChunk>
- vi.spyOn(parent.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the parent task
- const parentIterator = parent.attemptApiRequest(0)
- await parentIterator.next()
- // Create a subtask
- const child = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "child task",
- parentTask: parent,
- rootTask: parent,
- startTask: false,
- })
- vi.spyOn(child.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request with the child task
- const childIterator = child.attemptApiRequest(0)
- await childIterator.next()
- // Verify no delay was applied
- expect(mockDelay).not.toHaveBeenCalled()
- })
- it("should update global timestamp even when no rate limiting is needed", async () => {
- // Create task
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Mock the API stream response
- const mockStream = {
- async *[Symbol.asyncIterator]() {
- yield { type: "text", text: "response" }
- },
- async next() {
- return { done: true, value: { type: "text", text: "response" } }
- },
- async return() {
- return { done: true, value: undefined }
- },
- async throw(e: any) {
- throw e
- },
- [Symbol.asyncDispose]: async () => {},
- } as AsyncGenerator<ApiStreamChunk>
- vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
- // Make an API request
- const iterator = task.attemptApiRequest(0)
- await iterator.next()
- // Access the private static property via reflection for testing
- const globalTimestamp = (Task as any).lastGlobalApiRequestTime
- expect(globalTimestamp).toBeDefined()
- expect(globalTimestamp).toBeGreaterThan(0)
- })
- })
- describe("Dynamic Strategy Selection", () => {
- let mockProvider: any
- let mockApiConfig: any
- beforeEach(() => {
- vi.clearAllMocks()
- mockApiConfig = {
- apiProvider: "anthropic",
- apiKey: "test-key",
- }
- mockProvider = {
- context: {
- globalStorageUri: { fsPath: "/test/storage" },
- },
- getState: vi.fn(),
- }
- })
- it("should use MultiSearchReplaceDiffStrategy by default", async () => {
- mockProvider.getState.mockResolvedValue({
- experiments: {
- [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false,
- },
- })
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- enableDiff: true,
- task: "test task",
- startTask: false,
- })
- // Initially should be MultiSearchReplaceDiffStrategy
- expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
- expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace")
- })
- it("should switch to MultiFileSearchReplaceDiffStrategy when experiment is enabled", async () => {
- mockProvider.getState.mockResolvedValue({
- experiments: {
- [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true,
- },
- })
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- enableDiff: true,
- task: "test task",
- startTask: false,
- })
- // Initially should be MultiSearchReplaceDiffStrategy
- expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
- // Wait for async strategy update
- await new Promise((resolve) => setTimeout(resolve, 10))
- // Should have switched to MultiFileSearchReplaceDiffStrategy
- expect(task.diffStrategy).toBeInstanceOf(MultiFileSearchReplaceDiffStrategy)
- expect(task.diffStrategy?.getName()).toBe("MultiFileSearchReplace")
- })
- it("should keep MultiSearchReplaceDiffStrategy when experiments are undefined", async () => {
- mockProvider.getState.mockResolvedValue({})
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- enableDiff: true,
- task: "test task",
- startTask: false,
- })
- // Initially should be MultiSearchReplaceDiffStrategy
- expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
- // Wait for async strategy update
- await new Promise((resolve) => setTimeout(resolve, 10))
- // Should still be MultiSearchReplaceDiffStrategy
- expect(task.diffStrategy).toBeInstanceOf(MultiSearchReplaceDiffStrategy)
- expect(task.diffStrategy?.getName()).toBe("MultiSearchReplace")
- })
- it("should not create diff strategy when enableDiff is false", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- enableDiff: false,
- task: "test task",
- startTask: false,
- })
- expect(task.diffEnabled).toBe(false)
- expect(task.diffStrategy).toBeUndefined()
- })
- })
- describe("getApiProtocol", () => {
- it("should determine API protocol based on provider and model", async () => {
- // Test with Anthropic provider
- const anthropicConfig = {
- ...mockApiConfig,
- apiProvider: "anthropic" as const,
- apiModelId: "gpt-4",
- }
- const anthropicTask = new Task({
- provider: mockProvider,
- apiConfiguration: anthropicConfig,
- task: "test task",
- startTask: false,
- })
- // Should use anthropic protocol even with non-claude model
- expect(anthropicTask.apiConfiguration.apiProvider).toBe("anthropic")
- // Test with OpenRouter provider and Claude model
- const openrouterClaudeConfig = {
- apiProvider: "openrouter" as const,
- openRouterModelId: "anthropic/claude-3-opus",
- }
- const openrouterClaudeTask = new Task({
- provider: mockProvider,
- apiConfiguration: openrouterClaudeConfig,
- task: "test task",
- startTask: false,
- })
- expect(openrouterClaudeTask.apiConfiguration.apiProvider).toBe("openrouter")
- // Test with OpenRouter provider and non-Claude model
- const openrouterGptConfig = {
- apiProvider: "openrouter" as const,
- openRouterModelId: "openai/gpt-4",
- }
- const openrouterGptTask = new Task({
- provider: mockProvider,
- apiConfiguration: openrouterGptConfig,
- task: "test task",
- startTask: false,
- })
- expect(openrouterGptTask.apiConfiguration.apiProvider).toBe("openrouter")
- // Test with various Claude model formats
- const claudeModelFormats = [
- "claude-3-opus",
- "Claude-3-Sonnet",
- "CLAUDE-instant",
- "anthropic/claude-3-haiku",
- "some-provider/claude-model",
- ]
- for (const modelId of claudeModelFormats) {
- const config = {
- apiProvider: "openai" as const,
- openAiModelId: modelId,
- }
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: config,
- task: "test task",
- startTask: false,
- })
- // Verify the model ID contains claude (case-insensitive)
- expect(modelId.toLowerCase()).toContain("claude")
- }
- })
- it("should handle edge cases for API protocol detection", async () => {
- // Test with undefined provider
- const undefinedProviderConfig = {
- apiModelId: "claude-3-opus",
- }
- const undefinedProviderTask = new Task({
- provider: mockProvider,
- apiConfiguration: undefinedProviderConfig,
- task: "test task",
- startTask: false,
- })
- expect(undefinedProviderTask.apiConfiguration.apiProvider).toBeUndefined()
- // Test with no model ID
- const noModelConfig = {
- apiProvider: "openai" as const,
- }
- const noModelTask = new Task({
- provider: mockProvider,
- apiConfiguration: noModelConfig,
- task: "test task",
- startTask: false,
- })
- expect(noModelTask.apiConfiguration.apiProvider).toBe("openai")
- })
- })
- describe("submitUserMessage", () => {
- it("should always route through webview sendMessage invoke", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "initial task",
- startTask: false,
- })
- // Set up some existing messages to simulate an ongoing conversation
- task.clineMessages = [
- {
- ts: Date.now(),
- type: "say",
- say: "text",
- text: "Initial message",
- },
- ]
- // Call submitUserMessage
- task.submitUserMessage("test message", ["image1.png"])
- // Verify postMessageToWebview was called with sendMessage invoke
- expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "invoke",
- invoke: "sendMessage",
- text: "test message",
- images: ["image1.png"],
- })
- })
- it("should handle empty messages gracefully", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "initial task",
- startTask: false,
- })
- // Call with empty text and no images
- task.submitUserMessage("", [])
- // Should not call postMessageToWebview for empty messages
- expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
- // Call with whitespace only
- task.submitUserMessage(" ", [])
- expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
- })
- it("should route through webview for both new and existing tasks", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "initial task",
- startTask: false,
- })
- // Test with no messages (new task scenario)
- task.clineMessages = []
- task.submitUserMessage("new task", ["image1.png"])
- expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "invoke",
- invoke: "sendMessage",
- text: "new task",
- images: ["image1.png"],
- })
- // Clear mock
- mockProvider.postMessageToWebview.mockClear()
- // Test with existing messages (ongoing task scenario)
- task.clineMessages = [
- {
- ts: Date.now(),
- type: "say",
- say: "text",
- text: "Initial message",
- },
- ]
- task.submitUserMessage("follow-up message", ["image2.png"])
- expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "invoke",
- invoke: "sendMessage",
- text: "follow-up message",
- images: ["image2.png"],
- })
- })
- it("should handle undefined provider gracefully", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "initial task",
- startTask: false,
- })
- // Simulate weakref returning undefined
- Object.defineProperty(task, "providerRef", {
- value: { deref: () => undefined },
- writable: false,
- configurable: true,
- })
- // Spy on console.error to verify error is logged
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
- // Should log error but not throw
- task.submitUserMessage("test message")
- expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#submitUserMessage] Provider reference lost")
- expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
- // Restore console.error
- consoleErrorSpy.mockRestore()
- })
- })
- })
- describe("abortTask", () => {
- it("should set abort flag and emit TaskAborted event", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Spy on emit method
- const emitSpy = vi.spyOn(task, "emit")
- // Mock the dispose method to avoid actual cleanup
- vi.spyOn(task, "dispose").mockImplementation(() => {})
- // Call abortTask
- await task.abortTask()
- // Verify abort flag is set
- expect(task.abort).toBe(true)
- // Verify TaskAborted event was emitted
- expect(emitSpy).toHaveBeenCalledWith("taskAborted")
- })
- it("should be equivalent to clicking Cancel button functionality", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Mock the dispose method to track cleanup
- const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {})
- // Call abortTask
- await task.abortTask()
- // Verify the same behavior as Cancel button
- expect(task.abort).toBe(true)
- expect(disposeSpy).toHaveBeenCalled()
- })
- it("should work with TaskLike interface", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Cast to TaskLike to ensure interface compliance
- const taskLike = task as any // TaskLike interface from types package
- // Verify abortTask method exists and is callable
- expect(typeof taskLike.abortTask).toBe("function")
- // Mock the dispose method to avoid actual cleanup
- vi.spyOn(task, "dispose").mockImplementation(() => {})
- // Call abortTask through interface
- await taskLike.abortTask()
- // Verify it works
- expect(task.abort).toBe(true)
- })
- it("should handle errors during disposal gracefully", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Mock dispose to throw an error
- const mockError = new Error("Disposal failed")
- vi.spyOn(task, "dispose").mockImplementation(() => {
- throw mockError
- })
- // Spy on console.error to verify error is logged
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
- // abortTask should not throw even if dispose fails
- await expect(task.abortTask()).resolves.not.toThrow()
- // Verify error was logged
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Error during task"), mockError)
- // Verify abort flag is still set
- expect(task.abort).toBe(true)
- // Restore console.error
- consoleErrorSpy.mockRestore()
- })
- describe("Stream Failure Retry", () => {
- it("should not abort task on stream failure, only on user cancellation", async () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Spy on console.error to verify error logging
- const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
- // Spy on abortTask to verify it's NOT called for stream failures
- const abortTaskSpy = vi.spyOn(task, "abortTask").mockResolvedValue(undefined)
- // Test Case 1: Stream failure should NOT abort task
- task.abort = false
- task.abandoned = false
- // Simulate the catch block behavior for stream failure
- const streamFailureError = new Error("Stream failed mid-execution")
- // The key assertion: verify that when abort=false, abortTask is NOT called
- // This would normally happen in the catch block around line 2184
- const shouldAbort = task.abort
- expect(shouldAbort).toBe(false)
- // Verify error would be logged (this is what the new code does)
- console.error(
- `[Task#${task.taskId}.${task.instanceId}] Stream failed, will retry: ${streamFailureError.message}`,
- )
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Stream failed, will retry"))
- // Verify abortTask was NOT called
- expect(abortTaskSpy).not.toHaveBeenCalled()
- // Test Case 2: User cancellation SHOULD abort task
- task.abort = true
- // For user cancellation, abortTask SHOULD be called
- if (task.abort) {
- await task.abortTask()
- }
- expect(abortTaskSpy).toHaveBeenCalled()
- // Restore mocks
- consoleErrorSpy.mockRestore()
- })
- })
- describe("cancelCurrentRequest", () => {
- it("should cancel the current HTTP request via AbortController", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Create a real AbortController and spy on its abort method
- const mockAbortController = new AbortController()
- const abortSpy = vi.spyOn(mockAbortController, "abort")
- task.currentRequestAbortController = mockAbortController
- // Spy on console.log
- const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
- // Call cancelCurrentRequest
- task.cancelCurrentRequest()
- // Verify abort was called on the controller
- expect(abortSpy).toHaveBeenCalled()
- // Verify the controller was cleared
- expect(task.currentRequestAbortController).toBeUndefined()
- // Verify logging
- expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Aborting current HTTP request"))
- // Restore console.log
- consoleLogSpy.mockRestore()
- })
- it("should handle missing AbortController gracefully", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Ensure no controller exists
- task.currentRequestAbortController = undefined
- // Should not throw when called with no controller
- expect(() => task.cancelCurrentRequest()).not.toThrow()
- })
- it("should be called during dispose", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Spy on cancelCurrentRequest
- const cancelSpy = vi.spyOn(task, "cancelCurrentRequest")
- // Mock other dispose operations
- vi.spyOn(task.messageQueueService, "removeListener").mockImplementation(
- () => task.messageQueueService as any,
- )
- vi.spyOn(task.messageQueueService, "dispose").mockImplementation(() => {})
- vi.spyOn(task, "removeAllListeners").mockImplementation(() => task as any)
- // Call dispose
- task.dispose()
- // Verify cancelCurrentRequest was called
- expect(cancelSpy).toHaveBeenCalled()
- })
- })
- })
- })
- describe("Queued message processing after condense", () => {
- function createProvider(): any {
- const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
- const ctx = {
- globalState: {
- get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
- update: vi.fn().mockResolvedValue(undefined),
- keys: vi.fn().mockReturnValue([]),
- },
- globalStorageUri: storageUri,
- workspaceState: {
- get: vi.fn().mockImplementation((_key) => undefined),
- update: vi.fn().mockResolvedValue(undefined),
- keys: vi.fn().mockReturnValue([]),
- },
- secrets: {
- get: vi.fn().mockResolvedValue(undefined),
- store: vi.fn().mockResolvedValue(undefined),
- delete: vi.fn().mockResolvedValue(undefined),
- },
- extensionUri: { fsPath: "/mock/extension/path" },
- extension: { packageJSON: { version: "1.0.0" } },
- } as unknown as vscode.ExtensionContext
- const output = {
- appendLine: vi.fn(),
- append: vi.fn(),
- clear: vi.fn(),
- show: vi.fn(),
- hide: vi.fn(),
- dispose: vi.fn(),
- }
- const provider = new ClineProvider(ctx, output as any, "sidebar", new ContextProxy(ctx)) as any
- provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
- provider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
- provider.getState = vi.fn().mockResolvedValue({})
- return provider
- }
- const apiConfig: ProviderSettings = {
- apiProvider: "anthropic",
- apiModelId: "claude-3-5-sonnet-20241022",
- apiKey: "test-api-key",
- } as any
- it("processes queued message after condense completes", async () => {
- const provider = createProvider()
- const task = new Task({
- provider,
- apiConfiguration: apiConfig,
- task: "initial task",
- startTask: false,
- })
- // Make condense fast + deterministic
- vi.spyOn(task as any, "getSystemPrompt").mockResolvedValue("system")
- const submitSpy = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined)
- // Queue a message during condensing
- task.messageQueueService.addMessage("queued text", ["img1.png"])
- // Use fake timers to capture setTimeout(0) in processQueuedMessages
- vi.useFakeTimers()
- await task.condenseContext()
- // Flush the microtask that submits the queued message
- vi.runAllTimers()
- vi.useRealTimers()
- expect(submitSpy).toHaveBeenCalledWith("queued text", ["img1.png"])
- expect(task.messageQueueService.isEmpty()).toBe(true)
- })
- it("does not cross-drain queues between separate tasks", async () => {
- const providerA = createProvider()
- const providerB = createProvider()
- const taskA = new Task({
- provider: providerA,
- apiConfiguration: apiConfig,
- task: "task A",
- startTask: false,
- })
- const taskB = new Task({
- provider: providerB,
- apiConfiguration: apiConfig,
- task: "task B",
- startTask: false,
- })
- vi.spyOn(taskA as any, "getSystemPrompt").mockResolvedValue("system")
- vi.spyOn(taskB as any, "getSystemPrompt").mockResolvedValue("system")
- const spyA = vi.spyOn(taskA, "submitUserMessage").mockResolvedValue(undefined)
- const spyB = vi.spyOn(taskB, "submitUserMessage").mockResolvedValue(undefined)
- taskA.messageQueueService.addMessage("A message")
- taskB.messageQueueService.addMessage("B message")
- // Condense in task A should only drain A's queue
- vi.useFakeTimers()
- await taskA.condenseContext()
- vi.runAllTimers()
- vi.useRealTimers()
- expect(spyA).toHaveBeenCalledWith("A message", undefined)
- expect(spyB).not.toHaveBeenCalled()
- expect(taskB.messageQueueService.isEmpty()).toBe(false)
- // Now condense in task B should drain B's queue
- vi.useFakeTimers()
- await taskB.condenseContext()
- vi.runAllTimers()
- vi.useRealTimers()
- expect(spyB).toHaveBeenCalledWith("B message", undefined)
- expect(taskB.messageQueueService.isEmpty()).toBe(true)
- })
- })
- describe("pushToolResultToUserContent", () => {
- let mockProvider: any
- let mockApiConfig: ProviderSettings
- beforeEach(() => {
- mockApiConfig = {
- apiProvider: "anthropic",
- apiModelId: "claude-3-5-sonnet-20241022",
- apiKey: "test-api-key",
- }
- const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") }
- const mockExtensionContext = {
- globalState: {
- get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined),
- update: vi.fn().mockResolvedValue(undefined),
- keys: vi.fn().mockReturnValue([]),
- },
- globalStorageUri: storageUri,
- workspaceState: {
- get: vi.fn().mockImplementation((_key) => undefined),
- update: vi.fn().mockResolvedValue(undefined),
- keys: vi.fn().mockReturnValue([]),
- },
- secrets: {
- get: vi.fn().mockResolvedValue(undefined),
- store: vi.fn().mockResolvedValue(undefined),
- delete: vi.fn().mockResolvedValue(undefined),
- },
- extensionUri: { fsPath: "/mock/extension/path" },
- extension: { packageJSON: { version: "1.0.0" } },
- } as unknown as vscode.ExtensionContext
- const mockOutputChannel = {
- name: "test-output",
- appendLine: vi.fn(),
- append: vi.fn(),
- replace: vi.fn(),
- clear: vi.fn(),
- show: vi.fn(),
- hide: vi.fn(),
- dispose: vi.fn(),
- }
- mockProvider = new ClineProvider(
- mockExtensionContext,
- mockOutputChannel,
- "sidebar",
- new ContextProxy(mockExtensionContext),
- ) as any
- mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
- mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
- })
- it("should add tool_result when not a duplicate", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- const toolResult: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "test-id-1",
- content: "Test result",
- }
- const added = task.pushToolResultToUserContent(toolResult)
- expect(added).toBe(true)
- expect(task.userMessageContent).toHaveLength(1)
- expect(task.userMessageContent[0]).toEqual(toolResult)
- })
- it("should prevent duplicate tool_result with same tool_use_id", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- const toolResult1: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "duplicate-id",
- content: "First result",
- }
- const toolResult2: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "duplicate-id",
- content: "Second result (should be skipped)",
- }
- // Spy on console.warn to verify warning is logged
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
- // Add first result - should succeed
- const added1 = task.pushToolResultToUserContent(toolResult1)
- expect(added1).toBe(true)
- expect(task.userMessageContent).toHaveLength(1)
- // Add second result with same ID - should be skipped
- const added2 = task.pushToolResultToUserContent(toolResult2)
- expect(added2).toBe(false)
- expect(task.userMessageContent).toHaveLength(1)
- // Verify only the first result is in the array
- expect(task.userMessageContent[0]).toEqual(toolResult1)
- // Verify warning was logged
- expect(warnSpy).toHaveBeenCalledWith(
- expect.stringContaining("Skipping duplicate tool_result for tool_use_id: duplicate-id"),
- )
- warnSpy.mockRestore()
- })
- it("should allow different tool_use_ids to be added", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- const toolResult1: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "id-1",
- content: "Result 1",
- }
- const toolResult2: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "id-2",
- content: "Result 2",
- }
- const added1 = task.pushToolResultToUserContent(toolResult1)
- const added2 = task.pushToolResultToUserContent(toolResult2)
- expect(added1).toBe(true)
- expect(added2).toBe(true)
- expect(task.userMessageContent).toHaveLength(2)
- expect(task.userMessageContent[0]).toEqual(toolResult1)
- expect(task.userMessageContent[1]).toEqual(toolResult2)
- })
- it("should handle tool_result with is_error flag", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- const errorResult: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "error-id",
- content: "Error message",
- is_error: true,
- }
- const added = task.pushToolResultToUserContent(errorResult)
- expect(added).toBe(true)
- expect(task.userMessageContent).toHaveLength(1)
- expect(task.userMessageContent[0]).toEqual(errorResult)
- })
- it("should not interfere with other content types in userMessageContent", () => {
- const task = new Task({
- provider: mockProvider,
- apiConfiguration: mockApiConfig,
- task: "test task",
- startTask: false,
- })
- // Add text and image blocks manually
- task.userMessageContent.push(
- { type: "text", text: "Some text" },
- { type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
- )
- const toolResult: Anthropic.ToolResultBlockParam = {
- type: "tool_result",
- tool_use_id: "test-id",
- content: "Result",
- }
- const added = task.pushToolResultToUserContent(toolResult)
- expect(added).toBe(true)
- expect(task.userMessageContent).toHaveLength(3)
- expect(task.userMessageContent[0].type).toBe("text")
- expect(task.userMessageContent[1].type).toBe("image")
- expect(task.userMessageContent[2]).toEqual(toolResult)
- })
- })
|