Skip to content
16 changes: 3 additions & 13 deletions pkg/cli/logs_ci_scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ func TestLogsJSONOutputWithNoRuns(t *testing.T) {
t.Errorf("Expected TotalRuns to be 0, got %d", logsData.Summary.TotalRuns)
}

// Most importantly: verify total_tokens field exists
// This is what the CI test checks with jq -e '.summary.total_tokens'
// Verify all expected summary fields exist
var jsonMap map[string]any
if err := json.Unmarshal([]byte(output), &jsonMap); err != nil {
t.Fatalf("Failed to parse JSON as map: %v", err)
Expand All @@ -90,14 +89,8 @@ func TestLogsJSONOutputWithNoRuns(t *testing.T) {
t.Fatalf("Expected summary to be a map, got %T", jsonMap["summary"])
}

// This is the exact check the CI does
if _, exists := summary["total_tokens"]; !exists {
t.Errorf("Expected total_tokens field to exist in summary (CI test would fail). Summary: %+v", summary)
}

// Verify all expected summary fields exist
expectedFields := []string{
"total_runs", "total_duration", "total_tokens", "total_cost",
"total_runs", "total_duration",
"total_turns", "total_errors", "total_warnings", "total_missing_tools",
"total_episodes", "high_confidence_episodes",
}
Comment thread
pelikhan marked this conversation as resolved.
Expand Down Expand Up @@ -268,7 +261,7 @@ func TestLogsJSONOutputStructure(t *testing.T) {
// Verify summary has all required fields
summary := parsed["summary"].(map[string]any)
requiredFields := []string{
"total_runs", "total_duration", "total_tokens",
"total_runs", "total_duration",
"total_turns", "total_errors", "total_warnings", "total_missing_tools",
"total_episodes", "high_confidence_episodes",
}
Expand Down Expand Up @@ -340,7 +333,4 @@ func TestSummaryFileWrittenWithNoRuns(t *testing.T) {
if parsed.Summary.TotalRuns != 0 {
t.Errorf("Expected TotalRuns to be 0, got %d", parsed.Summary.TotalRuns)
}
if parsed.Summary.TotalTokens != 0 {
t.Errorf("Expected TotalTokens to be 0, got %d", parsed.Summary.TotalTokens)
}
}
17 changes: 6 additions & 11 deletions pkg/cli/logs_empty_runs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ func TestBuildLogsDataEmptyRuns(t *testing.T) {
if logsData.Summary.TotalRuns != 0 {
t.Errorf("Expected TotalRuns to be 0, got %d", logsData.Summary.TotalRuns)
}
if logsData.Summary.TotalTokens != 0 {
t.Errorf("Expected TotalTokens to be 0, got %d", logsData.Summary.TotalTokens)
}

// Verify runs array is empty
if len(logsData.Runs) != 0 {
Expand Down Expand Up @@ -68,12 +65,8 @@ func TestRenderLogsJSONEmptyRuns(t *testing.T) {
if parsedData.Summary.TotalRuns != 0 {
t.Errorf("Expected TotalRuns 0, got %d", parsedData.Summary.TotalRuns)
}
if parsedData.Summary.TotalTokens != 0 {
t.Errorf("Expected TotalTokens 0, got %d", parsedData.Summary.TotalTokens)
}

// Verify the JSON contains the total_tokens field
// This is the key test - the field should be present even when zero
// Verify the JSON summary is valid
var jsonMap map[string]any
if err := json.Unmarshal([]byte(output), &jsonMap); err != nil {
t.Fatalf("Failed to parse JSON as map: %v", err)
Expand All @@ -83,8 +76,10 @@ func TestRenderLogsJSONEmptyRuns(t *testing.T) {
if !ok {
t.Fatalf("Expected summary to be a map, got %T", jsonMap["summary"])
}

if _, exists := summary["total_tokens"]; !exists {
t.Errorf("Expected total_tokens field to exist in summary, but it was missing. Summary: %+v", summary)
if _, exists := summary["total_runs"]; !exists {
t.Fatalf("Expected total_runs field in summary. Summary: %+v", summary)
}
if _, exists := summary["total_tokens"]; exists {
t.Fatalf("Expected total_tokens to be omitted when token data is unavailable. Summary: %+v", summary)
}
}
5 changes: 3 additions & 2 deletions pkg/cli/logs_format_compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ func renderLogsCompact(data LogsData) {
summaryParts := []string{
"runs=" + strconv.Itoa(s.TotalRuns),
"duration=" + s.TotalDuration,
"tokens=" + strconv.Itoa(s.TotalTokens),
"turns=" + strconv.Itoa(s.TotalTurns),
"errors=" + strconv.Itoa(s.TotalErrors),
}
if s.TotalAIC > 0 {
summaryParts = append(summaryParts, "aic="+formatCompactAIC(s.TotalAIC))
}
if s.TotalTokens > 0 {
summaryParts = append(summaryParts, "tokens="+strconv.Itoa(s.TotalTokens))
}
if s.TotalWarnings > 0 {
summaryParts = append(summaryParts, "warnings="+strconv.Itoa(s.TotalWarnings))
}
Expand Down Expand Up @@ -229,7 +231,6 @@ func renderLogsCompactVerbose(data LogsData) {
summaryParts := []string{
"runs=" + strconv.Itoa(s.TotalRuns),
"duration=" + s.TotalDuration,
"tokens=" + strconv.Itoa(s.TotalTokens),
"action_min=" + fmt.Sprintf("%.1f", s.TotalActionMinutes),
"turns=" + strconv.Itoa(s.TotalTurns),
"errors=" + strconv.Itoa(s.TotalErrors),
Expand Down
17 changes: 12 additions & 5 deletions pkg/cli/logs_format_tsv.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,29 @@ import (

var logsTSVLog = logger.New("cli:logs_format_tsv")

func formatTSVSummaryTokens(totalTokens int) string {
if totalTokens == 0 {
return "n/a"
}
return strconv.Itoa(totalTokens)
}

// renderLogsTSV outputs the logs data as tab-separated values for maximum token efficiency.
// This format is ~24x more compact than JSON, making it ideal for agentic consumption
// where LLM context window tokens are the primary constraint.
//
// Output format:
//
// Line 1: Summary line (total_runs, total_duration, total_tokens, total_errors)
// Line 1: Summary line (total_runs, total_duration, total_tokens, total_turns, total_errors)
// Line 2: Column headers
// Lines 3+: One line per run with tab-separated fields
func renderLogsTSV(data LogsData) {
logsTSVLog.Printf("Rendering %d runs as TSV", data.Summary.TotalRuns)

s := data.Summary
// Summary line with key aggregates
Comment thread
pelikhan marked this conversation as resolved.
fmt.Fprintf(os.Stdout, "# %d runs | %s duration | %d tokens | %d turns | %d errors\n",
s.TotalRuns, s.TotalDuration, s.TotalTokens, s.TotalTurns, s.TotalErrors)
fmt.Fprintf(os.Stdout, "# %d runs | %s duration | %s tokens | %d turns | %d errors\n",
s.TotalRuns, s.TotalDuration, formatTSVSummaryTokens(s.TotalTokens), s.TotalTurns, s.TotalErrors)

if len(data.Runs) == 0 {
return
Expand Down Expand Up @@ -109,8 +116,8 @@ func renderLogsTSVVerbose(data LogsData) {
logsTSVLog.Printf("Rendering %d runs as verbose TSV", data.Summary.TotalRuns)

s := data.Summary
fmt.Fprintf(os.Stdout, "# %d runs | %s duration | %d tokens | %d turns | %d errors | %d missing_tools | %d github_api_calls\n",
s.TotalRuns, s.TotalDuration, s.TotalTokens, s.TotalTurns, s.TotalErrors, s.TotalMissingTools, s.TotalGitHubAPICalls)
fmt.Fprintf(os.Stdout, "# %d runs | %s duration | %s tokens | %d turns | %d errors | %d missing_tools | %d github_api_calls\n",
s.TotalRuns, s.TotalDuration, formatTSVSummaryTokens(s.TotalTokens), s.TotalTurns, s.TotalErrors, s.TotalMissingTools, s.TotalGitHubAPICalls)

if len(data.Runs) == 0 {
return
Expand Down
55 changes: 55 additions & 0 deletions pkg/cli/logs_format_tsv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//go:build !integration

package cli

import (
"strings"
"testing"
)

func TestRenderLogsTSVSummaryPreservesTokenField(t *testing.T) {
output, _ := captureOutput(t, func() error {
renderLogsTSV(LogsData{
Summary: LogsSummary{
TotalRuns: 2,
TotalDuration: "8m0s",
TotalTurns: 5,
TotalErrors: 1,
},
})
return nil
})

lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
t.Fatal("Expected TSV output")
}
if got, want := lines[0], "# 2 runs | 8m0s duration | n/a tokens | 5 turns | 1 errors"; got != want {
t.Fatalf("Unexpected TSV summary line:\n got: %q\nwant: %q", got, want)
}
}

func TestRenderLogsTSVVerboseSummaryPreservesTokenField(t *testing.T) {
output, _ := captureOutput(t, func() error {
renderLogsTSVVerbose(LogsData{
Summary: LogsSummary{
TotalRuns: 2,
TotalDuration: "8m0s",
TotalTokens: 1500,
TotalTurns: 5,
TotalErrors: 1,
TotalMissingTools: 2,
TotalGitHubAPICalls: 3,
},
})
return nil
})

lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) == 0 {
t.Fatal("Expected TSV verbose output")
}
if got, want := lines[0], "# 2 runs | 8m0s duration | 1500 tokens | 5 turns | 1 errors | 2 missing_tools | 3 github_api_calls"; got != want {
t.Fatalf("Unexpected TSV verbose summary line:\n got: %q\nwant: %q", got, want)
}
}
10 changes: 5 additions & 5 deletions pkg/cli/logs_json_clean_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ func TestJSONOutputNotCorruptedByStderr(t *testing.T) {
t.Fatalf("Expected summary to be a map, got %T", jsonMap["summary"])
}

// This is what the CI test checks: jq -e '.summary.total_tokens'
if _, exists := summary["total_tokens"]; !exists {
t.Errorf("Expected total_tokens field in summary. Summary: %+v", summary)
// Verify the summary contains the stable total_runs field
if _, exists := summary["total_runs"]; !exists {
t.Errorf("Expected total_runs field in summary. Summary: %+v", summary)
}

// Verify the output doesn't contain any stderr-like messages
Expand Down Expand Up @@ -202,7 +202,7 @@ func TestStderrMessagesAfterJSON(t *testing.T) {
t.Fatalf("Expected summary in JSON part, got %T", jsonMap["summary"])
}

if _, exists := summary["total_tokens"]; !exists {
t.Errorf("Expected total_tokens in JSON summary. Summary: %+v", summary)
if _, exists := summary["total_runs"]; !exists {
t.Errorf("Expected total_runs in JSON summary. Summary: %+v", summary)
}
}
18 changes: 9 additions & 9 deletions pkg/cli/logs_json_stderr_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func TestLogsJSONOutputBeforeStderr(t *testing.T) {
t.Fatalf("Failed to re-marshal parsed JSON: %v", err)
}

// Most importantly: verify total_tokens field exists in stdout JSON
// Most importantly: verify a stable summary field exists in stdout JSON
var jsonMap map[string]any
if err := json.Unmarshal([]byte(stdoutOutput), &jsonMap); err != nil {
t.Fatalf("Failed to parse stdout JSON as map: %v", err)
Expand All @@ -102,9 +102,9 @@ func TestLogsJSONOutputBeforeStderr(t *testing.T) {
t.Fatalf("Expected summary to be a map in stdout JSON, got %T", jsonMap["summary"])
}

// This is the exact check the CI does with jq -e '.summary.total_tokens'
if _, exists := summary["total_tokens"]; !exists {
t.Errorf("Expected total_tokens field to exist in stdout JSON summary (CI test would fail). Summary: %+v", summary)
// This mirrors a jq existence check against a summary field that is always present.
if _, exists := summary["total_runs"]; !exists {
t.Errorf("Expected total_runs field to exist in stdout JSON summary. Summary: %+v", summary)
}

// Verify stderr contains the warning message (after JSON was output)
Expand Down Expand Up @@ -218,7 +218,7 @@ func TestLogsJSONAndStderrRedirected(t *testing.T) {
t.Logf("Warning: Had to extract JSON from mixed output. This suggests stderr messages may be interfering.")
}

// Verify total_tokens exists
// Verify a stable summary field exists
var jsonMap map[string]any
if err := json.Unmarshal([]byte(output), &jsonMap); err == nil {
// JSON parsed cleanly - this is good
Expand All @@ -227,14 +227,14 @@ func TestLogsJSONAndStderrRedirected(t *testing.T) {
t.Fatalf("Expected summary to be a map, got %T", jsonMap["summary"])
}

if _, exists := summary["total_tokens"]; !exists {
t.Errorf("Expected total_tokens field to exist in summary. Summary: %+v", summary)
if _, exists := summary["total_runs"]; !exists {
t.Errorf("Expected total_runs field to exist in summary. Summary: %+v", summary)
}
} else {
// If the entire output isn't valid JSON, we have a problem
// Try using jq-like parsing that CI uses
if !strings.Contains(output, `"total_tokens"`) {
t.Errorf("Expected to find 'total_tokens' field somewhere in output. Output: %s", output)
if !strings.Contains(output, `"total_runs"`) {
t.Errorf("Expected to find 'total_runs' field somewhere in output. Output: %s", output)
}
}
}
4 changes: 0 additions & 4 deletions pkg/cli/logs_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ func TestRenderLogsJSON(t *testing.T) {
Summary: LogsSummary{
TotalRuns: 2,
TotalDuration: "8m0s",
TotalTokens: 1500,
TotalTurns: 5,
TotalErrors: 1,
TotalWarnings: 1,
Expand Down Expand Up @@ -276,9 +275,6 @@ func TestRenderLogsJSON(t *testing.T) {
if parsedData.Summary.TotalRuns != 2 {
t.Errorf("Expected TotalRuns 2, got %d", parsedData.Summary.TotalRuns)
}
if parsedData.Summary.TotalTokens != 1500 {
t.Errorf("Expected TotalTokens 1500, got %d", parsedData.Summary.TotalTokens)
}
if parsedData.Summary.TotalEpisodes != 1 {
t.Errorf("Expected TotalEpisodes 1, got %d", parsedData.Summary.TotalEpisodes)
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/cli/logs_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ type ContinuationData struct {
type LogsSummary struct {
TotalRuns int `json:"total_runs" console:"header:Total Runs"`
TotalDuration string `json:"total_duration" console:"header:Total Duration"`
TotalTokens int `json:"total_tokens" console:"header:Total Tokens,format:number"`
TotalAIC float64 `json:"total_aic,omitempty"`
TotalTokens int `json:"total_tokens,omitempty" console:"header:Total Tokens,format:number,omitempty"`
TotalActionMinutes float64 `json:"total_action_minutes" console:"header:Total Action Minutes"`
TotalTurns int `json:"total_turns" console:"header:Total Turns"`
TotalSteeringEvents int `json:"total_steering_events,omitempty" console:"header:Total Steering Events,format:number,omitempty"`
Expand Down Expand Up @@ -157,8 +157,8 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation

// Build summary
var totalDuration time.Duration
var totalTokens int
var totalAIC float64
var totalTokens int
var totalActionMinutes float64
var totalTurns int
var totalSteeringEvents int
Expand Down Expand Up @@ -191,10 +191,10 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation
if run.Duration > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--artifacts agent mode loses token summary: removing TotalTokens entirely drops the aggregate even when per-run token_usage fields are non-zero (populated by the agent artifact).

💡 Suggested fix

The PR's stated rationale is that the field is "always zero without --artifacts agent" — which is true. But when --artifacts agent IS used, run.TokenUsage (RunData.TokenUsage, line 109) is populated with real non-zero counts, and totalTokens += run.TokenUsage was the only place those were aggregated into the summary.

A less destructive fix: keep the field but suppress it when zero:

// In LogsSummary struct:
TotalTokens int `json:"total_tokens,omitempty" console:"header:Total Tokens,format:number,omitempty"`

This makes total_tokens absent from JSON in the default mode (matches the stated goal) while preserving the aggregate for users who download agent artifacts. The current change silently regresses --artifacts agent users who relied on the summary token count.

totalDuration += run.Duration
}
totalTokens += run.TokenUsage
if pr.TokenUsage != nil {
totalAIC += pr.TokenUsage.TotalAIC
}
totalTokens += run.TokenUsage
totalActionMinutes += run.ActionMinutes
totalTurns += run.Turns
if pr.TokenUsage != nil {
Expand Down Expand Up @@ -347,8 +347,8 @@ func buildLogsData(processedRuns []ProcessedRun, outputDir string, continuation
summary := LogsSummary{
TotalRuns: len(processedRuns),
TotalDuration: timeutil.FormatDuration(totalDuration),
TotalTokens: totalTokens,
TotalAIC: totalAIC,
TotalTokens: totalTokens,
TotalActionMinutes: totalActionMinutes,
TotalTurns: totalTurns,
TotalSteeringEvents: totalSteeringEvents,
Expand Down
1 change: 0 additions & 1 deletion pkg/cli/logs_report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ func TestRenderLogsConsoleUnified(t *testing.T) {
Summary: LogsSummary{
TotalRuns: 2,
TotalDuration: "10m30s",
TotalTokens: 2500,
TotalTurns: 8,
TotalErrors: 1,
TotalWarnings: 3,
Expand Down
4 changes: 0 additions & 4 deletions pkg/cli/logs_summary_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ func TestWriteSummaryFile(t *testing.T) {
Summary: LogsSummary{
TotalRuns: 3,
TotalDuration: "1h30m",
TotalTokens: 15000,
TotalTurns: 25,
TotalErrors: 2,
TotalWarnings: 5,
Expand Down Expand Up @@ -76,9 +75,6 @@ func TestWriteSummaryFile(t *testing.T) {
if parsedData.Summary.TotalRuns != logsData.Summary.TotalRuns {
t.Errorf("Expected TotalRuns %d, got %d", logsData.Summary.TotalRuns, parsedData.Summary.TotalRuns)
}
if parsedData.Summary.TotalTokens != logsData.Summary.TotalTokens {
t.Errorf("Expected TotalTokens %d, got %d", logsData.Summary.TotalTokens, parsedData.Summary.TotalTokens)
}
if len(parsedData.Runs) != len(logsData.Runs) {
t.Errorf("Expected %d runs, got %d", len(logsData.Runs), len(parsedData.Runs))
}
Expand Down
1 change: 0 additions & 1 deletion pkg/cli/mcp_schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,6 @@ func TestGeneratedSchemasValidateRealOutput(t *testing.T) {
Summary: LogsSummary{
TotalRuns: 5,
TotalDuration: "10m30s",
TotalTokens: 15000,
TotalTurns: 25,
},
Runs: []RunData{
Expand Down
Loading
Loading