Skip to content

Commit 5233f61

Browse files
committed
Fix #4888: Finalize pending api_req_started messages on task completion
- Add finish() method to Task class that finds all api_req_started messages without cost or cancelReason and sets their cost to 0 - Call finish() before emitting taskCompleted in attemptCompletionTool - Add comprehensive tests for the finish() method - Prevents UI spinners from persisting in completed task history
1 parent 3e28921 commit 5233f61

File tree

3 files changed

+214
-0
lines changed

3 files changed

+214
-0
lines changed

src/core/task/Task.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,39 @@ export class Task extends EventEmitter<ClineEvents> {
19081908
}
19091909
}
19101910

1911+
// Task completion
1912+
1913+
public async finish() {
1914+
// Find any api_req_started messages that are still pending (no cost and no cancelReason)
1915+
// While theoretically only the last one should be unfinished, the UI checks all messages
1916+
const messages = [...this.clineMessages]
1917+
let hasUpdates = false
1918+
1919+
for (let i = 0; i < messages.length; i++) {
1920+
const message = messages[i]
1921+
if (message.type === "say" && message.say === "api_req_started") {
1922+
const apiReqInfo: ClineApiReqInfo = JSON.parse(message.text || "{}")
1923+
1924+
// Check if this message is still pending (no cost and no cancelReason)
1925+
if (apiReqInfo.cost === undefined && apiReqInfo.cancelReason === undefined) {
1926+
// Update the message to have a cost of 0 to indicate completion
1927+
apiReqInfo.cost = 0
1928+
messages[i] = {
1929+
...message,
1930+
text: JSON.stringify(apiReqInfo),
1931+
}
1932+
hasUpdates = true
1933+
}
1934+
}
1935+
}
1936+
1937+
// Save the updated messages if any were modified
1938+
if (hasUpdates) {
1939+
this.clineMessages = messages
1940+
await this.saveClineMessages()
1941+
}
1942+
}
1943+
19111944
// Getters
19121945

