diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a6d33c..d2223ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
+- HTML and Markdown reports now identify the top-level duration as evaluation
+ wall time and show per-case tested-agent execution time plus input, output,
+ and total token usage. Benchmark cases include compact with-Skill,
+ without-Skill, and delta annotations beside the case heading. Agent-judge
+ time and tokens are reported separately and included in an explicit overall
+ token total.
- Codex JSONL parsing now accepts records up to a configurable 16 MiB default
while rejecting oversized records explicitly. The stdout stream is written to
a bounded-download artifact instead of being buffered without limit in host
diff --git a/internal/report/README.md b/internal/report/README.md
index c45f075..d2db039 100644
--- a/internal/report/README.md
+++ b/internal/report/README.md
@@ -67,6 +67,8 @@ package "internal/report" {
EndTime : time.Time
CaseResults : []CaseResult
TotalTokens : int
+ JudgeTokens : int
+ OverallTokens : int
Benchmark : *BenchmarkResult
+TotalDuration() time.Duration
+OverallPassRate() float64
@@ -78,6 +80,11 @@ package "internal/report" {
Status : judge.Status
DurationMs : int64
Turns : int
+ InputTokens : int
+ OutputTokens : int
+ JudgeDurationMs : int64
+ JudgeInputTokens : int
+ JudgeOutputTokens : int
Error : string
Grading : *judge.Result
}
@@ -160,9 +167,25 @@ end note
- Rendered with the standard library's `html/template`; the template is loaded via `go:embed` from `templates/report.html`
- Bundles responsive CSS styles
-- Displays: skill name, engine, model, start time, execution time, pass rate
+- Displays: skill name, engine, model, start time, evaluation wall time, pass rate, and separated tested-agent / judge / overall token totals
- Summary cards: Total / Passed / Failed / Skipped / Errors / Pass Rate
-- Per-case detail table: status icons, assertion results, evidence
+- Per-case details: compact tested-agent and optional agent-judge metrics beside the case heading, plus status icons, assertion results, and evidence; input/output token counts remain available as hover details
+- Benchmark cases show compact with-Skill, without-Skill, and delta metrics beside the case heading so execution cost remains secondary to the response and grading content
+
+### Metric semantics
+
+- `Input.TotalDuration()` is **evaluation wall time** (`EndTime - StartTime`). It
+ includes orchestration, tested-agent execution, judging, and other framework
+ overhead, so it is not expected to equal the sum of visible case execution
+ times, especially when cases run concurrently.
+- `CaseResult.DurationMs` is **tested-agent execution time** for that case and
+ configuration. It is the primary duration for comparing Skill behavior.
+- `CaseResult.InputTokens` and `OutputTokens` are tested-agent token usage.
+ `Input.TotalTokens` retains its existing JSON name for compatibility and is
+ the sum of those tested-agent tokens across all configurations.
+- `JudgeDurationMs`, `JudgeInputTokens`, and `JudgeOutputTokens` are populated
+ when the judge runs a separate agent session. `Input.JudgeTokens` aggregates
+ those tokens, and `Input.OverallTokens` is tested-agent plus judge tokens.
### JUnitReporter (`junit.go`)
diff --git a/internal/report/html.go b/internal/report/html.go
index 37cc1c0..be3edd4 100644
--- a/internal/report/html.go
+++ b/internal/report/html.go
@@ -58,15 +58,17 @@ type htmlReportData struct {
// -- Embedded JSON types for JavaScript consumption --
type embeddedReportData struct {
- SkillName string `json:"skill_name"`
- EngineName string `json:"engine_name"`
- ModelName string `json:"model_name"`
- StartTime string `json:"start_time"`
- Duration string `json:"duration"`
- TotalTokens int `json:"total_tokens"`
- Summary embeddedSummary `json:"summary"`
- Cases []embeddedCase `json:"cases"`
- Benchmark *BenchmarkResult `json:"benchmark,omitempty"`
+ SkillName string `json:"skill_name"`
+ EngineName string `json:"engine_name"`
+ ModelName string `json:"model_name"`
+ StartTime string `json:"start_time"`
+ EvaluationWallTime string `json:"evaluation_wall_time"`
+ AgentTokens int `json:"agent_tokens"`
+ JudgeTokens int `json:"judge_tokens"`
+ OverallTokens int `json:"overall_tokens"`
+ Summary embeddedSummary `json:"summary"`
+ Cases []embeddedCase `json:"cases"`
+ Benchmark *BenchmarkResult `json:"benchmark,omitempty"`
}
type embeddedSummary struct {
@@ -79,20 +81,28 @@ type embeddedSummary struct {
}
type embeddedCase struct {
- ID string `json:"id"`
- Title string `json:"title,omitempty"`
- Status string `json:"status"`
- DurationMs int64 `json:"duration_ms"`
- Duration string `json:"duration"`
- Turns int `json:"turns"`
- Error string `json:"error,omitempty"`
- Grading *embeddedGrading `json:"grading,omitempty"`
- Configuration string `json:"configuration,omitempty"`
- Prompt string `json:"prompt,omitempty"`
- Response string `json:"response,omitempty"`
- Baseline *embeddedCase `json:"baseline,omitempty"`
- TurnResults []embeddedTurn `json:"turn_results,omitempty"`
- JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"`
+ ID string `json:"id"`
+ Title string `json:"title,omitempty"`
+ Status string `json:"status"`
+ AgentDurationMs int64 `json:"agent_duration_ms"`
+ AgentDuration string `json:"agent_duration"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ AgentTokens int `json:"agent_tokens"`
+ JudgeDurationMs int64 `json:"judge_duration_ms"`
+ JudgeDuration string `json:"judge_duration"`
+ JudgeInputTokens int `json:"judge_input_tokens"`
+ JudgeOutputTokens int `json:"judge_output_tokens"`
+ JudgeTokens int `json:"judge_tokens"`
+ Turns int `json:"turns"`
+ Error string `json:"error,omitempty"`
+ Grading *embeddedGrading `json:"grading,omitempty"`
+ Configuration string `json:"configuration,omitempty"`
+ Prompt string `json:"prompt,omitempty"`
+ Response string `json:"response,omitempty"`
+ Baseline *embeddedCase `json:"baseline,omitempty"`
+ TurnResults []embeddedTurn `json:"turn_results,omitempty"`
+ JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"`
}
// embeddedTurn holds per-turn data for the HTML report JavaScript.
@@ -136,18 +146,26 @@ type caseStatusCounts struct {
func caseResultToEmbeddedCase(cr CaseResult) embeddedCase {
ec := embeddedCase{
- ID: cr.CaseID,
- Title: cr.Title,
- Status: string(cr.Status),
- DurationMs: cr.DurationMs,
- Duration: fmt.Sprintf("%.1fs", float64(cr.DurationMs)/1000.0),
- Turns: cr.Turns,
- Error: cr.Error,
- Configuration: cr.Configuration,
- Prompt: cr.Prompt,
- Response: cr.Response,
- TurnResults: caseTurnResultsToEmbedded(cr.TurnResults),
- JudgeSkills: cr.JudgeSkills,
+ ID: cr.CaseID,
+ Title: cr.Title,
+ Status: string(cr.Status),
+ AgentDurationMs: cr.DurationMs,
+ AgentDuration: fmt.Sprintf("%.1fs", float64(cr.DurationMs)/1000.0),
+ InputTokens: cr.InputTokens,
+ OutputTokens: cr.OutputTokens,
+ AgentTokens: cr.InputTokens + cr.OutputTokens,
+ JudgeDurationMs: cr.JudgeDurationMs,
+ JudgeDuration: fmt.Sprintf("%.1fs", float64(cr.JudgeDurationMs)/1000.0),
+ JudgeInputTokens: cr.JudgeInputTokens,
+ JudgeOutputTokens: cr.JudgeOutputTokens,
+ JudgeTokens: cr.JudgeInputTokens + cr.JudgeOutputTokens,
+ Turns: cr.Turns,
+ Error: cr.Error,
+ Configuration: cr.Configuration,
+ Prompt: cr.Prompt,
+ Response: cr.Response,
+ TurnResults: caseTurnResultsToEmbedded(cr.TurnResults),
+ JudgeSkills: cr.JudgeSkills,
}
if cr.Grading != nil {
eg := &embeddedGrading{
@@ -252,12 +270,14 @@ func (r *HTMLReporter) buildTemplateData(in Input) (htmlReportData, error) {
cases := buildEmbeddedCases(grouped, orderedIDs)
ed := embeddedReportData{
- SkillName: in.SkillName,
- EngineName: in.EngineName,
- ModelName: in.ModelName,
- StartTime: in.StartTime.Format(time.RFC3339),
- Duration: fmt.Sprintf("%.1fs", in.TotalDuration().Seconds()),
- TotalTokens: in.TotalTokens,
+ SkillName: in.SkillName,
+ EngineName: in.EngineName,
+ ModelName: in.ModelName,
+ StartTime: in.StartTime.Format(time.RFC3339),
+ EvaluationWallTime: fmt.Sprintf("%.1fs", in.TotalDuration().Seconds()),
+ AgentTokens: in.TotalTokens,
+ JudgeTokens: in.JudgeTokens,
+ OverallTokens: in.TotalTokens + in.JudgeTokens,
Summary: embeddedSummary{
Total: len(cases),
Passed: counts.passed,
diff --git a/internal/report/json.go b/internal/report/json.go
index 83af131..6bfbdf7 100644
--- a/internal/report/json.go
+++ b/internal/report/json.go
@@ -17,6 +17,9 @@ type JSONReporter struct {
// Write implements the Reporter interface.
func (r *JSONReporter) Write(_ context.Context, in Input) error {
+ // OverallTokens is derived so reports regenerated from an older result.json
+ // also receive a consistent explicit total.
+ in.OverallTokens = in.TotalTokens + in.JudgeTokens
data, err := json.MarshalIndent(in, "", " ")
if err != nil {
return fmt.Errorf("json marshal: %w", err)
diff --git a/internal/report/markdown.go b/internal/report/markdown.go
index d763e74..9736dba 100644
--- a/internal/report/markdown.go
+++ b/internal/report/markdown.go
@@ -60,26 +60,66 @@ func writeMarkdownSummary(sb *strings.Builder, in Input) {
fmt.Fprintf(sb, "| Errors | %d |\n", markdownCountByStatus(in, judge.StatusError))
fmt.Fprintf(sb, "| Skipped | %d |\n", markdownCountByStatus(in, judge.StatusSkip))
fmt.Fprintf(sb, "| Pass Rate | %.1f%% |\n", in.OverallPassRate()*100)
- fmt.Fprintf(sb, "| Duration | %s |\n", markdownDuration(in.TotalDuration().Milliseconds()))
- fmt.Fprintf(sb, "| Total Tokens | %d |\n\n", in.TotalTokens)
+ fmt.Fprintf(sb, "| Evaluation Wall Time | %s |\n", markdownDuration(in.TotalDuration().Milliseconds()))
+ fmt.Fprintf(sb, "| Tested Agent Tokens | %d |\n", in.TotalTokens)
+ fmt.Fprintf(sb, "| Judge Tokens | %d |\n", in.JudgeTokens)
+ fmt.Fprintf(sb, "| Overall Tokens | %d |\n\n", in.TotalTokens+in.JudgeTokens)
}
func writeMarkdownCases(sb *strings.Builder, in Input) {
sb.WriteString("## Cases\n\n")
- sb.WriteString("| Case | Title | Status | Duration | Turns |\n")
- sb.WriteString("|---|---|---|---:|---:|\n")
+ if markdownHasJudgeMetrics(in) {
+ writeMarkdownCasesWithJudgeMetrics(sb, in)
+ return
+ }
+ sb.WriteString("| Case | Title | Configuration | Status | Agent Time | Input Tokens | Output Tokens | Agent Tokens | Turns |\n")
+ sb.WriteString("|---|---|---|---|---:|---:|---:|---:|---:|\n")
+ for _, cr := range in.CaseResults {
+ fmt.Fprintf(sb, "| %s | %s | %s | %s | %s | %d | %d | %d | %d |\n",
+ markdownTableCell(cr.CaseID),
+ markdownTableCell(cr.Title),
+ markdownTableCell(cr.Configuration),
+ markdownTableCell(string(cr.Status)),
+ markdownDuration(cr.DurationMs),
+ cr.InputTokens,
+ cr.OutputTokens,
+ cr.InputTokens+cr.OutputTokens,
+ cr.Turns,
+ )
+ }
+ sb.WriteString("\n")
+}
+
+func writeMarkdownCasesWithJudgeMetrics(sb *strings.Builder, in Input) {
+ sb.WriteString("| Case | Title | Configuration | Status | Agent Time | Input Tokens | Output Tokens | Agent Tokens | Judge Time | Judge Tokens | Turns |\n")
+ sb.WriteString("|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|\n")
for _, cr := range in.CaseResults {
- fmt.Fprintf(sb, "| %s | %s | %s | %s | %d |\n",
+ fmt.Fprintf(sb, "| %s | %s | %s | %s | %s | %d | %d | %d | %s | %d | %d |\n",
markdownTableCell(cr.CaseID),
markdownTableCell(cr.Title),
+ markdownTableCell(cr.Configuration),
markdownTableCell(string(cr.Status)),
markdownDuration(cr.DurationMs),
+ cr.InputTokens,
+ cr.OutputTokens,
+ cr.InputTokens+cr.OutputTokens,
+ markdownDuration(cr.JudgeDurationMs),
+ cr.JudgeInputTokens+cr.JudgeOutputTokens,
cr.Turns,
)
}
sb.WriteString("\n")
}
+func markdownHasJudgeMetrics(in Input) bool {
+ for _, cr := range in.CaseResults {
+ if cr.JudgeDurationMs != 0 || cr.JudgeInputTokens != 0 || cr.JudgeOutputTokens != 0 {
+ return true
+ }
+ }
+ return false
+}
+
func writeMarkdownFailureDetails(sb *strings.Builder, in Input) {
var details strings.Builder
for _, cr := range in.CaseResults {
diff --git a/internal/report/reporter.go b/internal/report/reporter.go
index 00a0e38..9209f63 100644
--- a/internal/report/reporter.go
+++ b/internal/report/reporter.go
@@ -22,7 +22,9 @@ type Input struct {
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
CaseResults []CaseResult `json:"case_results"`
- TotalTokens int `json:"total_tokens"`
+ TotalTokens int `json:"total_tokens"` // tested-agent tokens across all configurations
+ JudgeTokens int `json:"judge_tokens"`
+ OverallTokens int `json:"overall_tokens"`
Benchmark *BenchmarkResult `json:"benchmark,omitempty"`
}
@@ -99,20 +101,23 @@ func (in Input) PrimaryCaseResults() []CaseResult {
// CaseResult represents the result of a single case execution.
type CaseResult struct {
- CaseID string `json:"case_id"`
- Title string `json:"title"`
- Status judge.Status `json:"status"`
- DurationMs int64 `json:"duration_ms"`
- Turns int `json:"turns"`
- InputTokens int `json:"input_tokens"`
- OutputTokens int `json:"output_tokens"`
- Error string `json:"error,omitempty"`
- Grading *judge.Result `json:"grading"`
- JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"`
- Configuration string `json:"configuration,omitempty"` // "with_skill" or "without_skill"
- Prompt string `json:"prompt,omitempty"` // input prompt sent to the agent
- Response string `json:"response,omitempty"` // agent final message
- TurnResults []CaseTurnResult `json:"turn_results,omitempty"` // per-turn outcomes; nil for single-turn
+ CaseID string `json:"case_id"`
+ Title string `json:"title"`
+ Status judge.Status `json:"status"`
+ DurationMs int64 `json:"duration_ms"`
+ Turns int `json:"turns"`
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ JudgeDurationMs int64 `json:"judge_duration_ms,omitempty"`
+ JudgeInputTokens int `json:"judge_input_tokens,omitempty"`
+ JudgeOutputTokens int `json:"judge_output_tokens,omitempty"`
+ Error string `json:"error,omitempty"`
+ Grading *judge.Result `json:"grading"`
+ JudgeSkills []judge.SkillInfo `json:"judge_skills,omitempty"`
+ Configuration string `json:"configuration,omitempty"` // "with_skill" or "without_skill"
+ Prompt string `json:"prompt,omitempty"` // input prompt sent to the agent
+ Response string `json:"response,omitempty"` // agent final message
+ TurnResults []CaseTurnResult `json:"turn_results,omitempty"` // per-turn outcomes; nil for single-turn
}
// CaseTurnResult holds the outcome of a single turn for reporting purposes.
diff --git a/internal/report/reporter_test.go b/internal/report/reporter_test.go
index 32ef125..16fc5b1 100644
--- a/internal/report/reporter_test.go
+++ b/internal/report/reporter_test.go
@@ -25,13 +25,21 @@ func sampleInput() Input {
ModelName: "openai/gpt-5.4",
StartTime: start,
EndTime: end,
+ TotalTokens: 1200,
+ JudgeTokens: 340,
+ OverallTokens: 1540,
CaseResults: []CaseResult{
{
- CaseID: "basic-success",
- Title: "Agent should identify a missing null check",
- Status: judge.StatusPass,
- DurationMs: 45200,
- Turns: 5,
+ CaseID: "basic-success",
+ Title: "Agent should identify a missing null check",
+ Status: judge.StatusPass,
+ DurationMs: 45200,
+ Turns: 5,
+ InputTokens: 1000,
+ OutputTokens: 200,
+ JudgeDurationMs: 12000,
+ JudgeInputTokens: 300,
+ JudgeOutputTokens: 40,
JudgeSkills: []judge.SkillInfo{
{
Source: "local_path",
@@ -136,6 +144,34 @@ func TestJSONReporter_Write(t *testing.T) {
}
}
+func TestJSONReporter_PreservesExecutionMetrics(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "result.json")
+ input := sampleInput()
+ input.OverallTokens = 0 // JSONReporter must derive this value, including for older input files.
+ if err := (&JSONReporter{OutputPath: path}).Write(context.Background(), input); err != nil {
+ t.Fatalf("JSONReporter.Write failed: %v", err)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read json file: %v", err)
+ }
+ var parsed Input
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if parsed.TotalTokens != 1200 || parsed.JudgeTokens != 340 || parsed.OverallTokens != 1540 {
+ t.Fatalf("token summary not preserved in JSON: agent=%d judge=%d overall=%d",
+ parsed.TotalTokens, parsed.JudgeTokens, parsed.OverallTokens)
+ }
+ first := parsed.CaseResults[0]
+ if first.InputTokens != 1000 || first.OutputTokens != 200 ||
+ first.JudgeInputTokens != 300 || first.JudgeOutputTokens != 40 || first.JudgeDurationMs != 12000 {
+ t.Fatalf("case metrics not preserved in JSON: %#v", first)
+ }
+}
+
func TestJSONReporter_ContainsAssertions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "result.json")
@@ -266,6 +302,62 @@ func TestHTMLReporter_Write(t *testing.T) {
if !strings.Contains(content, "evals/fixtures/judge-skill") {
t.Fatal("missing judge skill path in embedded report data")
}
+ for _, want := range []string{
+ "Evaluation wall time",
+ "Tested agent tokens",
+ "case-header-metrics",
+ "renderCompactMetrics",
+ "Agent ",
+ "Judge ",
+ "Delta",
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("HTML missing metric label %q", want)
+ }
+ }
+ if strings.Contains(content, ">Execution Metrics<") {
+ t.Fatal("HTML should keep execution metrics inline instead of rendering a standalone section")
+ }
+}
+
+func TestHTMLReporter_EmbedsPerConfigurationMetrics(t *testing.T) {
+ input := sampleInput()
+ input.CaseResults = []CaseResult{
+ {
+ CaseID: "benchmark-case", Configuration: "with_skill", Status: judge.StatusPass,
+ DurationMs: 47618, InputTokens: 247863, OutputTokens: 2482,
+ JudgeDurationMs: 10000, JudgeInputTokens: 5000, JudgeOutputTokens: 200,
+ },
+ {
+ CaseID: "benchmark-case", Configuration: "without_skill", Status: judge.StatusPass,
+ DurationMs: 14911, InputTokens: 59113, OutputTokens: 525,
+ JudgeDurationMs: 8000, JudgeInputTokens: 4000, JudgeOutputTokens: 100,
+ },
+ }
+
+ r := &HTMLReporter{}
+ data, err := r.buildTemplateData(input)
+ if err != nil {
+ t.Fatalf("buildTemplateData failed: %v", err)
+ }
+ var embedded embeddedReportData
+ if err := json.Unmarshal([]byte(data.EmbeddedDataJSON), &embedded); err != nil {
+ t.Fatalf("unmarshal embedded report data: %v", err)
+ }
+ if len(embedded.Cases) != 1 || embedded.Cases[0].Baseline == nil {
+ t.Fatalf("expected one case with baseline, got %#v", embedded.Cases)
+ }
+ withSkill := embedded.Cases[0]
+ withoutSkill := *withSkill.Baseline
+ if withSkill.AgentDurationMs != 47618 || withSkill.AgentTokens != 250345 {
+ t.Fatalf("with-skill metrics = %#v", withSkill)
+ }
+ if withoutSkill.AgentDurationMs != 14911 || withoutSkill.AgentTokens != 59638 {
+ t.Fatalf("without-skill metrics = %#v", withoutSkill)
+ }
+ if withSkill.JudgeDurationMs != 10000 || withSkill.JudgeTokens != 5200 {
+ t.Fatalf("with-skill judge metrics = %#v", withSkill)
+ }
}
func TestHTMLReporter_ContainsAssertionDetails(t *testing.T) {
@@ -349,7 +441,11 @@ func TestMarkdownReporter_Write(t *testing.T) {
"| Skipped | 1 |",
"| Pass Rate | 25.0% |",
"## Cases",
- "| basic-success | Agent should identify a missing null check | PASS | 45.2s | 5 |",
+ "| Evaluation Wall Time | 245.0s |",
+ "| Tested Agent Tokens | 1200 |",
+ "| Judge Tokens | 340 |",
+ "| Overall Tokens | 1540 |",
+ "| basic-success | Agent should identify a missing null check | - | PASS | 45.2s | 1000 | 200 | 1200 | 12.0s | 340 | 5 |",
"## Failure and Error Details",
"### edge-case-null",
"output_contains: 'graceful'",
diff --git a/internal/report/templates/report.html b/internal/report/templates/report.html
index b6ed43c..bc5e360 100644
--- a/internal/report/templates/report.html
+++ b/internal/report/templates/report.html
@@ -83,6 +83,12 @@
.case-info { display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; font-size: 0.875rem; }
.case-info .info-item { display: flex; align-items: center; gap: 0.25rem; }
.case-info .info-label { color: var(--text-muted); font-size: 0.75rem; }
+ .case-header { flex-wrap: wrap; }
+ .case-header-metrics { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-family: 'Inter', system-ui, -apple-system, sans-serif; font-size: 0.7rem; font-weight: 400; text-transform: none; letter-spacing: normal; color: var(--text-muted); }
+ .compact-metrics { display: inline-flex; align-items: center; gap: 0.3rem; white-space: nowrap; }
+ .compact-metrics strong { color: var(--text); font-size: 0.7rem; font-weight: 600; }
+ .compact-metrics.delta { opacity: 0.8; }
+ .metrics-divider { opacity: 0.6; }
.error-box { background: var(--error-bg); border: 1px solid var(--error-color); border-radius: 4px; padding: 0.75rem; color: var(--error-color); font-size: 0.875rem; white-space: pre-wrap; }
@@ -197,7 +203,7 @@
Eval Report:
@@ -263,8 +269,10 @@
Review Complete
function init() {
document.getElementById("skill-name").textContent = DATA.skill_name;
var meta = "Engine: " + DATA.engine_name + " | Model: " + DATA.model_name +
- " | Started: " + DATA.start_time + " | Duration: " + DATA.duration;
- if (DATA.total_tokens) meta += " | Tokens: " + DATA.total_tokens;
+ " | Started: " + DATA.start_time + " | Evaluation wall time: " + DATA.evaluation_wall_time;
+ meta += " | Tested agent tokens: " + fmtInteger(DATA.agent_tokens);
+ meta += " | Judge tokens: " + fmtInteger(DATA.judge_tokens);
+ meta += " | Overall tokens: " + fmtInteger(DATA.overall_tokens);
document.getElementById("header-meta").textContent = meta;
var sb = document.getElementById("summary-bar");
@@ -356,9 +364,9 @@
Review Complete
var info = '
Case: ' + esc(c.id) + '
';
if (c.title) info += '
Title: ' + esc(c.title) + '
';
- info += '
Duration: ' + c.duration + '
';
info += '
Turns: ' + c.turns + '
';
document.getElementById("case-info").innerHTML = info;
+ document.getElementById("case-header-metrics").innerHTML = renderCompactMetrics(c);
var promptSec = document.getElementById("prompt-section");
if (c.turn_results && c.turn_results.length > 0) {
@@ -391,6 +399,45 @@
Review Complete
if (main) main.scrollTop = 0;
}
+ function renderCompactMetrics(c) {
+ var baseline = c.baseline || null;
+ var primaryLabel = c.configuration === "without_skill" ? "Without Skill" : (baseline ? "With Skill" : "Result");
+ var html = compactMetric(primaryLabel, c);
+ if (baseline) {
+ html += compactMetric("Without Skill", baseline);
+ html += '
Delta' +
+ 'Agent ' + formatDurationDelta(c.agent_duration_ms - baseline.agent_duration_ms) +
+ ' / ' + formatIntegerDelta(c.agent_tokens - baseline.agent_tokens) + ' tokens';
+ }
+ return html;
+ }
+
+ function compactMetric(label, c) {
+ var agentTitle = "Input: " + fmtInteger(c.input_tokens) + ", Output: " + fmtInteger(c.output_tokens);
+ var html = '
' + label + '' +
+ '·Agent ' +
+ c.agent_duration + ' / ' + fmtInteger(c.agent_tokens) + ' tokens';
+ if (c.judge_duration_ms || c.judge_tokens) {
+ var judgeTitle = "Input: " + fmtInteger(c.judge_input_tokens) + ", Output: " + fmtInteger(c.judge_output_tokens);
+ html += '·Judge ' +
+ c.judge_duration + ' / ' + fmtInteger(c.judge_tokens) + ' tokens';
+ }
+ return html + '';
+ }
+
+ function fmtInteger(value) {
+ return Number(value || 0).toLocaleString("en-US");
+ }
+
+ function formatIntegerDelta(value) {
+ return (value > 0 ? "+" : "") + fmtInteger(value);
+ }
+
+ function formatDurationDelta(valueMs) {
+ var seconds = valueMs / 1000.0;
+ return (seconds > 0 ? "+" : "") + seconds.toFixed(1) + "s";
+ }
+
function renderResponse(c) {
var section = document.getElementById("response-section");
var body = document.getElementById("response-body");
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
index 84cb9d9..56df1ff 100644
--- a/internal/runner/runner.go
+++ b/internal/runner/runner.go
@@ -511,13 +511,16 @@ func buildReportInput(skillName string, grouped map[string]*caseResults, caseIDs
caseResult := evalResultToCaseResult(cr.withSkill)
input.CaseResults = append(input.CaseResults, caseResult)
input.TotalTokens += caseResult.InputTokens + caseResult.OutputTokens
+ input.JudgeTokens += caseResult.JudgeInputTokens + caseResult.JudgeOutputTokens
}
if cr.withoutSkill != nil {
caseResult := evalResultToCaseResult(cr.withoutSkill)
input.CaseResults = append(input.CaseResults, caseResult)
input.TotalTokens += caseResult.InputTokens + caseResult.OutputTokens
+ input.JudgeTokens += caseResult.JudgeInputTokens + caseResult.JudgeOutputTokens
}
}
+ input.OverallTokens = input.TotalTokens + input.JudgeTokens
return input
}
@@ -538,6 +541,11 @@ func evalResultToCaseResult(res *evaluator.EvalResult) report.CaseResult {
Response: responseContent(res),
TurnResults: evalTurnResultsToReport(res.TurnResults),
}
+ if res.Grading != nil && res.Grading.JudgeSession != nil {
+ cr.JudgeDurationMs = res.Grading.JudgeSession.DurationMs
+ cr.JudgeInputTokens = res.Grading.JudgeSession.InputTokens
+ cr.JudgeOutputTokens = res.Grading.JudgeSession.OutputTokens
+ }
if res.Error != nil {
cr.Error = res.Error.Error()
}
diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go
index affec27..b8dbd0c 100644
--- a/internal/runner/runner_test.go
+++ b/internal/runner/runner_test.go
@@ -433,10 +433,16 @@ func TestBuildReportInput_TokenAccumulation(t *testing.T) {
withSkill: &evaluator.EvalResult{
CaseID: "c1", Status: judge.StatusPass, Configuration: "with_skill",
SessionResult: &agent.SessionResult{InputTokens: 100, OutputTokens: 50},
+ Grading: &judge.Result{JudgeSession: &agent.SessionResult{
+ DurationMs: 2000, InputTokens: 30, OutputTokens: 10,
+ }},
},
withoutSkill: &evaluator.EvalResult{
CaseID: "c1", Status: judge.StatusPass, Configuration: "without_skill",
SessionResult: &agent.SessionResult{InputTokens: 80, OutputTokens: 40},
+ Grading: &judge.Result{JudgeSession: &agent.SessionResult{
+ DurationMs: 1500, InputTokens: 20, OutputTokens: 5,
+ }},
},
},
"c2": {
@@ -453,9 +459,18 @@ func TestBuildReportInput_TokenAccumulation(t *testing.T) {
if input.TotalTokens != 570 {
t.Errorf("TotalTokens = %d, want 570", input.TotalTokens)
}
+ if input.JudgeTokens != 65 {
+ t.Errorf("JudgeTokens = %d, want 65", input.JudgeTokens)
+ }
+ if input.OverallTokens != 635 {
+ t.Errorf("OverallTokens = %d, want 635", input.OverallTokens)
+ }
if len(input.CaseResults) != 3 {
t.Errorf("CaseResults count = %d, want 3", len(input.CaseResults))
}
+ if got := input.CaseResults[0]; got.JudgeDurationMs != 2000 || got.JudgeInputTokens != 30 || got.JudgeOutputTokens != 10 {
+ t.Errorf("with-skill judge metrics = %#v", got)
+ }
}
func TestRunner_WriteResults_NoWorkspace(t *testing.T) {