Fix model switching

This commit is contained in:
cte 2026-01-12 16:06:14 -08:00
parent 1b867d517b
commit 1150158d55
12 changed files with 394 additions and 787 deletions

View file

@ -1,6 +1,7 @@
import type * as acp from "@agentclientprotocol/sdk"
import { RooCodeAgent, type RooCodeAgentOptions } from "../agent.js"
import { RooCodeAgent } from "../agent.js"
import type { AcpSessionOptions } from "../session.js"
vi.mock("@/commands/auth/index.js", () => ({
login: vi.fn().mockResolvedValue({ success: true }),
@ -24,7 +25,7 @@ describe("RooCodeAgent", () => {
let agent: RooCodeAgent
let mockConnection: acp.AgentSideConnection
const defaultOptions: RooCodeAgentOptions = {
const defaultOptions: AcpSessionOptions = {
extensionPath: "/test/extension",
provider: "openrouter",
apiKey: "test-key",

View file

@ -118,26 +118,65 @@ describe("ModelService", () => {
expect(result).toEqual(DEFAULT_MODELS)
})
it("should transform API response to AcpModel format", async () => {
it("should transform API response to AcpModel format using name and description fields", 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" },
{
id: "anthropic/claude-3-sonnet",
name: "Claude 3 Sonnet",
description: "A balanced model for most tasks",
owned_by: "anthropic",
},
{
id: "openai/gpt-4",
name: "GPT-4",
description: "OpenAI's flagship model",
owned_by: "openai",
},
],
}),
})
const result = await service.fetchAvailableModels()
// Should include default model first with actual model name
expect(result[0]).toEqual(DEFAULT_MODELS[0])
// Should include transformed models with name and description from API
expect(result).toHaveLength(2)
expect(result).toContainEqual({
modelId: "anthropic/claude-3-sonnet",
name: "Claude 3 Sonnet",
description: "A balanced model for most tasks",
})
expect(result).toContainEqual({
modelId: "openai/gpt-4",
name: "GPT-4",
description: "OpenAI's flagship model",
})
})
// Should include transformed models
expect(result.length).toBeGreaterThan(1)
it("should sort models by model ID", async () => {
const service = new ModelService()
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ id: "openai/gpt-4", name: "GPT-4" },
{ id: "anthropic/claude-3-sonnet", name: "Claude 3 Sonnet" },
{ id: "google/gemini-pro", name: "Gemini Pro" },
],
}),
})
const result = await service.fetchAvailableModels()
// Should be sorted by model ID
expect(result[0]!.modelId).toBe("anthropic/claude-3-sonnet")
expect(result[1]!.modelId).toBe("google/gemini-pro")
expect(result[2]!.modelId).toBe("openai/gpt-4")
})
it("should include Authorization header when apiKey is provided", async () => {
@ -161,41 +200,6 @@ describe("ModelService", () => {
})
})
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()

View file

