Add mode and model pickers

This commit is contained in:
cte 2026-01-11 16:19:16 -08:00
parent 4b43a0d865
commit e92a0fca06
11 changed files with 777 additions and 63 deletions

View file

@ -0,0 +1,233 @@
/**
* Tests for ModelService
*/
import { ModelService, createModelService } from "../model-service.js"
import { DEFAULT_MODELS } from "../types.js"
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
describe("ModelService", () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetch.mockReset()
})
describe("constructor", () => {
it("should create a ModelService with default options", () => {
const service = new ModelService()
expect(service).toBeInstanceOf(ModelService)
})
it("should create a ModelService with custom options", () => {
const service = new ModelService({
apiUrl: "https://custom.api.com",
apiKey: "test-key",
timeout: 10000,
})
expect(service).toBeInstanceOf(ModelService)
})
})
describe("createModelService factory", () => {
it("should create a ModelService instance", () => {
const service = createModelService()
expect(service).toBeInstanceOf(ModelService)
})
it("should pass options to ModelService", () => {
const service = createModelService({
apiKey: "test-api-key",
})
expect(service).toBeInstanceOf(ModelService)
})
})
describe("fetchAvailableModels", () => {
it("should return cached models on subsequent calls", async () => {
const service = new ModelService()
// First call - should fetch from API
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
object: "list",
data: [
{ id: "model-1", owned_by: "test" },
{ id: "model-2", owned_by: "test" },
],
}),
})
const firstResult = await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(1)
// Second call - should use cache
const secondResult = await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(1) // No additional fetch
expect(secondResult).toEqual(firstResult)
})
it("should return DEFAULT_MODELS when API fails", async () => {
const service = new ModelService()
mockFetch.mockRejectedValueOnce(new Error("Network error"))
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should return DEFAULT_MODELS when API returns non-OK status", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
})
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should return DEFAULT_MODELS when API returns invalid response", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ invalid: "response" }),
})
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should return DEFAULT_MODELS on timeout", async () => {
const service = new ModelService({ timeout: 100 })
// Mock a fetch that never resolves
mockFetch.mockImplementationOnce(
() =>
new Promise((_, reject) => {
setTimeout(() => reject(new DOMException("Aborted", "AbortError")), 50)
}),
)
const result = await service.fetchAvailableModels()
expect(result).toEqual(DEFAULT_MODELS)
})
it("should transform API response to AcpModel format", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ id: "anthropic/claude-3-sonnet", owned_by: "anthropic" },
{ id: "openai/gpt-4", owned_by: "openai" },
],
}),
})
const result = await service.fetchAvailableModels()
// Should include default model first with actual model name
expect(result[0]).toEqual({
modelId: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
})
// Should include transformed models
expect(result.length).toBeGreaterThan(1)
})
it("should include Authorization header when apiKey is provided", async () => {
const service = new ModelService({ apiKey: "test-api-key" })
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] }),
})
await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-api-key",
}),
}),
)
})
})
describe("getModelState", () => {
it("should return model state with current model ID", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "anthropic/claude-sonnet-4.5" }],
}),
})
const state = await service.getModelState("anthropic/claude-sonnet-4.5")
expect(state).toEqual({
availableModels: expect.any(Array),
currentModelId: "anthropic/claude-sonnet-4.5",
})
})
it("should fall back to 'default' if current model ID is not in available models", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "model-1" }],
}),
})
const state = await service.getModelState("non-existent-model")
expect(state.currentModelId).toBe(DEFAULT_MODELS[0]!.modelId)
})
})
describe("clearCache", () => {
it("should clear the cached models", async () => {
const service = new ModelService()
// First fetch
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "model-1" }],
}),
})
await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(1)
// Clear cache
service.clearCache()
// Second fetch - should call API again
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: "model-2" }],
}),
})
await service.fetchAvailableModels()
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
})

View file

@ -15,6 +15,9 @@ vi.mock("@/agent/extension-host.js", () => {
activate: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
sendToExtension: vi.fn(),
// Add on/off methods for extension host events (e.g., extensionWebviewMessage)
on: vi.fn().mockReturnThis(),
off: vi.fn().mockReturnThis(),
})),
}
})

