Skip to content

Record which agent produced each history row - #93

Merged
ndr-ds merged 7 commits into
mainfrom
ndr-ds/agentctl-history-provider
Sep 1, 2026
Merged

ndr-ds merged 7 commits into
mainfrom
ndr-ds/agentctl-history-provider

Conversation

@ndr-ds

@ndr-ds ndr-ds commented Aug 31, 2026 •

Copy link
Copy Markdown

history.csv recorded eight columns with no notion of which agent produced a session, so --history and --stats could not tell a Claude session's cost from a Codex one. This adds provider as a persisted dimension.

Widening this file is the hazard, not the feature

The old reader was line.splitn(8, ',') with if fields.len() < 8 { continue }, and the header is written only when the file does not exist — so an upgraded install keeps its 8-column header forever while new rows carry nine. Any header-driven scheme is therefore out, and both naive directions corrupt data:

  • a reader requiring nine fields silently drops every historical row;
  • splitn(8) on a nine-field row leaves "0.0100,Codex" in the cost field, which parse().unwrap_or(0.0) turns into $0.00 — silent cost loss, no error.

project and model already have commas replaced with ; at write time — since the commit that created this file — so field counts are unambiguous and per-row detection is safe. The reader splits fully and requires ≥ 8 fields.

The ninth field is treated three ways, because "absent" and "unrecognised" are not the same fact:

ninth field reads as why
absent Claude every row predating the column is a Claude session by construction
a known label that product, normalised so codex / CODEX / Codex do not split a group
an unrecognised label kept verbatim attributing another product's spend to Claude is a lie about money

That last row is why SessionRecord::provider is a String rather than the enum: a build that predates a third product must still report its rows honestly rather than fold them into a product it happens to know.

The upshot is that a single file can hold both widths at once, which is exactly what a user's history looks like after upgrading.

Verified end to end

Against a fixture holding one old-width row and one new-width row:

Timestamp              PID     Agent    Project     Model        Duration    Input   Output    Cost
2026-08-31T10:00:00    111     Claude   alpha       claude-op...    1h00m     1.0k     2.0k    $1.2
2026-08-31T10:01:00    222     Codex    beta        gpt-5          30m00s      500      700   $0.40
2 sessions                                                          1h30m     1.5k     2.7k    $1.6

The pre-column row reads as Claude at its true $1.25, not $0.00.

What it surfaces

  • --history gains an Agent column.
  • --stats gains a per-agent cost/duration/session breakdown, sorted largest-cost first like the project table, and suppressed when only one agent appears so single-product users see no new noise.

Tests

Eleven, each mutation-checked — revert the guard and the matching test fails:

  • rows_of_either_width_survive_in_the_same_file — the upgrade case. Restoring splitn(8) fails it on the corrupted cost.
  • an_unknown_provider_label_keeps_the_row — an unrecognised label keeps both the cost and the label. Collapsing it to Claude fails it.
  • a_known_label_is_normalised_for_grouping — codex, CODEX, Codex all group as one.
  • a_header_line_is_never_data_wherever_it_appears — covers both the 8- and 9-column header forms.
  • a_short_row_is_rejected — truncated rows are still refused.
  • a_written_row_round_trips_through_the_parser — for every product in AgentProvider::all().
  • the_header_and_a_row_have_the_same_width — dropping the column from the row fails both this and the round-trip.
  • every_provider_label_round_trips — label and from_label stay inverse.
  • truncate_does_not_panic_on_a_multibyte_name — restoring the byte slice reproduces the crash.
  • truncate_strips_control_characters — an ESC must not survive into the terminal.

format_row is extracted from the writer purely so the round-trip can be tested without mutating HOME; HEADER is a constant beside it so the two cannot drift. AgentProvider::from_label is derived from all() + label(), so adding a third product cannot leave the inverse behind.

cargo clippy --all-targets -- -D warnings clean; 807 tests pass.

A second bug, found while auditing row shapes

record_session computes needs_header from !path.exists() before opening the file, with no lock, so two processes ending a session at the same moment both write a header — the second lands mid-file. The reader only skipped a header at line 0, so any later one parsed as data: cost_usd fails to parse, unwrap_or(0.0) makes it zero, and a phantom $0.00 session inflates every count in --history and --stats.

Pre-existing, but it lives in the function this PR rewrites, so it is fixed here: parse_record rejects a header line wherever it appears, which also removes the need for the i == 0 special case.

Both fixes verified together against a file holding a pre-column row, a Codex row, an unknown-product row and a trailing header — --stats reports 3 sessions, not 4, and books $0.90 to Gemini rather than to Claude.

A crash found while probing the new column

truncate bounded by bytes — &s[..max - 3] — so agentctl --history panicked outright on any non-ASCII project directory name:

panicked at src/history.rs:451:28:
end byte index 17 is not a char boundary; it is inside '語'

Pre-existing and reachable with a real directory name. It counts characters now, and also strips control characters, because these values come off disk and go to a terminal: a provider label containing ESC[31m previously reached the terminal raw. All three --stats breakdown loops and the --history Agent column route their label through it — before this, only per-project did.

Verified through the built binary with escapes planted in both the provider and model fields: zero raw ESC bytes in the output, columns intact.

Known limitations, stated rather than hidden

Two display-only issues I deliberately did not fix, both needing a label no writer produces:

  • A label longer than its column truncates to the same text as another sharing its prefix, so two rows can look identical while counting separately. Every number stays correct; only the labels are ambiguous. Disambiguating means widening the column or inventing a scheme, both larger than the problem.
  • CJK labels misalign, because Rust's {:<25} counts characters while those glyphs take two terminal columns. Fixing it needs unicode-width as a direct dependency — it is transitive only today — which belongs in its own change, not a migration PR.

An older binary reading a file this one has written will still parse cost,provider into the cost field and report $0.00 for those rows. That is inherent to positional CSV and cannot be fixed from this side. It requires a downgrade after new rows exist; the data is not lost, and re-upgrading reads it correctly. Worth knowing before rolling back.

@ndr-ds
ndr-ds marked this pull request as ready for review September 1, 2026 22:48
@ndr-ds
ndr-ds merged commit 84fae8f into main Sep 1, 2026
5 checks passed
@ndr-ds
ndr-ds deleted the ndr-ds/agentctl-history-provider branch September 1, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant