Skip to content

Commit 0c43cb2

Browse files
authored
feat: report usage and cost if available (#595)
1 parent 7d14c05 commit 0c43cb2

19 files changed

Lines changed: 419 additions & 90 deletions

File tree

packages/agent/src/commands/run.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type StepResult,
1212
type WorkflowRunner,
1313
type OnStepComplete,
14+
type UsageReport,
1415
} from 'rover-core';
1516
import {
1617
ROVER_LOG_FILENAME,
@@ -26,6 +27,35 @@ import { createAgent } from '../lib/agents/index.js';
2627
import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2728
import { join } from 'node:path';
2829

30+
/**
31+
* Build display properties for a UsageReport, if any metrics are present.
32+
*/
33+
function usageProperties(
34+
usage: UsageReport | undefined
35+
): Record<string, string> {
36+
const props: Record<string, string> = {};
37+
if (!usage) return props;
38+
39+
if (usage.inputTokens !== undefined) {
40+
props['Input Tokens'] = colors.cyan(usage.inputTokens.toLocaleString());
41+
}
42+
if (usage.outputTokens !== undefined) {
43+
props['Output Tokens'] = colors.cyan(usage.outputTokens.toLocaleString());
44+
}
45+
if (usage.totalTokens !== undefined) {
46+
props['Total Tokens'] = colors.cyan(usage.totalTokens.toLocaleString());
47+
}
48+
if (usage.cost !== undefined) {
49+
const currency = usage.currency ?? 'USD';
50+
props['Cost'] = colors.yellow(`${usage.cost.toFixed(4)} ${currency}`);
51+
}
52+
if (usage.model) {
53+
props['Model'] = colors.gray(usage.model);
54+
}
55+
56+
return props;
57+
}
58+
2959
/**
3060
* Helper function to display step results consistently
3161
*/
@@ -40,6 +70,7 @@ function displayStepResults(
4070
ID: colors.cyan(result.id),
4171
Status: result.success ? colors.green('✓ Success') : colors.red('✗ Failed'),
4272
Duration: colors.yellow(`${result.duration.toFixed(2)}s`),
73+
...usageProperties(result.usage),
4374
};
4475

4576
if (result.error) {
@@ -494,6 +525,7 @@ export const runCommand = async (
494525
'Failed Steps': colors.red(failedSteps.toString()),
495526
'Skipped Steps': colors.yellow(skippedSteps.toString()),
496527
Status: status,
528+
...usageProperties(runResult.usage),
497529
});
498530

499531
// Mark workflow as completed in status file

packages/agent/src/lib/__tests__/acp-provider.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22

33
vi.mock('../acp-invoke.js', () => ({
4-
acpInvoke: vi.fn().mockResolvedValue('mock response'),
4+
acpInvoke: vi.fn().mockResolvedValue({ response: 'mock response' }),
55
}));
66

77
vi.mock('rover-prompts', () => ({
@@ -42,7 +42,7 @@ const mockedAcpInvoke = vi.mocked(acpInvoke);
4242
describe('ACPProvider', () => {
4343
beforeEach(() => {
4444
mockedAcpInvoke.mockClear();
45-
mockedAcpInvoke.mockResolvedValue('mock response');
45+
mockedAcpInvoke.mockResolvedValue({ response: 'mock response' });
4646
});
4747

4848
describe('systemPrompt', () => {

packages/agent/src/lib/acp-invoke.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import colors from 'ansi-colors';
1515
import { VERBOSE, launch } from 'rover-core';
1616
import { ACPClient } from './acp-client.js';
1717
import { GeminiOrQwenACPClient } from './gemini-or-qwen-acp-client.js';
18+
import type { InvokeResult } from 'rover-core';
19+
import type { UsageReport } from 'rover-schemas';
1820

1921
/**
2022
* Format an error into a human-readable string.
@@ -134,9 +136,11 @@ export interface ACPInvokeConfig {
134136
/**
135137
* One-shot ACP invocation: spawn agent -> init -> session -> prompt -> capture -> cleanup.
136138
*
137-
* Returns the captured agent response text.
139+
* Returns the captured agent response text along with usage metrics.
138140
*/
139-
export async function acpInvoke(config: ACPInvokeConfig): Promise<string> {
141+
export async function acpInvoke(
142+
config: ACPInvokeConfig
143+
): Promise<InvokeResult> {
140144
const { agentName, prompt, cwd, model, systemPrompt } = config;
141145

142146
const finalPrompt = systemPrompt
@@ -278,7 +282,41 @@ export async function acpInvoke(config: ACPInvokeConfig): Promise<string> {
278282

279283
const response = client.stopCapturing(sessionId);
280284

281-
return response;
285+
// Extract usage metrics from the ACP response and client cost tracking
286+
let usage: UsageReport | undefined;
287+
288+
const promptUsage = promptResult.usage;
289+
const promptCost = client.getLastPromptCost(sessionId);
290+
291+
if (promptUsage || promptCost.amount > 0) {
292+
const inputTokens = promptUsage?.inputTokens
293+
? Number(promptUsage.inputTokens)
294+
: undefined;
295+
const outputTokens = promptUsage?.outputTokens
296+
? Number(promptUsage.outputTokens)
297+
: undefined;
298+
const cachedRead = promptUsage?.cachedReadTokens
299+
? Number(promptUsage.cachedReadTokens)
300+
: 0;
301+
const cachedWrite = promptUsage?.cachedWriteTokens
302+
? Number(promptUsage.cachedWriteTokens)
303+
: 0;
304+
305+
const totalTokens =
306+
inputTokens !== undefined || outputTokens !== undefined
307+
? (inputTokens ?? 0) + (outputTokens ?? 0) + cachedRead + cachedWrite
308+
: undefined;
309+
310+
usage = {
311+
inputTokens,
312+
outputTokens,
313+
totalTokens,
314+
cost: promptCost.amount > 0 ? promptCost.amount : undefined,
315+
currency: promptCost.amount > 0 ? promptCost.currency : undefined,
316+
};
317+
}
318+
319+
return { response, usage };
282320
} catch (error) {
283321
if (VERBOSE) {
284322
console.error(colors.red('[ACP] Error:'), colors.red(formatError(error)));

packages/agent/src/lib/acp-provider.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { UserSettingsManager } from 'rover-core';
1414
import { acpInvoke } from './acp-invoke.js';
1515
import { parseJsonResponse } from './json-parser.js';
1616
import { PromptBuilder, type IPromptTask } from 'rover-prompts';
17+
import type { InvokeResult } from 'rover-core';
1718

1819
export interface ACPProviderConfig {
1920
/** Agent name (claude, codex, cursor, gemini, qwen, copilot, opencode). */
@@ -51,7 +52,8 @@ export class ACPProvider {
5152
}
5253

5354
/**
54-
* Send a prompt to the agent via ACP and return the response.
55+
* Send a prompt to the agent via ACP and return the response
56+
* along with usage metrics.
5557
*/
5658
async invoke(
5759
prompt: string,
@@ -61,7 +63,7 @@ export class ACPProvider {
6163
model?: string;
6264
systemPrompt?: string;
6365
} = {}
64-
): Promise<string> {
66+
): Promise<InvokeResult> {
6567
const { json = false, cwd, model, systemPrompt } = options;
6668

6769
let finalPrompt = prompt;
@@ -94,11 +96,11 @@ You MUST output a valid JSON string as an output. Just output the JSON string an
9496
);
9597

9698
try {
97-
const response = await this.invoke(prompt, {
99+
const result = await this.invoke(prompt, {
98100
json: true,
99101
cwd: projectPath,
100102
});
101-
return parseJsonResponse<IPromptTask>(response);
103+
return parseJsonResponse<IPromptTask>(result.response);
102104
} catch (error) {
103105
console.error(`Failed to expand task with ${this.agentName}:`, error);
104106
return null;
@@ -122,8 +124,8 @@ You MUST output a valid JSON string as an output. Just output the JSON string an
122124
);
123125

124126
try {
125-
const response = await this.invoke(prompt, { json: true });
126-
return parseJsonResponse<IPromptTask>(response);
127+
const result = await this.invoke(prompt, { json: true });
128+
return parseJsonResponse<IPromptTask>(result.response);
127129
} catch (error) {
128130
console.error(
129131
`Failed to expand iteration instructions with ${this.agentName}:`,
@@ -149,14 +151,14 @@ You MUST output a valid JSON string as an output. Just output the JSON string an
149151
recentCommits,
150152
summaries
151153
);
152-
const response = await this.invoke(prompt);
154+
const result = await this.invoke(prompt);
153155

154-
if (!response) {
156+
if (!result.response) {
155157
return null;
156158
}
157159

158160
// Clean up the response to get just the commit message
159-
const lines = response
161+
const lines = result.response
160162
.split('\n')
161163
.filter((line: string) => line.trim() !== '');
162164
return lines[0] || null;
@@ -179,9 +181,9 @@ You MUST output a valid JSON string as an output. Just output the JSON string an
179181
diffContext,
180182
conflictedContent
181183
);
182-
const response = await this.invoke(prompt);
184+
const result = await this.invoke(prompt);
183185

184-
return response;
186+
return result.response;
185187
} catch (err) {
186188
return null;
187189
}
@@ -200,8 +202,8 @@ You MUST output a valid JSON string as an output. Just output the JSON string an
200202
);
201203

202204
try {
203-
const response = await this.invoke(prompt, { json: true });
204-
return parseJsonResponse<Record<string, any>>(response);
205+
const result = await this.invoke(prompt, { json: true });
206+
return parseJsonResponse<Record<string, any>>(result.response);
205207
} catch (error) {
206208
console.error(
207209
`Failed to extract GitHub inputs with ${this.agentName}:`,

packages/agent/src/lib/acp-runner.ts

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
showList,
3232
type StepResult,
3333
} from 'rover-core';
34+
import type { UsageReport } from 'rover-schemas';
3435
import { ACPClient } from './acp-client.js';
3536
import { GeminiOrQwenACPClient } from './gemini-or-qwen-acp-client.js';
3637
import { createAgent } from './agents/index.js';
@@ -286,8 +287,7 @@ export class ACPRunner {
286287
): Promise<{
287288
stopReason: string;
288289
response: string;
289-
tokens?: number;
290-
cost?: number;
290+
usage?: UsageReport;
291291
}> {
292292
if (!this.isConnectionInitialized || !this.connection) {
293293
throw new Error(
@@ -380,18 +380,43 @@ export class ACPRunner {
380380
const response = this.client.stopCapturing(sessionId);
381381

382382
// Extract usage stats from the ACP response
383-
const tokens = promptResult.usage
384-
? (promptResult.usage.inputTokens ?? 0) +
385-
(promptResult.usage.outputTokens ?? 0) +
386-
(promptResult.usage.cachedReadTokens ?? 0) +
387-
(promptResult.usage.cachedWriteTokens ?? 0)
388-
: undefined;
389-
390-
// Get per-prompt cost delta from the client's tracked usage_update events
383+
let usage: UsageReport | undefined;
384+
385+
const promptUsage = promptResult.usage;
391386
const promptCost = this.client.getLastPromptCost(sessionId);
392-
const cost = promptCost.amount > 0 ? promptCost.amount : undefined;
393387

394-
return { stopReason: promptResult.stopReason, response, tokens, cost };
388+
if (promptUsage || promptCost.amount > 0) {
389+
const inputTokens = promptUsage?.inputTokens
390+
? Number(promptUsage.inputTokens)
391+
: undefined;
392+
const outputTokens = promptUsage?.outputTokens
393+
? Number(promptUsage.outputTokens)
394+
: undefined;
395+
const cachedRead = promptUsage?.cachedReadTokens
396+
? Number(promptUsage.cachedReadTokens)
397+
: 0;
398+
const cachedWrite = promptUsage?.cachedWriteTokens
399+
? Number(promptUsage.cachedWriteTokens)
400+
: 0;
401+
402+
const totalTokens =
403+
inputTokens !== undefined || outputTokens !== undefined
404+
? (inputTokens ?? 0) +
405+
(outputTokens ?? 0) +
406+
cachedRead +
407+
cachedWrite
408+
: undefined;
409+
410+
usage = {
411+
inputTokens,
412+
outputTokens,
413+
totalTokens,
414+
cost: promptCost.amount > 0 ? promptCost.amount : undefined,
415+
currency: promptCost.amount > 0 ? promptCost.currency : undefined,
416+
};
417+
}
418+
419+
return { stopReason: promptResult.stopReason, response, usage };
395420
} catch (error) {
396421
console.log(
397422
colors.red('[ACP] Prompt failed:'),
@@ -547,11 +572,14 @@ export class ACPRunner {
547572
// Send the prompt via ACP session
548573
const promptResult = await this.sendPrompt(sessionId, prompt);
549574

550-
// Track usage stats from ACP response
551-
const tokens = promptResult.tokens;
552-
const cost = promptResult.cost;
553-
// Use the model set for this step, or the default model
575+
// Build usage report from ACP response
554576
const model = stepModel || this.defaultModel;
577+
let usage: UsageReport | undefined = promptResult.usage;
578+
if (usage) {
579+
usage = { ...usage, model };
580+
} else if (model) {
581+
usage = { model };
582+
}
555583

556584
if (VERBOSE) {
557585
console.log(
@@ -588,18 +616,19 @@ export class ACPRunner {
588616
stepName: step.name,
589617
agent: this.tool,
590618
duration,
591-
tokens,
592-
cost,
593-
model,
619+
tokens: usage?.totalTokens,
620+
inputTokens: usage?.inputTokens,
621+
outputTokens: usage?.outputTokens,
622+
cost: usage?.cost,
623+
currency: usage?.currency,
624+
model: usage?.model,
594625
});
595626

596627
return {
597628
id: step.id,
598629
success: true,
599630
duration,
600-
tokens,
601-
cost,
602-
model,
631+
usage,
603632
outputs,
604633
};
605634
} catch (error) {

packages/agent/src/lib/agents/claude.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export class ClaudeAgent extends BaseAgent {
189189
}
190190

191191
toolArguments(): string[] {
192-
return ['-y', '@zed-industries/claude-code-acp'];
192+
return ['-y', '@zed-industries/claude-agent-acp'];
193193
}
194194

195195
toolInteractiveArguments(

packages/cli/src/lib/agents/acp-agent-base.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* - getEnvironmentVariables(): Docker environment variables
1313
*/
1414

15-
import { launch } from 'rover-core';
15+
import { launch, type InvokeResult } from 'rover-core';
1616
import type { WorkflowInput } from 'rover-schemas';
1717
import { ACPProvider, type IPromptTask } from '@endorhq/agent';
1818

@@ -42,7 +42,7 @@ export abstract class ACPAgentBase {
4242
async invoke(
4343
prompt: string,
4444
options: { json?: boolean; cwd?: string; model?: string } = {}
45-
): Promise<string> {
45+
): Promise<InvokeResult> {
4646
try {
4747
return await this.provider.invoke(prompt, options);
4848
} catch (error) {

0 commit comments

Comments
 (0)