mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Add mode and model pickers
This commit is contained in:
parent
4b43a0d865
commit
e92a0fca06
11 changed files with 777 additions and 63 deletions
233
apps/cli/src/acp/__tests__/model-service.test.ts
Normal file
233
apps/cli/src/acp/__tests__/model-service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
219
apps/cli/src/acp/model-service.ts
Normal file
219
apps/cli/src/acp/model-service.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
73
apps/cli/src/acp/types.ts
Normal 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",
|
||||
},
|
||||
]
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue