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
46 changes: 33 additions & 13 deletions src/components/tasks/BackgroundTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as React from 'react';
import { Text } from 'src/ink.js';
import type { BackgroundTaskState } from 'src/tasks/types.js';
import type { DeepImmutable } from 'src/types/utils.js';
import { truncate } from 'src/utils/format.js';
import { truncate, formatNumber } from 'src/utils/format.js';
import { toInkColor } from 'src/utils/ink.js';
import { plural } from 'src/utils/stringUtils.js';
import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js';
Expand Down Expand Up @@ -123,26 +123,46 @@ export function BackgroundTask(t0) {
} else {
t1 = $[24];
}
const tokenCount = "result" in task ? task.result?.totalTokens : task.progress?.tokenCount;
const toolUseCount = "result" in task ? task.result?.totalToolUseCount : task.progress?.toolUseCount;
let tToken;
if ($[25] !== tokenCount) {
tToken = tokenCount !== undefined && tokenCount > 0 && <Text dimColor={true}> · {formatNumber(tokenCount)} tokens</Text>;
$[25] = tokenCount;
$[26] = tToken;
} else {
tToken = $[26];
}
let tTool;
if ($[27] !== toolUseCount) {
tTool = toolUseCount !== undefined && toolUseCount > 0 && <Text dimColor={true}> · {toolUseCount} {toolUseCount === 1 ? "tool" : "tools"}</Text>;
$[27] = toolUseCount;
$[28] = tTool;
} else {
tTool = $[28];
}
const t2 = task.status === "completed" ? "done" : undefined;
const t3 = task.status === "completed" && !task.notified ? ", unread" : undefined;
let t4;
if ($[25] !== t2 || $[26] !== t3 || $[27] !== task.status) {
if ($[29] !== t2 || $[30] !== t3 || $[31] !== task.status) {
t4 = <TaskStatusText status={task.status} label={t2} suffix={t3} />;
$[25] = t2;
$[26] = t3;
$[27] = task.status;
$[28] = t4;
$[29] = t2;
$[30] = t3;
$[31] = task.status;
$[32] = t4;
} else {
t4 = $[28];
t4 = $[32];
}
let t5;
if ($[29] !== t1 || $[30] !== t4) {
t5 = <Text>{t1}{" "}{t4}</Text>;
$[29] = t1;
$[30] = t4;
$[31] = t5;
if ($[33] !== t1 || $[34] !== t4 || $[35] !== tToken || $[36] !== tTool) {
t5 = <Text>{t1}{" "}{tToken}{tTool}{" "}{t4}</Text>;
$[33] = t1;
$[34] = t4;
$[35] = tToken;
$[36] = tTool;
$[37] = t5;
} else {
t5 = $[31];
t5 = $[37];
}
return t5;
}
Expand Down
42 changes: 41 additions & 1 deletion src/tasks/LocalAgentTask/LocalAgentTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,43 @@ export function getTokenCountFromTracker(tracker: ProgressTracker): number {
return tracker.latestInputTokens + tracker.cumulativeOutputTokens;
}

/**
* Recompute token counters from accumulated assistant messages.
*
* Streaming providers (Verboo/OpenAI shim) emit message_start with zeroed
* usage; the real usage arrives in message_delta, which mutates the LAST
* yielded message in place (see claude.ts message_delta handler). Reading
* usage at yield time therefore undercounts tokens — tool calls count but
* tokens stay 0. Because lifecycle loops keep references to every yielded
* message, this recompute converges to the real usage once each request's
* message_delta lands on the accumulated references.
*
* Input tokens are cumulative per request (latest wins); output tokens are
* per-request and summed.
*/
export function syncProgressUsageFromMessages(
tracker: ProgressTracker,
messages: Message[],
): void {
let latestInputTokens = 0;
let cumulativeOutputTokens = 0;
for (const message of messages) {
if (message.type !== 'assistant') continue;
const usage = message.message.usage;
if (!usage) continue;
const inputTokens =
usage.input_tokens +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0);
if (inputTokens > latestInputTokens) {
latestInputTokens = inputTokens;
}
cumulativeOutputTokens += usage.output_tokens;
}
tracker.latestInputTokens = latestInputTokens;
tracker.cumulativeOutputTokens = cumulativeOutputTokens;
}

/**
* Resolver function that returns a human-readable activity description
* for a given tool name and input. Used to pre-compute descriptions
Expand Down Expand Up @@ -245,7 +282,10 @@ export function enqueueAgentNotification({
// results may reference stale task output. The prompt suggestion text is
// preserved; only the pre-computed response is discarded.
abortSpeculation(setAppState);
const summary = status === 'completed' ? `Agent "${description}" completed` : status === 'failed' ? `Agent "${description}" failed: ${error || 'Unknown error'}` : `Agent "${description}" was stopped`;
// Strip angle brackets from user/model-supplied description so the summary
// never breaks the <summary> envelope (BUG-4: parser regex truncation / malformed XML).
const safeDescription = description.replace(/[<>]/g, '');
const summary = status === 'completed' ? `Agent "${safeDescription}" completed` : status === 'failed' ? `Agent "${safeDescription}" failed: ${error || 'Unknown error'}` : `Agent "${safeDescription}" was stopped`;
const outputPath = getTaskOutputPath(taskId);
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : '';
const resultSection = finalMessage ? `\n<result>${finalMessage}</result>` : '';
Expand Down
120 changes: 120 additions & 0 deletions src/tasks/LocalAgentTask/tokenProgress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { expect, test } from 'bun:test'
import {
createProgressTracker,
getProgressUpdate,
syncProgressUsageFromMessages,
updateProgressFromMessage,
} from './LocalAgentTask.js'
import { createAssistantMessage } from '../../utils/messages.js'

// BUG-1 regression: streaming (Verboo/OpenAI shim) yields assistant messages
// with zeroed usage; the real usage arrives in message_delta which mutates the
// yielded message in place AFTER the consumer already read it. Tool calls
// counted, tokens stuck at 0. syncProgressUsageFromMessages re-derives tokens
// from the accumulated references (which message_delta mutated).
test('syncProgressUsageFromMessages converges tokens after in-place usage mutation', () => {
const tracker = createProgressTracker()
const agentMessages: Parameters<typeof syncProgressUsageFromMessages>[1] = []

// Request 1: text + Bash tool_use, both yielded with usage 0
const m1 = createAssistantMessage({
content: 'ok',
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: null,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
inference_geo: null,
iterations: null,
speed: null,
},
})
const m2 = createAssistantMessage({
content: [
{
type: 'tool_use',
id: 'tu1',
name: 'Bash',
input: { command: 'ls' },
},
],
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: null,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
inference_geo: null,
iterations: null,
speed: null,
},
})

// Consumer processes m1 then m2 as they arrive (usage still 0)
agentMessages.push(m1, m2)
for (const m of agentMessages) updateProgressFromMessage(tracker, m)
syncProgressUsageFromMessages(tracker, agentMessages)
expect(tracker.toolUseCount).toBe(1)
expect(getProgressUpdate(tracker).tokenCount).toBe(0)

// message_delta mutates ONLY the last message in place with the real usage
;(m2.message as { usage: unknown }).usage = {
input_tokens: 1500,
output_tokens: 40,
cache_creation_input_tokens: 5000,
cache_read_input_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: null,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
inference_geo: null,
iterations: null,
speed: null,
}

// Next message arrives (request 2) → recompute picks up the mutation
const m3 = createAssistantMessage({
content: [
{ type: 'tool_use', id: 'tu2', name: 'Read', input: { file_path: 'x' } },
],
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: null,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
inference_geo: null,
iterations: null,
speed: null,
},
})
agentMessages.push(m3)
updateProgressFromMessage(tracker, m3)
syncProgressUsageFromMessages(tracker, agentMessages)
expect(tracker.toolUseCount).toBe(2)
// input 1500 + cache 5000 (request 1, accumulated) + output 40; request 2 still pending
expect(getProgressUpdate(tracker).tokenCount).toBe(6540)

// Request 2's message_delta lands (mutates m3), agent ends → final recompute
;(m3.message as { usage: unknown }).usage = {
input_tokens: 1700,
output_tokens: 25,
cache_creation_input_tokens: 5000,
cache_read_input_tokens: 0,
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
service_tier: null,
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
inference_geo: null,
iterations: null,
speed: null,
}
syncProgressUsageFromMessages(tracker, agentMessages)
// latest input 1700 + cache 5000, outputs 40 + 25 = 65
expect(getProgressUpdate(tracker).tokenCount).toBe(6765)
})
18 changes: 16 additions & 2 deletions src/tools/AgentTool/AgentTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { startAgentSummarization } from '../../services/AgentSummary/agentSummar
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js';
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js';
import { clearDumpState } from '../../services/api/dumpPrompts.js';
import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, createProgressTracker, enqueueAgentNotification, failAgentTask as failAsyncAgent, getProgressUpdate, getTokenCountFromTracker, isLocalAgentTask, killAsyncAgent, registerAgentForeground, registerAsyncAgent, syncProgressUsageFromMessages, unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage } from '../../tasks/LocalAgentTask/LocalAgentTask.js';
import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js';
import { assembleToolPool } from '../../tools.js';
import { asAgentId } from '../../types/ids.js';
Expand Down Expand Up @@ -252,6 +252,13 @@ export const AgentTool = buildTool({
cwd
}: AgentToolInput, toolUseContext, canUseTool, assistantMessage, onProgress?) {
const startTime = Date.now();
// Coordinator workers must use the default model — the coordinator system
// prompt instructs this and model routing assumes it. Silently swallowing
// the param hides mistakes (the model may believe it selected a model).
// Log so the mismatch is visible instead of silent.
if (isCoordinatorMode() && modelParam !== undefined) {
logForDebugging(`[Coordinator] Agent model override ignored (coordinator mode): "${modelParam}"`);
}
const model = isCoordinatorMode() ? undefined : modelParam;

// Get app state for permission mode and agent filtering
Expand Down Expand Up @@ -1003,6 +1010,7 @@ export const AgentTool = buildTool({

// Track progress for backgrounded agents
updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools);
syncProgressUsageFromMessages(tracker, agentMessages);
updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState);
const lastToolName = getLastToolUseName(msg);
if (lastToolName) {
Expand All @@ -1011,6 +1019,7 @@ export const AgentTool = buildTool({
}
if (!isCurrentBackground()) return;
if (backgroundController.signal.aborted) throw new AbortError();
syncProgressUsageFromMessages(tracker, agentMessages);
const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata);

// Mark task completed FIRST so TaskOutput(block=true)
Expand Down Expand Up @@ -1068,7 +1077,7 @@ export const AgentTool = buildTool({
reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
const worktreeResult = await cleanupWorktreeIfNeeded();
if (!isCurrentBackground()) return;
if (!isCurrentBackground()) return;
const partialResult = extractPartialResult(agentMessages);
enqueueAgentNotification({
taskId: backgroundedTaskId,
Expand Down Expand Up @@ -1138,6 +1147,7 @@ export const AgentTool = buildTool({

// Emit task_progress for the VS Code subagent panel
updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools);
syncProgressUsageFromMessages(syncTracker, agentMessages);
if (foregroundTaskId) {
const lastToolName = getLastToolUseName(message);
if (lastToolName) {
Expand Down Expand Up @@ -1232,6 +1242,10 @@ export const AgentTool = buildTool({
// closure owns a separate stop function (stopBackgroundedSummarization).
stopForegroundSummarization?.();

// BUG-1 fix: last request's message_delta lands after the final yield;
// recompute so progress/SDK totals carry real token counts.
syncProgressUsageFromMessages(syncTracker, agentMessages);

// Unregister foreground task if agent completed without being backgrounded
if (foregroundTaskId && !wasReplaced) {
unregisterAgentForeground(foregroundTaskId, rootSetAppState, foregroundExecutionId);
Expand Down
9 changes: 9 additions & 0 deletions src/tools/AgentTool/agentToolUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
getTokenCountFromTracker,
isLocalAgentTask,
killAsyncAgent,
syncProgressUsageFromMessages,
type ProgressTracker,
updateAgentProgress as updateAsyncAgentProgress,
updateProgressFromMessage,
Expand Down Expand Up @@ -625,6 +626,10 @@ export async function runAsyncAgentLifecycle({
resolveActivity,
toolUseContext.options.tools,
)
// BUG-1 fix: message_delta mutates usage in-place after the message is
// yielded; recompute from the accumulated references so tokens converge
// to the real values once each request's usage lands.
syncProgressUsageFromMessages(tracker, agentMessages)
updateAsyncAgentProgress(
taskId,
getProgressUpdate(tracker),
Expand All @@ -648,6 +653,10 @@ export async function runAsyncAgentLifecycle({
if (!isCurrentExecution()) return
if (abortController.signal.aborted) throw new AbortError()

// BUG-1 fix: last request's message_delta lands after the final yield —
// recompute so the completion notification carries real token counts.
syncProgressUsageFromMessages(tracker, agentMessages)

const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)

// Mark task completed FIRST so TaskOutput(block=true) unblocks
Expand Down