From e92a0fca063ecbe50ac730f28942b45e3f84be03 Mon Sep 17 00:00:00 2001 From: cte Date: Sun, 11 Jan 2026 16:19:16 -0800 Subject: [PATCH] Add mode and model pickers --- .../src/acp/__tests__/model-service.test.ts | 233 ++++++++++++++++++ apps/cli/src/acp/__tests__/session.test.ts | 3 + apps/cli/src/acp/agent.ts | 98 ++++---- apps/cli/src/acp/index.ts | 7 + apps/cli/src/acp/interfaces.ts | 17 ++ apps/cli/src/acp/model-service.ts | 219 ++++++++++++++++ apps/cli/src/acp/session-event-handler.ts | 128 +++++++++- apps/cli/src/acp/session.ts | 58 ++++- apps/cli/src/acp/types.ts | 73 ++++++ apps/cli/src/commands/cli/run.ts | 2 +- apps/cli/src/lib/utils/provider.ts | 2 +- 11 files changed, 777 insertions(+), 63 deletions(-) create mode 100644 apps/cli/src/acp/__tests__/model-service.test.ts create mode 100644 apps/cli/src/acp/model-service.ts create mode 100644 apps/cli/src/acp/types.ts diff --git a/apps/cli/src/acp/__tests__/model-service.test.ts b/apps/cli/src/acp/__tests__/model-service.test.ts new file mode 100644 index 0000000000..d2faf110ce --- /dev/null +++ b/apps/cli/src/acp/__tests__/model-service.test.ts @@ -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) + }) + }) +}) diff --git a/apps/cli/src/acp/__tests__/session.test.ts b/apps/cli/src/acp/__tests__/session.test.ts index 895872f9ac..2d5d2802a0 100644 --- a/apps/cli/src/acp/__tests__/session.test.ts +++ b/apps/cli/src/acp/__tests__/session.test.ts @@ -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(), })), } }) diff --git a/apps/cli/src/acp/agent.ts b/apps/cli/src/acp/agent.ts index 3b66933f59..72860e95be 100644 --- a/apps/cli/src/acp/agent.ts +++ b/apps/cli/src/acp/agent.ts @@ -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 = 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 { - acpLog.request("authenticate", { methodId: params.methodId }) + async authenticate(_params: acp.AuthenticateRequest): Promise { + 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 { + async newSession(params: acp.NewSessionRequest): Promise { 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 { + const currentModelId = this.options.model || DEFAULT_MODELS[0]!.modelId + return this.modelService.getModelState(currentModelId) + } + // =========================================================================== // Prompt Handling // =========================================================================== diff --git a/apps/cli/src/acp/index.ts b/apps/cli/src/acp/index.ts index 1e06292d19..ab0594fcd9 100644 --- a/apps/cli/src/acp/index.ts +++ b/apps/cli/src/acp/index.ts @@ -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, diff --git a/apps/cli/src/acp/interfaces.ts b/apps/cli/src/acp/interfaces.ts index 3bbd872d91..d05795b4e8 100644 --- a/apps/cli/src/acp/interfaces.ts +++ b/apps/cli/src/acp/interfaces.ts @@ -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(event: K, handler: ExtensionHostEvents[K]): void + + /** + * Unsubscribe from extension host events. + */ + off(event: K, handler: ExtensionHostEvents[K]): void + /** * Activate the extension host. */ diff --git a/apps/cli/src/acp/model-service.ts b/apps/cli/src/acp/model-service.ts new file mode 100644 index 0000000000..17da48d84d --- /dev/null +++ b/apps/cli/src/acp/model-service.ts @@ -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 { + // 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 = { + "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 { + 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) +} diff --git a/apps/cli/src/acp/session-event-handler.ts b/apps/cli/src/acp/session-event-handler.ts index 431a665bd3..a838203777 100644 --- a/apps/cli/src/acp/session-event-handler.ts +++ b/apps/cli/src/acp/session-event-handler.ts @@ -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`) + } } // ============================================================================= diff --git a/apps/cli/src/acp/session.ts b/apps/cli/src/acp/session.ts index f10e758872..1d4cf787e5 100644 --- a/apps/cli/src/acp/session.ts +++ b/apps/cli/src/acp/session.ts @@ -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[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() diff --git a/apps/cli/src/acp/types.ts b/apps/cli/src/acp/types.ts new file mode 100644 index 0000000000..cc0789726e --- /dev/null +++ b/apps/cli/src/acp/types.ts @@ -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", + }, +] diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 5b305ce275..04fe5d0045 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -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({ diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 64aec430c1..3edef87d94 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -2,7 +2,7 @@ import { RooCodeSettings } from "@roo-code/types" import type { SupportedProvider } from "@/types/index.js" -const envVarMap: Record = { +export const envVarMap: Record = { anthropic: "ANTHROPIC_API_KEY", "openai-native": "OPENAI_API_KEY", gemini: "GOOGLE_API_KEY",