View file

@ -13,6 +13,9 @@ import { DEFAULT_FLAGS } from "@/types/constants.js"
import { AcpSession, type AcpSessionOptions } from "./session.js"
import { acpLog } from "./logger.js"
import { ModelService, createModelService } from "./model-service.js"
import { type ExtendedNewSessionResponse, type AcpModelState, DEFAULT_MODELS } from "./types.js"
import { envVarMap } from "@/lib/utils/provider.js"
// =============================================================================
// Types
@ -31,15 +34,6 @@ export interface RooCodeAgentOptions {
mode?: string
}
// =============================================================================
// Auth Method IDs
// =============================================================================
const AUTH_METHODS = {
ROO_CLOUD: "roo-cloud",
API_KEY: "api-key",
} as const
// =============================================================================
// Available Modes
// =============================================================================
@ -81,11 +75,17 @@ export class RooCodeAgent implements acp.Agent {
private sessions: Map<string, AcpSession> = new Map()
private clientCapabilities: acp.ClientCapabilities | undefined
private isAuthenticated = false
private readonly modelService: ModelService
constructor(
private readonly options: RooCodeAgentOptions,
private readonly connection: acp.AgentSideConnection,
) {}
) {
// Initialize model service with optional API key
this.modelService = createModelService({
apiKey: options.apiKey,
})
}
// ===========================================================================
// Initialization
@ -109,14 +109,9 @@ export class RooCodeAgent implements acp.Agent {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods: [
{
id: AUTH_METHODS.ROO_CLOUD,
id: "roo",
name: "Sign in with Roo Code Cloud",
description: "Sign in with your Roo Code Cloud account for access to all features",
},
{
id: AUTH_METHODS.API_KEY,
name: "Use API Key",
description: "Use an API key directly (set OPENROUTER_API_KEY or similar environment variable)",
description: `Sign in with your Roo Code Cloud account or BYOK by exporting an API key Environment Variable (${Object.values(envVarMap).join(", ")})`,
},
],
agentCapabilities: {
@ -137,45 +132,17 @@ export class RooCodeAgent implements acp.Agent {
// ===========================================================================
/**
* Authenticate with the specified method.
* Authenticate with Roo Code Cloud.
*/
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | void> {
acpLog.request("authenticate", { methodId: params.methodId })
async authenticate(_params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | void> {
const result = await login({ verbose: false })
switch (params.methodId) {
case AUTH_METHODS.ROO_CLOUD: {
acpLog.info("Agent", "Starting Roo Code Cloud login flow")
// Trigger Roo Code Cloud login flow
const result = await login({ verbose: false })
if (!result.success) {
acpLog.error("Agent", "Roo Code Cloud login failed")
throw acp.RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud")
}
this.isAuthenticated = true
acpLog.info("Agent", "Roo Code Cloud login successful")
break
}
case AUTH_METHODS.API_KEY: {
// API key authentication - verify key exists
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
if (!apiKey) {
acpLog.error("Agent", "No API key found")
throw acp.RequestError.authRequired(
undefined,
"No API key found. Set OPENROUTER_API_KEY environment variable.",
)
}
this.isAuthenticated = true
acpLog.info("Agent", "API key authentication successful")
break
}
default:
acpLog.error("Agent", `Unknown auth method: ${params.methodId}`)
throw acp.RequestError.invalidParams(undefined, `Unknown auth method: ${params.methodId}`)
if (!result.success) {
throw acp.RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud")
}
this.isAuthenticated = true
acpLog.response("authenticate", {})
return {}
}
@ -187,7 +154,7 @@ export class RooCodeAgent implements acp.Agent {
/**
* Create a new session.
*/
async newSession(params: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
async newSession(params: acp.NewSessionRequest): Promise<ExtendedNewSessionResponse> {
acpLog.request("newSession", { cwd: params.cwd })
// Require authentication
@ -202,6 +169,7 @@ export class RooCodeAgent implements acp.Agent {
}
const sessionId = randomUUID()
const initialMode = this.options.mode || "code"
acpLog.info("Agent", `Creating new session: ${sessionId}`)
const sessionOptions: AcpSessionOptions = {
@ -209,7 +177,7 @@ export class RooCodeAgent implements acp.Agent {
provider: this.options.provider || "openrouter",
apiKey: this.options.apiKey || process.env.OPENROUTER_API_KEY,
model: this.options.model || DEFAULT_FLAGS.model,
mode: this.options.mode || "code",
mode: initialMode,
}
acpLog.debug("Agent", "Session options", {
@ -230,11 +198,31 @@ export class RooCodeAgent implements acp.Agent {
this.sessions.set(sessionId, session)
acpLog.info("Agent", `Session created successfully: ${sessionId}`)
const response = { sessionId }
// Fetch model state asynchronously (don't block session creation)
const modelState = await this.getModelState()
// Build response with modes and models
const response: ExtendedNewSessionResponse = {
sessionId,
modes: {
currentModeId: initialMode,
availableModes: AVAILABLE_MODES,
},
models: modelState,
}
acpLog.response("newSession", response)
return response
}
/**
* Get the current model state.
*/
private async getModelState(): Promise<AcpModelState> {
const currentModelId = this.options.model || DEFAULT_MODELS[0]!.modelId
return this.modelService.getModelState(currentModelId)
}
// ===========================================================================
// Prompt Handling
// ===========================================================================

View file

@ -2,6 +2,13 @@
export { type RooCodeAgentOptions, RooCodeAgent } from "./agent.js"
export { type AcpSessionOptions, AcpSession } from "./session.js"
// Types for mode and model pickers
export type { AcpModel, AcpModelState, ExtendedNewSessionResponse } from "./types.js"
export { DEFAULT_MODELS } from "./types.js"
// Model service
export { ModelService, createModelService, type ModelServiceOptions } from "./model-service.js"
// Interfaces for dependency injection
export type {
IAcpLogger,

View file

@ -151,6 +151,13 @@ export interface IExtensionClient {
// Extension Host Interface
// =============================================================================
/**
* Events emitted by the extension host.
*/
export interface ExtensionHostEvents {
extensionWebviewMessage: (msg: unknown) => void
}
/**
* Interface for extension host interactions.
*/
@ -160,6 +167,16 @@ export interface IExtensionHost {
*/
readonly client: IExtensionClient
/**
* Subscribe to extension host events.
*/
on<K extends keyof ExtensionHostEvents>(event: K, handler: ExtensionHostEvents[K]): void
/**
* Unsubscribe from extension host events.
*/
off<K extends keyof ExtensionHostEvents>(event: K, handler: ExtensionHostEvents[K]): void
/**
* Activate the extension host.
*/

View file

@ -0,0 +1,219 @@
/**
* Model Service for ACP
*
* Fetches and caches available models from the Roo Code API.
*/
import type { AcpModel, AcpModelState } from "./types.js"
import { DEFAULT_MODELS } from "./types.js"
import { acpLog } from "./logger.js"
// =============================================================================
// Types
// =============================================================================
export interface ModelServiceOptions {
/** Base URL for the API (defaults to https://api.roocode.com) */
apiUrl?: string
/** API key for authentication */
apiKey?: string
/** Request timeout in milliseconds (defaults to 5000) */
timeout?: number
}
/**
* Response structure from /proxy/v1/models endpoint.
* Based on OpenAI-compatible model listing format.
*/
interface ModelsApiResponse {
object?: string
data?: Array<{
id: string
object?: string
created?: number
owned_by?: string
// Additional fields may be present
}>
}
// =============================================================================
// Constants
// =============================================================================
const DEFAULT_API_URL = "https://api.roocode.com"
const DEFAULT_TIMEOUT = 5000
// =============================================================================
// ModelService Class
// =============================================================================
/**
* Service for fetching and managing available models.
*/
export class ModelService {
private readonly apiUrl: string
private readonly apiKey?: string
private readonly timeout: number
private cachedModels: AcpModel[] | null = null
constructor(options: ModelServiceOptions = {}) {
this.apiUrl = options.apiUrl || DEFAULT_API_URL
this.apiKey = options.apiKey
this.timeout = options.timeout || DEFAULT_TIMEOUT
}
/**
* Fetch available models from the API.
* Returns cached models if available, otherwise fetches from API.
* Falls back to default models on error.
*/
async fetchAvailableModels(): Promise<AcpModel[]> {
// Return cached models if available
if (this.cachedModels) {
return this.cachedModels
}
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`
}
const response = await fetch(`${this.apiUrl}/proxy/v1/models`, {
method: "GET",
headers,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
acpLog.warn("ModelService", `API returned ${response.status}, using default models`)
this.cachedModels = DEFAULT_MODELS
return this.cachedModels
}
const data = (await response.json()) as ModelsApiResponse
if (!data.data || !Array.isArray(data.data)) {
acpLog.warn("ModelService", "Invalid API response format, using default models")
this.cachedModels = DEFAULT_MODELS
return this.cachedModels
}
// Transform API response to AcpModel format
this.cachedModels = this.transformApiResponse(data.data)
acpLog.debug("ModelService", `Fetched ${this.cachedModels.length} models from API`)
return this.cachedModels
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
acpLog.warn("ModelService", "Request timed out, using default models")
} else {
acpLog.warn(
"ModelService",
`Failed to fetch models: ${error instanceof Error ? error.message : String(error)}`,
)
}
this.cachedModels = DEFAULT_MODELS
return this.cachedModels
}
}
/**
* Get the current model state including available models and current selection.
*/
async getModelState(currentModelId: string): Promise<AcpModelState> {
const availableModels = await this.fetchAvailableModels()
// Validate that currentModelId exists in available models
const modelExists = availableModels.some((m) => m.modelId === currentModelId)
const effectiveModelId = modelExists ? currentModelId : DEFAULT_MODELS[0]!.modelId
return {
availableModels,
currentModelId: effectiveModelId,
}
}
/**
* Clear the cached models, forcing a refresh on next fetch.
*/
clearCache(): void {
this.cachedModels = null
}
/**
* Transform API response to AcpModel format.
*/
private transformApiResponse(
data: Array<{
id: string
object?: string
created?: number
owned_by?: string
}>,
): AcpModel[] {
// If API returns models, transform them
// For now, we'll create a simple mapping
// In practice, the API should return model metadata including pricing
const models: AcpModel[] = []
const defaultModel = DEFAULT_MODELS[0]!
// Always include the default model first (shows actual model name)
models.push(defaultModel)
// Add models from API response
for (const model of data) {
// Skip if it's already in our list or if it's a system model
if (model.id === defaultModel.modelId || model.id.startsWith("_")) {
continue
}
models.push({
modelId: model.id,
name: this.formatModelName(model.id),
description: model.owned_by ? `Provided by ${model.owned_by}` : undefined,
})
}
// If no models from API, return defaults
if (models.length === 1) {
return DEFAULT_MODELS
}
return models
}
/**
* Format a model ID into a human-readable name.
*/
private formatModelName(modelId: string): string {
// Convert model IDs like "anthropic/claude-3-sonnet" to "Claude 3 Sonnet"
const parts = modelId.split("/")
const name = parts[parts.length - 1] || modelId
return name
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a new ModelService instance.
*/
export function createModelService(options?: ModelServiceOptions): ModelService {
return new ModelService(options)
}

View file

@ -1,11 +1,12 @@
/**
* Session Event Handler
*
* Handles events from the ExtensionClient and translates them to ACP updates.
* Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates.
* Extracted from session.ts for better separation of concerns.
*/
import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types"
import type { SessionMode } from "@agentclientprotocol/sdk"
import type { ClineMessage, ClineAsk, ClineSay, ExtensionMessage, ExtensionState, ModeConfig } from "@roo-code/types"
import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js"
@ -14,6 +15,7 @@ import { isUserEcho } from "./utils/index.js"
import type {
IAcpLogger,
IExtensionClient,
IExtensionHost,
IPromptStateMachine,
ICommandStreamManager,
IToolContentStreamManager,
@ -96,6 +98,8 @@ export interface SessionEventHandlerDeps {
logger: IAcpLogger
/** Extension client for event subscription */
client: IExtensionClient
/** Extension host for host-level events (modes, etc.) */
extensionHost: IExtensionHost
/** Prompt state machine */
promptState: IPromptStateMachine
/** Delta tracker for streaming */
@ -116,6 +120,8 @@ export interface SessionEventHandlerDeps {
sendToExtension: (message: unknown) => void
/** Workspace path */
workspacePath: string
/** Initial mode ID */
initialModeId: string
}
/**
@ -123,22 +129,30 @@ export interface SessionEventHandlerDeps {
*/
export type TaskCompletedCallback = (success: boolean) => void
/**
* Callback for mode changes.
*/
export type ModeChangedCallback = (modeId: string, availableModes: SessionMode[]) => void
// =============================================================================
// SessionEventHandler Class
// =============================================================================
/**
* Handles events from the ExtensionClient and translates them to ACP updates.
* Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates.
*
* Responsibilities:
* - Subscribe to extension client events
* - Subscribe to extension host events (mode changes, etc.)
* - Handle streaming for text/reasoning messages
* - Handle tool permission requests
* - Handle task completion
* - Track mode state changes
*/
export class SessionEventHandler {
private readonly logger: IAcpLogger
private readonly client: IExtensionClient
private readonly extensionHost: IExtensionHost
private readonly promptState: IPromptStateMachine
private readonly deltaTracker: IDeltaTracker
private readonly commandStreamManager: ICommandStreamManager
@ -151,6 +165,16 @@ export class SessionEventHandler {
private readonly workspacePath: string
private taskCompletedCallback: TaskCompletedCallback | null = null
private modeChangedCallback: ModeChangedCallback | null = null
/** Current mode ID (Roo Code mode like 'code', 'architect', etc.) */
private currentModeId: string
/** Available modes from extension state */
private availableModes: SessionMode[] = []
/** Listener for extension host messages */
private extensionMessageListener: ((msg: unknown) => void) | null = null
/**
* Track processed permission requests to prevent duplicates.
@ -163,6 +187,7 @@ export class SessionEventHandler {
constructor(deps: SessionEventHandlerDeps) {
this.logger = deps.logger
this.client = deps.client
this.extensionHost = deps.extensionHost
this.promptState = deps.promptState
this.deltaTracker = deps.deltaTracker
this.commandStreamManager = deps.commandStreamManager
@ -173,6 +198,7 @@ export class SessionEventHandler {
this.respondWithText = deps.respondWithText
this.sendToExtension = deps.sendToExtension
this.workspacePath = deps.workspacePath
this.currentModeId = deps.initialModeId
}
// ===========================================================================
@ -180,7 +206,7 @@ export class SessionEventHandler {
// ===========================================================================
/**
* Set up event handlers to translate ExtensionClient events to ACP updates.
* Set up event handlers to translate ExtensionClient and ExtensionHost events to ACP updates.
*/
setupEventHandlers(): void {
// Handle new messages
@ -208,6 +234,12 @@ export class SessionEventHandler {
this.client.on("taskCompleted", (event: unknown) => {
this.handleTaskCompleted(event as TaskCompletedEvent)
})
// Handle extension host messages (modes, state, etc.)
this.extensionMessageListener = (msg: unknown) => {
this.handleExtensionMessage(msg as ExtensionMessage)
}
this.extensionHost.on("extensionWebviewMessage", this.extensionMessageListener)
}
/**
@ -217,6 +249,27 @@ export class SessionEventHandler {
this.taskCompletedCallback = callback
}
/**
* Set the callback for mode changes.
*/
onModeChanged(callback: ModeChangedCallback): void {
this.modeChangedCallback = callback
}
/**
* Get the current mode ID.
*/
getCurrentModeId(): string {
return this.currentModeId
}
/**
* Get the available modes.
*/
getAvailableModes(): SessionMode[] {
return this.availableModes
}
/**
* Reset state for a new prompt.
*/
@ -227,6 +280,16 @@ export class SessionEventHandler {
this.processedPermissions.clear()
}
/**
* Clean up event listeners.
*/
cleanup(): void {
if (this.extensionMessageListener) {
this.extensionHost.off("extensionWebviewMessage", this.extensionMessageListener)
this.extensionMessageListener = null
}
}
// ===========================================================================
// Message Handling
// ===========================================================================
@ -417,6 +480,63 @@ export class SessionEventHandler {
this.taskCompletedCallback(event.success)
}
}
// ===========================================================================
// Extension Message Handling (Modes, State)
// ===========================================================================
/**
* Handle extension messages for mode and state updates.
*/
private handleExtensionMessage(msg: ExtensionMessage): void {
// Handle "modes" message - list of available modes
if (msg.type === "modes" && msg.modes) {
this.logger.debug("SessionEventHandler", `Received modes: ${msg.modes.length} modes`)
this.availableModes = msg.modes.map((m) => ({
id: m.slug,
name: m.name,
description: undefined,
}))
}
// Handle "state" message - includes current mode
if (msg.type === "state" && msg.state) {
const state = msg.state as ExtensionState
if (state.mode && state.mode !== this.currentModeId) {
const previousMode = this.currentModeId
this.currentModeId = state.mode
this.logger.info("SessionEventHandler", `Mode changed: ${previousMode} -> ${this.currentModeId}`)
// Send mode update notification
this.sendUpdate({
sessionUpdate: "current_mode_update",
currentModeId: this.currentModeId,
})
// Notify callback
if (this.modeChangedCallback) {
this.modeChangedCallback(this.currentModeId, this.availableModes)
}
}
// Update available modes from customModes
if (state.customModes && Array.isArray(state.customModes)) {
this.updateAvailableModesFromConfig(state.customModes as ModeConfig[])
}
}
}
/**
* Update available modes from ModeConfig array.
*/
private updateAvailableModesFromConfig(modes: ModeConfig[]): void {
this.availableModes = modes.map((m) => ({
id: m.slug,
name: m.name,
description: undefined,
}))
this.logger.debug("SessionEventHandler", `Updated available modes: ${this.availableModes.length} modes`)
}
}
// =============================================================================

View file

@ -10,11 +10,13 @@ import {
type ClientCapabilities,
type PromptRequest,
type PromptResponse,
type SessionModeState,
AgentSideConnection,
} from "@agentclientprotocol/sdk"
import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js"
import { DEFAULT_MODELS } from "./types.js"
import { extractPromptText, extractPromptImages } from "./translator.js"
import { acpLog } from "./logger.js"
import { DeltaTracker } from "./delta-tracker.js"
@ -86,11 +88,15 @@ export class AcpSession implements IAcpSession {
/** Workspace path for resolving relative file paths */
private readonly workspacePath: string
/** Current model ID */
private currentModelId: string = DEFAULT_MODELS[0]!.modelId
private constructor(
private readonly sessionId: string,
private readonly extensionHost: ExtensionHost,
private readonly connection: AgentSideConnection,
workspacePath: string,
initialMode: string,
deps: AcpSessionDependencies = {},
) {
this.workspacePath = workspacePath
@ -132,9 +138,11 @@ export class AcpSession implements IAcpSession {
logger: this.logger,
})
// Create event handler with extension host for mode tracking
this.eventHandler = createSessionEventHandler({
logger: this.logger,
client: extensionHost.client,
extensionHost,
promptState: this.promptState,
deltaTracker: this.deltaTracker,
commandStreamManager: this.commandStreamManager,
@ -146,6 +154,7 @@ export class AcpSession implements IAcpSession {
sendToExtension: (message) =>
this.extensionHost.sendToExtension(message as Parameters<typeof this.extensionHost.sendToExtension>[0]),
workspacePath,
initialModeId: initialMode,
})
this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success))
@ -199,7 +208,7 @@ export class AcpSession implements IAcpSession {
await extensionHost.activate()
logger.info("Session", `ExtensionHost activated for session ${sessionId}`)
const session = new AcpSession(sessionId, extensionHost, connection, cwd, deps)
const session = new AcpSession(sessionId, extensionHost, connection, cwd, options.mode, deps)
session.setupEventHandlers()
return session
@ -211,6 +220,7 @@ export class AcpSession implements IAcpSession {
/**
* Set up event handlers to translate ExtensionClient events to ACP updates.
* This includes both ExtensionClient events and ExtensionHost events (modes, state).
*/
private setupEventHandlers(): void {
this.eventHandler.setupEventHandlers()
@ -285,13 +295,54 @@ export class AcpSession implements IAcpSession {
}
/**
* Set the session mode.
* Set the session mode (Roo Code operational mode like 'code', 'architect').
* The mode change is tracked by the event handler which listens to extension state updates.
*/
setMode(mode: string): void {
this.logger.info("Session", `Setting mode to: ${mode}`)
this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings: { mode } })
}
/**
* Set the current model.
* This updates the provider settings to use the specified model.
*/
setModel(modelId: string): void {
this.logger.info("Session", `Setting model to: ${modelId}`)
this.currentModelId = modelId
// Map model ID to extension settings
// The property is apiModelId for most providers
this.extensionHost.sendToExtension({
type: "updateSettings",
updatedSettings: { apiModelId: modelId },
})
}
/**
* Get the current mode state (delegated to event handler).
*/
getModeState(): SessionModeState {
return {
currentModeId: this.eventHandler.getCurrentModeId(),
availableModes: this.eventHandler.getAvailableModes(),
}
}
/**
* Get the current mode ID (delegated to event handler).
*/
getCurrentModeId(): string {
return this.eventHandler.getCurrentModeId()
}
/**
* Get the current model ID.
*/
getCurrentModelId(): string {
return this.currentModelId
}
/**
* Dispose of the session and release resources.
*/
@ -299,6 +350,9 @@ export class AcpSession implements IAcpSession {
this.logger.info("Session", `Disposing session ${this.sessionId}`)
this.cancel()
// Clean up event handler listeners
this.eventHandler.cleanup()
// Flush any remaining buffered updates.
await this.updateBuffer.flush()
await this.extensionHost.dispose()

73
apps/cli/src/acp/types.ts Normal file
View file

@ -0,0 +1,73 @@
/**
* ACP Types for Mode and Model Pickers
*
* Extends the standard ACP types with model support for the Roo Code agent.
*/
import type * as acp from "@agentclientprotocol/sdk"
// =============================================================================
// Model Types
// =============================================================================
/**
* Represents an available model in the ACP interface.
*/
export interface AcpModel {
/** Unique identifier for the model */
modelId: string
/** Human-readable name */
name: string
/** Optional description with details like pricing */
description?: string
}
/**
* State of available models and current selection.
*/
export interface AcpModelState {
/** List of available models */
availableModels: AcpModel[]
/** Currently selected model ID */
currentModelId: string
}
// =============================================================================
// Extended Response Types
// =============================================================================
/**
* Extended NewSessionResponse that includes model state.
* The standard ACP NewSessionResponse only includes sessionId and optional modes.
* We extend it with models for our implementation.
*/
export interface ExtendedNewSessionResponse extends acp.NewSessionResponse {
/** Model state for the session */
models?: AcpModelState
}
// =============================================================================
// Default Constants
// =============================================================================
/**
* Default models available when API is not accessible.
* These map to Roo Code Cloud model tiers.
*/
export const DEFAULT_MODELS: AcpModel[] = [
{
modelId: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
},
{
modelId: "anthropic/claude-opus-4.5",
name: "Claude Opus 4.5",
description: "Most capable for complex work",
},
{
modelId: "anthropic/claude-haiku-4.5",
name: "Claude Haiku 4.5",
description: "Fastest for quick answers",
},
]

View file

@ -168,7 +168,7 @@ export async function run(workspaceArg: string, options: FlagOptions) {
console.log(ASCII_ROO)
console.log()
console.log(
`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
`[roo] Running ${options.model || DEFAULT_FLAGS.model} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
)
const host = new ExtensionHost({

View file

@ -2,7 +2,7 @@ import { RooCodeSettings } from "@roo-code/types"
import type { SupportedProvider } from "@/types/index.js"
const envVarMap: Record<SupportedProvider, string> = {
export const envVarMap: Record<SupportedProvider, string> = {
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",