Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
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
67 changes: 67 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,73 @@ import fs from "fs"
import path from "path"
import os from "os"

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

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 --prompt-file option", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests exercise fs.writeFileSync/readFileSync round-trips but never call run(). Does the --prompt-file branch in run.ts (existence check, process.exit(1) on a missing file, file-content-to-prompt assignment) have coverage anywhere else?

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
48 changes: 40 additions & 8 deletions apps/cli/src/commands/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { fileURLToPath } from "url"
import { createElement } from "react"
import pWaitFor from "p-wait-for"

import { providerIdentifiers } from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"

import {
FlagOptions,
ReasoningEffortFlagOptions,
SupportedProvider,
isSupportedProvider,
supportedProviders,
DEFAULT_FLAGS,
Expand Down Expand Up @@ -49,6 +52,35 @@ function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}

export function resolveModel(flagModel?: string, settingsModel?: string): string {
return flagModel || settingsModel || DEFAULT_FLAGS.model
}

export function resolveReasoningEffort(
flagReasoningEffort?: ReasoningEffortFlagOptions,
settingsReasoningEffort?: ReasoningEffortFlagOptions,
): ReasoningEffortFlagOptions {
return flagReasoningEffort || settingsReasoningEffort || DEFAULT_FLAGS.reasoningEffort
}

export function resolveProvider(
flagProvider?: SupportedProvider,
settingsProvider?: SupportedProvider,
): SupportedProvider {
return flagProvider ?? settingsProvider ?? providerIdentifiers.openrouter
}

export function resolveWorkspacePath(workspace?: string): string {
return workspace ? path.resolve(workspace) : process.cwd()
}

export function resolveLegacyRequireApproval(
requireApproval?: boolean,
dangerouslySkipPermissions?: boolean,
): boolean | undefined {
return requireApproval ?? (dangerouslySkipPermissions === undefined ? undefined : !dangerouslySkipPermissions)
}

export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
setLogger({
info: () => {},
Expand Down Expand Up @@ -119,14 +151,14 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption

// Determine effective values: CLI flags > settings file > DEFAULT_FLAGS.
const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode
const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model
const effectiveReasoningEffort =
flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort
const effectiveProvider = flagOptions.provider ?? settings.provider ?? "openrouter"
const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd()
const legacyRequireApprovalFromSettings =
settings.requireApproval ??
(settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions)
const effectiveModel = resolveModel(flagOptions.model, settings.model)
const effectiveReasoningEffort = resolveReasoningEffort(flagOptions.reasoningEffort, settings.reasoningEffort)
const effectiveProvider = resolveProvider(flagOptions.provider, settings.provider)
const effectiveWorkspacePath = resolveWorkspacePath(flagOptions.workspace)
const legacyRequireApprovalFromSettings = resolveLegacyRequireApproval(
settings.requireApproval,
settings.dangerouslySkipPermissions,
)
const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false
const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false
const rawConsecutiveMistakeLimit =
Expand Down
37 changes: 37 additions & 0 deletions apps/cli/src/lib/utils/__tests__/context-window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { providerIdentifiers, type ProviderSettings } from "@roo-code/types"

import { DEFAULT_CONTEXT_WINDOW, getContextWindow } from "../context-window.js"

describe("getContextWindow", () => {
it.each([
[providerIdentifiers.openrouter, "openRouterModelId"],
[providerIdentifiers.ollama, "ollamaModelId"],
[providerIdentifiers.lmstudio, "lmStudioModelId"],
[providerIdentifiers.openai, "openAiModelId"],
[providerIdentifiers.requesty, "requestyModelId"],
[providerIdentifiers.unbound, "unboundModelId"],
[providerIdentifiers.litellm, "litellmModelId"],
[providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId"],
] as const)("uses the provider-specific model field for %s", (provider, modelField) => {
const config = { apiProvider: provider, [modelField]: "selected-model" } as ProviderSettings
const routerModels = { [provider]: { "selected-model": { contextWindow: 123_456 } } }

expect(getContextWindow(routerModels, config)).toBe(123_456)
})

it("uses apiModelId for providers without a specialized model field", () => {
const config: ProviderSettings = {
apiProvider: providerIdentifiers.anthropic,
apiModelId: "selected-model",
}
const routerModels = {
[providerIdentifiers.anthropic]: { "selected-model": { contextWindow: 64_000 } },
}

expect(getContextWindow(routerModels, config)).toBe(64_000)
})

it("returns the default when the selected model is unavailable", () => {
expect(getContextWindow({}, { apiProvider: providerIdentifiers.openrouter })).toBe(DEFAULT_CONTEXT_WINDOW)
})
})
Loading
Loading