@ -93,20 +93,26 @@ describe("AcpSession", () => {
describe("create", () => {
it("should create a session with a unique ID", async () => {
const session = await AcpSession.create(
"test-session-1",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session-1",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(session).toBeDefined()
expect(session.getSessionId()).toBe("test-session-1")
})
it("should create ExtensionHost with correct config", async () => {
await AcpSession.create("test-session-2", "/test/workspace", mockConnection, undefined, defaultOptions)
await AcpSession.create({
sessionId: "test-session-2",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(ExtensionHost).toHaveBeenCalledWith(
expect.objectContaining({
@ -121,26 +127,25 @@ describe("AcpSession", () => {
})
it("should accept client capabilities", async () => {
const clientCapabilities: acp.ClientCapabilities = {
fs: {
readTextFile: true,
writeTextFile: true,
},
}
const session = await AcpSession.create(
"test-session-3",
"/test/workspace",
mockConnection,
clientCapabilities,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session-3",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(session).toBeDefined()
})
it("should activate the extension host", async () => {
await AcpSession.create("test-session-4", "/test/workspace", mockConnection, undefined, defaultOptions)
await AcpSession.create({
sessionId: "test-session-4",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
expect(mockHostInstance.activate).toHaveBeenCalled()
@ -149,13 +154,13 @@ describe("AcpSession", () => {
describe("prompt", () => {
it("should send a task to the extension host", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
@ -181,13 +186,13 @@ describe("AcpSession", () => {
})
it("should handle image prompts", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
@ -215,13 +220,13 @@ describe("AcpSession", () => {
describe("cancel", () => {
it("should send cancel message to extension host", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
@ -243,13 +248,13 @@ describe("AcpSession", () => {
describe("setMode", () => {
it("should update the session mode", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
@ -264,13 +269,13 @@ describe("AcpSession", () => {
describe("dispose", () => {
it("should dispose the extension host", async () => {
const session = await AcpSession.create(
"test-session",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "test-session",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
const mockHostInstance = vi.mocked(ExtensionHost).mock.results[0]!.value
@ -282,13 +287,13 @@ describe("AcpSession", () => {
describe("getSessionId", () => {
it("should return the session ID", async () => {
const session = await AcpSession.create(
"my-unique-session-id",
"/test/workspace",
mockConnection,
undefined,
defaultOptions,
)
const session = await AcpSession.create({
sessionId: "my-unique-session-id",
cwd: "/test/workspace",
connection: mockConnection,
options: defaultOptions,
deps: {},
})
expect(session.getSessionId()).toBe("my-unique-session-id")
})

View file

@ -5,65 +5,39 @@
* This allows ACP clients like Zed to use Roo Code as their AI coding assistant.
*/
import * as acp from "@agentclientprotocol/sdk"
import {
type Agent,
type ClientCapabilities,
type CancelNotification,
// Requests + Responses
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type SetSessionModelRequest,
type SetSessionModelResponse,
type AuthenticateRequest,
type AuthenticateResponse,
type PromptRequest,
type PromptResponse,
// Classes
AgentSideConnection,
RequestError,
// Constants
PROTOCOL_VERSION,
} from "@agentclientprotocol/sdk"
import { randomUUID } from "node:crypto"
import { login, status } from "@/commands/auth/index.js"
import { DEFAULT_FLAGS } from "@/types/constants.js"
import { envVarMap } from "@/lib/utils/provider.js"
import { login, status } from "@/commands/auth/index.js"
import { AcpSession, type AcpSessionOptions } from "./session.js"
import { AVAILABLE_MODES, DEFAULT_MODELS } from "./types.js"
import { type AcpSessionOptions, AcpSession } from "./session.js"
import { acpLog } from "./logger.js"
import { ModelService, createModelService } from "./model-service.js"
import { type ExtendedNewSessionResponse, type AcpModelState } from "./types.js"
import { envVarMap } from "@/lib/utils/provider.js"
// =============================================================================
// Types
// =============================================================================
export interface RooCodeAgentOptions {
/** Path to the extension bundle */
extensionPath: string
/** API provider (defaults to openrouter) */
provider?: string
/** API key (optional, may come from environment) */
apiKey?: string
/** Model to use (defaults to a sensible default) */
model?: string
/** Initial mode (defaults to code) */
mode?: string
}
// =============================================================================
// Available Modes
// =============================================================================
const AVAILABLE_MODES: acp.SessionMode[] = [
{
id: "code",
name: "Code",
description: "Write, modify, and refactor code",
},
{
id: "architect",
name: "Architect",
description: "Plan and design system architecture",
},
{
id: "ask",
name: "Ask",
description: "Ask questions and get explanations",
},
{
id: "debug",
name: "Debug",
description: "Debug issues and troubleshoot problems",
},
]
// =============================================================================
// RooCodeAgent Class
// =============================================================================
/**
* RooCodeAgent implements the ACP Agent interface.
@ -71,42 +45,30 @@ const AVAILABLE_MODES: acp.SessionMode[] = [
* It manages multiple sessions, each with its own ExtensionHost instance,
* and handles protocol-level operations like initialization and authentication.
*/
export class RooCodeAgent implements acp.Agent {
export class RooCodeAgent implements Agent {
private sessions: Map<string, AcpSession> = new Map()
private clientCapabilities: acp.ClientCapabilities | undefined
private clientCapabilities: ClientCapabilities | undefined
private isAuthenticated = false
private readonly modelService: ModelService
constructor(
private readonly options: RooCodeAgentOptions,
private readonly connection: acp.AgentSideConnection,
private readonly options: AcpSessionOptions,
private readonly connection: AgentSideConnection,
) {
// Initialize model service with optional API key
this.modelService = createModelService({
apiKey: options.apiKey,
})
acpLog.info("Agent", `RooCodeAgent constructor: connection=${connection}`)
this.modelService = createModelService({ apiKey: options.apiKey })
}
// ===========================================================================
// Initialization
// ===========================================================================
/**
* Initialize the agent and exchange capabilities with the client.
*/
async initialize(params: acp.InitializeRequest): Promise<acp.InitializeResponse> {
acpLog.request("initialize", { protocolVersion: params.protocolVersion })
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
acpLog.request("initialize", params)
this.clientCapabilities = params.clientCapabilities
acpLog.debug("Agent", "Client capabilities", this.clientCapabilities)
// Check if already authenticated via environment or existing credentials
const authStatus = await status({ verbose: false })
this.isAuthenticated = authStatus.authenticated
acpLog.debug("Agent", `Auth status: ${this.isAuthenticated ? "authenticated" : "not authenticated"}`)
// Check if already authenticated via environment or existing credentials.
const { authenticated } = await status({ verbose: false })
acpLog.debug("Agent", `Auth status: ${authenticated ? "authenticated" : "not authenticated"}`)
const response: acp.InitializeResponse = {
protocolVersion: acp.PROTOCOL_VERSION,
return {
protocolVersion: PROTOCOL_VERSION,
authMethods: [
{
id: "roo",
@ -122,27 +84,118 @@ export class RooCodeAgent implements acp.Agent {
},
},
}
}
acpLog.response("initialize", response)
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
acpLog.request("newSession", params)
// @TODO: Detect other env vars for different provider and choose
// the correct provider or throw.
if (!this.isAuthenticated) {
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
if (!apiKey) {
acpLog.error("Agent", "newSession failed: not authenticated and no API key")
throw RequestError.authRequired()
}
this.isAuthenticated = true
}
const sessionId = randomUUID()
const provider = this.options.provider || "openrouter"
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
const mode = this.options.mode || AVAILABLE_MODES[0]!.id
const model = this.options.model || DEFAULT_FLAGS.model
const session = await AcpSession.create({
sessionId,
cwd: params.cwd,
connection: this.connection,
options: {
extensionPath: this.options.extensionPath,
provider,
apiKey,
model,
mode,
},
deps: {
logger: acpLog,
},
})
this.sessions.set(sessionId, session)
const availableModels = await this.modelService.fetchAvailableModels()
const modelExists = availableModels.some((m) => m.modelId === model)
const response: NewSessionResponse = {
sessionId,
modes: { currentModeId: mode, availableModes: AVAILABLE_MODES },
models: {
availableModels,
currentModelId: modelExists ? model : DEFAULT_MODELS[0]!.modelId,
},
}
acpLog.response("newSession", response)
return response
}
// ===========================================================================
// Authentication
// ===========================================================================
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
acpLog.request("setSessionMode", params)
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `setSessionMode failed: session not found: ${params.sessionId}`)
throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const mode = AVAILABLE_MODES.find((m) => m.id === params.modeId)
if (!mode) {
acpLog.error("Agent", `setSessionMode failed: unknown mode: ${params.modeId}`)
throw RequestError.invalidParams(undefined, `Unknown mode: ${params.modeId}`)
}
session.setMode(params.modeId)
acpLog.response("setSessionMode", {})
return {}
}
async unstable_setSessionModel?(params: SetSessionModelRequest): Promise<SetSessionModelResponse | void> {
acpLog.request("setSessionMode", params)
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `unstable_setSessionModel failed: session not found: ${params.sessionId}`)
throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const availableModels = await this.modelService.fetchAvailableModels()
const modelExists = availableModels.some((m) => m.modelId === params.modelId)
if (!modelExists) {
acpLog.error("Agent", `unstable_setSessionModel failed: model not found: ${params.modelId}`)
throw RequestError.invalidParams(undefined, `Model not found: ${params.modelId}`)
}
session.setModel(params.modelId)
acpLog.response("unstable_setSessionModel", {})
return {}
}
async authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse | void> {
acpLog.request("authenticate", params)
/**
* Authenticate with Roo Code Cloud.
*/
async authenticate(params: acp.AuthenticateRequest): Promise<acp.AuthenticateResponse | void> {
if (params.methodId !== "roo") {
throw acp.RequestError.invalidParams(undefined, `Invalid auth method: ${params.methodId}`)
throw RequestError.invalidParams(undefined, `Invalid auth method: ${params.methodId}`)
}
const result = await login({ verbose: false })
if (!result.success) {
throw acp.RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud")
throw RequestError.authRequired(undefined, "Failed to authenticate with Roo Code Cloud")
}
this.isAuthenticated = true
@ -151,90 +204,7 @@ export class RooCodeAgent implements acp.Agent {
return {}
}
// ===========================================================================
// Session Management
// ===========================================================================
/**
* Create a new session.
*/
async newSession(params: acp.NewSessionRequest): Promise<ExtendedNewSessionResponse> {
acpLog.request("newSession", { cwd: params.cwd })
// Require authentication
if (!this.isAuthenticated) {
// Check if API key is available
const apiKey = this.options.apiKey || process.env.OPENROUTER_API_KEY
if (!apiKey) {
acpLog.error("Agent", "newSession failed: not authenticated and no API key")
throw acp.RequestError.authRequired()
}
this.isAuthenticated = true
}
const sessionId = randomUUID()
const initialMode = this.options.mode || "code"
acpLog.info("Agent", `Creating new session: ${sessionId}`)
const sessionOptions: AcpSessionOptions = {
extensionPath: this.options.extensionPath,
provider: this.options.provider || "openrouter",
apiKey: this.options.apiKey || process.env.OPENROUTER_API_KEY,
model: this.options.model || DEFAULT_FLAGS.model,
mode: initialMode,
}
acpLog.debug("Agent", "Session options", {
extensionPath: sessionOptions.extensionPath,
provider: sessionOptions.provider,
model: sessionOptions.model,
mode: sessionOptions.mode,
})
const session = await AcpSession.create(
sessionId,
params.cwd,
this.connection,
this.clientCapabilities,
sessionOptions,
)
this.sessions.set(sessionId, session)
acpLog.info("Agent", `Session created successfully: ${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_FLAGS.model
return this.modelService.getModelState(currentModelId)
}
// ===========================================================================
// Prompt Handling
// ===========================================================================
/**
* Process a prompt request.
*/
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
async prompt(params: PromptRequest): Promise<PromptResponse> {
acpLog.request("prompt", {
sessionId: params.sessionId,
promptLength: params.prompt?.length ?? 0,
@ -243,7 +213,7 @@ export class RooCodeAgent implements acp.Agent {
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `prompt failed: session not found: ${params.sessionId}`)
throw acp.RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
throw RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const response = await session.prompt(params)
@ -251,14 +221,7 @@ export class RooCodeAgent implements acp.Agent {
return response
}
// ===========================================================================
// Session Control
// ===========================================================================
/**
* Cancel an ongoing prompt.
*/
async cancel(params: acp.CancelNotification): Promise<void> {
async cancel(params: CancelNotification): Promise<void> {
acpLog.request("cancel", { sessionId: params.sessionId })
const session = this.sessions.get(params.sessionId)
@ -270,37 +233,6 @@ export class RooCodeAgent implements acp.Agent {
}
}
/**
* Set the session mode.
*/
async setSessionMode(params: acp.SetSessionModeRequest): Promise<acp.SetSessionModeResponse | void> {
acpLog.request("setSessionMode", { sessionId: params.sessionId, modeId: params.modeId })
const session = this.sessions.get(params.sessionId)
if (!session) {
acpLog.error("Agent", `setSessionMode failed: session not found: ${params.sessionId}`)
throw acp.RequestError.invalidParams(undefined, `Session not found: ${params.sessionId}`)
}
const mode = AVAILABLE_MODES.find((m) => m.id === params.modeId)
if (!mode) {
acpLog.error("Agent", `setSessionMode failed: unknown mode: ${params.modeId}`)
throw acp.RequestError.invalidParams(undefined, `Unknown mode: ${params.modeId}`)
}
session.setMode(params.modeId)
acpLog.info("Agent", `Set session ${params.sessionId} mode to: ${params.modeId}`)
acpLog.response("setSessionMode", {})
return {}
}
// ===========================================================================
// Cleanup
// ===========================================================================
/**
* Dispose of all sessions and cleanup.
*/
async dispose(): Promise<void> {
acpLog.info("Agent", `Disposing ${this.sessions.size} sessions`)
const disposals = Array.from(this.sessions.values()).map((session) => session.dispose())

View file

@ -3,8 +3,6 @@
*
* Manages streaming of command execution output with code fence wrapping.
* Handles both live command execution events and final command_output messages.
*
* Extracted from session.ts to separate the command output streaming concern.
*/
import type { ClineMessage } from "@roo-code/types"
@ -106,33 +104,36 @@ export class CommandStreamManager {
const output = message.text || ""
const isPartial = message.partial === true
// Skip partial updates - streaming is handled by handleExecutionOutput()
// Skip partial updates - streaming is handled by handleExecutionOutput().
if (isPartial) {
return
}
// Handle completion - update the tool call UI
// Handle completion - update the tool call UI.
const pendingCall = this.findMostRecentPendingCommand()
if (pendingCall) {
// Send closing code fence as agent_message_chunk if we had streaming output
// Send closing code fence as agent_message_chunk if we had streaming output.
const hadStreamingOutput = this.commandCodeFencesSent.has(pendingCall.toolCallId)
if (hadStreamingOutput) {
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "```\n" },
})
this.commandCodeFencesSent.delete(pendingCall.toolCallId)
}
// Command completed - send final tool_call_update with completed status
// Note: Zed doesn't display tool_call_update content, so we just mark it complete
// Command completed - send final tool_call_update with completed status.
// Note: Zed doesn't display tool_call_update content, so we just mark it complete.
this.sendUpdate({
sessionUpdate: "tool_call_update",
toolCallId: pendingCall.toolCallId,
status: "completed",
rawOutput: { output },
})
this.pendingCommandCalls.delete(pendingCall.toolCallId)
}
}
@ -152,21 +153,24 @@ export class CommandStreamManager {
* Uses executionId toolCallId mapping for robust routing.
*/
handleExecutionOutput(executionId: string, output: string): void {
// Find or establish the toolCallId for this executionId
// Find or establish the toolCallId for this executionId.
let toolCallId = this.executionToToolCallId.get(executionId)
if (!toolCallId) {
// First output for this executionId - establish the mapping
// First output for this executionId - establish the mapping.
const pendingCall = this.findMostRecentPendingCommand()
if (!pendingCall) {
return
}
toolCallId = pendingCall.toolCallId
this.executionToToolCallId.set(executionId, toolCallId)
}
// Use executionId as the message key for delta tracking
// Use executionId as the message key for delta tracking.
const delta = this.deltaTracker.getDelta(executionId, output)
if (!delta) {
return
}
@ -175,13 +179,14 @@ export class CommandStreamManager {
const isFirstChunk = !this.commandCodeFencesSent.has(toolCallId)
if (isFirstChunk) {
this.commandCodeFencesSent.add(toolCallId)
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "```\n" },
})
}
// Send the delta as agent_message_chunk for Zed visibility
// Send the delta as agent_message_chunk for Zed visibility.
this.sendUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
@ -201,7 +206,7 @@ export class CommandStreamManager {
*/
reset(): void {
// Clear all pending commands - any from previous prompts are now stale
// and would cause duplicate completion messages if not cleaned up
// and would cause duplicate completion messages if not cleaned up.
this.pendingCommandCalls.clear()
this.commandCodeFencesSent.clear()
this.executionToToolCallId.clear()
@ -221,10 +226,6 @@ export class CommandStreamManager {
return this.commandCodeFencesSent.size > 0
}
// ===========================================================================
// Private Methods
// ===========================================================================
/**
* Find the most recent pending command call.
*/

View file

@ -1,186 +1,2 @@
// Main agent exports
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,
IAcpSession,
IContentFormatter,
IExtensionClient,
IExtensionHost,
IDeltaTracker,
IPromptStateMachine,
ICommandStreamManager,
IToolContentStreamManager,
AcpSessionDependencies,
SendUpdateFn,
PromptStateType,
PromptCompletionResult,
StreamManagerOptions,
} from "./interfaces.js"
export { NullLogger } from "./interfaces.js"
// Logger
export { RooCodeAgent } from "./agent.js"
export { acpLog } from "./logger.js"
// Utilities
export { DeltaTracker } from "./delta-tracker.js"
// Shared utility functions
export {
// Result type
type Result,
ok,
err,
// Formatting functions
formatSearchResults,
formatReadContent,
wrapInCodeBlock,
// Content extraction
extractContentFromParams,
// File operations
readFileContent,
readFileContentAsync,
resolveFilePath,
resolveFilePathUnsafe,
// Validation
isUserEcho,
hasValidFilePath,
// Config
type FormatConfig,
DEFAULT_FORMAT_CONFIG,
} from "./utils/index.js"
// Tool Registry
export {
// Categories
TOOL_CATEGORIES,
type ToolCategory,
type KnownToolName,
// Detection functions
isEditTool,
isReadTool,
isSearchTool,
isListFilesTool,
isExecuteTool,
isDeleteTool,
isMoveTool,
isThinkTool,
isFetchTool,
isSwitchModeTool,
isFileWriteTool,
// Kind mapping
mapToolToKind,
// Validation schemas
FilePathParamsSchema,
FileWriteParamsSchema,
FileMoveParamsSchema,
SearchParamsSchema,
ListFilesParamsSchema,
CommandParamsSchema,
ThinkParamsSchema,
SwitchModeParamsSchema,
GenericToolParamsSchema,
ToolMessageSchema,
// Parameter types
type FilePathParams,
type FileWriteParams,
type FileMoveParams,
type SearchParams,
type ListFilesParams,
type CommandParams,
type ThinkParams,
type SwitchModeParams,
type GenericToolParams,
type ToolParams,
type ToolMessage,
// Validation functions
type ValidationResult,
validateToolParams,
parseToolParams,
parseToolMessage,
} from "./tool-registry.js"
// State management
export { PromptStateMachine, createPromptStateMachine, type PromptStateMachineOptions } from "./prompt-state.js"
// Content formatting
export {
// Direct function exports (preferred for simple use)
formatToolResult,
extractFileContent,
extractFileContentAsync,
// Re-exported utilities
formatSearchResults as formatSearch,
formatReadContent as formatRead,
wrapInCodeBlock as wrapCode,
isUserEcho as checkUserEcho,
// Class-based DI
ContentFormatter,
createContentFormatter,
type ContentFormatterConfig,
} from "./content-formatter.js"
// Tool handlers
export {
type ToolHandler,
type ToolHandlerContext,
type ToolHandleResult,
ToolHandlerRegistry,
// Individual handlers for extension
CommandToolHandler,
FileEditToolHandler,
FileReadToolHandler,
SearchToolHandler,
ListFilesToolHandler,
DefaultToolHandler,
} from "./tool-handler.js"
// Stream managers
export { CommandStreamManager, type PendingCommand, type CommandStreamManagerOptions } from "./command-stream.js"
export { ToolContentStreamManager, type ToolContentStreamManagerOptions } from "./tool-content-stream.js"
// Session event handler
export {
SessionEventHandler,
createSessionEventHandler,
type SessionEventHandlerDeps,
type TaskCompletedCallback,
} from "./session-event-handler.js"
// Translation utilities
export {
// Message translation
translateToAcpUpdate,
isPermissionAsk,
isCompletionAsk,
createPermissionOptions,
// Tool parsing
parseToolFromMessage,
generateToolTitle,
extractToolContent,
buildToolCallFromMessage,
type ToolCallInfo,
// Prompt extraction
extractPromptText,
extractPromptImages,
extractPromptResources,
// Location extraction
extractLocations,
extractFilePathsFromSearchResults,
type LocationParams,
// Diff parsing
parseUnifiedDiff,
isUnifiedDiff,
type ParsedDiff,
// Backward compatibility
mapToolKind,
} from "./translator.js"

View file

@ -4,49 +4,32 @@
* Fetches and caches available models from the Roo Code API.
*/
import type { AcpModel, AcpModelState } from "./types.js"
import type { ModelInfo } from "@agentclientprotocol/sdk"
import { DEFAULT_MODELS } from "./types.js"
import { acpLog } from "./logger.js"
// =============================================================================
// Types
// =============================================================================
const DEFAULT_API_URL = "https://api.roocode.com"
const DEFAULT_TIMEOUT = 5_000
interface RooModel {
id: string
name: string
description?: string
object?: string
created?: number
owned_by?: string
}
export interface ModelServiceOptions {
/** Base URL for the API (defaults to https://api.roocode.com) */
/** Base URL for the API (defaults to DEFAULT_API_URL) */
apiUrl?: string
/** API key for authentication */
apiKey?: string
/** Request timeout in milliseconds (defaults to 5000) */
/** Request timeout in milliseconds (defaults to DEFAULT_TIMEOUT) */
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.
*/
@ -54,7 +37,7 @@ export class ModelService {
private readonly apiUrl: string
private readonly apiKey?: string
private readonly timeout: number
private cachedModels: AcpModel[] | null = null
private cachedModels: ModelInfo[] | null = null
constructor(options: ModelServiceOptions = {}) {
this.apiUrl = options.apiUrl || DEFAULT_API_URL
@ -67,8 +50,7 @@ export class ModelService {
* 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
async fetchAvailableModels(): Promise<ModelInfo[]> {
if (this.cachedModels) {
return this.cachedModels
}
@ -99,7 +81,7 @@ export class ModelService {
return this.cachedModels
}
const data = (await response.json()) as ModelsApiResponse
const data = await response.json()
if (!data.data || !Array.isArray(data.data)) {
acpLog.warn("ModelService", "Invalid API response format, using default models")
@ -107,10 +89,7 @@ export class ModelService {
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`)
this.cachedModels = this.translateModels(data.data)
return this.cachedModels
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
@ -121,27 +100,12 @@ export class 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.
*/
@ -149,68 +113,15 @@ export class ModelService {
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[] = []
private translateModels(data: RooModel[]): ModelInfo[] {
const models: ModelInfo[] = data
.map(({ id, name, description }) => ({ modelId: id, name, description }))
.sort((a, b) => a.modelId.localeCompare(b.modelId))
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(" ")
return models.length === 0 ? DEFAULT_MODELS : models
}
}
// =============================================================================
// Factory Function
// =============================================================================
/**
* Create a new ModelService instance.
*/

View file

@ -2,11 +2,18 @@
* Session Event Handler
*
* Handles events from the ExtensionClient and ExtensionHost, translating them to ACP updates.
* Extracted from session.ts for better separation of concerns.
*/
import type { SessionMode } from "@agentclientprotocol/sdk"
import type { ClineMessage, ClineAsk, ClineSay, ExtensionMessage, ExtensionState, ModeConfig } from "@roo-code/types"
import type {
ClineMessage,
ClineAsk,
ClineSay,
ExtensionMessage,
ExtensionState,
WebviewMessage,
ModeConfig,
} from "@roo-code/types"
import type { WaitingForInputEvent, TaskCompletedEvent, CommandExecutionOutputEvent } from "@/agent/events.js"
@ -123,7 +130,7 @@ export interface SessionEventHandlerDeps {
/** Callback to respond with text */
respondWithText: (text: string) => void
/** Callback to send message to extension */
sendToExtension: (message: unknown) => void
sendToExtension: (message: WebviewMessage) => void
/** Workspace path */
workspacePath: string
/** Initial mode ID */
@ -169,7 +176,7 @@ export class SessionEventHandler {
private readonly sendUpdate: SendUpdateFn
private readonly approveAction: () => void
private readonly respondWithText: (text: string) => void
private readonly sendToExtension: (message: unknown) => void
private readonly sendToExtension: (message: WebviewMessage) => void
private readonly workspacePath: string
private readonly isCancelling: () => boolean

View file

@ -6,14 +6,15 @@
*/
import {
type SessionNotification,
type ClientCapabilities,
type SessionUpdate,
type PromptRequest,
type PromptResponse,
type SessionModeState,
AgentSideConnection,
} from "@agentclientprotocol/sdk"
import type { SupportedProvider } from "@/types/types.js"
import { getProviderSettings } from "@/lib/utils/provider.js"
import { type ExtensionHostOptions, ExtensionHost } from "@/agent/extension-host.js"
import { AgentLoopState } from "@/agent/agent-state.js"
@ -35,20 +36,11 @@ import type {
} from "./interfaces.js"
import { type Result, ok, err } from "./utils/index.js"
// =============================================================================
// Types
// =============================================================================
export interface AcpSessionOptions {
/** Path to the extension bundle */
extensionPath: string
/** API provider */
provider: string
/** API key (optional, may come from environment) */
provider: SupportedProvider
apiKey?: string
/** Model to use */
model: string
/** Initial mode */
mode: string
}
@ -81,9 +73,6 @@ export class AcpSession implements IAcpSession {
/** Session event handler for managing extension events */
private readonly eventHandler: SessionEventHandler
/** Workspace path for resolving relative file paths */
private readonly workspacePath: string
/** Current model ID */
private currentModelId: string = DEFAULT_MODELS[0]!.modelId
@ -94,27 +83,18 @@ export class AcpSession implements IAcpSession {
private readonly sessionId: string,
private readonly extensionHost: ExtensionHost,
private readonly connection: AgentSideConnection,
workspacePath: string,
initialMode: string,
private readonly workspacePath: string,
private readonly options: AcpSessionOptions,
deps: AcpSessionDependencies = {},
) {
this.workspacePath = workspacePath
// Initialize dependencies with defaults or injected instances.
this.logger = deps.logger ?? acpLog
this.promptState = deps.createPromptStateMachine?.() ?? new PromptStateMachine({ logger: this.logger })
this.deltaTracker = deps.createDeltaTracker?.() ?? new DeltaTracker()
// Initialize tool handler registry.
const sendUpdate = (update: SessionUpdate) => connection.sessionUpdate({ sessionId: this.sessionId, update })
this.toolHandlerRegistry = new ToolHandlerRegistry()
// Create send update callback for stream managers.
// Updates are sent directly to preserve chunk ordering.
const sendUpdate = (update: SessionNotification["update"]) => {
void this.sendUpdateDirect(update)
}
// Initialize stream managers with injected logger.
this.commandStreamManager = new CommandStreamManager({
deltaTracker: this.deltaTracker,
sendUpdate,
@ -127,7 +107,7 @@ export class AcpSession implements IAcpSession {
logger: this.logger,
})
// Create event handler with extension host for mode tracking
// Create event handler with extension host for mode tracking.
this.eventHandler = createSessionEventHandler({
logger: this.logger,
client: extensionHost.client,
@ -139,22 +119,21 @@ export class AcpSession implements IAcpSession {
toolHandlerRegistry: this.toolHandlerRegistry,
sendUpdate,
approveAction: () => this.extensionHost.client.approve(),
respondWithText: (text: string) => this.extensionHost.client.respond(text),
sendToExtension: (message) =>
this.extensionHost.sendToExtension(message as Parameters<typeof this.extensionHost.sendToExtension>[0]),
respondWithText: (text: string, images?: string[]) => this.extensionHost.client.respond(text, images),
sendToExtension: (message) => this.extensionHost.sendToExtension(message),
workspacePath,
initialModeId: initialMode,
initialModeId: this.options.mode,
isCancelling: () => this.isCancelling,
})
this.eventHandler.onTaskCompleted((success) => this.handleTaskCompleted(success))
// Listen for state changes to log and detect cancellation completion
// Listen for state changes to log and detect cancellation completion.
this.extensionHost.client.on("stateChange", (event) => {
const prev = event.previousState
const curr = event.currentState
// Only log if something actually changed
// Only log if something actually changed.
const stateChanged =
prev.state !== curr.state ||
prev.isRunning !== curr.isRunning ||
@ -188,32 +167,25 @@ export class AcpSession implements IAcpSession {
})
}
// ===========================================================================
// Factory Method
// ===========================================================================
/**
* Create a new AcpSession.
*
* This initializes an ExtensionHost for the given working directory
* and sets up event handlers to stream updates to the ACP client.
*
* @param sessionId - Unique session identifier
* @param cwd - Working directory for the session
* @param connection - ACP connection for sending updates
* @param _clientCapabilities - Client capabilities (currently unused)
* @param options - Session configuration options
* @param deps - Optional dependencies for testing
*/
static async create(
sessionId: string,
cwd: string,
connection: AgentSideConnection,
_clientCapabilities: ClientCapabilities | undefined,
options: AcpSessionOptions,
deps: AcpSessionDependencies = {},
): Promise<AcpSession> {
// Create ExtensionHost with ACP-specific configuration.
static async create({
sessionId,
cwd,
connection,
options,
deps,
}: {
sessionId: string
cwd: string
connection: AgentSideConnection
options: AcpSessionOptions
deps: AcpSessionDependencies
}): Promise<AcpSession> {
const hostOptions: ExtensionHostOptions = {
mode: options.mode,
user: null,
@ -222,16 +194,14 @@ export class AcpSession implements IAcpSession {
model: options.model,
workspacePath: cwd,
extensionPath: options.extensionPath,
// ACP mode: disable direct output, we stream through ACP.
disableOutput: true,
// Don't persist state - ACP clients manage their own sessions.
ephemeral: true,
disableOutput: true, // ACP mode: disable direct output, we stream through ACP.
ephemeral: true, // Don't persist state - ACP clients manage their own sessions.
}
const extensionHost = new ExtensionHost(hostOptions)
await extensionHost.activate()
const session = new AcpSession(sessionId, extensionHost, connection, cwd, options.mode, deps)
const session = new AcpSession(sessionId, extensionHost, connection, cwd, options, deps)
session.setupEventHandlers()
return session
@ -365,13 +335,8 @@ export class AcpSession implements IAcpSession {
*/
setModel(modelId: string): void {
this.currentModelId = modelId
// Map model ID to extension settings
// The property is apiModelId for most providers
this.extensionHost.sendToExtension({
type: "updateSettings",
updatedSettings: { apiModelId: modelId },
})
const updatedSettings = getProviderSettings(this.options.provider, this.options.apiKey, modelId)
this.extensionHost.sendToExtension({ type: "updateSettings", updatedSettings })
}
/**
@ -403,10 +368,7 @@ export class AcpSession implements IAcpSession {
*/
async dispose(): Promise<void> {
this.cancel()
// Clean up event handler listeners
this.eventHandler.cleanup()
await this.extensionHost.dispose()
}
@ -419,10 +381,10 @@ export class AcpSession implements IAcpSession {
*
* @returns Result indicating success or failure with error details.
*/
private async sendUpdateDirect(update: SessionNotification["update"]): Promise<Result<void>> {
private async sendUpdate(update: SessionUpdate): Promise<Result<void>> {
try {
// Log the update being sent to ACP connection (commented out - too noisy)
// this.logger.info("Session", `OUT: ${JSON.stringify(update)}`)
this.logger.info("Session", `OUT: ${JSON.stringify(update)}`)
await this.connection.sessionUpdate({ sessionId: this.sessionId, update })
return ok(undefined)
} catch (error) {

View file

@ -1,76 +1,42 @@
/**
* ACP Types for Mode and Model Pickers
*
* Extends the standard ACP types with model support for the Roo Code agent.
*/
import type { ModelInfo, SessionMode } from "@agentclientprotocol/sdk"
import type * as acp from "@agentclientprotocol/sdk"
import { DEFAULT_FLAGS } from "@/types/constants.js"
// =============================================================================
// 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.
* The first model uses DEFAULT_FLAGS.model as the source of truth.
*/
export const DEFAULT_MODELS: AcpModel[] = [
{
modelId: DEFAULT_FLAGS.model,
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
},
export const DEFAULT_MODELS: ModelInfo[] = [
{
modelId: "anthropic/claude-opus-4.5",
name: "Claude Opus 4.5",
description: "Most capable for complex work",
},
{
modelId: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
description: "Best balance of speed and capability",
},
{
modelId: "anthropic/claude-haiku-4.5",
name: "Claude Haiku 4.5",
description: "Fastest for quick answers",
},
]
export const AVAILABLE_MODES: SessionMode[] = [
{
id: "code",
name: "Code",
description: "Write, modify, and refactor code",
},
{
id: "architect",
name: "Architect",
description: "Plan and design system architecture",
},
{
id: "ask",
name: "Ask",
description: "Ask questions and get explanations",
},
{
id: "debug",
name: "Debug",
description: "Debug issues and troubleshoot problems",
},
]

View file

@ -6,7 +6,7 @@ import * as acpSdk from "@agentclientprotocol/sdk"
import { type SupportedProvider, DEFAULT_FLAGS } from "@/types/index.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
import { type RooCodeAgentOptions, RooCodeAgent, acpLog } from "@/acp/index.js"
import { RooCodeAgent, acpLog } from "@/acp/index.js"
export interface AcpCommandOptions {
extension?: string
@ -25,14 +25,6 @@ export async function runAcpServer(options: AcpCommandOptions): Promise<void> {
process.exit(1)
}
const agentOptions: RooCodeAgentOptions = {
extensionPath,
provider: options.provider || DEFAULT_FLAGS.provider,
model: options.model || DEFAULT_FLAGS.model,
mode: options.mode || DEFAULT_FLAGS.mode,
apiKey: options.apiKey || process.env.OPENROUTER_API_KEY,
}
// Set up stdio streams for ACP communication.
// Note: We write to stdout (agent -> client) and read from stdin (client -> agent).
const stdout = Writable.toWeb(process.stdout) as WritableStream<Uint8Array>
@ -45,7 +37,17 @@ export async function runAcpServer(options: AcpCommandOptions): Promise<void> {
const connection = new acpSdk.AgentSideConnection((conn: acpSdk.AgentSideConnection) => {
acpLog.info("Command", "Agent connection established")
agent = new RooCodeAgent(agentOptions, conn)
agent = new RooCodeAgent(
{
extensionPath,
provider: options.provider ?? DEFAULT_FLAGS.provider,
model: options.model || DEFAULT_FLAGS.model,
mode: options.mode || DEFAULT_FLAGS.mode,
apiKey: options.apiKey || process.env.OPENROUTER_API_KEY,
},
conn,
)
return agent
}, stream)

View file

@ -4,7 +4,7 @@ export const DEFAULT_FLAGS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.5",
provider: "openrouter",
provider: "openrouter" as const,
}
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]