diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 46da7485ed..2123c48468 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1908,6 +1908,39 @@ export class Task extends EventEmitter { } } + // Task completion + + public async finish() { + // Find any api_req_started messages that are still pending (no cost and no cancelReason) + // While theoretically only the last one should be unfinished, the UI checks all messages + const messages = [...this.clineMessages] + let hasUpdates = false + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + if (message.type === "say" && message.say === "api_req_started") { + const apiReqInfo: ClineApiReqInfo = JSON.parse(message.text || "{}") + + // Check if this message is still pending (no cost and no cancelReason) + if (apiReqInfo.cost === undefined && apiReqInfo.cancelReason === undefined) { + // Update the message to have a cost of 0 to indicate completion + apiReqInfo.cost = 0 + messages[i] = { + ...message, + text: JSON.stringify(apiReqInfo), + } + hasUpdates = true + } + } + } + + // Save the updated messages if any were modified + if (hasUpdates) { + this.clineMessages = messages + await this.saveClineMessages() + } + } + // Getters public get cwd() { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 693f72d1c7..7ccdbb85e7 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1334,5 +1334,184 @@ describe("Cline", () => { expect(task.diffStrategy).toBeUndefined() }) }) + + describe("finish method", () => { + it("should finalize all pending api_req_started messages with cost 0", async () => { + const [cline, task] = Task.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + }) + + // Mock saveClineMessages + const saveSpy = vi.spyOn(cline as any, "saveClineMessages").mockResolvedValue(undefined) + + // Set up messages with multiple api_req_started messages + cline.clineMessages = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Regular message", + }, + { + ts: Date.now(), + type: "say", + say: "api_req_started", + text: JSON.stringify({ + request: "test request 1", + tokensIn: 100, + tokensOut: 50, + // No cost - should be updated + }), + }, + { + ts: Date.now(), + type: "say", + say: "api_req_started", + text: JSON.stringify({ + request: "test request 2", + tokensIn: 200, + tokensOut: 100, + cost: 0.001, // Already has cost + }), + }, + { + ts: Date.now(), + type: "say", + say: "text", + text: "Another regular message", + }, + { + ts: Date.now(), + type: "say", + say: "api_req_started", + text: JSON.stringify({ + request: "test request 3", + tokensIn: 300, + tokensOut: 150, + // No cost or cancelReason - should be updated + }), + }, + ] + + // Call finish + await cline.finish() + + // Verify saveClineMessages was called + expect(saveSpy).toHaveBeenCalled() + + // Verify the messages were updated correctly + const messages = cline.clineMessages + + // First api_req_started (index 1) had no cost, should now have cost 0 + const msg1 = JSON.parse(messages[1].text || "{}") + expect(msg1.cost).toBe(0) + expect(msg1.request).toBe("test request 1") + + // Second api_req_started (index 2) already had cost, should be unchanged + const msg2 = JSON.parse(messages[2].text || "{}") + expect(msg2.cost).toBe(0.001) + + // Last api_req_started (index 4) had no cost/cancelReason, should now have cost 0 + const msg3 = JSON.parse(messages[4].text || "{}") + expect(msg3.cost).toBe(0) + expect(msg3.request).toBe("test request 3") + + // Verify that ALL api_req_started messages now have either cost or cancelReason + const apiReqMessages = messages.filter((m) => m.type === "say" && m.say === "api_req_started") + for (const msg of apiReqMessages) { + const apiReqInfo = JSON.parse(msg.text || "{}") + const hasCost = apiReqInfo.cost !== undefined + const hasCancelReason = apiReqInfo.cancelReason !== undefined + expect(hasCost || hasCancelReason).toBe(true) + } + + await cline.abortTask(true) + await task.catch(() => {}) + }) + + it("should not save if no api_req_started messages need updating", async () => { + const [cline, task] = Task.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + }) + + // Mock saveClineMessages + const saveSpy = vi.spyOn(cline as any, "saveClineMessages").mockResolvedValue(undefined) + + // Set up messages where all api_req_started already have cost or cancelReason + cline.clineMessages = [ + { + ts: Date.now(), + type: "say", + say: "api_req_started", + text: JSON.stringify({ + request: "test request 1", + tokensIn: 100, + tokensOut: 50, + cost: 0.001, // Has cost + }), + }, + { + ts: Date.now(), + type: "say", + say: "api_req_started", + text: JSON.stringify({ + request: "test request 2", + tokensIn: 200, + tokensOut: 100, + cancelReason: "User cancelled", // Has cancelReason + }), + }, + ] + + // Clear any calls from setup + saveSpy.mockClear() + + // Call finish + await cline.finish() + + // Verify saveClineMessages was NOT called since no updates were needed + expect(saveSpy).not.toHaveBeenCalled() + + await cline.abortTask(true) + await task.catch(() => {}) + }) + + it("should handle case with no api_req_started messages", async () => { + const [cline, task] = Task.create({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + }) + + // Mock saveClineMessages + const saveSpy = vi.spyOn(cline as any, "saveClineMessages").mockResolvedValue(undefined) + + // Set up messages with no api_req_started + cline.clineMessages = [ + { + ts: Date.now(), + type: "say", + say: "text", + text: "Just a regular message", + }, + ] + + // Clear any calls from setup + saveSpy.mockClear() + + // Call finish + await cline.finish() + + // Verify saveClineMessages was NOT called + expect(saveSpy).not.toHaveBeenCalled() + + await cline.abortTask(true) + await task.catch(() => {}) + }) + }) }) }) diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index 57f5870022..5535655204 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -45,6 +45,7 @@ export async function attemptCompletionTool( // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) await cline.say("completion_result", removeClosingTag("result", result), undefined, false) + await cline.finish() TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage) @@ -68,6 +69,7 @@ export async function attemptCompletionTool( // Command execution is permanently disabled in attempt_completion // Users must use execute_command tool separately before attempt_completion await cline.say("completion_result", result, undefined, false) + await cline.finish() TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage)