Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
76d3837
feat: add model-level tool-call capability and policy resolution
Jul 26, 2026
c290d77
feat: wire MiMo provider controls and tighten argument normalization
Jul 26, 2026
b989fdd
feat: add ghost quarantine and max-one tool call enforcement
Jul 26, 2026
7fba2f0
feat: add tool-call policy telemetry events
Jul 26, 2026
307baa4
fix: resolve no-explicit-any lint errors in mimo and telemetry files
Jul 26, 2026
45b99c7
fix: preserve parallel behavior for known providers without explicit …
Jul 26, 2026
47deb83
fix: port NativeToolParseFailure infrastructure and clean error-inter…
Jul 30, 2026
5f21220
fix: port cleaned mimo.ts provider from backup to match spec types
Jul 30, 2026
e07abd9
fix(mimo): suppress parallel tool calls at provider stream level
Jul 31, 2026
1a9dfc2
fix(mimo): apply strict tool schemas via convertToolsForOpenAI()
myk1yt Aug 2, 2026
3cd9242
fix(mimo): pass openAiToolStrictMode setting to convertToolsForOpenAI
Aug 3, 2026
a43d4d9
fix(mimo): pass tools to convertToolsForOpenAI without extra strictMo…
Aug 3, 2026
4245e3f
fix(mimo): drop argument fragments of disguised parallel tool calls
Aug 3, 2026
280da35
fix: correct misleading error-interception comments in tool-call parser
Aug 3, 2026
8267250
fix: clear stale native tool-call parse failures on new API request
Aug 3, 2026
30256cb
fix(mimo): retry once without strict tool schemas on endpoint rejection
Aug 3, 2026
2a6842e
test(b12): add mimo error retry and ghost quarantine coverage for cod…
Aug 5, 2026
b68d77b
fix(test): replace 'as any' with typed assertion in mistral.spec.ts
Aug 5, 2026
d52f9aa
fix(b17): re-encode mimo.spec.ts to UTF-8, remove stale eslint suppre…
Aug 5, 2026
341c861
fix(b17): strip BOM from mimo.spec.ts, add totalCost to mistral usage…
Aug 5, 2026
50f84a5
test(b17): add 4 coverage tests for mimo.ts edge cases
Aug 5, 2026
61efc90
fix(b17): remove stale mimo.spec.ts eslint-suppression entry (0 any t…
Aug 5, 2026
0b229dd
fix: prune stale eslint-suppressions.json entries
Aug 6, 2026
af018ae
fix: make codecov/patch informational for PRs with large new code
Aug 6, 2026
7767c23
chore: remove temp file progress.txt
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
f125045
Merge branch 'main' into pr/b17-provider-cost-v2
myk1yt Aug 7, 2026
3c4b293
fix: prune unused eslint suppressions
Aug 7, 2026
276e425
refactor: reuse shared XAI response client mock (#1182)
zoomote[bot] Aug 7, 2026
2fcfe90
refactor: reuse shared CustomModesManager test helpers (#1190)
zoomote[bot] Aug 8, 2026
d2070d6
test(e2e): add provider cost suite
Aug 8, 2026
17d2c14
fix(test): add aimock fixture for provider-cost e2e (PR #1132)
Aug 8, 2026
712bb66
fix(types,e2e): allow any valid URL for mimoBaseUrl to support local …
Aug 8, 2026
59fcd0e
fix(vscode-e2e): emit attempt_completion in provider-cost stub and fi…
Aug 8, 2026
200d2aa
fix(telemetry): record tool usage once centrally, sanitize raw tool n…
edelauna Aug 8, 2026
fb57aeb
refactor: reuse code-index reset helpers (#1194)
zoomote[bot] Aug 8, 2026
d5085a2
refactor: reuse shared config test helpers (#1195)
zoomote[bot] Aug 8, 2026
5afa9b3
refactor: reuse terminal test reset helpers (#1197)
zoomote[bot] Aug 8, 2026
84e54cf
refactor: reuse webview chat render helper (#1196)
zoomote[bot] Aug 8, 2026
7f07a1d
fix(task): skip saveClineMessages when history task aborts before mes…
edelauna Aug 8, 2026
ca86e38
refactor: reuse shared test helpers in config import/export spec (#1198)
zoomote[bot] Aug 8, 2026
a800299
refactor(webview): complete provider identifier migration (#1141)
WebMad Aug 8, 2026
ef32d7e
chore(deps): update dependency mermaid to v11.16.1 [security] (#1193)
renovate[bot] Aug 8, 2026
2b2641b
chore(deps): update dependency undici to v6.28.0 [security] (#1161)
renovate[bot] Aug 8, 2026
2a62b64
refactor: reuse shared reset helper in code-index specs (#1199)
zoomote[bot] Aug 8, 2026
25fa8d4
refactor: reuse shared reset helper in remaining code-index specs (#1…
zoomote[bot] Aug 8, 2026
1c60627
refactor(webview): use provider identifiers in ApiOptions (#1146)
WebMad Aug 8, 2026
02f7902
refactor: reuse webview render helper in chat and settings specs (#1203)
zoomote[bot] Aug 9, 2026
20dd966
refactor: reuse shared reset helper in provider specs (#1202)
zoomote[bot] Aug 9, 2026
03db526
refactor: reuse webview render helper in remaining settings specs (#1…
zoomote[bot] Aug 9, 2026
4c42f91
refactor: reuse shared reset helper in semble and terminal specs (#1201)
zoomote[bot] Aug 9, 2026
9a921cf
Merge branch 'main' into pr/b17-provider-cost-v2
myk1yt Aug 9, 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This file provides guidance to agents when working with code in this repository.

- Settings View Pattern: When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions.
- Changesets: Do NOT create `.changeset` files for each commit or code change. Changesets are managed separately by maintainers and should not be generated by agents during normal development.
- CHANGELOG: Do NOT update `CHANGELOG.md` or `src/CHANGELOG.md` in individual PRs. CHANGELOG entries are added in bulk during release preparation PRs.

## ESLint Suppressions

Expand Down
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