19131946
public get cwd() {

src/core/task/__tests__/Task.spec.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,5 +1334,184 @@ describe("Cline", () => {
13341334
expect(task.diffStrategy).toBeUndefined()
13351335
})
13361336
})
1337+
1338+
describe("finish method", () => {
1339+
it("should finalize all pending api_req_started messages with cost 0", async () => {
1340+
const [cline, task] = Task.create({
1341+
provider: mockProvider,
1342+
apiConfiguration: mockApiConfig,
1343+
task: "test task",
1344+
})
1345+
1346+
// Mock saveClineMessages
1347+
const saveSpy = vi.spyOn(cline as any, "saveClineMessages").mockResolvedValue(undefined)
1348+
1349+
// Set up messages with multiple api_req_started messages
1350+
cline.clineMessages = [
1351+
{
1352+
ts: Date.now(),
1353+
type: "say",
1354+
say: "text",
1355+
text: "Regular message",
1356+
},
1357+
{
1358+
ts: Date.now(),
1359+
type: "say",
1360+
say: "api_req_started",
1361+
text: JSON.stringify({
1362+
request: "test request 1",
1363+
tokensIn: 100,
1364+
tokensOut: 50,
1365+
// No cost - should be updated
1366+
}),
1367+
},
1368+
{
1369+
ts: Date.now(),
1370+
type: "say",
1371+
say: "api_req_started",
1372+
text: JSON.stringify({
1373+
request: "test request 2",
1374+
tokensIn: 200,
1375+
tokensOut: 100,
1376+
cost: 0.001, // Already has cost
1377+
}),
1378+
},
1379+
{
1380+
ts: Date.now(),
1381+
type: "say",
1382+
say: "text",
1383+
text: "Another regular message",
1384+
},
1385+
{
1386+
ts: Date.now(),
1387+
type: "say",
1388+
say: "api_req_started",
1389+
text: JSON.stringify({
1390+
request: "test request 3",
1391+
tokensIn: 300,
1392+
tokensOut: 150,
1393+
// No cost or cancelReason - should be updated
1394+
}),
1395+
},
1396+
]
1397+
1398+
// Call finish
1399+
await cline.finish()
1400+
1401+
// Verify saveClineMessages was called
1402+
expect(saveSpy).toHaveBeenCalled()
1403+
1404+
// Verify the messages were updated correctly
1405+
const messages = cline.clineMessages
1406+
1407+
// First api_req_started (index 1) had no cost, should now have cost 0
1408+
const msg1 = JSON.parse(messages[1].text || "{}")
1409+
expect(msg1.cost).toBe(0)
1410+
expect(msg1.request).toBe("test request 1")
1411+
1412+
// Second api_req_started (index 2) already had cost, should be unchanged
1413+
const msg2 = JSON.parse(messages[2].text || "{}")
1414+
expect(msg2.cost).toBe(0.001)
1415+
1416+
// Last api_req_started (index 4) had no cost/cancelReason, should now have cost 0
1417+
const msg3 = JSON.parse(messages[4].text || "{}")
1418+
expect(msg3.cost).toBe(0)
1419+
expect(msg3.request).toBe("test request 3")
1420+
1421+
// Verify that ALL api_req_started messages now have either cost or cancelReason
1422+
const apiReqMessages = messages.filter((m) => m.type === "say" && m.say === "api_req_started")
1423+
for (const msg of apiReqMessages) {
1424+
const apiReqInfo = JSON.parse(msg.text || "{}")
1425+
const hasCost = apiReqInfo.cost !== undefined
1426+
const hasCancelReason = apiReqInfo.cancelReason !== undefined
1427+
expect(hasCost || hasCancelReason).toBe(true)
1428+
}
1429+
1430+
await cline.abortTask(true)
1431+
await task.catch(() => {})
1432+
})
1433+
1434+
it("should not save if no api_req_started messages need updating", async () => {
1435+
const [cline, task] = Task.create({
1436+
provider: mockProvider,
1437+
apiConfiguration: mockApiConfig,
1438+
task: "test task",
1439+
})
1440+
1441+
// Mock saveClineMessages
1442+
const saveSpy = vi.spyOn(cline as any, "saveClineMessages").mockResolvedValue(undefined)
1443+
1444+
// Set up messages where all api_req_started already have cost or cancelReason
1445+
cline.clineMessages = [
1446+
{
1447+
ts: Date.now(),
1448+
type: "say",
1449+
say: "api_req_started",
1450+
text: JSON.stringify({
1451+
request: "test request 1",
1452+
tokensIn: 100,
1453+
tokensOut: 50,
1454+
cost: 0.001, // Has cost
1455+
}),
1456+
},
1457+
{
1458+
ts: Date.now(),
1459+
type: "say",
1460+
say: "api_req_started",
1461+
text: JSON.stringify({
1462+
request: "test request 2",
1463+
tokensIn: 200,
1464+
tokensOut: 100,
1465+
cancelReason: "User cancelled", // Has cancelReason
1466+
}),
1467+
},
1468+
]
1469+
1470+
// Clear any calls from setup
1471+
saveSpy.mockClear()
1472+
1473+
// Call finish
1474+
await cline.finish()
1475+
1476+
// Verify saveClineMessages was NOT called since no updates were needed
1477+
expect(saveSpy).not.toHaveBeenCalled()
1478+
1479+
await cline.abortTask(true)
1480+
await task.catch(() => {})
1481+
})
1482+
1483+
it("should handle case with no api_req_started messages", async () => {
1484+
const [cline, task] = Task.create({
1485+
provider: mockProvider,
1486+
apiConfiguration: mockApiConfig,
1487+
task: "test task",
1488+
})
1489+
1490+
// Mock saveClineMessages
1491+
const saveSpy = vi.spyOn(cline as any, "saveClineMessages").mockResolvedValue(undefined)
1492+
1493+
// Set up messages with no api_req_started
1494+
cline.clineMessages = [
1495+
{
1496+
ts: Date.now(),
1497+
type: "say",
1498+
say: "text",
1499+
text: "Just a regular message",
1500+
},
1501+
]
1502+
1503+
// Clear any calls from setup
1504+
saveSpy.mockClear()
1505+
1506+
// Call finish
1507+
await cline.finish()
1508+
1509+
// Verify saveClineMessages was NOT called
1510+
expect(saveSpy).not.toHaveBeenCalled()
1511+
1512+
await cline.abortTask(true)
1513+
await task.catch(() => {})
1514+
})
1515+
})
13371516
})
13381517
})

src/core/tools/attemptCompletionTool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export async function attemptCompletionTool(
4545
// we have command string, which means we have the result as well, so finish it (doesnt have to exist yet)
4646
await cline.say("completion_result", removeClosingTag("result", result), undefined, false)
4747

48+
await cline.finish()
4849
TelemetryService.instance.captureTaskCompleted(cline.taskId)
4950
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage)
5051

@@ -68,6 +69,7 @@ export async function attemptCompletionTool(
6869
// Command execution is permanently disabled in attempt_completion
6970
// Users must use execute_command tool separately before attempt_completion
7071
await cline.say("completion_result", result, undefined, false)
72+
await cline.finish()
7173
TelemetryService.instance.captureTaskCompleted(cline.taskId)
7274
cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage)
7375

0 commit comments

Comments
 (0)