fix(copilot): classify CLI sessions by source provenance, not producer - #945
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes Copilot session parsing by classifying JSONL sources based on discovery provenance (CLI session-state vs VS Code transcript location) rather than sniffing session.start.producer, restoring correct session.shutdown rollup handling for Copilot CLI and GitHub Copilot desktop app sessions. It also improves attribution for newer CLI subagent lifecycle events and makes resumed-session shutdown accounting append-only via per-leg deltas.
Changes:
- Tag discovered Copilot sources as
sourceType: 'jsonl' | 'transcript'and pass an explicitisTranscriptflag into the JSONL parser; derive transcript session IDs from filename; seed (not gate) transcript model inference. - Emit per-leg shutdown deltas (keyed by occurrence) for resumed CLI sessions with cumulative shutdown rollups; handle counter resets; support
subagent.started/subagent.completedwith nested attribution. - Bump Copilot parse cache version and daily cache version to force one-time re-derivation; expand/adjust tests and update changelog.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/providers/copilot.ts | Provenance-based transcript vs CLI parsing; per-leg shutdown deltas; subagent lifecycle handling; transcript sessionId derivation. |
| src/session-cache.ts | Copilot parse-version bump to invalidate misparsed cached sessions. |
| src/daily-cache.ts | Daily cache version bump to re-derive finalized days under the new Copilot parsing behavior. |
| tests/providers/copilot.test.ts | Adds regression + subagent + transcript-path + resumed-session delta coverage. |
| tests/parser.test.ts | Integration test for durable merge behavior when a resumed CLI session file grows between parses. |
| CHANGELOG.md | Documents the fixed Copilot CLI token/cache accounting and subagent attribution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Attaching some real session files I captured while working on this — figured they'd be useful since #684's notes mention it was written without a complete real event file. All four CLI-store sessions were created just for this (throwaway marker prompts, nothing sensitive, no redaction needed), so they're byte-faithful examples of each format. README inside has expected parse numbers and a one-command repro. What's Included
Pre-fix these parse to 0 input / 0 cache / 0.0% hit rate with the app session missing; with this PR they match the README tables exactly. |
Review feedback on getagentseal#945: the fallback relied on splice's negative-index clamping to no-op on an empty stack. An explicit branch with pop() reads as intended without knowing that semantics detail. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ozymandiashh
left a comment
There was a problem hiding this comment.
Blocking changes required on this exact head:
-
Both commits contain the prohibited
Co-Authored-By: Claude Fable 5trailer. CONTRIBUTING.md explicitly bans Claude/Anthropic co-author trailers, and the repository workflow scans every PR commit. Please rewrite both commits to remove the trailers and rerun current CI. -
In
src/providers/copilot.ts:799-812, a completion with a non-empty but unmatchedtoolCallIdfalls through toactiveSubagents.pop(), removing an unrelated active run. Reproduction: startcall-A/explore, completecall-unknown, then emit an assistant message. Expected attribution remainsexplore; actual attribution is missing. Only a genuinely ID-less legacy completion may use stack fallback, if that behavior is intended. A non-empty unmatched ID should no-op, with a regression test.
The core #944 provenance/delta/cache work otherwise looked sound. Targeted tests (97/97), typecheck, CLI build, and diff check passed locally, but this head has no remote check runs.
The Copilot CLI writes the same session.start producer ('copilot-agent')
that VS Code transcripts carry, so the parser's content-based detection
classified every CLI session as a transcript and skipped its
session.shutdown rollup — the only place the CLI records input,
cache-read and cache-write tokens. Cache hit rate read 0.0% and cost was
dramatically underreported. The GitHub Copilot desktop app writes the
same session store, so its usage is equally affected — and sessions
whose toolCallIds match no known prefix (e.g. kimi models) were dropped
entirely by transcript model inference.
Whether a JSONL file is a transcript is now decided at discovery time
from where the file was found (~/.copilot/session-state vs
workspaceStorage GitHub.copilot-chat/transcripts): transcript sources
carry sourceType 'transcript' and createJsonlParser takes isTranscript
as an explicit parameter, with the producer sniff removed. Transcript
model inference seeds the model instead of gating the file, so explicit
model info still parses. Field sniffing was not an option: real VS Code
transcripts carry copilotVersion too (0.46.2/0.60.0 observed), so the
issue's proposed producer+copilotVersion check misfires on them.
Resumed sessions append one CUMULATIVE shutdown rollup per leg; the
supplementary calls are emitted as per-leg deltas keyed by occurrence so
a growing file stays append-only under the durable union-by-key cache
merge, each leg on its own timestamp. Subagent runs (which land in the
parent rollup as their own model entry) are billed per model, and
delegated turns are attributed via the subagent.started/completed events
newer CLIs write in place of subagent.selected.
The copilot session cache takes a parse-version bump and the daily cache
bumps from v16 to v17 so days finalized under the misclassification
re-derive once; sourceless days carry forward as-is per v14 semantics.
Verified by A/B against main on real sessions from all three surfaces
(CLI, GitHub Copilot app, VS Code): the totals delta reconciles exactly
to the three sessions' shutdown rollups, and VS Code chatSessions
output is byte-identical. Regression tests are built from redacted real
CLI 1.0.78 sessions, including a 3-leg resumed session and a delegating
session.
Fixes getagentseal#944
Review feedback on getagentseal#945: the fallback relied on splice's negative-index clamping to no-op on an empty stack. An explicit branch with pop() reads as intended without knowing that semantics detail. No behavior change.
…letion Review feedback on getagentseal#945: a subagent.completed whose non-empty toolCallId matches no active run referred to a run the parser never saw start, but fell through to popping the most recent active run — stripping an unrelated subagent's attribution from its remaining turns. A non-empty unmatched id is now a no-op; only a genuinely ID-less completion (the transitional-CLI shape, keyed like subagent.selected) ends the most recent run.
69b8aca to
e61a7d3
Compare
|
Both blockers addressed on the new head (
Targeted tests 98/98 and typecheck green locally on this head. |
ozymandiashh
left a comment
There was a problem hiding this comment.
Re-reviewed exact head e61a7d3 after the history rewrite. The prohibited trailers are gone, the unmatched non-empty toolCallId now no-ops without evicting an unrelated active subagent, and the exact regression is covered. Targeted tests (189), typecheck, CLI build, and diff check pass locally. No remaining blocker found.
Fixes #944. Helps #943 (the local copilot cost estimate that credit tracking would reconcile against is now accurate; the credit feature itself is not implemented here).
Problem
The Copilot CLI writes the same
session.startproducer (copilot-agent) that VS Code transcripts carry, so the parser's content-based detection classified every CLI session as a transcript and skipped itssession.shutdownrollup — the only place CLI sessions record input, cache-read and cache-write tokens. Cache hit rate read 0.0% and cost was dramatically underreported.Two adjacent consequences confirmed on real data:
~/.copilot/session-state/store (client_name: github/autopilot), so its usage was equally broken — worse, app sessions using models whose toolCallIds match no known prefix (e.g. kimi) were dropped entirely by transcript model inference.subagent.started/subagent.completed, not thesubagent.selectedevent the parser handled.Why provenance, not field sniffing
The issue proposes checking
producer && !copilotVersion. Real VS Code transcripts carrycopilotVersiontoo — verified on three real transcripts (extension versions 0.46.2 and 0.60.0), so that check would misclassify actual transcripts as CLI sessions. CLI session-state files and VS Code transcripts live in disjoint locations, so discovery already knows the answer; the parser now uses it.Changes
src/providers/copilot.tssourceType: 'transcript'; CLI session-state keeps'jsonl'.createJsonlParsertakesisTranscriptas an explicit parameter; the producer sniff is removed.sessionIdderives from the file basename (transcripts/<id>.jsonl), so transcripts no longer collapse into one shared"transcripts"session/dedup namespace.--resumeleg appends a cumulative shutdown rollup. Supplementary calls are now emitted as per-leg deltas keyed by occurrence (copilot:<sid>:shutdown:<model>:<n>) — append-only under the durable union-by-key cache merge, each leg on its own timestamp. A cumulative total that goes backwards is treated as a counter reset (fresh delta baseline).subagent.started/completedare handled alongside the stickysubagent.selected; completion removes only its own run (matched bytoolCallId), so nested runs fall back to the outer label instead of wiping attribution. Delegated usage lands in the parent rollup as its own model entry and is billed at that model's rates.src/session-cache.ts— copilot parse-version bump (…-source-provenance-v1) so cached misparsed sessions re-parse once.src/daily-cache.ts—DAILY_CACHE_VERSION/MIN_SUPPORTED_VERSION16 → 17 so finalized daily rollups re-derive under the fix (v14 carry-forward semantics preserve sourceless days).Validation
tokenDetails; all subagent turns attributed, parent turns clean.parser.tsintegration test proving the durable merge appends only the new leg when a session file grows between parses.