-
Notifications
You must be signed in to change notification settings - Fork 221
feature: local-usage-stats (3/4) #1133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
myk1yt
wants to merge
24
commits into
Zoo-Code-Org:main
Choose a base branch
from
myk1yt:pr/b15-usage-capture-v2
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
4ed65de
feat(stats): define usage event and message contracts
k1yt 98410d3
feat(stats): add append-only local usage store and aggregation
k1yt 9e0b062
feat(stats): record final usage for each API attempt
k1yt 4672f5a
fix(types): prefix unused destructured vars with underscore in usage-…
1ed85e5
fix: add Task.usage-stats.spec.ts to eslint-suppressions for no-expli…
eac60bd
feat(usage): add usage aggregation service
a8fac1f
feat(usage): add costRecalculation module and tests from B15 source
a6e6332
fix(types): remove non-existent task-organization export from index.ts
f079b9b
fix(types): replace any with proper typed casts in Task.usage-stats.s…
b4fa68f
fix(ci): strip BOM from costRecalculation files and fix qwen-code pri…
3cd77c3
fix(ci): prune stale eslint suppressions after rebase onto b13
640aadb
feat(usage): add usage aggregation service
7959db7
feat(stats): add usage capture — provider deltas, Task finalization, …
48b836f
fix(types): resolve all TS errors from B15 cherry-pick - cast any to …
a45cce0
fix(stats): restore base behaviors clobbered by B15 cherry-pick
ea143f7
fix(types): remove non-existent task-organization export from index.ts
34b2778
fix(stats): add rootTaskId and endpoint to CSV export columns
13d18d7
fix(stats): add rootTaskId to UsageEventV1 schema for CSV export
97e337b
Merge branch 'pr/b14-usage-aggregation-v2' into pr/b15-usage-capture-v2
cee03a5
fix(stats): extract endpoint domain for MiMo provider
0b02cbe
chore: remove temp file progress.txt
0e51311
Merge branch 'main' into pr/b15-usage-capture-v2
myk1yt 75fbbe5
chore: remove temporary docs and scripts from PR diff
94d3a26
test(e2e): add usage capture suite
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| import * as assert from "assert" | ||
| import * as fs from "fs/promises" | ||
| import * as path from "path" | ||
|
|
||
| import * as vscode from "vscode" | ||
|
|
||
| import { UsageEventV1 } from "@roo-code/types" | ||
|
|
||
| import { setDefaultSuiteTimeout } from "./test-utils" | ||
| import { waitFor, waitUntilCompleted } from "./utils" | ||
|
|
||
| /** | ||
| * E2E coverage for the Token Usage Capture hooks (PR #1133). | ||
| * | ||
| * The extension wires a UsageRecorder into every Task. When an API attempt | ||
| * reaches its terminal finalize boundary (completed / failed / cancelled), | ||
| * the recorder appends a UsageEventV1 to the on-disk NDJSON store under | ||
| * `<globalStorage>/usage-stats/events-*.ndjson`. | ||
| * | ||
| * These tests run a real task against the aimock-backed OpenRouter endpoint, | ||
| * then read the segment files directly to assert that: | ||
| * - a usage event was captured for the completed API call | ||
| * - the capture hook recorded provider/model/token fields correctly | ||
| * - idempotency keys are unique across events (no double-recording) | ||
| */ | ||
|
|
||
| type UsageEvent = typeof UsageEventV1._output | ||
|
|
||
| const USAGE_STATS_DIRNAME = "usage-stats" | ||
| const SEGMENT_PREFIX = "events-" | ||
| const SEGMENT_EXT = ".ndjson" | ||
|
|
||
| /** | ||
| * Read every usage event from the store's segment files. | ||
| * Corrupt or unparseable lines are skipped — the store itself quarantines | ||
| * them, so the test should not fail on them. | ||
| */ | ||
| const readAllUsageEvents = async (statsDir: string): Promise<UsageEvent[]> => { | ||
| let files: string[] | ||
|
|
||
| try { | ||
| files = await fs.readdir(statsDir) | ||
| } catch { | ||
| // Store directory does not exist yet — no events recorded. | ||
| return [] | ||
| } | ||
|
|
||
| const segmentFiles = files.filter((f) => f.startsWith(SEGMENT_PREFIX) && f.endsWith(SEGMENT_EXT)) | ||
| const events: UsageEvent[] = [] | ||
|
|
||
| for (const file of segmentFiles) { | ||
| const content = await fs.readFile(path.join(statsDir, file), "utf-8") | ||
|
|
||
| for (const line of content.split("\n")) { | ||
| const trimmed = line.trim() | ||
| if (!trimmed) continue | ||
|
|
||
| try { | ||
| const parsed = UsageEventV1.safeParse(JSON.parse(trimmed)) | ||
| if (parsed.success) { | ||
| events.push(parsed.data) | ||
| } | ||
| } catch { | ||
| // skip corrupt line | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return events | ||
| } | ||
|
|
||
| suite("Roo Code Usage Capture", function () { | ||
| setDefaultSuiteTimeout(this) | ||
|
|
||
| let statsDir: string | ||
|
|
||
| suiteSetup(async function () { | ||
| // The extension writes usage events to | ||
| // <userData>/User/globalStorage/ZooCodeOrganization.zoo-code/usage-stats/ | ||
| // (ExtensionContext.globalStorageUri.fsPath + "usage-stats"). | ||
| // | ||
| // The test runner (@vscode/test-electron) launches VS Code with its | ||
| // default --user-data-dir at <repoRoot>/.vscode-test/user-data unless | ||
| // overridden. We resolve the directory by probing known candidates and | ||
| // picking the first one that exists after the first task run; before | ||
| // any event is written the directory may not exist yet, so the probe | ||
| // falls back to the test-electron default. | ||
| const extension = vscode.extensions.getExtension("ZooCodeOrganization.zoo-code") | ||
| assert.ok(extension, "Extension not found") | ||
|
|
||
| const repoRoot = path.resolve(__dirname, "..", "..", "..") | ||
| const candidates = [ | ||
| process.env.VSCODE_TEST_USER_DATA_DIR && | ||
| path.join( | ||
| process.env.VSCODE_TEST_USER_DATA_DIR, | ||
| "User", | ||
| "globalStorage", | ||
| "ZooCodeOrganization.zoo-code", | ||
| USAGE_STATS_DIRNAME, | ||
| ), | ||
| path.join( | ||
| repoRoot, | ||
| ".vscode-test", | ||
| "user-data", | ||
| "User", | ||
| "globalStorage", | ||
| "ZooCodeOrganization.zoo-code", | ||
| USAGE_STATS_DIRNAME, | ||
| ), | ||
| ].filter((c): c is string => !!c) | ||
|
|
||
| for (const candidate of candidates) { | ||
| try { | ||
| await fs.access(candidate) | ||
| statsDir = candidate | ||
| return | ||
| } catch { | ||
| // try next candidate | ||
| } | ||
| } | ||
|
|
||
| // Nothing written yet — use the test-electron default; the directory | ||
| // will be created by UsageEventStore on first append. | ||
| const fallback = candidates[candidates.length - 1] | ||
| assert.ok(fallback, "At least one globalStorage candidate must be resolvable") | ||
| statsDir = fallback | ||
| }) | ||
|
|
||
| test("captures a usage event when an API call completes", async () => { | ||
| const api = globalThis.api | ||
|
|
||
| // Snapshot pre-existing event ids so we only assert on events this | ||
| // test run created (the store persists across test runs). | ||
| const preExistingIds = new Set((await readAllUsageEvents(statsDir)).map((e) => e.eventId)) | ||
|
|
||
| const taskId = await waitUntilCompleted({ | ||
| api, | ||
| start: () => | ||
| api.startNewTask({ | ||
| configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, | ||
| text: "USAGE_CAPTURE_SMOKE: what is your name?", | ||
| }), | ||
| }) | ||
|
|
||
| // finalizeUsageEvent is fire-and-forget from the task's perspective, so | ||
| // poll the store until the event for this task appears. | ||
| let eventsForTask: UsageEvent[] = [] | ||
|
|
||
| await waitFor(async () => { | ||
| const all = await readAllUsageEvents(statsDir) | ||
| eventsForTask = all.filter((e) => !preExistingIds.has(e.eventId) && e.taskId === taskId) | ||
| return eventsForTask.length > 0 | ||
| }) | ||
|
|
||
| const completed = eventsForTask.find((e) => e.status === "completed") | ||
| assert.ok(completed, `A completed usage event should be recorded for task ${taskId}`) | ||
|
|
||
| // The capture hook must record provider/model/mode provenance. | ||
| assert.strictEqual(completed.provider, "openrouter", "Provider should match the configured apiProvider") | ||
| assert.ok(completed.model.length > 0, "Model id should be recorded") | ||
| assert.strictEqual(completed.mode, "ask", "Mode should match the task mode") | ||
|
|
||
| // Token usage captured from the aimock usage payload. | ||
| assert.ok( | ||
| (completed.usage.inputTokens?.value ?? 0) > 0, | ||
| "Input tokens should be captured from the API usage payload", | ||
| ) | ||
| assert.ok( | ||
| (completed.usage.outputTokens?.value ?? 0) > 0, | ||
| "Output tokens should be captured from the API usage payload", | ||
| ) | ||
| assert.strictEqual(completed.usage.inputTokens?.source, "provider") | ||
| assert.strictEqual(completed.usage.outputTokens?.source, "provider") | ||
|
|
||
| // Provenance + schema version invariants. | ||
| assert.strictEqual(completed.schemaVersion, 1) | ||
| assert.strictEqual(completed.provenance, "live") | ||
| assert.ok(completed.idempotencyKey.startsWith(`${taskId}:`), "Idempotency key should embed the taskId") | ||
| }) | ||
|
|
||
| test("capture hook fires for each task and never double-records", async () => { | ||
| const api = globalThis.api | ||
|
|
||
| const preExistingIds = new Set((await readAllUsageEvents(statsDir)).map((e) => e.eventId)) | ||
|
|
||
| const taskId = await waitUntilCompleted({ | ||
| api, | ||
| start: () => | ||
| api.startNewTask({ | ||
| configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, | ||
| text: "USAGE_CAPTURE_HOOK_2: what is your name?", | ||
| }), | ||
| }) | ||
|
|
||
| let eventsForTask: UsageEvent[] = [] | ||
|
|
||
| await waitFor(async () => { | ||
| const all = await readAllUsageEvents(statsDir) | ||
| eventsForTask = all.filter((e) => !preExistingIds.has(e.eventId) && e.taskId === taskId) | ||
| return eventsForTask.length > 0 | ||
| }) | ||
|
|
||
| // Hook fired for this task too. | ||
| assert.ok( | ||
| eventsForTask.some((e) => e.status === "completed"), | ||
| "Usage capture hook should fire for every completed task", | ||
| ) | ||
|
|
||
| // Idempotency: no two events anywhere in the store may share an | ||
| // idempotencyKey — the recorder dedupes on requestKey:status. | ||
| const all = await readAllUsageEvents(statsDir) | ||
| const keys = all.map((e) => e.idempotencyKey) | ||
| assert.strictEqual( | ||
| new Set(keys).size, | ||
| keys.length, | ||
| "Idempotency keys must be unique (no double-recorded usage events)", | ||
| ) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Scope the idempotency assertion to this test's records.
The store persists across test runs, but this assertion checks every historical event. A duplicate from an earlier run can fail this test without involving
taskId. Filter to records created for this task before validating idempotency.🤖 Prompt for AI Agents