Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f149073
[Refactor] Add shared test utilities and pilot refactors (#1171)
zoomote[bot] Aug 6, 2026
81867c5
feat(stats): define usage event and message contracts
k1yt Jul 18, 2026
7303140
feat(stats): add append-only local usage store and aggregation
k1yt Jul 18, 2026
9c1c86f
feat(stats): record final usage for each API attempt
k1yt Jul 18, 2026
5dc9ac9
fix(types): prefix unused destructured vars with underscore in usage-…
Aug 2, 2026
2565e40
fix: add Task.usage-stats.spec.ts to eslint-suppressions for no-expli…
Aug 2, 2026
4507d71
test(b13): add UsageStatsService tests and error path coverage for co…
Aug 5, 2026
81e605a
feat: add usage statistics event store and contracts
Aug 4, 2026
0007a95
feat: add usage aggregation, cost recalculation, and service
Aug 4, 2026
8360d73
fix(lint): prune stale eslint suppressions from squash merge conflict…
Aug 4, 2026
791585c
chore: make codecov/patch informational to unblock PRs
Aug 4, 2026
df4b418
fix: prune stale eslint-suppressions.json entries
Aug 6, 2026
1446670
chore: remove temp scripts and files from CI fix sessions
Aug 6, 2026
2a12f87
chore: remove temp metadata files from scripts/
Aug 6, 2026
3e88d47
refactor(cli): canonicalize provider identifiers (#1110)
WebMad Aug 7, 2026
d33e40d
[Refactor] Reuse shared API options in provider tests (#1178)
zoomote[bot] Aug 7, 2026
26d9076
Merge branch 'main' into pr/b14-usage-aggregation-v2
myk1yt Aug 7, 2026
4cd7eee
fix: prune unused eslint suppressions
Aug 7, 2026
263126f
chore: remove temporary docs and scripts from PR diff
Aug 7, 2026
3f09898
test(e2e): add usage aggregation suite
Aug 8, 2026
783da6b
fix(test): add aimock fixture for usage-aggregation e2e (PR #1131)
Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,10 @@ qdrant_storage/
plans/

roo-cli-*.tar.gz*

# Session reports and temp artifacts
docs/26*/
coverage-json/
scripts/fix_*.py
scripts/resolve_*.py
scripts/insert_*.py
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ Prefer the narrowest test layer that proves the behavior. This follows standard
- Use `apps/vscode-e2e` only when the behavior depends on the real VS Code extension host, VS Code workspace APIs, extension activation, webview/extension messaging, file watcher behavior, or a complete user workflow.
- Keep e2e tests focused on high-value smoke coverage across boundaries. Avoid placing detailed protocol, parsing, storage, retry, or edge-case assertions in e2e when they can be covered reliably at a lower layer.
- When fixing a regression, add the regression test at the lowest layer that would have failed for the bug. Add an e2e test only if lower-level tests cannot represent the failure mode.

## Shared Test Utilities

- Use `src/test-utils/stream.ts` for mechanical async-stream setup and collection.
- Use the typed helpers in `src/test-utils/api.ts`, `src/test-utils/fs.ts`, `src/test-utils/reset.ts`, and `src/test-utils/vscode.ts` when they remove repeated setup without hiding the scenario.
- Keep provider-specific payloads, failure streams, and assertions inline when they explain the behavior under test.
- Prefer shared helpers for mechanical duplication; use fixtures only when setup is reusable, typed, and independently disposable.
- New helpers must preserve failure clarity, return fresh objects, and avoid `as any`; keep unavoidable VS Code structural casts inside the helper with a brief explanation.
134 changes: 115 additions & 19 deletions apps/cli/src/commands/cli/__tests__/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,45 @@
import fs from "fs"
import os from "os"
import path from "path"
import { EventEmitter } from "events"

import { openRouterDefaultModelId, providerIdentifiers } from "@roo-code/types"

import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
import { isRecord } from "@/lib/utils/guards.js"

import { listSessions, parseFormat } from "../list.js"
import { listModels, listSessions, parseFormat } from "../list.js"

const extensionHostMock = vi.hoisted(() => ({
activate: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined),
options: [] as unknown[],
responses: [] as unknown[],
sendToExtension: vi.fn(),
}))

vi.mock("@/agent/index.js", () => ({
ExtensionHost: class extends EventEmitter {
client = {
isInitialized: () => true,
on: vi.fn(() => () => undefined),
}

constructor(options: unknown) {
super()
extensionHostMock.options.push(options)
}

activate = extensionHostMock.activate
dispose = extensionHostMock.dispose

sendToExtension(message: unknown): void {
extensionHostMock.sendToExtension(message)
for (const response of extensionHostMock.responses) {
this.emit("extensionWebviewMessage", response)
}
}
},
}))

vi.mock("@/lib/task-history/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/task-history/index.js")>()
Expand Down Expand Up @@ -39,30 +77,88 @@ describe("parseFormat", () => {
})
})

describe("router model extraction", () => {
// This mirrors the extraction logic in requestOpenRouterModels (list.ts:226-228)
const extractOpenRouterModels = (routerModelsRaw: unknown) => {
const routerModels = isRecord(routerModelsRaw) ? routerModelsRaw : {}
const openRouterModels = routerModels.openrouter
return isRecord(openRouterModels) ? openRouterModels : {}
}
describe("listModels", () => {
let tempDir: string
let workspacePath: string
let extensionPath: string

it("extracts openrouter models from valid routerModels", () => {
const models = { "openai/gpt-4.1": { contextWindow: 128000, supportsPromptCache: false } }
const result = extractOpenRouterModels({ openrouter: models })
expect(result).toEqual(models)
beforeEach(() => {
vi.clearAllMocks()
extensionHostMock.options.length = 0
extensionHostMock.responses.length = 0

tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-list-test-"))
workspacePath = path.join(tempDir, "workspace")
extensionPath = path.join(tempDir, "extension")
fs.mkdirSync(workspacePath)
fs.mkdirSync(extensionPath)
fs.writeFileSync(path.join(extensionPath, "extension.js"), "")
})

it("returns empty object when routerModels is null", () => {
expect(extractOpenRouterModels(null)).toEqual({})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
vi.restoreAllMocks()
})

it("returns empty object when openrouter key is missing", () => {
expect(extractOpenRouterModels({ requesty: {} })).toEqual({})
const captureStdout = async (fn: () => Promise<void>): Promise<string> => {
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
await fn()
return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")
}

it("creates a host with resolved paths and returns OpenRouter models", async () => {
const models = { "openai/gpt-4.1": { contextWindow: 128000, supportsPromptCache: false } }
extensionHostMock.responses.push(
{ type: "unrelatedMessage" },
{ type: "routerModels", routerModels: { [providerIdentifiers.openrouter]: models } },
)

const output = await captureStdout(() =>
listModels({
format: "json",
workspace: path.relative(process.cwd(), workspacePath),
extension: path.relative(process.cwd(), extensionPath),
apiKey: "test-api-key",
debug: true,
}),
)

expect(extensionHostMock.options).toEqual([
expect.objectContaining({
mode: "code",
provider: providerIdentifiers.openrouter,
model: openRouterDefaultModelId,
apiKey: "test-api-key",
workspacePath,
extensionPath,
nonInteractive: true,
ephemeral: true,
debug: true,
exitOnComplete: true,
exitOnError: false,
disableOutput: true,
}),
])
expect(extensionHostMock.activate).toHaveBeenCalledOnce()
expect(extensionHostMock.sendToExtension).toHaveBeenCalledWith({
type: "requestRouterModels",
values: { provider: providerIdentifiers.openrouter },
})
expect(extensionHostMock.dispose).toHaveBeenCalledOnce()
expect(JSON.parse(output)).toEqual({ models })
})

it("returns empty object when openrouter value is not a record", () => {
expect(extractOpenRouterModels({ openrouter: "invalid" })).toEqual({})
it.each([
["a malformed routerModels value", null],
["a malformed OpenRouter value", { [providerIdentifiers.openrouter]: "invalid" }],
])("returns an empty model record for %s", async (_description, routerModels) => {
extensionHostMock.responses.push({ type: "routerModels", routerModels })

const output = await captureStdout(() =>
listModels({ format: "json", workspace: workspacePath, extension: extensionPath }),
)

expect(JSON.parse(output)).toEqual({ models: {} })
})
})

Expand Down
146 changes: 146 additions & 0 deletions apps/cli/src/commands/cli/__tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,152 @@ import fs from "fs"
import path from "path"
import os from "os"

import { providerIdentifiers } from "@roo-code/types"
import { DEFAULT_FLAGS, FlagOptions } from "@/types/index.js"
import {
resolveLegacyRequireApproval,
resolveModel,
resolveProvider,
resolveReasoningEffort,
resolveWorkspacePath,
run,
} from "../run.js"

const runCommandMocks = vi.hoisted(() => ({
activate: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined),
loadSettings: vi.fn(),
options: [] as unknown[],
runTask: vi.fn(async () => undefined),
}))

vi.mock("@/lib/storage/index.js", () => ({
loadSettings: runCommandMocks.loadSettings,
}))

vi.mock("@/agent/index.js", () => ({
ExtensionHost: class {
client = {}

constructor(options: unknown) {
runCommandMocks.options.push(options)
}

activate = runCommandMocks.activate
dispose = runCommandMocks.dispose
runTask = runCommandMocks.runTask
},
}))

describe("resolveModel", () => {
it("uses the CLI flag before the settings model", () => {
expect(resolveModel("flag-model", "settings-model")).toBe("flag-model")
})

it("uses the settings model when the CLI flag is absent", () => {
expect(resolveModel(undefined, "settings-model")).toBe("settings-model")
})

it("uses the default model when neither the CLI flag nor settings provide one", () => {
expect(resolveModel()).toBe(DEFAULT_FLAGS.model)
})
})

describe("resolveReasoningEffort", () => {
it("uses CLI, settings, and default values in priority order", () => {
expect(resolveReasoningEffort("high", "low")).toBe("high")
expect(resolveReasoningEffort(undefined, "low")).toBe("low")
expect(resolveReasoningEffort()).toBe(DEFAULT_FLAGS.reasoningEffort)
})
})

describe("resolveProvider", () => {
it("uses CLI, settings, and openrouter values in priority order", () => {
expect(resolveProvider(providerIdentifiers.anthropic, providerIdentifiers.gemini)).toBe(
providerIdentifiers.anthropic,
)
expect(resolveProvider(undefined, providerIdentifiers.gemini)).toBe(providerIdentifiers.gemini)
expect(resolveProvider()).toBe(providerIdentifiers.openrouter)
})
})

describe("resolveWorkspacePath", () => {
it("resolves the provided workspace path", () => {
expect(resolveWorkspacePath("relative/workspace")).toBe(path.resolve("relative/workspace"))
})

it("uses the current working directory when workspace is absent", () => {
expect(resolveWorkspacePath()).toBe(process.cwd())
})
})

describe("resolveLegacyRequireApproval", () => {
it.each([
{ requireApproval: true, dangerouslySkipPermissions: true, expected: true },
{ requireApproval: false, dangerouslySkipPermissions: false, expected: false },
{ requireApproval: undefined, dangerouslySkipPermissions: false, expected: true },
{ requireApproval: undefined, dangerouslySkipPermissions: true, expected: false },
{ requireApproval: undefined, dangerouslySkipPermissions: undefined, expected: undefined },
])(
"resolves requireApproval=$requireApproval and dangerouslySkipPermissions=$dangerouslySkipPermissions",
({ requireApproval, dangerouslySkipPermissions, expected }) => {
expect(resolveLegacyRequireApproval(requireApproval, dangerouslySkipPermissions)).toBe(expected)
},
)
})

describe("run command option resolution", () => {
let workspacePath: string

beforeEach(() => {
vi.clearAllMocks()
runCommandMocks.options.length = 0
workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "roo-run-test-"))
})

afterEach(() => {
fs.rmSync(workspacePath, { recursive: true, force: true })
vi.restoreAllMocks()
})

it("passes resolved settings and workspace values to the extension host", async () => {
runCommandMocks.loadSettings.mockResolvedValue({
model: "settings-model",
reasoningEffort: "high",
provider: providerIdentifiers.anthropic,
dangerouslySkipPermissions: false,
})
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never)
const flags: FlagOptions = {
continue: false,
workspace: path.relative(process.cwd(), workspacePath),
print: true,
stdinPromptStream: false,
signalOnlyExit: false,
debug: false,
requireApproval: false,
exitOnError: false,
apiKey: "test-api-key",
ephemeral: true,
oneshot: false,
}

await run("test prompt", flags)

expect(runCommandMocks.options).toEqual([
expect.objectContaining({
model: "settings-model",
reasoningEffort: "high",
provider: providerIdentifiers.anthropic,
workspacePath,
nonInteractive: false,
}),
])
expect(runCommandMocks.runTask).toHaveBeenCalledWith("test prompt", undefined)
expect(exitSpy).toHaveBeenCalledWith(0)
})
})

describe("run command --prompt-file option", () => {
let tempDir: string
let promptFilePath: string
Expand Down
10 changes: 5 additions & 5 deletions apps/cli/src/commands/cli/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import pWaitFor from "p-wait-for"

import type { TaskSessionEntry } from "@roo-code/core/cli"
import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types"
import { openRouterDefaultModelId } from "@roo-code/types"
import { openRouterDefaultModelId, providerIdentifiers } from "@roo-code/types"

import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js"
import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
Expand Down Expand Up @@ -105,13 +105,13 @@ function outputSessionsText(sessions: SessionLike[]): void {
async function createListHost(options: BaseListOptions, hostOptions: ListHostOptions): Promise<ExtensionHost> {
const workspacePath = resolveWorkspacePath(options.workspace)
const extensionPath = resolveExtensionPath(options.extension)
const apiKey = options.apiKey || getApiKeyFromEnv("openrouter")
const apiKey = options.apiKey || getApiKeyFromEnv(providerIdentifiers.openrouter)

const extensionHostOptions: ExtensionHostOptions = {
mode: "code",
reasoningEffort: undefined,
user: null,
provider: "openrouter",
provider: providerIdentifiers.openrouter,
model: openRouterDefaultModelId,
apiKey,
workspacePath,
Expand Down Expand Up @@ -217,14 +217,14 @@ function requestModes(host: ExtensionHost): Promise<ModeLike[]> {
function requestOpenRouterModels(host: ExtensionHost): Promise<ModelRecord> {
return requestFromExtension(
host,
{ type: "requestRouterModels", values: { provider: "openrouter" } },
{ type: "requestRouterModels", values: { provider: providerIdentifiers.openrouter } },
(message) => {
if (message.type !== "routerModels") {
return undefined
}

const routerModels = isRecord(message.routerModels) ? message.routerModels : {}
const openRouterModels = routerModels.openrouter
const openRouterModels = routerModels[providerIdentifiers.openrouter]
return isRecord(openRouterModels) ? (openRouterModels as ModelRecord) : {}
},
)
Expand Down
Loading
Loading