diff --git a/src/core/Cline.ts b/src/core/Cline.ts index e32dca97e79..15ac9af001b 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -2103,7 +2103,7 @@ export class Cline extends EventEmitter { ]) } - async getEnvironmentDetails(includeFileDetails: boolean = false) { + async getEnvironmentDetails(includeFileDetails: boolean = false, currentTime?: Date) { let details = "" const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = @@ -2256,22 +2256,13 @@ export class Cline extends EventEmitter { } // Add current time information with timezone - const now = new Date() - const formatter = new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: true, - }) - const timeZone = formatter.resolvedOptions().timeZone - const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation - const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) - const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) - const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` - details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})` + const now = currentTime ?? new Date() + // Get formatted time strings + const zuluTimeString = now.toISOString() + const localTimeString = now.toString() + + details += `\n\n# Current Zulu Date/Time\n${zuluTimeString}` + details += `\n# Current Local Date/Time\n${localTimeString}` // Add context tokens information const { contextTokens, totalCost } = getApiMetrics(this.clineMessages) diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 30680854256..d74293e88bb 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -341,49 +341,22 @@ describe("Cline", () => { }) describe("getEnvironmentDetails", () => { - let originalDate: DateConstructor - let mockDate: Date + let originalGetTimezoneOffset: () => number beforeEach(() => { - originalDate = global.Date - const fixedTime = new Date("2024-01-01T12:00:00Z") - mockDate = new Date(fixedTime) - mockDate.getTimezoneOffset = jest.fn().mockReturnValue(420) // UTC-7 - - class MockDate extends Date { - constructor() { - super() - return mockDate - } - static override now() { - return mockDate.getTime() - } - } - - global.Date = MockDate as DateConstructor - - // Create a proper mock of Intl.DateTimeFormat - const mockDateTimeFormat = { - resolvedOptions: () => ({ - timeZone: "America/Los_Angeles", - }), - format: () => "1/1/2024, 5:00:00 AM", - } - - const MockDateTimeFormat = function (this: any) { - return mockDateTimeFormat - } as any + jest.useFakeTimers() + jest.setSystemTime(new Date("2024-01-01T12:00:00Z")) - MockDateTimeFormat.prototype = mockDateTimeFormat - MockDateTimeFormat.supportedLocalesOf = jest.fn().mockReturnValue(["en-US"]) - - global.Intl.DateTimeFormat = MockDateTimeFormat + // Mock Date.prototype.getTimezoneOffset() + originalGetTimezoneOffset = Date.prototype.getTimezoneOffset + Date.prototype.getTimezoneOffset = jest.fn(() => 420) // UTC-7 (420 minutes) }) afterEach(() => { - global.Date = originalDate + // Restore original methods and timers + Date.prototype.getTimezoneOffset = originalGetTimezoneOffset + jest.useRealTimers() }) - it("should include timezone information in environment details", async () => { const cline = new Cline({ provider: mockProvider, @@ -392,576 +365,571 @@ describe("Cline", () => { startTask: false, }) + // Call the function without injecting date; it should use mocked Date.now and TZ env var const details = await cline["getEnvironmentDetails"](false) - // Verify timezone information is present and formatted correctly. - expect(details).toContain("America/Los_Angeles") - expect(details).toMatch(/UTC-7:00/) // Fixed offset for America/Los_Angeles. - expect(details).toContain("# Current Time") - expect(details).toMatch(/1\/1\/2024.*5:00:00 AM.*\(America\/Los_Angeles, UTC-7:00\)/) // Full time string format. + // Verify the new time formats are present + expect(details).toContain("# Current Zulu Date/Time\n2024-01-01T12:00:00.000Z") + // Use regex for local time string due to potential variations in timezone name formatting + expect(details).toMatch(/# Current Local Date\/Time\nMon Jan 01 2024 13:00:00 GMT[+-]\d{4} \(.*\)/) }) + }) // Close the describe("getEnvironmentDetails", ...) block here - describe("API conversation handling", () => { - it("should clean conversation history before sending to API", async () => { - const [cline, task] = Cline.create({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - }) - - cline.abandoned = true - await task + describe("API conversation handling", () => { + it("should clean conversation history before sending to API", async () => { + const [cline, task] = Cline.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + }) - // Set up mock stream. - const mockStreamForClean = (async function* () { - yield { type: "text", text: "test response" } - })() + cline.abandoned = true + await task - // Set up spy. - const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean) - jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy) + // Set up mock stream. + const mockStreamForClean = (async function* () { + yield { type: "text", text: "test response" } + })() - // Mock getEnvironmentDetails to return empty details. - jest.spyOn(cline as any, "getEnvironmentDetails").mockResolvedValue("") + // Set up spy. + const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean) + jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy) - // Mock loadContext to return unmodified content. - jest.spyOn(cline as any, "loadContext").mockImplementation(async (content) => [content, ""]) + // Mock getEnvironmentDetails to return empty details. + jest.spyOn(cline as any, "getEnvironmentDetails").mockResolvedValue("") - // 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, - }) + // Mock loadContext to return unmodified content. + jest.spyOn(cline as any, "loadContext").mockImplementation(async (content) => [content, ""]) - // Add a message with extra properties to the conversation history - const messageWithExtra = { + // Add test message to conversation history. + cline.apiConversationHistory = [ + { role: "user" as const, content: [{ type: "text" as const, text: "test message" }], ts: Date.now(), - extraProp: "should be removed", - } + }, + ] - cline.apiConversationHistory = [messageWithExtra] + // Mock abort state + Object.defineProperty(cline, "abort", { + get: () => false, + set: () => {}, + configurable: true, + }) - // Trigger an API request - await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false) + // 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", + } - // Get the conversation history from the first API call - const history = cleanMessageSpy.mock.calls[0][1] - expect(history).toBeDefined() - expect(history.length).toBeGreaterThan(0) + cline.apiConversationHistory = [messageWithExtra] - // 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" }], - }) + // Trigger an API request + await cline.recursivelyMakeClineRequests([{ type: "text", text: "test request" }], false) + + // Get the conversation history from the first API call + const history = cleanMessageSpy.mock.calls[0][1] + expect(history).toBeDefined() + expect(history.length).toBeGreaterThan(0) - // Verify extra properties were removed - expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"]) + // 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" }], }) - it("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", - } + // Verify extra properties were removed + expect(Object.keys(cleanedMessage!)).toEqual(["role", "content"]) + }) - // 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, - ], - }, - ] + it("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", + } - // Test with model that supports images - const [clineWithImages, taskWithImages] = Cline.create({ - provider: mockProvider, - apiConfiguration: configWithImages, - task: "test task", - }) + // 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, + ], + }, + ] - // Mock the model info to indicate image support - jest.spyOn(clineWithImages.api, "getModel").mockReturnValue({ - id: "claude-3-sonnet", - info: { - supportsImages: true, - supportsPromptCache: true, - supportsComputerUse: true, - contextWindow: 200000, - maxTokens: 4096, - inputPrice: 0.25, - outputPrice: 0.75, - } as ModelInfo, - }) + // Test with model that supports images + const [clineWithImages, taskWithImages] = Cline.create({ + provider: mockProvider, + apiConfiguration: configWithImages, + task: "test task", + }) - clineWithImages.apiConversationHistory = conversationHistory + // Mock the model info to indicate image support + jest.spyOn(clineWithImages.api, "getModel").mockReturnValue({ + id: "claude-3-sonnet", + info: { + supportsImages: true, + supportsPromptCache: true, + supportsComputerUse: true, + contextWindow: 200000, + maxTokens: 4096, + inputPrice: 0.25, + outputPrice: 0.75, + } as ModelInfo, + }) - // Test with model that doesn't support images - const [clineWithoutImages, taskWithoutImages] = Cline.create({ - provider: mockProvider, - apiConfiguration: configWithoutImages, - task: "test task", - }) + clineWithImages.apiConversationHistory = conversationHistory - // Mock the model info to indicate no image support - jest.spyOn(clineWithoutImages.api, "getModel").mockReturnValue({ - id: "gpt-3.5-turbo", - info: { - supportsImages: false, - supportsPromptCache: false, - supportsComputerUse: false, - contextWindow: 16000, - maxTokens: 2048, - inputPrice: 0.1, - outputPrice: 0.2, - } as ModelInfo, - }) + // Test with model that doesn't support images + const [clineWithoutImages, taskWithoutImages] = Cline.create({ + provider: mockProvider, + apiConfiguration: configWithoutImages, + task: "test task", + }) - clineWithoutImages.apiConversationHistory = conversationHistory + // Mock the model info to indicate no image support + jest.spyOn(clineWithoutImages.api, "getModel").mockReturnValue({ + id: "gpt-3.5-turbo", + info: { + supportsImages: false, + supportsPromptCache: false, + supportsComputerUse: false, + contextWindow: 16000, + maxTokens: 2048, + inputPrice: 0.1, + outputPrice: 0.2, + } as ModelInfo, + }) - // Mock abort state for both instances - Object.defineProperty(clineWithImages, "abort", { - get: () => false, - set: () => {}, - configurable: true, - }) + clineWithoutImages.apiConversationHistory = conversationHistory - Object.defineProperty(clineWithoutImages, "abort", { - get: () => false, - set: () => {}, - configurable: true, - }) + // Mock abort state for both instances + Object.defineProperty(clineWithImages, "abort", { + get: () => false, + set: () => {}, + configurable: true, + }) - // Mock environment details and context loading - jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("") - jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("") - jest.spyOn(clineWithImages as any, "loadContext").mockImplementation(async (content) => [content, ""]) - jest.spyOn(clineWithoutImages as any, "loadContext").mockImplementation(async (content) => [ - content, - "", - ]) - - // 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 = jest.fn().mockReturnValue(mockStreamWithImages) - const noImagesSpy = jest.fn().mockReturnValue(mockStreamWithoutImages) - - jest.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy) - jest.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" } }, - ], - }, - ] + Object.defineProperty(clineWithoutImages, "abort", { + get: () => false, + set: () => {}, + configurable: true, + }) - clineWithImages.abandoned = true - await taskWithImages.catch(() => {}) + // Mock environment details and context loading + jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("") + jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("") + jest.spyOn(clineWithImages as any, "loadContext").mockImplementation(async (content) => [content, ""]) + jest.spyOn(clineWithoutImages as any, "loadContext").mockImplementation(async (content) => [content, ""]) - clineWithoutImages.abandoned = true - await taskWithoutImages.catch(() => {}) + // Set up mock streams + const mockStreamWithImages = (async function* () { + yield { type: "text", text: "test response" } + })() - // Trigger API requests - await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }]) - await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }]) + const mockStreamWithoutImages = (async function* () { + yield { type: "text", text: "test response" } + })() - // Get the calls - const imagesCalls = imagesSpy.mock.calls - const noImagesCalls = noImagesSpy.mock.calls + // Set up spies + const imagesSpy = jest.fn().mockReturnValue(mockStreamWithImages) + const noImagesSpy = jest.fn().mockReturnValue(mockStreamWithoutImages) - // Verify model with image support preserves image blocks - 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") + jest.spyOn(clineWithImages.api, "createMessage").mockImplementation(imagesSpy) + jest.spyOn(clineWithoutImages.api, "createMessage").mockImplementation(noImagesSpy) - // Verify model without image support converts image blocks to text - 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]", - }) - }) + // 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" } }, + ], + }, + ] - it.skip("should handle API retry with countdown", async () => { - const [cline, task] = Cline.create({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - }) + clineWithImages.abandoned = true + await taskWithImages.catch(() => {}) - // Mock delay to track countdown timing - const mockDelay = jest.fn().mockResolvedValue(undefined) - jest.spyOn(require("delay"), "default").mockImplementation(mockDelay) + clineWithoutImages.abandoned = true + await taskWithoutImages.catch(() => {}) - // Mock say to track messages - const saySpy = jest.spyOn(cline, "say") + // Trigger API requests + await clineWithImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }]) + await clineWithoutImages.recursivelyMakeClineRequests([{ type: "text", text: "test request" }]) - // Create a stream that fails on first chunk - const mockError = new Error("API Error") - const mockFailedStream = { - 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 + // Get the calls + const imagesCalls = imagesSpy.mock.calls + const noImagesCalls = noImagesSpy.mock.calls - // 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 - - // Mock createMessage to fail first then succeed - let firstAttempt = true - jest.spyOn(cline.api, "createMessage").mockImplementation(() => { - if (firstAttempt) { - firstAttempt = false - return mockFailedStream - } - return mockSuccessStream - }) + // Verify model with image support preserves image blocks + 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") - // Set alwaysApproveResubmit and requestDelaySeconds - mockProvider.getState = jest.fn().mockResolvedValue({ - alwaysApproveResubmit: true, - requestDelaySeconds: 3, - }) + // Verify model without image support converts image blocks to text + 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]", + }) + }) - // 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, - request: "test request", - }), - }, - ] + it.skip("should handle API retry with countdown", async () => { + const [cline, task] = Cline.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + }) - // Trigger API request - const iterator = cline.attemptApiRequest(0) - await iterator.next() - - // Calculate expected delay for first retry - const baseDelay = 3 // from requestDelaySeconds - - // 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, - ) + // Mock delay to track countdown timing + const mockDelay = jest.fn().mockResolvedValue(undefined) + jest.spyOn(require("delay"), "default").mockImplementation(mockDelay) + + // Mock say to track messages + const saySpy = jest.spyOn(cline, "say") + + // Create a stream that fails on first chunk + const mockError = new Error("API Error") + const mockFailedStream = { + 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 + + // 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 + + // Mock createMessage to fail first then succeed + let firstAttempt = true + jest.spyOn(cline.api, "createMessage").mockImplementation(() => { + if (firstAttempt) { + firstAttempt = false + return mockFailedStream } + return mockSuccessStream + }) + + // Set alwaysApproveResubmit and requestDelaySeconds + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 3, + }) + // 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, + request: "test request", + }), + }, + ] + + // Trigger API request + const iterator = cline.attemptApiRequest(0) + await iterator.next() + + // Calculate expected delay for first retry + const baseDelay = 3 // from requestDelaySeconds + + // Verify countdown messages + for (let i = baseDelay; i > 0; i--) { expect(saySpy).toHaveBeenCalledWith( "api_req_retry_delayed", - expect.stringContaining("Retrying now"), + expect.stringContaining(`Retrying in ${i} seconds`), undefined, - false, + true, ) + } - // Calculate expected delay calls for countdown - const totalExpectedDelays = baseDelay // One delay per second for countdown - expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays) - expect(mockDelay).toHaveBeenCalledWith(1000) + expect(saySpy).toHaveBeenCalledWith( + "api_req_retry_delayed", + expect.stringContaining("Retrying now"), + undefined, + false, + ) - // 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...`, - ) + // Calculate expected delay calls for countdown + const totalExpectedDelays = baseDelay // One delay per second for countdown + expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays) + expect(mockDelay).toHaveBeenCalledWith(1000) - await cline.abortTask(true) - await task.catch(() => {}) + // 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] = Cline.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", }) - it.skip("should not apply retry delay twice", async () => { - const [cline, task] = Cline.create({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - }) + // Mock delay to track countdown timing + const mockDelay = jest.fn().mockResolvedValue(undefined) + jest.spyOn(require("delay"), "default").mockImplementation(mockDelay) - // Mock delay to track countdown timing - const mockDelay = jest.fn().mockResolvedValue(undefined) - jest.spyOn(require("delay"), "default").mockImplementation(mockDelay) + // Mock say to track messages + const saySpy = jest.spyOn(cline, "say") - // Mock say to track messages - const saySpy = jest.spyOn(cline, "say") + // Create a stream that fails on first chunk + const mockError = new Error("API Error") + const mockFailedStream = { + 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 - // Create a stream that fails on first chunk - const mockError = new Error("API Error") - const mockFailedStream = { - 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 + // 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 + + // Mock createMessage to fail first then succeed + let firstAttempt = true + jest.spyOn(cline.api, "createMessage").mockImplementation(() => { + if (firstAttempt) { + firstAttempt = false + return mockFailedStream + } + return mockSuccessStream + }) - // 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 - - // Mock createMessage to fail first then succeed - let firstAttempt = true - jest.spyOn(cline.api, "createMessage").mockImplementation(() => { - if (firstAttempt) { - firstAttempt = false - return mockFailedStream - } - return mockSuccessStream - }) + // Set alwaysApproveResubmit and requestDelaySeconds + mockProvider.getState = jest.fn().mockResolvedValue({ + alwaysApproveResubmit: true, + requestDelaySeconds: 3, + }) - // Set alwaysApproveResubmit and requestDelaySeconds - mockProvider.getState = jest.fn().mockResolvedValue({ - alwaysApproveResubmit: true, - requestDelaySeconds: 3, + // 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, + request: "test request", + }), + }, + ] + + // Trigger API request + const iterator = cline.attemptApiRequest(0) + await iterator.next() + + // Verify delay is only applied for the countdown + const baseDelay = 3 // from requestDelaySeconds + 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("loadContext", () => { + it("should process mentions in task and feedback tags", async () => { + const [cline, task] = Cline.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", }) - // Mock previous API request message - cline.clineMessages = [ + // Mock parseMentions to track calls + const mockParseMentions = jest.fn().mockImplementation((text) => `processed: ${text}`) + jest.spyOn(require("../../core/mentions"), "parseMentions").mockImplementation(mockParseMentions) + + const userContent = [ { - ts: Date.now(), - type: "say", - say: "api_req_started", - text: JSON.stringify({ - tokensIn: 100, - tokensOut: 50, - cacheWrites: 0, - cacheReads: 0, - request: "test request", - }), - }, + type: "text", + text: "Regular text with @/some/path", + } as const, + { + type: "text", + text: "Text with @/some/path in task tags", + } as const, + { + type: "tool_result", + tool_use_id: "test-id", + content: [ + { + type: "text", + text: "Check @/some/path", + }, + ], + } as Anthropic.ToolResultBlockParam, + { + type: "tool_result", + tool_use_id: "test-id-2", + content: [ + { + type: "text", + text: "Regular tool result with @/path", + }, + ], + } as Anthropic.ToolResultBlockParam, ] - // Trigger API request - const iterator = cline.attemptApiRequest(0) - await iterator.next() + // Process the content + const [processedContent] = await cline["loadContext"](userContent) - // Verify delay is only applied for the countdown - const baseDelay = 3 // from requestDelaySeconds - const expectedDelayCount = baseDelay // One delay per second for countdown - expect(mockDelay).toHaveBeenCalledTimes(expectedDelayCount) - expect(mockDelay).toHaveBeenCalledWith(1000) // Each delay should be 1 second + // Regular text should not be processed + expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe("Regular text with @/some/path") - // 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"), + // Text within task tags should be processed + expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:") + expect(mockParseMentions).toHaveBeenCalledWith( + "Text with @/some/path in task tags", + expect.any(String), + expect.any(Object), ) - 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, + // 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(mockParseMentions).toHaveBeenCalledWith( + "Check @/some/path", + expect.any(String), + expect.any(Object), ) + // 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") + await cline.abortTask(true) await task.catch(() => {}) }) - - describe("loadContext", () => { - it("should process mentions in task and feedback tags", async () => { - const [cline, task] = Cline.create({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - }) - - // Mock parseMentions to track calls - const mockParseMentions = jest.fn().mockImplementation((text) => `processed: ${text}`) - jest.spyOn(require("../../core/mentions"), "parseMentions").mockImplementation(mockParseMentions) - - const userContent = [ - { - type: "text", - text: "Regular text with @/some/path", - } as const, - { - type: "text", - text: "Text with @/some/path in task tags", - } as const, - { - type: "tool_result", - tool_use_id: "test-id", - content: [ - { - type: "text", - text: "Check @/some/path", - }, - ], - } as Anthropic.ToolResultBlockParam, - { - type: "tool_result", - tool_use_id: "test-id-2", - content: [ - { - type: "text", - text: "Regular tool result with @/path", - }, - ], - } as Anthropic.ToolResultBlockParam, - ] - - // Process the content - const [processedContent] = await cline["loadContext"](userContent) - - // Regular text should not be processed - expect((processedContent[0] as Anthropic.TextBlockParam).text).toBe("Regular text with @/some/path") - - // Text within task tags should be processed - expect((processedContent[1] as Anthropic.TextBlockParam).text).toContain("processed:") - expect(mockParseMentions).toHaveBeenCalledWith( - "Text with @/some/path in task tags", - expect.any(String), - expect.any(Object), - ) - - // 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(mockParseMentions).toHaveBeenCalledWith( - "Check @/some/path", - expect.any(String), - expect.any(Object), - ) - - // 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") - - await cline.abortTask(true) - await task.catch(() => {}) - }) - }) }) }) })