Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,39 @@ export class Task extends EventEmitter<ClineEvents> {
}
}

// 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() {
Expand Down
179 changes: 179 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
})
})
})
})
2 changes: 2 additions & 0 deletions src/core/tools/attemptCompletionTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand Down