Skip to content

feat: QuietOutputFormatter silently discards token usage — emit to stderr #257

Description

@zzl360

Problem

When using --format quiet, token usage information (inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens, totalTokens) and cost are completely discarded — they appear neither in stdout nor stderr.

This makes it impossible for callers (CI runners, orchestrators, billing dashboards) to track API consumption without switching to --format json, which changes the stdout contract entirely.

Steps to Reproduce

acpx --cwd /tmp --approve-all --format quiet --verbose \                                                                                                                                                                                                                    
     claude exec "Reply with exactly: OK"                                                                                                                                                                                                                                     

stdout:

OK                                                                                                                                                                                                                                                                            

stderr:

[acpx] spawning installed built-in agent @agentclientprotocol/[email protected] ...
[acpx] initialized protocol version 1                                                                                                                                                                                                                                         

Token usage and cost: nowhere to be found.

Compare with --format json, where the final JSON-RPC response contains full usage:

{                                                                                                                                                                                                                                                                           
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "stopReason": "end_turn",                                                                                                                                                                                                                                                 
    "usage": {                                                                                                                                                                                                                                                                
      "inputTokens": 17030,                                                                                                                                                                                                                                                   
      "outputTokens": 4,                                                                                                                                                                                                                                                      
      "cachedReadTokens": 0,                                                                                                                                                                                                                                                
      "cachedWriteTokens": 0,                                                                                                                                                                                                                                                 
      "totalTokens": 17034                                                                                                                                                                                                                                                    
    }
  }                                                                                                                                                                                                                                                                           
}                                                                                                                                                                                                                                                                           

Root Cause

QuietOutputFormatter.onAcpMessage() in src/output.ts only handles two cases and silently ignores everything else:

onAcpMessage(message) {                                                                                                                                                                                                                                                     
  // Case 1: collect text chunks                                                                                                                                                                                                                                              
  const update = extractSessionUpdateNotification(message);                                                                                                                                                                                                                   
  if (update?.update.sessionUpdate === "agent_message_chunk" && ...) {                                                                                                                                                                                                        
    this.chunks.push(update.update.content.text);                                                                                                                                                                                                                             
    return;                                                                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                                                                           
  // Case 2: flush text on stop                                                                                                                                                                                                                                               
  if (parsePromptStopReason(message)) this.flushBufferedOutput();                                                                                                                                                                                                             
  // ← result.usage is right inside `message.result` here, but is never read                                                                                                                                                                                                  
}                                                                                                                                                                                                                                                                             
                                                                                                                                                                                                                                                                              
onError(_params) {}  // errors also silently dropped                                                                                                                                                                                                                          
flush() {}                                                                                                                                                                                                                                                                    

parsePromptStopReason() already parses message.result to extract stopReasonmessage.result.usage is sitting right there at the same time but is never consumed.

Proposed Fix

Emit usage and cost to stderr when the final result arrives. This follows standard CLI conventions: stdout stays clean for piping, stderr carries diagnostic/meta information.

onAcpMessage(message) {                                                                                                                                                                                                                                                     
  const update = extractSessionUpdateNotification(message);
  if (
    update?.update.sessionUpdate === "agent_message_chunk" &&                                                                                                                                                                                                                 
    update.update.content.type === "text"                                                                                                                                                                                                                                     
  ) {                                                                                                                                                                                                                                                                         
    this.chunks.push(update.update.content.text);                                                                                                                                                                                                                             
    return;                                                                                                                                                                                                                                                                 
  }

  const stopReason = parsePromptStopReason(message);
  if (stopReason) {                                                                                                                                                                                                                                                           
    this.flushBufferedOutput();
                                                                                                                                                                                                                                                                              
    // emit token usage to stderr — keeps stdout clean for piping                                                                                                                                                                                                           
    const result = (message as { result?: Record<string, unknown> }).result;                                                                                                                                                                                                  
    const usage = result?.usage as Record<string, number> | undefined;                                                                                                                                                                                                        
    if (usage) {                                                                                                                                                                                                                                                              
      process.stderr.write(                                                                                                                                                                                                                                                   
        `[acpx] tokens: input=${usage.inputTokens} output=${usage.outputTokens}` +                                                                                                                                                                                            
        ` cache_read=${usage.cachedReadTokens} cache_write=${usage.cachedWriteTokens}` +                                                                                                                                                                                      
        ` total=${usage.totalTokens}\n`                                                                                                                                                                                                                                       
      );                                                                                                                                                                                                                                                                      
    }                                                                                                                                                                                                                                                                         
    const cost = result?.cost as { amount?: number; currency?: string } | undefined;                                                                                                                                                                                          
    if (cost?.amount != null) {                                                                                                                                                                                                                                               
      process.stderr.write(`[acpx] cost: ${cost.amount} ${cost.currency ?? "USD"}\n`);                                                                                                                                                                                        
    }                                                                                                                                                                                                                                                                         
  }                                                                                                                                                                                                                                                                           
}                                                                                                                                                                                                                                                                             

After the fix, stderr would look like:

[acpx] spawning installed built-in agent @agentclientprotocol/[email protected] ...                                                                                                                                                                                   
[acpx] initialized protocol version 1
[acpx] tokens: input=17030 output=4 cache_read=0 cache_write=0 total=17034                                                                                                                                                                                                    
[acpx] cost: 0.051276 USD                                                                                                                                                                                                                                                     

stdout remains unchanged (OK), so existing callers that pipe or parse stdout are completely unaffected.

Versions

  • acpx: 0.5.3
  • @agentclientprotocol/claude-agent-acp: 0.29.2

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions