diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts deleted file mode 100644 index 040c1947e2ed..000000000000 --- a/packages/opencode/src/acp-next/agent.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - RequestError, - type Agent as ACPAgent, - type AgentSideConnection, - type AuthenticateRequest, - type CancelNotification, - type CloseSessionRequest, - type ForkSessionRequest, - type InitializeRequest, - type ListSessionsRequest, - type LoadSessionRequest, - type NewSessionRequest, - type PromptRequest, - type ResumeSessionRequest, - type SetSessionConfigOptionRequest, - type SetSessionModelRequest, - type SetSessionModeRequest, -} from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" -import * as ACPNextError from "./error" -import * as ACPNextService from "./service" - -export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { - return { - create: (connection: AgentSideConnection) => { - return new Agent(ACPNextService.make({ sdk: _sdk, connection })) - }, - } -} - -export class Agent implements ACPAgent { - constructor(private readonly service: ACPNextService.Interface) {} - - initialize(params: InitializeRequest) { - return run(this.service.initialize(params)) - } - - authenticate(params: AuthenticateRequest) { - return run(this.service.authenticate(params)) - } - - newSession(params: NewSessionRequest) { - return run(this.service.newSession(params)) - } - - loadSession(params: LoadSessionRequest) { - return run(this.service.loadSession(params)) - } - - listSessions(params: ListSessionsRequest) { - return run(this.service.listSessions(params)) - } - - resumeSession(params: ResumeSessionRequest) { - return run(this.service.resumeSession(params)) - } - - closeSession(params: CloseSessionRequest) { - return run(this.service.closeSession(params)) - } - - unstable_forkSession(params: ForkSessionRequest) { - return run(this.service.forkSession(params)) - } - - setSessionConfigOption(params: SetSessionConfigOptionRequest) { - return run(this.service.setSessionConfigOption(params)) - } - - setSessionMode(params: SetSessionModeRequest) { - return run(this.service.setSessionMode(params)) - } - - unstable_setSessionModel(params: SetSessionModelRequest) { - return run(this.service.setSessionModel(params)) - } - - prompt(params: PromptRequest) { - return run(this.service.prompt(params)) - } - - cancel(params: CancelNotification) { - return run(this.service.cancel(params)) - } -} - -function run(effect: Effect.Effect) { - return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => { - if (defect instanceof RequestError) throw defect - throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect)) - }) -} - -export * as ACPNext from "./agent" diff --git a/packages/opencode/src/acp-next/session.ts b/packages/opencode/src/acp-next/session.ts deleted file mode 100644 index fa803c978156..000000000000 --- a/packages/opencode/src/acp-next/session.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import type { Message, Part } from "@opencode-ai/sdk/v2" -import { Context, Effect, Layer, Ref } from "effect" -import type { ModelID, ProviderID } from "../provider/schema" -import * as ACPNextError from "./error" - -export type SelectedModel = { - providerID: ProviderID - modelID: ModelID -} - -export type KnownMessagePartMetadata = { - messageId: string - partId: string - partType?: Part["type"] - role?: Message["role"] - ignored?: boolean - toolCallId?: string - metadata?: unknown -} - -export type Info = { - id: string - cwd: string - mcpServers: readonly McpServer[] - createdAt: Date - model?: SelectedModel - variant?: string - modeId?: string - knownParts: ReadonlyMap -} - -export type StoreInput = { - id: string - cwd: string - mcpServers?: readonly McpServer[] - createdAt?: Date - model?: SelectedModel - variant?: string - modeId?: string -} - -export type RecordPartMetadataInput = { - sessionId: string - messageId: string - partId: string - partType?: Part["type"] - role?: Message["role"] - ignored?: boolean - toolCallId?: string - metadata?: unknown -} - -export type PartMetadataLookupInput = { - sessionId: string - messageId: string - partId: string -} - -export type Interface = { - readonly create: (input: StoreInput) => Effect.Effect - readonly load: (input: StoreInput) => Effect.Effect - readonly list: (cwd?: string) => Effect.Effect - readonly get: (sessionId: string) => Effect.Effect - readonly tryGet: (sessionId: string) => Effect.Effect - readonly remove: (sessionId: string) => Effect.Effect - readonly setModel: ( - sessionId: string, - model: SelectedModel | undefined, - ) => Effect.Effect - readonly getModel: (sessionId: string) => Effect.Effect - readonly setVariant: ( - sessionId: string, - variant: string | undefined, - ) => Effect.Effect - readonly getVariant: (sessionId: string) => Effect.Effect - readonly setMode: ( - sessionId: string, - modeId: string | undefined, - ) => Effect.Effect - readonly getMode: (sessionId: string) => Effect.Effect - readonly recordPartMetadata: ( - input: RecordPartMetadataInput, - ) => Effect.Effect - readonly getPartMetadata: ( - input: PartMetadataLookupInput, - ) => Effect.Effect - readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ACPNext/Session") {} - -type State = Map - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const sessions = yield* Ref.make(new Map()) - - const store = Effect.fn("ACPNext.Session.store")(function* (input: StoreInput) { - const session = makeSession(input) - yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) - return snapshot(session) - }) - - const tryGet = Effect.fn("ACPNext.Session.tryGet")(function* (sessionId: string) { - const session = (yield* Ref.get(sessions)).get(sessionId) - if (!session) return - return snapshot(session) - }) - - const get = Effect.fn("ACPNext.Session.get")(function* (sessionId: string) { - const session = yield* tryGet(sessionId) - if (session) return session - return yield* new ACPNextError.SessionNotFoundError({ sessionId }) - }) - - const update = Effect.fn("ACPNext.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) { - const result = yield* Ref.modify(sessions, (state) => { - const session = state.get(sessionId) - if (!session) return [undefined, state] as const - const next = fn(session) - return [snapshot(next), new Map(state).set(sessionId, next)] as const - }) - if (result) return result - return yield* new ACPNextError.SessionNotFoundError({ sessionId }) - }) - - const remove = Effect.fn("ACPNext.Session.remove")(function* (sessionId: string) { - return yield* Ref.modify(sessions, (state) => { - const session = state.get(sessionId) - if (!session) return [undefined, state] as const - const next = new Map(state) - next.delete(sessionId) - return [snapshot(session), next] as const - }) - }) - - const setModel: Interface["setModel"] = Effect.fn("ACPNext.Session.setModel")((sessionId, model) => - update(sessionId, (session) => ({ ...session, model })), - ) - - const setVariant: Interface["setVariant"] = Effect.fn("ACPNext.Session.setVariant")((sessionId, variant) => - update(sessionId, (session) => ({ ...session, variant })), - ) - - const setMode: Interface["setMode"] = Effect.fn("ACPNext.Session.setMode")((sessionId, modeId) => - update(sessionId, (session) => ({ ...session, modeId })), - ) - - const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACPNext.Session.recordPartMetadata")(( - input, - ) => { - const metadata = { - messageId: input.messageId, - partId: input.partId, - partType: input.partType, - role: input.role, - ignored: input.ignored, - toolCallId: input.toolCallId, - metadata: input.metadata, - } - return update(input.sessionId, (session) => ({ - ...session, - knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), - })).pipe(Effect.as(metadata)) - }) - - return Service.of({ - create: store, - load: store, - list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) { - return [...(yield* Ref.get(sessions)).values()] - .filter((session) => !cwd || session.cwd === cwd) - .map(snapshot) - .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - }), - get, - tryGet, - remove, - setModel, - getModel: Effect.fn("ACPNext.Session.getModel")(function* (sessionId) { - return (yield* get(sessionId)).model - }), - setVariant, - getVariant: Effect.fn("ACPNext.Session.getVariant")(function* (sessionId) { - return (yield* get(sessionId)).variant - }), - setMode, - getMode: Effect.fn("ACPNext.Session.getMode")(function* (sessionId) { - return (yield* get(sessionId)).modeId - }), - recordPartMetadata, - getPartMetadata: Effect.fn("ACPNext.Session.getPartMetadata")(function* (input) { - return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) - }), - tryGetPartMetadata: Effect.fn("ACPNext.Session.tryGetPartMetadata")(function* (input) { - return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) - }), - }) - }), -) - -export const defaultLayer = layer - -function makeSession(input: StoreInput): Info { - return { - id: input.id, - cwd: input.cwd, - mcpServers: [...(input.mcpServers ?? [])], - createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), - model: input.model, - variant: input.variant, - modeId: input.modeId, - knownParts: new Map(), - } -} - -function snapshot(session: Info): Info { - return { - ...session, - mcpServers: [...session.mcpServers], - createdAt: new Date(session.createdAt), - knownParts: new Map(session.knownParts), - } -} - -function partMetadataKey(input: { messageId: string; partId: string }) { - return `${input.messageId}:${input.partId}` -} - -export * as ACPNextSession from "./session" diff --git a/packages/opencode/src/acp/README.md b/packages/opencode/src/acp/README.md deleted file mode 100644 index aab33259bb18..000000000000 --- a/packages/opencode/src/acp/README.md +++ /dev/null @@ -1,174 +0,0 @@ -# ACP (Agent Client Protocol) Implementation - -This directory contains a clean, protocol-compliant implementation of the [Agent Client Protocol](https://agentclientprotocol.com/) for opencode. - -## Architecture - -The implementation follows a clean separation of concerns: - -### Core Components - -- **`agent.ts`** - Implements the `Agent` interface from `@agentclientprotocol/sdk` - - Handles initialization and capability negotiation - - Manages session lifecycle (`session/new`, `session/load`) - - Processes prompts and returns responses - - Properly implements ACP protocol v1 - -- **`client.ts`** - Implements the `Client` interface for client-side capabilities - - File operations (`readTextFile`, `writeTextFile`) - - Permission requests (auto-approves for now) - - Terminal support (stub implementation) - -- **`session.ts`** - Session state management - - Creates and tracks ACP sessions - - Maps ACP sessions to internal opencode sessions - - Maintains working directory context - - Handles MCP server configurations - -- **`server.ts`** - ACP server startup and lifecycle - - Sets up JSON-RPC over stdio using the official library - - Manages graceful shutdown on SIGTERM/SIGINT - - Provides Instance context for the agent - -- **`types.ts`** - Type definitions for internal use - -## Usage - -### Command Line - -```bash -# Start the ACP server in the current directory -opencode acp - -# Start in a specific directory -opencode acp --cwd /path/to/project -``` - -### Question Tool Opt-In - -ACP excludes `QuestionTool` by default. - -```bash -OPENCODE_ENABLE_QUESTION_TOOL=1 opencode acp -``` - -Enable this only for ACP clients that support interactive question prompts. - -### Programmatic - -```typescript -import { ACPServer } from "./acp/server" - -await ACPServer.start() -``` - -### Integration with Zed - -Add to your Zed configuration (`~/.config/zed/settings.json`): - -```json -{ - "agent_servers": { - "OpenCode": { - "command": "opencode", - "args": ["acp"] - } - } -} -``` - -## Protocol Compliance - -This implementation follows the ACP specification v1: - -✅ **Initialization** - -- Proper `initialize` request/response with protocol version negotiation -- Capability advertisement (`agentCapabilities`) -- Authentication support (stub) - -✅ **Session Management** - -- `session/new` - Create new conversation sessions -- `session/load` - Resume existing sessions (basic support) -- Working directory context (`cwd`) -- MCP server configuration support - -✅ **Prompting** - -- `session/prompt` - Process user messages -- Content block handling (text, resources) -- Response with stop reasons - -✅ **Client Capabilities** - -- File read/write operations -- Permission requests -- Terminal support (stub for future) - -## Current Limitations - -### Not Yet Implemented - -1. **Streaming Responses** - Currently returns complete responses instead of streaming via `session/update` notifications -2. **Tool Call Reporting** - Doesn't report tool execution progress -3. **Session Modes** - No mode switching support yet -4. **Authentication** - No actual auth implementation -5. **Terminal Support** - Placeholder only -6. **Session Persistence** - `session/load` doesn't restore actual conversation history - -### Future Enhancements - -- **Real-time Streaming**: Implement `session/update` notifications for progressive responses -- **Tool Call Visibility**: Report tool executions as they happen -- **Session Persistence**: Save and restore full conversation history -- **Mode Support**: Implement different operational modes (ask, code, etc.) -- **Enhanced Permissions**: More sophisticated permission handling -- **Terminal Integration**: Full terminal support via opencode's bash tool - -## Testing - -```bash -# Run ACP tests -bun test test/acp.test.ts - -# Test manually with stdio -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}' | opencode acp -``` - -## Design Decisions - -### Why the Official Library? - -We use `@agentclientprotocol/sdk` instead of implementing JSON-RPC ourselves because: - -- Ensures protocol compliance -- Handles edge cases and future protocol versions -- Reduces maintenance burden -- Works with other ACP clients automatically - -### Clean Architecture - -Each component has a single responsibility: - -- **Agent** = Protocol interface -- **Client** = Client-side operations -- **Session** = State management -- **Server** = Lifecycle and I/O - -This makes the codebase maintainable and testable. - -### Mapping to OpenCode - -ACP sessions map cleanly to opencode's internal session model: - -- ACP `session/new` → creates internal Session -- ACP `session/prompt` → uses SessionPrompt.prompt() -- Working directory context preserved per-session -- Tool execution uses existing ToolRegistry - -## References - -- [ACP Specification](https://agentclientprotocol.com/) -- [TypeScript Library](https://github.com/agentclientprotocol/typescript-sdk) -- [Protocol Examples](https://github.com/agentclientprotocol/typescript-sdk/tree/main/src/examples) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8b74b9c9bad3..a7c59a261551 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -3,1964 +3,93 @@ import { type Agent as ACPAgent, type AgentSideConnection, type AuthenticateRequest, - type AuthMethod, type CancelNotification, type CloseSessionRequest, - type CloseSessionResponse, type ForkSessionRequest, - type ForkSessionResponse, type InitializeRequest, - type InitializeResponse, type ListSessionsRequest, - type ListSessionsResponse, type LoadSessionRequest, type NewSessionRequest, - type PermissionOption, - type PlanEntry, type PromptRequest, type ResumeSessionRequest, - type ResumeSessionResponse, - type Role, - type SessionInfo, - type SetSessionModelRequest, - type SessionConfigOption, type SetSessionConfigOptionRequest, - type SetSessionConfigOptionResponse, + type SetSessionModelRequest, type SetSessionModeRequest, - type SetSessionModeResponse, - type ToolCallContent, - type ToolKind, - type Usage, } from "@agentclientprotocol/sdk" - -import * as Log from "@opencode-ai/core/util/log" -import { pathToFileURL } from "url" -import { Filesystem } from "@/util/filesystem" -import { Hash } from "@opencode-ai/core/util/hash" -import { ACPSessionManager } from "./session" -import type { ACPConfig } from "./types" -import { ACPRuntime } from "./runtime" -import { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "../provider/schema" -import { MessageV2 } from "@/session/message-v2" -import { ConfigMCP } from "@/config/mcp" -import { Todo } from "@/session/todo" -import { Result, Schema } from "effect" -import { LoadAPIKeyError } from "ai" -import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" -import { applyPatch } from "diff" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ShellID } from "@/tool/shell/id" - -type ModeOption = { id: string; name: string; description?: string } -type ModelOption = { modelId: string; name: string } -const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info))) - -const DEFAULT_VARIANT_VALUE = "default" - -const log = Log.create({ service: "acp-agent" }) - -async function getContextLimit( - sdk: OpencodeClient, - providerID: ProviderID, - modelID: ModelID, - directory: string, -): Promise { - const providers = await sdk.config - .providers({ directory }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to get providers for context limit", { error }) - return [] - }) - - const provider = providers.find((p) => p.id === providerID) - const model = provider?.models[modelID] - return model?.limit.context ?? null -} - -async function sendUsageUpdate( - connection: AgentSideConnection, - sdk: OpencodeClient, - sessionID: string, - directory: string, -): Promise { - const messages = await sdk.session - .messages({ sessionID, directory }, { throwOnError: true }) - .then((x) => x.data) - .catch((error) => { - log.error("failed to fetch messages for usage update", { error }) - return undefined - }) - - if (!messages) return - - const assistantMessages = messages.filter( - (m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant", - ) - - const lastAssistant = assistantMessages[assistantMessages.length - 1] - if (!lastAssistant) return - - const msg = lastAssistant.info - if (!msg.providerID || !msg.modelID) return - const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) - - if (!size) { - // Cannot calculate usage without known context size - return - } - - const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0) - const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0) - - await connection - .sessionUpdate({ - sessionId: sessionID, - update: { - sessionUpdate: "usage_update", - used, - size, - cost: { amount: totalCost, currency: "USD" }, - }, - }) - .catch((error) => { - log.error("failed to send usage update", { error }) - }) -} +import { Effect } from "effect" +import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import * as ACPError from "./error" +import * as ACPService from "./service" export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { return { - create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { - return new Agent(connection, fullConfig) + create: (connection: AgentSideConnection) => { + return new Agent(ACPService.make({ sdk: _sdk, connection })) }, } } export class Agent implements ACPAgent { - private connection: AgentSideConnection - private config: ACPConfig - private sdk: OpencodeClient - private sessionManager: ACPSessionManager - private eventAbort = new AbortController() - private eventStarted = false - private shellSnapshots = new Map() - private toolStarts = new Set() - private permissionQueues = new Map>() - private permissionOptions: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] - - constructor(connection: AgentSideConnection, config: ACPConfig) { - this.connection = connection - this.config = config - this.sdk = config.sdk - this.sessionManager = new ACPSessionManager(this.sdk) - this.startEventSubscription() - } - - private startEventSubscription() { - if (this.eventStarted) return - this.eventStarted = true - this.runEventSubscription().catch((error) => { - if (this.eventAbort.signal.aborted) return - log.error("event subscription failed", { error }) - }) - } - - private async runEventSubscription() { - while (true) { - if (this.eventAbort.signal.aborted) return - const events = await this.sdk.global.event({ - signal: this.eventAbort.signal, - }) - for await (const event of events.stream) { - if (this.eventAbort.signal.aborted) return - const payload = event?.payload - if (!payload) continue - await this.handleEvent(payload as Event).catch((error) => { - log.error("failed to handle event", { error, type: payload.type }) - }) - } - } - } - - private async handleEvent(event: Event) { - switch (event.type) { - case "permission.asked": { - const permission = event.properties - const session = this.sessionManager.tryGet(permission.sessionID) - if (!session) return - - const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() - const next = prev - .then(async () => { - const directory = session.cwd - - const res = await this.connection - .requestPermission({ - sessionId: permission.sessionID, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options: this.permissionOptions, - }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return undefined - }) - - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return - } - - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : "" - const newContent = getNewContent(content, diff) - - if (newContent) { - void this.connection.writeTextFile({ - sessionId: session.id, - path: filepath, - content: newContent, - }) - } - } - - await this.sdk.permission.reply({ - requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", - directory, - }) - }) - .catch((error) => { - log.error("failed to handle permission", { error, permissionID: permission.id }) - }) - .finally(() => { - if (this.permissionQueues.get(permission.sessionID) === next) { - this.permissionQueues.delete(permission.sessionID) - } - }) - this.permissionQueues.set(permission.sessionID, next) - return - } - - case "message.part.updated": { - log.info("message part updated", { event: event.properties }) - const props = event.properties - const part = props.part - const session = this.sessionManager.tryGet(part.sessionID) - if (!session) return - const sessionId = session.id - - if (part.type === "tool") { - await this.toolStart(sessionId, part) - - switch (part.state.status) { - case "pending": - this.shellSnapshots.delete(part.callID) - return - - case "running": - const output = this.shellOutput(part) - const content: ToolCallContent[] = [] - if (output) { - const hash = Hash.fast(output) - if (part.tool === ShellID.ToolID) { - if (this.shellSnapshots.get(part.callID) === hash) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - } - this.shellSnapshots.set(part.callID, hash) - } - content.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(content.length > 0 && { content }), - }, - }) - .catch((error) => { - log.error("failed to send tool in_progress to ACP", { error }) - }) - return - - case "completed": { - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content = completedToolContent(part, kind) - - if (part.tool === "todowrite") { - const parsedTodos = decodeTodos(part.state.output) - if (Result.isSuccess(parsedTodos)) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.success.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((error) => { - log.error("failed to send session update for todo", { error }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.failure }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: completedToolRawOutput(part), - }, - }) - .catch((error) => { - log.error("failed to send tool completed to ACP", { error }) - }) - return - } - case "error": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((error) => { - log.error("failed to send tool error to ACP", { error }) - }) - return - } - } - - // ACP clients already know the prompt they just submitted, so replaying - // live user parts duplicates the message. We still replay user history in - // loadSession() and forkSession() via processMessage(). - if (part.type !== "text" && part.type !== "file") return - - return - } - - case "message.part.delta": { - const props = event.properties - const session = this.sessionManager.tryGet(props.sessionID) - if (!session) return - const sessionId = session.id - - const message = await this.sdk.session - .message( - { - sessionID: props.sessionID, - messageID: props.messageID, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - - if (!message || message.info.role !== "assistant") return - - const part = message.parts.find((p) => p.id === props.partID) - if (!part) return - - if (part.type === "text" && props.field === "text" && part.ignored !== true) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send text delta to ACP", { error }) - }) - return - } - - if (part.type === "reasoning" && props.field === "text") { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: props.messageID, - content: { - type: "text", - text: props.delta, - }, - }, - }) - .catch((error) => { - log.error("failed to send reasoning delta to ACP", { error }) - }) - } - return - } - } - } - - async initialize(params: InitializeRequest): Promise { - log.info("initialize", { protocolVersion: params.protocolVersion }) - - const authMethod: AuthMethod = { - description: "Run `opencode auth login` in the terminal", - name: "Login with opencode", - id: "opencode-login", - } - - // If client supports terminal-auth capability, use that instead. - if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { - authMethod._meta = { - "terminal-auth": { - command: "opencode", - args: ["auth", "login"], - label: "OpenCode Login", - }, - } - } - - return { - protocolVersion: 1, - agentCapabilities: { - loadSession: true, - mcpCapabilities: { - http: true, - sse: true, - }, - promptCapabilities: { - embeddedContext: true, - image: true, - }, - sessionCapabilities: { - close: {}, - fork: {}, - list: {}, - resume: {}, - }, - }, - authMethods: [authMethod], - agentInfo: { - name: "OpenCode", - version: InstallationVersion, - }, - } - } - - async authenticate(_params: AuthenticateRequest) { - throw new Error("Authentication not implemented") - } - - async newSession(params: NewSessionRequest) { - const directory = params.cwd - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - const state = await this.sessionManager.create(params.cwd, params.mcpServers, model) - const sessionId = state.id + constructor(private readonly service: ACPService.Interface) {} - log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) - - const load = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - return { - sessionId, - configOptions: load.configOptions, - models: load.models, - modes: load.modes, - _meta: load._meta, - } - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + initialize(params: InitializeRequest) { + return run(this.service.initialize(params)) } - async loadSession(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - - try { - const model = await defaultModel(this.config, directory) - - // Store ACP session state - await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers: params.mcpServers, - sessionId, - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + authenticate(params: AuthenticateRequest) { + return run(this.service.authenticate(params)) } - async listSessions(params: ListSessionsRequest): Promise { - try { - const cursor = params.cursor ? Number(params.cursor) : undefined - const limit = 100 - - const sessions = await this.sdk.session - .list( - { - directory: params.cwd ?? undefined, - roots: true, - }, - { throwOnError: true }, - ) - .then((x) => x.data ?? []) - - const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated) - const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted - const page = filtered.slice(0, limit) - - const entries: SessionInfo[] = page.map((session) => ({ - sessionId: session.id, - cwd: session.directory, - title: session.title, - updatedAt: new Date(session.time.updated).toISOString(), - })) - - const last = page[page.length - 1] - const next = filtered.length > limit && last ? String(last.time.updated) : undefined - - const response: ListSessionsResponse = { - sessions: entries, - } - if (next) response.nextCursor = next - return response - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + newSession(params: NewSessionRequest) { + return run(this.service.newSession(params)) } - async unstable_forkSession(params: ForkSessionRequest): Promise { - const directory = params.cwd - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - - const forked = await this.sdk.session - .fork( - { - sessionID: params.sessionId, - directory, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - - if (!forked) { - throw new Error("Fork session returned no data") - } - - const sessionId = forked.id - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("fork_session", { sessionId, mcpServers: mcpServers.length }) - - const mode = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - for (const msg of messages ?? []) { - log.debug("replay message", msg) - await this.processMessage(msg) - } - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return mode - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + loadSession(params: LoadSessionRequest) { + return run(this.service.loadSession(params)) } - async resumeSession(params: ResumeSessionRequest): Promise { - const directory = params.cwd - const sessionId = params.sessionId - const mcpServers = params.mcpServers ?? [] - - try { - const model = await defaultModel(this.config, directory) - await this.sessionManager.load(sessionId, directory, mcpServers, model) - - const messages = await this.loadSessionMessages(directory, sessionId, 20) - this.restoreSessionStateFromMessages(sessionId, messages) - - log.info("resume_session", { sessionId, mcpServers: mcpServers.length }) - - const result = await this.loadSessionMode({ - cwd: directory, - mcpServers, - sessionId, - }) - - await sendUsageUpdate(this.connection, this.sdk, sessionId, directory) - - return result - } catch (e) { - const error = MessageV2.fromError(e, { - providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"), - }) - if (LoadAPIKeyError.isInstance(error)) { - throw RequestError.authRequired() - } - throw e - } + listSessions(params: ListSessionsRequest) { + return run(this.service.listSessions(params)) } - async closeSession(params: CloseSessionRequest): Promise { - const session = this.sessionManager.remove(params.sessionId) - if (!session) return {} - - await this.sdk.session - .abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId }) - }) - - this.permissionQueues.delete(params.sessionId) - log.info("close_session", { sessionId: params.sessionId }) - return {} - } - - private async processMessage(message: SessionMessageResponse) { - log.debug("process message", message) - if (message.info.role !== "assistant" && message.info.role !== "user") return - const sessionId = message.info.sessionID - - for (const part of message.parts) { - if (part.type === "tool") { - await this.toolStart(sessionId, part) - switch (part.state.status) { - case "pending": - this.shellSnapshots.delete(part.callID) - break - case "running": - const output = this.shellOutput(part) - const runningContent: ToolCallContent[] = [] - if (output) { - runningContent.push({ - type: "content", - content: { - type: "text", - text: output, - }, - }) - } - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - ...(runningContent.length > 0 && { content: runningContent }), - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - const kind = toToolKind(part.tool) - const content = completedToolContent(part, kind) - - if (part.tool === "todowrite") { - const parsedTodos = decodeTodos(part.state.output) - if (Result.isSuccess(parsedTodos)) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.success.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.failure }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: completedToolRawOutput(part), - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - this.toolStarts.delete(part.callID) - this.shellSnapshots.delete(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - metadata: part.state.metadata, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - if (part.text) { - const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - ...(audience && { annotations: { audience } }), - }, - }, - }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) - }) - } - } else if (part.type === "file") { - // Replay file attachments as appropriate ACP content blocks. - // OpenCode stores files internally as { type: "file", url, filename, mime }. - // We convert these back to ACP blocks based on the URL scheme and MIME type: - // - file:// URLs → resource_link - // - data: URLs with image/* → image block - // - data: URLs with text/* or application/json → resource with text - // - data: URLs with other types → resource with blob - const url = part.url - const filename = part.filename ?? "file" - const mime = part.mime || "application/octet-stream" - const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk" - - if (url.startsWith("file://")) { - // Local file reference - send as resource_link - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource_link", uri: url, name: filename, mimeType: mime }, - }, - }) - .catch((err) => { - log.error("failed to send resource_link to ACP", { error: err }) - }) - } else if (url.startsWith("data:")) { - // Embedded content - parse data URL and send as appropriate block type - const base64Match = url.match(/^data:([^;]+);base64,(.*)$/) - const dataMime = base64Match?.[1] - const base64Data = base64Match?.[2] ?? "" - - const effectiveMime = dataMime || mime - - if (effectiveMime.startsWith("image/")) { - // Image - send as image block - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { - type: "image", - mimeType: effectiveMime, - data: base64Data, - uri: pathToFileURL(filename).href, - }, - }, - }) - .catch((err) => { - log.error("failed to send image to ACP", { error: err }) - }) - } else { - // Non-image: text types get decoded, binary types stay as blob - const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json" - const fileUri = pathToFileURL(filename).href - const resource = isText - ? { - uri: fileUri, - mimeType: effectiveMime, - text: Buffer.from(base64Data, "base64").toString("utf-8"), - } - : { uri: fileUri, mimeType: effectiveMime, blob: base64Data } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: messageChunk, - messageId: message.info.id, - content: { type: "resource", resource }, - }, - }) - .catch((err) => { - log.error("failed to send resource to ACP", { error: err }) - }) - } - } - // URLs that don't match file:// or data: are skipped (unsupported) - } else if (part.type === "reasoning") { - if (part.text) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", - messageId: message.info.id, - content: { - type: "text", - text: part.text, - }, - }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } + resumeSession(params: ResumeSessionRequest) { + return run(this.service.resumeSession(params)) } - private shellOutput(part: ToolPart) { - if (part.tool !== ShellID.ToolID) return - if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return - const output = part.state.metadata["output"] - if (typeof output !== "string") return - return output + closeSession(params: CloseSessionRequest) { + return run(this.service.closeSession(params)) } - private async toolStart(sessionId: string, part: ToolPart) { - if (this.toolStarts.has(part.callID)) return - this.toolStarts.add(part.callID) - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((error) => { - log.error("failed to send tool pending to ACP", { error }) - }) + unstable_forkSession(params: ForkSessionRequest) { + return run(this.service.forkSession(params)) } - private async loadAvailableModes(directory: string): Promise { - const agents = await this.config.sdk.app - .agents( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - return agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) - .map((agent) => ({ - id: agent.name, - name: agent.name, - description: agent.description, - })) + setSessionConfigOption(params: SetSessionConfigOptionRequest) { + return run(this.service.setSessionConfigOption(params)) } - private async resolveModeState( - directory: string, - sessionId: string, - ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> { - const availableModes = await this.loadAvailableModes(directory) - const storedModeId = this.sessionManager.get(sessionId).modeId - if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) { - return { availableModes, currentModeId: storedModeId } - } - - const currentModeId = await (async () => { - if (!availableModes.length) return undefined - const defaultAgent = await ACPRuntime.defaultAgentInfo(directory) - const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id - this.sessionManager.setMode(sessionId, resolvedModeId) - return resolvedModeId - })() - - return { availableModes, currentModeId } + setSessionMode(params: SetSessionModeRequest) { + return run(this.service.setSessionMode(params)) } - private async loadSessionMode(params: LoadSessionRequest) { - const directory = params.cwd - const sessionId = params.sessionId - const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory)) - - const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentVariant = this.sessionManager.getVariant(sessionId) - if (currentVariant && !availableVariants.includes(currentVariant)) { - this.sessionManager.setVariant(sessionId, undefined) - } - const availableModels = buildAvailableModels(entries) - const modeState = await this.resolveModeState(directory, sessionId) - const currentModeId = modeState.currentModeId - const modes = currentModeId - ? { - availableModes: modeState.availableModes, - currentModeId, - } - : undefined - - const commands = await this.config.sdk.command - .list( - { - directory, - }, - { throwOnError: true }, - ) - .then((resp) => resp.data!) - - const availableCommands = commands.map((command) => ({ - name: command.name, - description: command.description ?? "", - })) - const names = new Set(availableCommands.map((c) => c.name)) - if (!names.has("compact")) - availableCommands.push({ - name: "compact", - description: "compact the session", - }) - - const mcpServers: Record = {} - for (const server of params.mcpServers) { - if ("type" in server) { - mcpServers[server.name] = { - url: server.url, - headers: server.headers.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - type: "remote", - } - } else { - mcpServers[server.name] = { - type: "local", - command: [server.command, ...server.args], - environment: server.env.reduce>((acc, { name, value }) => { - acc[name] = value - return acc - }, {}), - } - } - } - - await Promise.all( - Object.entries(mcpServers).map(async ([key, mcp]) => { - await this.sdk.mcp - .add( - { - directory, - name: key, - config: mcp, - }, - { throwOnError: true }, - ) - .catch((error) => { - log.error("failed to add mcp server", { name: key, error }) - }) - }), - ) - - setTimeout(() => { - void this.connection.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "available_commands_update", - availableCommands, - }, - }) - }, 0) - - return { - sessionId, - models: { - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), - availableModels, - }, - modes, - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false), - availableModels, - currentVariant, - availableVariants, - modes, - }), - _meta: buildVariantMeta({ - model, - variant: this.sessionManager.getVariant(sessionId), - availableVariants, - }), - } + unstable_setSessionModel(params: SetSessionModelRequest) { + return run(this.service.setSessionModel(params)) } - async unstable_setSessionModel(params: SetSessionModelRequest) { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - - const selection = parseModelSelection(params.modelId, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - - const entries = sortProvidersByName(providers) - const availableVariants = modelVariantsFromProviders(entries, selection.model) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined - - await this.connection.sessionUpdate({ - sessionId: session.id, - update: { - sessionUpdate: "config_option_update", - configOptions: buildConfigOptions({ - currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false), - availableModels: buildAvailableModels(entries), - currentVariant: selection.variant, - availableVariants, - modes, - }), - }, - }) - - return { - _meta: buildVariantMeta({ - model: selection.model, - variant: selection.variant, - availableVariants, - }), - } + prompt(params: PromptRequest) { + return run(this.service.prompt(params)) } - async setSessionMode(params: SetSessionModeRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.modeId)) { - throw new Error(`Agent not found: ${params.modeId}`) - } - this.sessionManager.setMode(params.sessionId, params.modeId) - } - - async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise { - const session = this.sessionManager.get(params.sessionId) - const providers = await this.sdk.config - .providers({ directory: session.cwd }, { throwOnError: true }) - .then((x) => x.data!.providers) - const entries = sortProvidersByName(providers) - - if (params.configId === "model") { - if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string") - const selection = parseModelSelection(params.value, providers) - this.sessionManager.setModel(session.id, selection.model) - this.sessionManager.setVariant(session.id, selection.variant) - } else if (params.configId === "effort") { - if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string") - const current = session.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, current) - if (!availableVariants.includes(params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` })) - } - this.sessionManager.setVariant(session.id, params.value) - } else if (params.configId === "mode") { - if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string") - const availableModes = await this.loadAvailableModes(session.cwd) - if (!availableModes.some((mode) => mode.id === params.value)) { - throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` })) - } - this.sessionManager.setMode(session.id, params.value) - } else { - throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` })) - } - - const updatedSession = this.sessionManager.get(session.id) - const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd)) - const availableVariants = modelVariantsFromProviders(entries, model) - const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false) - const availableModels = buildAvailableModels(entries) - const modeState = await this.resolveModeState(session.cwd, session.id) - const modes = modeState.currentModeId - ? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId } - : undefined - - return { - configOptions: buildConfigOptions({ - currentModelId, - availableModels, - currentVariant: updatedSession.variant, - availableVariants, - modes, - }), - } - } - - async prompt(params: PromptRequest) { - const sessionID = params.sessionId - const session = this.sessionManager.get(sessionID) - const directory = session.cwd - - const current = session.model - const model = current ?? (await defaultModel(this.config, directory)) - if (!current) { - this.sessionManager.setModel(session.id, model) - } - const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name - - const parts: Array< - | { type: "text"; text: string; synthetic?: boolean; ignored?: boolean } - | { type: "file"; url: string; filename: string; mime: string } - > = [] - for (const part of params.prompt) { - switch (part.type) { - case "text": - const audience = part.annotations?.audience - const forAssistant = audience?.length === 1 && audience[0] === "assistant" - const forUser = audience?.length === 1 && audience[0] === "user" - parts.push({ - type: "text" as const, - text: part.text, - ...(forAssistant && { synthetic: true }), - ...(forUser && { ignored: true }), - }) - break - case "image": { - const parsed = parseUri(part.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "image" - if (part.data) { - parts.push({ - type: "file", - url: `data:${part.mimeType};base64,${part.data}`, - filename, - mime: part.mimeType, - }) - } else if (part.uri && part.uri.startsWith("http:")) { - parts.push({ - type: "file", - url: part.uri, - filename, - mime: part.mimeType, - }) - } - break - } - - case "resource_link": - const parsed = parseUri(part.uri) - // Use the name from resource_link if available - if (part.name && parsed.type === "file") { - parsed.filename = part.name - } - parts.push(parsed) - - break - - case "resource": { - const resource = part.resource - if ("text" in resource && resource.text) { - parts.push({ - type: "text", - text: resource.text, - }) - } else if ("blob" in resource && resource.blob && resource.mimeType) { - // Binary resource (PDFs, etc.): store as file part with data URL - const parsed = parseUri(resource.uri ?? "") - const filename = parsed.type === "file" ? parsed.filename : "file" - parts.push({ - type: "file", - url: `data:${resource.mimeType};base64,${resource.blob}`, - filename, - mime: resource.mimeType, - }) - } - break - } - - default: - break - } - } - - log.info("parts", { parts }) - - const cmd = (() => { - const text = parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim() - - if (!text.startsWith("/")) return - - const [name, ...rest] = text.slice(1).split(/\s+/) - return { name, args: rest.join(" ").trim() } - })() - - const buildUsage = (msg: AssistantMessage): Usage => ({ - totalTokens: - msg.tokens.input + - msg.tokens.output + - msg.tokens.reasoning + - (msg.tokens.cache?.read ?? 0) + - (msg.tokens.cache?.write ?? 0), - inputTokens: msg.tokens.input, - outputTokens: msg.tokens.output, - thoughtTokens: msg.tokens.reasoning || undefined, - cachedReadTokens: msg.tokens.cache?.read || undefined, - cachedWriteTokens: msg.tokens.cache?.write || undefined, - }) - - if (!cmd) { - const response = await this.sdk.session.prompt({ - sessionID, - model: { - providerID: model.providerID, - modelID: model.modelID, - }, - variant: this.sessionManager.getVariant(sessionID), - parts, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - const command = await this.config.sdk.command - .list({ directory }, { throwOnError: true }) - .then((x) => x.data!.find((c) => c.name === cmd.name)) - if (command) { - const response = await this.sdk.session.command({ - sessionID, - command: command.name, - arguments: cmd.args, - model: model.providerID + "/" + model.modelID, - agent, - directory, - }) - const msg = response.data?.info - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - usage: msg ? buildUsage(msg) : undefined, - _meta: {}, - } - } - - switch (cmd.name) { - case "compact": - await this.config.sdk.session.summarize( - { - sessionID, - directory, - providerID: model.providerID, - modelID: model.modelID, - }, - { throwOnError: true }, - ) - break - } - - await sendUsageUpdate(this.connection, this.sdk, sessionID, directory) - - return { - stopReason: "end_turn" as const, - _meta: {}, - } - } - - async cancel(params: CancelNotification) { - const session = this.sessionManager.get(params.sessionId) - await this.config.sdk.session.abort( - { - sessionID: params.sessionId, - directory: session.cwd, - }, - { throwOnError: true }, - ) - } - - private async loadSessionMessages(directory: string, sessionId: string, limit?: number) { - return this.sdk.session - .messages( - { - sessionID: sessionId, - directory, - limit, - }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((error) => { - log.error("unexpected error when fetching message", { error }) - return undefined - }) - } - - private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) { - const lastUser = messages?.findLast((message) => message.info.role === "user")?.info - if (lastUser?.role !== "user") return - - this.sessionManager.setModel(sessionId, { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - }) - this.sessionManager.setVariant(sessionId, lastUser.model.variant) - if (lastUser.agent) { - this.sessionManager.setMode(sessionId, lastUser.agent) - } - } -} - -function toToolKind(toolName: string): ToolKind { - const tool = toolName.toLocaleLowerCase() - - switch (tool) { - case ShellID.ToolID: - return "execute" - - case "webfetch": - return "fetch" - - case "edit": - case "patch": - case "write": - return "edit" - - case "grep": - case "glob": - case "repo_clone": - case "repo_overview": - case "context7_resolve_library_id": - case "context7_get_library_docs": - return "search" - - case "read": - return "read" - - default: - return "other" - } -} - -function toLocations(toolName: string, input: Record): { path: string }[] { - const tool = toolName.toLocaleLowerCase() - - switch (tool) { - case "read": - case "edit": - case "write": - return input["filePath"] ? [{ path: input["filePath"] }] : [] - case "glob": - case "grep": - return input["path"] ? [{ path: input["path"] }] : [] - case "repo_clone": - return input["path"] ? [{ path: input["path"] }] : [] - case "repo_overview": - return input["path"] ? [{ path: input["path"] }] : [] - case ShellID.ToolID: - return [] - default: - return [] - } -} - -function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { - if (part.state.status !== "completed") return [] - - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - content.push(...imageContents(part.state.attachments ?? [])) - return content -} - -function completedToolRawOutput(part: ToolPart) { - if (part.state.status !== "completed") return {} - return { - output: part.state.output, - metadata: part.state.metadata, - ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + cancel(params: CancelNotification) { + return run(this.service.cancel(params)) } } -function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { - return attachments.flatMap((attachment): ToolCallContent[] => { - const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) - const mime = match?.[1] ?? attachment.mime - if (!mime.startsWith("image/")) return [] - const data = match?.[2] - if (data === undefined) return [] - return [ - { - type: "content" as const, - content: { - type: "image" as const, - mimeType: mime, - data, - }, - }, - ] +function run(effect: Effect.Effect) { + return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => { + if (defect instanceof RequestError) throw defect + throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect)) }) } -async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { - const sdk = config.sdk - const configured = config.defaultModel - if (configured) return configured - - const directory = cwd ?? process.cwd() - - const specified = await sdk.config - .get({ directory }, { throwOnError: true }) - .then((resp) => { - const cfg = resp.data - if (!cfg || !cfg.model) return undefined - return Provider.parseModel(cfg.model) - }) - .catch((error) => { - log.error("failed to load user config for default model", { error }) - return undefined - }) - - const providers = await sdk.config - .providers({ directory }, { throwOnError: true }) - .then((x) => x.data?.providers ?? []) - .catch((error) => { - log.error("failed to list providers for default model", { error }) - return [] - }) - - if (specified && providers.length) { - const provider = providers.find((p) => p.id === specified.providerID) - if (provider && provider.models[specified.modelID]) return specified - } - - if (specified && !providers.length) return specified - - const lastUsed = await lastUsedModel(sdk, directory, providers) - if (lastUsed) return lastUsed - - const opencodeProvider = providers.find((p) => p.id === "opencode") - if (opencodeProvider) { - const [best] = Provider.sort(Object.values(opencodeProvider.models)) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } - } - } - - const models = providers.flatMap((p) => Object.values(p.models)) - const [best] = Provider.sort(models) - if (best) { - return { - providerID: ProviderID.make(best.providerID), - modelID: ModelID.make(best.id), - } - } - - if (specified) return specified - throw new Error("No models available") -} - -async function lastUsedModel( - sdk: OpencodeClient, - directory: string, - providers: Array<{ id: string; models: Record }>, -): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> { - const session = await sdk.session - .list({ directory, roots: true, limit: 1 }, { throwOnError: true }) - .then((x) => x.data?.[0]) - .catch((error) => { - log.error("failed to list sessions for default model", { error }) - return undefined - }) - if (!session) return - - const lastUser = await sdk.session - .messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true }) - .then((x) => x.data?.findLast((message) => message.info.role === "user")?.info) - .catch((error) => { - log.error("failed to load session messages for default model", { error, sessionID: session.id }) - return undefined - }) - if (lastUser?.role !== "user") return - - const provider = providers.find((entry) => entry.id === lastUser.model.providerID) - if (!provider?.models[lastUser.model.modelID]) return - return { - providerID: ProviderID.make(lastUser.model.providerID), - modelID: ModelID.make(lastUser.model.modelID), - } -} - -function parseUri( - uri: string, -): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } { - try { - if (uri.startsWith("file://")) { - const path = uri.slice(7) - const name = path.split("/").pop() || path - return { - type: "file", - url: uri, - filename: name, - mime: "text/plain", - } - } - if (uri.startsWith("zed://")) { - const url = new URL(uri) - const path = url.searchParams.get("path") - if (path) { - const name = path.split("/").pop() || path - return { - type: "file", - url: pathToFileURL(path).href, - filename: name, - mime: "text/plain", - } - } - } - return { - type: "text", - text: uri, - } - } catch { - return { - type: "text", - text: uri, - } - } -} - -function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined { - const result = applyPatch(fileOriginal, unifiedDiff) - if (result === false) { - log.error("Failed to apply unified diff (context mismatch)") - return undefined - } - return result -} - -function sortProvidersByName(providers: T[]): T[] { - return [...providers].sort((a, b) => { - const nameA = a.name.toLowerCase() - const nameB = b.name.toLowerCase() - if (nameA < nameB) return -1 - if (nameA > nameB) return 1 - return 0 - }) -} - -function modelVariantsFromProviders( - providers: Array<{ id: string; models: Record }> }>, - model: { providerID: ProviderID; modelID: ModelID }, -): string[] { - const provider = providers.find((entry) => entry.id === model.providerID) - if (!provider) return [] - const modelInfo = provider.models[model.modelID] - if (!modelInfo?.variants) return [] - return Object.keys(modelInfo.variants) -} - -function buildAvailableModels( - providers: Array<{ id: string; name: string; models: Record }>, - options: { includeVariants?: boolean } = {}, -): ModelOption[] { - const includeVariants = options.includeVariants ?? false - return providers.flatMap((provider) => { - const unsorted: Array<{ id: string; name: string; variants?: Record }> = Object.values(provider.models) - const models = Provider.sort(unsorted) - return models.flatMap((model) => { - const base: ModelOption = { - modelId: `${provider.id}/${model.id}`, - name: `${provider.name}/${model.name}`, - } - if (!includeVariants || !model.variants) return [base] - const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE) - const variantOptions = variants.map((variant) => ({ - modelId: `${provider.id}/${model.id}/${variant}`, - name: `${provider.name}/${model.name} (${variant})`, - })) - return [base, ...variantOptions] - }) - }) -} - -function formatModelIdWithVariant( - model: { providerID: ProviderID; modelID: ModelID }, - variant: string | undefined, - availableVariants: string[], - includeVariant: boolean, -) { - const base = `${model.providerID}/${model.modelID}` - if (!includeVariant || availableVariants.length === 0) return base - const selectedVariant = - variant && availableVariants.includes(variant) - ? variant - : availableVariants.includes(DEFAULT_VARIANT_VALUE) - ? DEFAULT_VARIANT_VALUE - : availableVariants[0] - return `${base}/${selectedVariant}` -} - -function buildVariantMeta(input: { - model: { providerID: ProviderID; modelID: ModelID } - variant?: string - availableVariants: string[] -}) { - return { - opencode: { - modelId: `${input.model.providerID}/${input.model.modelID}`, - variant: input.variant ?? null, - availableVariants: input.availableVariants, - }, - } -} - -function parseModelSelection( - modelId: string, - providers: Array<{ id: string; models: Record }> }>, -): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { - const parsed = Provider.parseModel(modelId) - const provider = providers.find((p) => p.id === parsed.providerID) - if (!provider) { - return { model: parsed, variant: undefined } - } - - // Check if modelID exists directly - if (provider.models[parsed.modelID]) { - return { model: parsed, variant: undefined } - } - - // Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high") - const segments = parsed.modelID.split("/") - if (segments.length > 1) { - const candidateVariant = segments[segments.length - 1] - const baseModelId = segments.slice(0, -1).join("/") - const baseModelInfo = provider.models[baseModelId] - if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { - return { - model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, - variant: candidateVariant, - } - } - } - - return { model: parsed, variant: undefined } -} - -function buildConfigOptions(input: { - currentModelId: string - availableModels: ModelOption[] - currentVariant?: string - availableVariants?: string[] - modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined -}): SessionConfigOption[] { - const options: SessionConfigOption[] = [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: input.currentModelId, - options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })), - }, - ] - if (input.availableVariants?.length) { - options.push({ - id: "effort", - name: "Effort", - description: "Available effort levels for this model", - category: "thought_level", - type: "select", - currentValue: - input.currentVariant && input.availableVariants.includes(input.currentVariant) - ? input.currentVariant - : input.availableVariants.includes(DEFAULT_VARIANT_VALUE) - ? DEFAULT_VARIANT_VALUE - : input.availableVariants[0], - options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })), - }) - } - if (input.modes) { - options.push({ - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: input.modes.currentModeId, - options: input.modes.availableModes.map((m) => ({ - value: m.id, - name: m.name, - ...(m.description ? { description: m.description } : {}), - })), - }) - } - return options -} - -function formatVariantName(variant: string) { - return variant - .split(/[_-]/) - .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part)) - .join(" ") -} - export * as ACP from "./agent" diff --git a/packages/opencode/src/acp-next/config-option.ts b/packages/opencode/src/acp/config-option.ts similarity index 100% rename from packages/opencode/src/acp-next/config-option.ts rename to packages/opencode/src/acp/config-option.ts diff --git a/packages/opencode/src/acp-next/content.ts b/packages/opencode/src/acp/content.ts similarity index 100% rename from packages/opencode/src/acp-next/content.ts rename to packages/opencode/src/acp/content.ts diff --git a/packages/opencode/src/acp-next/directory.ts b/packages/opencode/src/acp/directory.ts similarity index 93% rename from packages/opencode/src/acp-next/directory.ts rename to packages/opencode/src/acp/directory.ts index 90ffa36358ff..c49613dd06fe 100644 --- a/packages/opencode/src/acp-next/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -5,7 +5,7 @@ import { InstanceStore } from "@/project/instance-store" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" -import type * as ACPNextError from "./error" +import type * as ACPError from "./error" export type ModelOption = { readonly providerID: ProviderID @@ -39,18 +39,18 @@ export type Snapshot = { } export interface LoaderInterface { - readonly load: (directory: string) => Effect.Effect + readonly load: (directory: string) => Effect.Effect } export interface Interface { - readonly get: (directory: string) => Effect.Effect - readonly refresh: (directory: string) => Effect.Effect + readonly get: (directory: string) => Effect.Effect + readonly refresh: (directory: string) => Effect.Effect readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined } -export class Loader extends Context.Service()("@opencode/ACPNextDirectoryLoader") {} +export class Loader extends Context.Service()("@opencode/ACPDirectoryLoader") {} -export class Service extends Context.Service()("@opencode/ACPNextDirectory") {} +export class Service extends Context.Service()("@opencode/ACPDirectory") {} export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}` @@ -110,7 +110,7 @@ export const loaderLayer = Layer.effect( const command = yield* Command.Service return Loader.of({ - load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) { + load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) { const ctx = yield* store.load({ directory }) return yield* Effect.gen(function* () { const providers = yield* provider.list() @@ -142,7 +142,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const loader = yield* Loader - const snapshots = yield* SynchronizedRef.make(new Map>()) + const snapshots = yield* SynchronizedRef.make(new Map>()) const cached = Effect.fnUntraced(function* (directory: string) { return yield* SynchronizedRef.modifyEffect( @@ -166,11 +166,11 @@ export const layer = Layer.effect( ) }) - const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) { + const get = Effect.fn("ACPDirectory.get")(function* (directory: string) { return yield* yield* cached(directory) }) - const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) { + const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) { return yield* SynchronizedRef.modifyEffect( snapshots, Effect.fnUntraced(function* (items) { diff --git a/packages/opencode/src/acp-next/error.ts b/packages/opencode/src/acp/error.ts similarity index 77% rename from packages/opencode/src/acp-next/error.ts rename to packages/opencode/src/acp/error.ts index 1d4af53b5030..119c2b775aea 100644 --- a/packages/opencode/src/acp-next/error.ts +++ b/packages/opencode/src/acp/error.ts @@ -2,51 +2,51 @@ import { RequestError } from "@agentclientprotocol/sdk" import { Schema } from "effect" export class SessionNotFoundError extends Schema.TaggedErrorClass()( - "ACPNextSessionNotFoundError", + "ACPSessionNotFoundError", { sessionId: Schema.String, }, ) {} export class InvalidConfigOptionError extends Schema.TaggedErrorClass()( - "ACPNextInvalidConfigOptionError", + "ACPInvalidConfigOptionError", { configId: Schema.String, }, ) {} -export class InvalidModelError extends Schema.TaggedErrorClass()("ACPNextInvalidModelError", { +export class InvalidModelError extends Schema.TaggedErrorClass()("ACPInvalidModelError", { modelId: Schema.String, providerId: Schema.optional(Schema.String), }) {} -export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPNextInvalidEffortError", { +export class InvalidEffortError extends Schema.TaggedErrorClass()("ACPInvalidEffortError", { effort: Schema.String, }) {} -export class InvalidModeError extends Schema.TaggedErrorClass()("ACPNextInvalidModeError", { +export class InvalidModeError extends Schema.TaggedErrorClass()("ACPInvalidModeError", { mode: Schema.String, }) {} -export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPNextAuthRequiredError", { +export class AuthRequiredError extends Schema.TaggedErrorClass()("ACPAuthRequiredError", { providerId: Schema.optional(Schema.String), }) {} export class UnknownAuthMethodError extends Schema.TaggedErrorClass()( - "ACPNextUnknownAuthMethodError", + "ACPUnknownAuthMethodError", { methodId: Schema.String, }, ) {} export class UnsupportedOperationError extends Schema.TaggedErrorClass()( - "ACPNextUnsupportedOperationError", + "ACPUnsupportedOperationError", { method: Schema.String, }, ) {} -export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPNextServiceFailureError", { +export class ServiceFailureError extends Schema.TaggedErrorClass()("ACPServiceFailureError", { safeMessage: Schema.String, service: Schema.optional(Schema.String), }) {} @@ -64,26 +64,26 @@ export type Error = export function toRequestError(error: Error) { switch (error._tag) { - case "ACPNextSessionNotFoundError": + case "ACPSessionNotFoundError": return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`) - case "ACPNextInvalidConfigOptionError": + case "ACPInvalidConfigOptionError": return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`) - case "ACPNextInvalidModelError": + case "ACPInvalidModelError": return RequestError.invalidParams( { providerId: error.providerId, modelId: error.modelId }, `model not found: ${error.modelId}`, ) - case "ACPNextInvalidEffortError": + case "ACPInvalidEffortError": return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`) - case "ACPNextInvalidModeError": + case "ACPInvalidModeError": return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`) - case "ACPNextAuthRequiredError": + case "ACPAuthRequiredError": return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required") - case "ACPNextUnknownAuthMethodError": + case "ACPUnknownAuthMethodError": return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`) - case "ACPNextUnsupportedOperationError": + case "ACPUnsupportedOperationError": return RequestError.methodNotFound(error.method) - case "ACPNextServiceFailureError": + case "ACPServiceFailureError": return RequestError.internalError({ service: error.service }, error.safeMessage) } } diff --git a/packages/opencode/src/acp-next/event.ts b/packages/opencode/src/acp/event.ts similarity index 95% rename from packages/opencode/src/acp-next/event.ts rename to packages/opencode/src/acp/event.ts index 9df7d8142d6a..df105d6acf77 100644 --- a/packages/opencode/src/acp-next/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -10,8 +10,8 @@ import type { ToolPart, } from "@opencode-ai/sdk/v2" import { Effect } from "effect" -import { ACPNextSession } from "./session" -import { ACPNextPermission } from "./permission" +import { ACPSession } from "./session" +import { ACPPermission } from "./permission" import { duplicateRunningToolUpdate, errorToolUpdate, @@ -21,7 +21,7 @@ import { completedToolUpdate, } from "./tool" -const log = Log.create({ service: "acp-next-event" }) +const log = Log.create({ service: "acp-event" }) type Connection = Pick & Partial> @@ -32,7 +32,7 @@ type GlobalEventStream = { stream: AsyncIterable } -export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) { +export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) { const subscription = new Subscription(input) subscription.start() return subscription @@ -42,17 +42,17 @@ export class Subscription { private readonly abort = new AbortController() private readonly shellSnapshots = new Map() private readonly toolStarts = new Set() - private readonly permission: ACPNextPermission.Handler + private readonly permission: ACPPermission.Handler private started = false constructor( private readonly input: { sdk: OpencodeClient connection: Connection - session: ACPNextSession.Interface + session: ACPSession.Interface }, ) { - this.permission = new ACPNextPermission.Handler(input) + this.permission = new ACPPermission.Handler(input) } start() { @@ -316,4 +316,4 @@ export class Subscription { } } -export * as ACPNextEvent from "./event" +export * as ACPEvent from "./event" diff --git a/packages/opencode/src/acp-next/permission.ts b/packages/opencode/src/acp/permission.ts similarity index 95% rename from packages/opencode/src/acp-next/permission.ts rename to packages/opencode/src/acp/permission.ts index 73ebb38b1f72..cefd2a34f361 100644 --- a/packages/opencode/src/acp-next/permission.ts +++ b/packages/opencode/src/acp/permission.ts @@ -3,11 +3,11 @@ import * as Log from "@opencode-ai/core/util/log" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { exists, readText } from "@/util/filesystem" -import type { ACPNextSession } from "./session" +import type { ACPSession } from "./session" import { toLocations, toToolKind, type ToolInput } from "./tool" import { Effect } from "effect" -const log = Log.create({ service: "acp-next-permission" }) +const log = Log.create({ service: "acp-permission" }) type PermissionEvent = Extract type Reply = "once" | "always" | "reject" @@ -26,7 +26,7 @@ export class Handler { private readonly input: { sdk: OpencodeClient connection: Connection - session: ACPNextSession.Interface + session: ACPSession.Interface }, ) {} @@ -142,4 +142,4 @@ function stringValue(value: unknown) { return typeof value === "string" ? value : undefined } -export * as ACPNextPermission from "./permission" +export * as ACPPermission from "./permission" diff --git a/packages/opencode/src/acp-next/profile.ts b/packages/opencode/src/acp/profile.ts similarity index 96% rename from packages/opencode/src/acp-next/profile.ts rename to packages/opencode/src/acp/profile.ts index 4b79d6e903ae..9e728b6a1aad 100644 --- a/packages/opencode/src/acp-next/profile.ts +++ b/packages/opencode/src/acp/profile.ts @@ -39,4 +39,4 @@ function write(name: string, durationMs: number, fields?: Record(input: { directory: string; effect: Effect.Effect }) { - const ctx = await InstanceRuntime.load({ directory: input.directory }) - return AppRuntime.runPromise(input.effect.pipe(Effect.provideService(InstanceRef, ctx))) -} - -export const defaultAgentInfo = (directory: string) => - runDirectory({ - directory, - effect: Agent.Service.use((svc) => svc.defaultInfo()), - }) - -export * as ACPRuntime from "./runtime" diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp/service.ts similarity index 88% rename from packages/opencode/src/acp-next/service.ts rename to packages/opencode/src/acp/service.ts index 2f8147ef67c4..6b2b18219c9d 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -33,22 +33,22 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" -import * as ACPNextError from "./error" +import * as ACPError from "./error" import { buildConfigOptions, parseModelSelection } from "./config-option" import { promptContentToParts } from "./content" import { Directory } from "./directory" -import { ACPNextEvent } from "./event" -import { ACPNextSession } from "./session" +import { ACPEvent } from "./event" +import { ACPSession } from "./session" import { UsageService } from "./usage" -import { ACPNextProfile } from "./profile" +import { ACPProfile } from "./profile" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import type { Command } from "@/command" export const AuthMethodID = "opencode-login" -const log = Log.create({ service: "acp-next-service" }) +const log = Log.create({ service: "acp-service" }) -export type Error = ACPNextError.Error +export type Error = ACPError.Error type ServiceConnection = Pick & Partial> @@ -70,26 +70,26 @@ export type Interface = { readonly cancel: (input: CancelNotification) => Effect.Effect } -export class Service extends Context.Service()("@opencode/ACPNext/Service") {} +export class Service extends Context.Service()("@opencode/ACP/Service") {} export function make(input: { sdk: OpencodeClient connection?: ServiceConnection directory?: Directory.Interface - session?: ACPNextSession.Interface + session?: ACPSession.Interface usage?: UsageService.Interface - eventSubscription?: (subscription: ACPNextEvent.Subscription) => void + eventSubscription?: (subscription: ACPEvent.Subscription) => void }): Interface { const session = input.session ?? makeSessionService() const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() const sessionSnapshots = new Map() const events = input.connection - ? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session }) + ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) : undefined if (events) input.eventSubscription?.(events) - const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) { + const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { const started = performance.now() const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", @@ -132,25 +132,25 @@ export function make(input: { version: InstallationVersion, }, } - ACPNextProfile.duration("acp.initialize", started) + ACPProfile.duration("acp.initialize", started) return response }) - const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) { + const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) { if (params.methodId !== AuthMethodID) { - return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId }) + return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId }) } return {} }) - const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) { + const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) { const started = performance.now() const snapshot = yield* directoryService.get(cwd) - ACPNextProfile.duration("acp.directory.snapshot", started) + ACPProfile.duration("acp.directory.snapshot", started) return snapshot }) - const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) { + const configSnapshot = Effect.fn("ACP.configSnapshot")(function* (state: ACPSession.Info) { const snapshot = sessionSnapshots.get(state.id) if (snapshot) return snapshot const loaded = yield* directorySnapshot(state.cwd) @@ -158,7 +158,7 @@ export function make(input: { return loaded }) - const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) { + const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) { const started = performance.now() const snapshot = yield* directorySnapshot(params.cwd) const selected = selectDefaultModel(snapshot) @@ -202,11 +202,11 @@ export function make(input: { modeId: state.modeId, }), } - ACPNextProfile.duration("acp.newSession", started) + ACPProfile.duration("acp.newSession", started) return response }) - const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) { + const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) yield* request( () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), @@ -245,7 +245,7 @@ export function make(input: { } }) - const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) { + const listSessions = Effect.fn("ACP.listSessions")(function* (params: ListSessionsRequest) { const cursor = params.cursor ? Number(params.cursor) : undefined const limit = 100 const sessions = yield* request( @@ -291,7 +291,7 @@ export function make(input: { } }) - const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) { + const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) yield* request( () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), @@ -330,7 +330,7 @@ export function make(input: { } }) - const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) { + const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) { const removed = yield* session.remove(params.sessionId) registeredMcp.delete(params.sessionId) sessionSnapshots.delete(params.sessionId) @@ -349,7 +349,7 @@ export function make(input: { return {} }) - const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) { + const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) const forked = yield* request( () => @@ -393,13 +393,13 @@ export function make(input: { } }) - const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* ( + const setSessionConfigOption = Effect.fn("ACP.setSessionConfigOption")(function* ( params: SetSessionConfigOptionRequest, ) { const current = yield* session.get(params.sessionId) const snapshot = yield* configSnapshot(current) if (typeof params.value !== "string") { - return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) } if (params.configId === "model") { @@ -421,7 +421,7 @@ export function make(input: { const model = current.model ?? selectDefaultModel(snapshot) const variants = Directory.variants(snapshot, model) if (!variants || !Object.keys(variants).includes(params.value)) { - return yield* new ACPNextError.InvalidEffortError({ effort: params.value }) + return yield* new ACPError.InvalidEffortError({ effort: params.value }) } const state = yield* session.setVariant(params.sessionId, params.value) return { @@ -435,7 +435,7 @@ export function make(input: { if (params.configId === "mode") { if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { - return yield* new ACPNextError.InvalidModeError({ mode: params.value }) + return yield* new ACPError.InvalidModeError({ mode: params.value }) } const state = yield* session.setMode(params.sessionId, params.value) return { @@ -447,20 +447,20 @@ export function make(input: { } } - return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId }) + return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId }) }) - const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) { + const setSessionMode = Effect.fn("ACP.setSessionMode")(function* (params: SetSessionModeRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* configSnapshot(current) if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { - return yield* new ACPNextError.InvalidModeError({ mode: params.modeId }) + return yield* new ACPError.InvalidModeError({ mode: params.modeId }) } yield* session.setMode(params.sessionId, params.modeId) return {} }) - const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) { + const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* configSnapshot(current) const selected = yield* parseSelectedModel(snapshot, params.modelId) @@ -487,7 +487,7 @@ export function make(input: { setSessionConfigOption, setSessionMode, setSessionModel, - prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) { + prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) { const current = yield* session.get(params.sessionId) const snapshot = yield* directorySnapshot(current.cwd) const selected = current.model ?? selectDefaultModel(snapshot) @@ -563,15 +563,15 @@ export function make(input: { yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd) return promptResponse(undefined, params.messageId) }), - cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) { - return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" }) + cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) { + return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" }) }), } } function makeSessionService() { - return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -592,7 +592,7 @@ function makeDirectoryService(sdk: OpencodeClient) { function makeUsageService(sdk: OpencodeClient) { const limits = new Map>() - const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")( + const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACP.promptUsage.contextLimit")( function* (params) { const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` const current = limits.get(key) @@ -615,7 +615,7 @@ function makeUsageService(sdk: OpencodeClient) { }, ) - const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")( + const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")( function* (params) { const messages = yield* request( () => @@ -675,7 +675,7 @@ function makeUsageService(sdk: OpencodeClient) { }) } -function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) { +function replayMessages(subscription: ACPEvent.Subscription | undefined, messages: SessionMessageResponse[]) { if (!subscription) return Effect.void return Effect.promise(async () => { for (const message of messages) { @@ -724,23 +724,23 @@ function request(fn: () => Promise>, service?: string) { } function profiledRequest(name: string, fn: () => Promise>, service?: string) { - return request(() => ACPNextProfile.measure(name, fn), service) + return request(() => ACPProfile.measure(name, fn), service) } async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { - return ACPNextProfile.measure("acp.directory.load", async () => { + return ACPProfile.measure("acp.directory.load", async () => { const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([ - ACPNextProfile.measure("acp.directory.provider.list", () => + ACPProfile.measure("acp.directory.provider.list", () => sdk.config.providers({ directory }, { throwOnError: true }), ), - ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () => + ACPProfile.measure("acp.directory.mode.defaultAgent.load", () => sdk.app.agents({ directory }, { throwOnError: true }), ), - ACPNextProfile.measure("acp.directory.command.list", () => + ACPProfile.measure("acp.directory.command.list", () => sdk.command.list({ directory }, { throwOnError: true }), ), - ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), - ACPNextProfile.measure("acp.directory.defaultModel.config", () => + ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), + ACPProfile.measure("acp.directory.defaultModel.config", () => sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), ), ]) @@ -754,7 +754,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) { > const defaultModelStarted = performance.now() const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) - ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) + ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel }) const modes = agents .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) .map((agent) => ({ @@ -872,14 +872,14 @@ function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { const model = provider?.models[ModelID.make(selected.model.modelID)] if (!model) { return Effect.fail( - new ACPNextError.InvalidModelError({ + new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId, }), ) } if (selected.variant && !model.variants?.[selected.variant]) { - return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant })) + return Effect.fail(new ACPError.InvalidEffortError({ effort: selected.variant })) } return Effect.succeed({ model: { @@ -954,7 +954,7 @@ function registerMcpServers( ).pipe( Effect.tap(() => Effect.sync(() => - ACPNextProfile.duration("acp.mcp.register", started, { + ACPProfile.duration("acp.mcp.register", started, { count: pending.size, }), ), @@ -1020,20 +1020,20 @@ function isSdkResponse(value: T | SdkResponse): value is SdkResponse { } function fromUnknownError(error: unknown, service?: string): Error { - if (isACPNextError(error)) return error + if (isACPError(error)) return error if (isAuthRequired(error)) { - return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) }) + return new ACPError.AuthRequiredError({ providerId: findProviderID(error) }) } - return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service }) + return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service }) } -function isACPNextError(error: unknown): error is Error { +function isACPError(error: unknown): error is Error { return ( typeof error === "object" && error !== null && "_tag" in error && typeof error._tag === "string" && - error._tag.startsWith("ACPNext") + error._tag.startsWith("ACP") ) } diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index cc1ed0be3098..6b1c7eaf2b71 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,122 +1,232 @@ -import { RequestError, type McpServer } from "@agentclientprotocol/sdk" -import type { ACPSessionState } from "./types" -import * as Log from "@opencode-ai/core/util/log" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { McpServer } from "@agentclientprotocol/sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { Context, Effect, Layer, Ref } from "effect" +import type { ModelID, ProviderID } from "../provider/schema" +import * as ACPError from "./error" + +export type SelectedModel = { + providerID: ProviderID + modelID: ModelID +} -const log = Log.create({ service: "acp-session-manager" }) +export type KnownMessagePartMetadata = { + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} -export class ACPSessionManager { - private sessions = new Map() - private sdk: OpencodeClient +export type Info = { + id: string + cwd: string + mcpServers: readonly McpServer[] + createdAt: Date + model?: SelectedModel + variant?: string + modeId?: string + knownParts: ReadonlyMap +} - constructor(sdk: OpencodeClient) { - this.sdk = sdk - } +export type StoreInput = { + id: string + cwd: string + mcpServers?: readonly McpServer[] + createdAt?: Date + model?: SelectedModel + variant?: string + modeId?: string +} - tryGet(sessionId: string): ACPSessionState | undefined { - return this.sessions.get(sessionId) - } +export type RecordPartMetadataInput = { + sessionId: string + messageId: string + partId: string + partType?: Part["type"] + role?: Message["role"] + ignored?: boolean + toolCallId?: string + metadata?: unknown +} - async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { - const session = await this.sdk.session - .create( - { - directory: cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data!) - - const sessionId = session.id - const resolvedModel = model - - const state: ACPSessionState = { - id: sessionId, - cwd, - mcpServers, - createdAt: new Date(), - model: resolvedModel, - } - log.info("creating_session", { state }) - - this.sessions.set(sessionId, state) - return state - } +export type PartMetadataLookupInput = { + sessionId: string + messageId: string + partId: string +} - async load( +export type Interface = { + readonly create: (input: StoreInput) => Effect.Effect + readonly load: (input: StoreInput) => Effect.Effect + readonly list: (cwd?: string) => Effect.Effect + readonly get: (sessionId: string) => Effect.Effect + readonly tryGet: (sessionId: string) => Effect.Effect + readonly remove: (sessionId: string) => Effect.Effect + readonly setModel: ( sessionId: string, - cwd: string, - mcpServers: McpServer[], - model?: ACPSessionState["model"], - ): Promise { - const session = await this.sdk.session - .get( - { - sessionID: sessionId, - directory: cwd, - }, - { throwOnError: true }, - ) - .then((x) => x.data!) - - const resolvedModel = model - - const state: ACPSessionState = { - id: sessionId, - cwd, - mcpServers, - createdAt: new Date(session.time.created), - model: resolvedModel, - } - log.info("loading_session", { state }) - - this.sessions.set(sessionId, state) - return state - } - - get(sessionId: string): ACPSessionState { - const session = this.sessions.get(sessionId) - if (!session) { - log.error("session not found", { sessionId }) - throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) - } - return session - } - - getModel(sessionId: string) { - const session = this.get(sessionId) - return session.model - } - - setModel(sessionId: string, model: ACPSessionState["model"]) { - const session = this.get(sessionId) - session.model = model - this.sessions.set(sessionId, session) - return session - } - - getVariant(sessionId: string) { - const session = this.get(sessionId) - return session.variant - } + model: SelectedModel | undefined, + ) => Effect.Effect + readonly getModel: (sessionId: string) => Effect.Effect + readonly setVariant: ( + sessionId: string, + variant: string | undefined, + ) => Effect.Effect + readonly getVariant: (sessionId: string) => Effect.Effect + readonly setMode: ( + sessionId: string, + modeId: string | undefined, + ) => Effect.Effect + readonly getMode: (sessionId: string) => Effect.Effect + readonly recordPartMetadata: ( + input: RecordPartMetadataInput, + ) => Effect.Effect + readonly getPartMetadata: ( + input: PartMetadataLookupInput, + ) => Effect.Effect + readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect +} - setVariant(sessionId: string, variant?: string) { - const session = this.get(sessionId) - session.variant = variant - this.sessions.set(sessionId, session) - return session +export class Service extends Context.Service()("@opencode/ACP/Session") {} + +type State = Map + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Ref.make(new Map()) + + const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) { + const session = makeSession(input) + yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session)) + return snapshot(session) + }) + + const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) { + const session = (yield* Ref.get(sessions)).get(sessionId) + if (!session) return + return snapshot(session) + }) + + const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) { + const session = yield* tryGet(sessionId) + if (session) return session + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) + + const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) { + const result = yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = fn(session) + return [snapshot(next), new Map(state).set(sessionId, next)] as const + }) + if (result) return result + return yield* new ACPError.SessionNotFoundError({ sessionId }) + }) + + const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) { + return yield* Ref.modify(sessions, (state) => { + const session = state.get(sessionId) + if (!session) return [undefined, state] as const + const next = new Map(state) + next.delete(sessionId) + return [snapshot(session), next] as const + }) + }) + + const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) => + update(sessionId, (session) => ({ ...session, model })), + ) + + const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) => + update(sessionId, (session) => ({ ...session, variant })), + ) + + const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) => + update(sessionId, (session) => ({ ...session, modeId })), + ) + + const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")(( + input, + ) => { + const metadata = { + messageId: input.messageId, + partId: input.partId, + partType: input.partType, + role: input.role, + ignored: input.ignored, + toolCallId: input.toolCallId, + metadata: input.metadata, + } + return update(input.sessionId, (session) => ({ + ...session, + knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata), + })).pipe(Effect.as(metadata)) + }) + + return Service.of({ + create: store, + load: store, + list: Effect.fn("ACP.Session.list")(function* (cwd?: string) { + return [...(yield* Ref.get(sessions)).values()] + .filter((session) => !cwd || session.cwd === cwd) + .map(snapshot) + .toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + }), + get, + tryGet, + remove, + setModel, + getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) { + return (yield* get(sessionId)).model + }), + setVariant, + getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) { + return (yield* get(sessionId)).variant + }), + setMode, + getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) { + return (yield* get(sessionId)).modeId + }), + recordPartMetadata, + getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) { + return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input)) + }), + tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) { + return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input)) + }), + }) + }), +) + +export const defaultLayer = layer + +function makeSession(input: StoreInput): Info { + return { + id: input.id, + cwd: input.cwd, + mcpServers: [...(input.mcpServers ?? [])], + createdAt: input.createdAt ? new Date(input.createdAt) : new Date(), + model: input.model, + variant: input.variant, + modeId: input.modeId, + knownParts: new Map(), } +} - setMode(sessionId: string, modeId: string) { - const session = this.get(sessionId) - session.modeId = modeId - this.sessions.set(sessionId, session) - return session +function snapshot(session: Info): Info { + return { + ...session, + mcpServers: [...session.mcpServers], + createdAt: new Date(session.createdAt), + knownParts: new Map(session.knownParts), } +} - remove(sessionId: string): ACPSessionState | undefined { - const session = this.sessions.get(sessionId) - this.sessions.delete(sessionId) - return session - } +function partMetadataKey(input: { messageId: string; partId: string }) { + return `${input.messageId}:${input.partId}` } + +export * as ACPSession from "./session" diff --git a/packages/opencode/src/acp-next/tool.ts b/packages/opencode/src/acp/tool.ts similarity index 100% rename from packages/opencode/src/acp-next/tool.ts rename to packages/opencode/src/acp/tool.ts diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts deleted file mode 100644 index 2c3e886bc185..000000000000 --- a/packages/opencode/src/acp/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" -import type { ProviderID, ModelID } from "../provider/schema" - -export interface ACPSessionState { - id: string - cwd: string - mcpServers: McpServer[] - createdAt: Date - model?: { - providerID: ProviderID - modelID: ModelID - } - variant?: string - modeId?: string -} - -export interface ACPConfig { - sdk: OpencodeClient - defaultModel?: { - providerID: ProviderID - modelID: ModelID - } -} diff --git a/packages/opencode/src/acp-next/usage.ts b/packages/opencode/src/acp/usage.ts similarity index 95% rename from packages/opencode/src/acp-next/usage.ts rename to packages/opencode/src/acp/usage.ts index eabbb02af2e1..aae32466965e 100644 --- a/packages/opencode/src/acp-next/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -7,7 +7,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" -const log = Log.create({ service: "acp-next-usage" }) +const log = Log.create({ service: "acp-usage" }) export type AssistantTokenCost = Pick @@ -60,14 +60,14 @@ export interface Interface { } export class MessageLoader extends Context.Service()( - "@opencode/ACPNextUsageMessageLoader", + "@opencode/ACPUsageMessageLoader", ) {} export class ContextLimitLoader extends Context.Service()( - "@opencode/ACPNextUsageContextLimitLoader", + "@opencode/ACPUsageContextLimitLoader", ) {} -export class Service extends Context.Service()("@opencode/ACPNextUsage") {} +export class Service extends Context.Service()("@opencode/ACPUsage") {} export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface { return MessageLoader.of({ @@ -124,7 +124,7 @@ export const contextLimitLoaderLayer = Layer.effect( const provider = yield* Provider.Service return ContextLimitLoader.of({ - providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) { + providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) { const ctx = yield* store.load({ directory }) return yield* Effect.gen(function* () { return yield* provider.list() @@ -168,7 +168,7 @@ export const layer = Layer.effect( ) }) - const contextLimit = Effect.fn("ACPNextUsage.contextLimit")(function* (input: { + const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: { readonly directory: string readonly providerID: ProviderID readonly modelID: ModelID @@ -176,7 +176,7 @@ export const layer = Layer.effect( return yield* yield* cachedLimit(input) }) - const sendUpdate = Effect.fn("ACPNextUsage.sendUpdate")(function* (input: { + const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: { readonly connection: UsageConnection readonly sessionID: string readonly directory: string diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 35abfec7e5a9..99647e5e2a2a 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,13 +3,11 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" -import { ACPNext } from "@/acp-next/agent" import { Server } from "@/server/server" import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { RuntimeFlags } from "@/effect/runtime-flags" -import { ACPNextProfile } from "@/acp-next/profile" +import { ACPProfile } from "@/acp/profile" const log = Log.create({ service: "acp-command" }) @@ -24,13 +22,10 @@ export const AcpCommand = effectCmd({ }) }, handler: Effect.fn("Cli.acp")(function* (args) { - ACPNextProfile.mark("cli.acp.handler") + ACPProfile.mark("cli.acp.handler") process.env.OPENCODE_CLIENT = "acp" - const flags = yield* RuntimeFlags.Service const opts = yield* resolveNetworkOptions(args) - const server = yield* Effect.promise(() => - ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)), - ) + const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts))) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, @@ -61,11 +56,11 @@ export const AcpCommand = effectCmd({ }) const stream = ndJsonStream(input, output) - const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk }) + const agent = ACP.init({ sdk }) new AgentSideConnection((conn) => { - ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext }) - return agent.create(conn, { sdk }) + ACPProfile.mark("cli.acp.connection.create") + return agent.create(conn) }, stream) log.info("setup connection") diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index e0360ea72214..5765afee4e7b 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -50,7 +50,6 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), - acpNext: bool("OPENCODE_ACP_NEXT"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), diff --git a/packages/opencode/test/acp/agent-interface.test.ts b/packages/opencode/test/acp/agent-interface.test.ts deleted file mode 100644 index 7c4633d7d828..000000000000 --- a/packages/opencode/test/acp/agent-interface.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ACP } from "../../src/acp/agent" -import type { Agent as ACPAgent } from "@agentclientprotocol/sdk" - -/** - * Type-level test: This line will fail to compile if ACP.Agent - * doesn't properly implement the ACPAgent interface. - * - * The SDK checks for methods like `agent.unstable_setSessionModel` at runtime - * and throws "Method not found" if they're missing. TypeScript allows optional - * interface methods to be omitted, but the SDK still expects them. - * - * @see https://github.com/agentclientprotocol/typescript-sdk/commit/7072d3f - */ -type _AssertAgentImplementsACPAgent = ACP.Agent extends ACPAgent ? true : never -const _typeCheck: _AssertAgentImplementsACPAgent = true - -/** - * Runtime verification that optional methods the SDK expects are actually implemented. - * The SDK's router checks `if (!agent.methodName)` and throws MethodNotFound if missing. - */ -describe("acp.agent interface compliance", () => { - // Extract method names from the ACPAgent interface type - type ACPAgentMethods = keyof ACPAgent - - // Methods that the SDK's router explicitly checks for at runtime - const sdkCheckedMethods: ACPAgentMethods[] = [ - // Required - "initialize", - "newSession", - "prompt", - "cancel", - // Optional but checked by SDK router - "loadSession", - "setSessionMode", - "authenticate", - // Capability-gated methods checked by the SDK router - "listSessions", - "resumeSession", - "closeSession", - "unstable_forkSession", - "unstable_setSessionModel", - ] - - test("Agent implements all SDK-checked methods", () => { - for (const method of sdkCheckedMethods) { - expect(typeof ACP.Agent.prototype[method as keyof typeof ACP.Agent.prototype], `Missing method: ${method}`).toBe( - "function", - ) - } - }) -}) diff --git a/packages/opencode/test/acp-next/config-option.test.ts b/packages/opencode/test/acp/config-option.test.ts similarity index 98% rename from packages/opencode/test/acp-next/config-option.test.ts rename to packages/opencode/test/acp/config-option.test.ts index d538ee81c01a..846cfadad67e 100644 --- a/packages/opencode/test/acp-next/config-option.test.ts +++ b/packages/opencode/test/acp/config-option.test.ts @@ -8,7 +8,7 @@ import { formatVariantName, parseModelSelection, type ConfigOptionProvider, -} from "@/acp-next/config-option" +} from "@/acp/config-option" const providers: ConfigOptionProvider[] = [ { @@ -46,7 +46,7 @@ const providers: ConfigOptionProvider[] = [ }, ] -describe("acp-next config options", () => { +describe("acp config options", () => { test("builds the model select option with ACP verifier category", () => { expect( buildModelSelectOption({ diff --git a/packages/opencode/test/acp-next/content.test.ts b/packages/opencode/test/acp/content.test.ts similarity index 97% rename from packages/opencode/test/acp-next/content.test.ts rename to packages/opencode/test/acp/content.test.ts index 88f8608345b1..90f62f9d1892 100644 --- a/packages/opencode/test/acp-next/content.test.ts +++ b/packages/opencode/test/acp/content.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import type { ContentBlock } from "@agentclientprotocol/sdk" import { pathToFileURL } from "node:url" -import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content" +import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content" -describe("acp-next content conversion", () => { +describe("acp content conversion", () => { test("plain text block becomes a text part", () => { expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }]) }) @@ -158,7 +158,7 @@ describe("acp-next content conversion", () => { }) }) -describe("acp-next replay conversion", () => { +describe("acp replay conversion", () => { test("replays text audience annotations", () => { expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([ { diff --git a/packages/opencode/test/acp-next/directory.test.ts b/packages/opencode/test/acp/directory.test.ts similarity index 98% rename from packages/opencode/test/acp-next/directory.test.ts rename to packages/opencode/test/acp/directory.test.ts index 050ff06d45bb..aa5fc12df2ba 100644 --- a/packages/opencode/test/acp-next/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Directory } from "@/acp-next/directory" +import { Directory } from "@/acp/directory" import { Command } from "@/command" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" @@ -97,7 +97,7 @@ const fakeLayer = (calls: string[]) => ), ) -describe("ACP next directory snapshot", () => { +describe("ACP directory snapshot", () => { it.effect("two concurrent callers share one load", () => { const calls: string[] = [] return Effect.gen(function* () { diff --git a/packages/opencode/test/acp-next/error.test.ts b/packages/opencode/test/acp/error.test.ts similarity index 53% rename from packages/opencode/test/acp-next/error.test.ts rename to packages/opencode/test/acp/error.test.ts index a82c6c576e11..f96ab2823dbb 100644 --- a/packages/opencode/test/acp-next/error.test.ts +++ b/packages/opencode/test/acp/error.test.ts @@ -1,35 +1,35 @@ import { describe, expect, test } from "bun:test" import { RequestError } from "@agentclientprotocol/sdk" -import * as ACPNextError from "../../src/acp-next/error" +import * as ACPError from "../../src/acp/error" -describe("acp-next.error", () => { +describe("acp.error", () => { test("maps validation failures to invalid params", () => { - const cases: ACPNextError.Error[] = [ - new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }), - new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }), - new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }), - new ACPNextError.InvalidEffortError({ effort: "extreme" }), - new ACPNextError.InvalidModeError({ mode: "turbo" }), + const cases: ACPError.Error[] = [ + new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }), + new ACPError.InvalidConfigOptionError({ configId: "temperature" }), + new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }), + new ACPError.InvalidEffortError({ effort: "extreme" }), + new ACPError.InvalidModeError({ mode: "turbo" }), ] - expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([ + expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([ -32602, -32602, -32602, -32602, -32602, ]) }) test("includes safe validation details", () => { - expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({ + expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({ code: -32602, data: { sessionId: "ses_123" }, }) - expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({ + expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({ code: -32602, data: { modelId: "gpt-missing" }, }) }) test("maps auth required to the SDK auth error", () => { - const requestError = ACPNextError.toRequestError(new ACPNextError.AuthRequiredError({ providerId: "anthropic" })) + const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" })) expect(requestError).toBeInstanceOf(RequestError) expect(requestError.code).toBe(-32000) @@ -38,8 +38,8 @@ describe("acp-next.error", () => { }) test("maps unsupported operations to method not found", () => { - const requestError = ACPNextError.toRequestError( - new ACPNextError.UnsupportedOperationError({ method: "session/new" }), + const requestError = ACPError.toRequestError( + new ACPError.UnsupportedOperationError({ method: "session/new" }), ) expect(requestError.code).toBe(-32601) @@ -47,8 +47,8 @@ describe("acp-next.error", () => { }) test("maps service failures to safe internal errors", () => { - const requestError = ACPNextError.toRequestError( - new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }), + const requestError = ACPError.toRequestError( + new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }), ) expect(requestError.code).toBe(-32603) @@ -57,8 +57,8 @@ describe("acp-next.error", () => { }) test("wraps unknown defects without leaking raw details", () => { - const requestError = ACPNextError.toRequestError( - ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")), + const requestError = ACPError.toRequestError( + ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")), ) const serialized = JSON.stringify(requestError.toErrorResponse()) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts deleted file mode 100644 index a01680e30596..000000000000 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ /dev/null @@ -1,977 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { ACP } from "../../src/acp/agent" -import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { - Event, - EventMessagePartUpdated, - ToolStateCompleted, - ToolStatePending, - ToolStateRunning, -} from "@opencode-ai/sdk/v2" -import { provideTestInstance, tmpdir } from "../fixture/fixture" - -const pollUntil = async ( - check: () => T | undefined | false | Promise, - message: string, - opts?: { timeoutMs?: number; intervalMs?: number }, -): Promise => { - const timeoutMs = opts?.timeoutMs ?? 2000 - const intervalMs = opts?.intervalMs ?? 5 - const started = Date.now() - while (true) { - const v = await check() - if (v !== undefined && v !== null && v !== false) return v as T - if (Date.now() - started > timeoutMs) throw new Error(message) - await new Promise((r) => setTimeout(r, intervalMs)) - } -} - -type SessionUpdateParams = Parameters[0] -type RequestPermissionParams = Parameters[0] -type RequestPermissionResult = Awaited> - -type GlobalEventEnvelope = { - directory?: string - payload?: Event -} - -type EventController = { - push: (event: GlobalEventEnvelope) => void - close: () => void -} - -function inProgressText(update: SessionUpdateParams["update"]) { - if (update.sessionUpdate !== "tool_call_update") return undefined - if (update.status !== "in_progress") return undefined - if (!update.content || !Array.isArray(update.content)) return undefined - const first = update.content[0] - if (!first || first.type !== "content") return undefined - if (first.content.type !== "text") return undefined - return first.content.text -} - -function isToolCallUpdate( - update: SessionUpdateParams["update"], -): update is Extract { - return update.sessionUpdate === "tool_call_update" -} - -function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { - return sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update) - .filter(isToolCallUpdate) - .find((u) => u.toolCallId === callID && u.status === "completed") -} - -function toolEvent( - sessionId: string, - cwd: string, - opts: { - callID: string - tool: string - input: Record - } & ({ status: "running"; metadata?: Record } | { status: "pending"; raw: string }), -): GlobalEventEnvelope { - const state: ToolStatePending | ToolStateRunning = - opts.status === "running" - ? { - status: "running", - input: opts.input, - ...(opts.metadata && { metadata: opts.metadata }), - time: { start: Date.now() }, - } - : { - status: "pending", - input: opts.input, - raw: opts.raw, - } - const payload: EventMessagePartUpdated = { - id: `evt_${opts.callID}`, - type: "message.part.updated", - properties: { - sessionID: sessionId, - time: Date.now(), - part: { - id: `part_${opts.callID}`, - sessionID: sessionId, - messageID: `msg_${opts.callID}`, - type: "tool", - callID: opts.callID, - tool: opts.tool, - state, - }, - }, - } - return { directory: cwd, payload } -} - -function completedToolEvent( - sessionId: string, - cwd: string, - opts: { - callID: string - tool: string - input: Record - output: string - attachments?: ToolStateCompleted["attachments"] - }, -): GlobalEventEnvelope { - const state: ToolStateCompleted = { - status: "completed", - input: opts.input, - output: opts.output, - title: opts.tool, - metadata: {}, - time: { start: Date.now() - 1, end: Date.now() }, - ...(opts.attachments && { attachments: opts.attachments }), - } - const payload: EventMessagePartUpdated = { - id: `evt_${opts.callID}`, - type: "message.part.updated", - properties: { - sessionID: sessionId, - time: Date.now(), - part: { - id: `part_${opts.callID}`, - sessionID: sessionId, - messageID: `msg_${opts.callID}`, - type: "tool", - callID: opts.callID, - tool: opts.tool, - state, - }, - }, - } - return { directory: cwd, payload } -} - -function createEventStream() { - const queue: GlobalEventEnvelope[] = [] - const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] - const state = { closed: false } - - const push = (event: GlobalEventEnvelope) => { - const waiter = waiters.shift() - if (waiter) { - waiter(event) - return - } - queue.push(event) - } - - const close = () => { - state.closed = true - for (const waiter of waiters.splice(0)) { - waiter(undefined) - } - } - - const stream = async function* (signal?: AbortSignal) { - while (true) { - if (signal?.aborted) return - const next = queue.shift() - if (next) { - yield next - continue - } - if (state.closed) return - const value = await new Promise((resolve) => { - waiters.push(resolve) - if (!signal) return - signal.addEventListener("abort", () => resolve(undefined), { once: true }) - }) - if (!value) return - yield value - } - } - - return { controller: { push, close } satisfies EventController, stream } -} - -function createFakeAgent() { - const updates = new Map() - const chunks = new Map() - const sessionUpdates: SessionUpdateParams[] = [] - const record = (sessionId: string, type: string) => { - const list = updates.get(sessionId) ?? [] - list.push(type) - updates.set(sessionId, list) - } - - const connection = { - async sessionUpdate(params: SessionUpdateParams) { - sessionUpdates.push(params) - const update = params.update - const type = update?.sessionUpdate ?? "unknown" - record(params.sessionId, type) - if (update?.sessionUpdate === "agent_message_chunk") { - const content = update.content - if (content?.type !== "text") return - if (typeof content.text !== "string") return - chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text) - } - }, - async requestPermission(_params: RequestPermissionParams): Promise { - return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult - }, - } as unknown as AgentSideConnection - - const { controller, stream } = createEventStream() - const calls = { - eventSubscribe: 0, - sessionCreate: 0, - } - - const sdk = { - global: { - event: async (opts?: { signal?: AbortSignal }) => { - calls.eventSubscribe++ - return { stream: stream(opts?.signal) } - }, - }, - session: { - create: async (_params?: any) => { - calls.sessionCreate++ - return { - data: { - id: `ses_${calls.sessionCreate}`, - time: { created: new Date().toISOString() }, - }, - } - }, - get: async (_params?: any) => { - return { - data: { - id: "ses_1", - time: { created: new Date().toISOString() }, - }, - } - }, - messages: async () => { - return { data: [] } - }, - message: async (params?: any) => { - // Return a message with parts that can be looked up by partID - return { - data: { - info: { - role: "assistant", - }, - parts: [ - { - id: params?.messageID ? `${params.messageID}_part` : "part_1", - type: "text", - text: "", - }, - ], - }, - } - }, - }, - permission: { - respond: async () => { - return { data: true } - }, - }, - config: { - providers: async () => { - return { - data: { - providers: [ - { - id: "opencode", - name: "opencode", - models: { - "big-pickle": { id: "big-pickle", name: "big-pickle" }, - }, - }, - ], - }, - } - }, - }, - app: { - agents: async () => { - return { - data: [ - { - name: "build", - description: "build", - mode: "agent", - }, - ], - } - }, - }, - command: { - list: async () => { - return { data: [] } - }, - }, - mcp: { - add: async () => { - return { data: true } - }, - }, - } as any - - const agent = new ACP.Agent(connection, { - sdk, - defaultModel: { providerID: "opencode", modelID: "big-pickle" }, - } as any) - - const stop = () => { - controller.close() - ;(agent as any).eventAbort.abort() - } - - return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection } -} - -describe("acp.agent event subscription", () => { - test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, updates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionB, - messageID: "msg_1", - partID: "msg_1_part", - field: "text", - delta: "hello", - }, - }, - } as any) - - await pollUntil( - () => (updates.get(sessionB) ?? []).includes("agent_message_chunk"), - "sessionB never received agent_message_chunk", - ) - - expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false) - expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true) - - stop() - }, - }) - }) - - test("does not emit user_message_chunk for live prompt parts", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push({ - directory: cwd, - payload: { - type: "message.part.updated", - properties: { - sessionID: sessionId, - time: Date.now(), - part: { - id: "part_1", - sessionID: sessionId, - messageID: "msg_user", - type: "text", - text: "hello", - }, - }, - }, - } as any) - - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionId, - messageID: "msg_marker", - partID: "msg_marker_part", - field: "text", - delta: "marker", - }, - }, - } as any) - - await pollUntil( - () => - sessionUpdates.some((u) => u.sessionId === sessionId && u.update.sessionUpdate === "agent_message_chunk"), - "marker event was never processed", - ) - - expect( - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .some((u) => u.update.sessionUpdate === "user_message_chunk"), - ).toBe(false) - - stop() - }, - }) - }) - - test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, chunks, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - const tokenA = ["ALPHA_", "111", "_X"] - const tokenB = ["BETA_", "222", "_Y"] - - const push = (sessionId: string, messageID: string, delta: string) => { - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionId, - messageID, - partID: `${messageID}_part`, - field: "text", - delta, - }, - }, - } as any) - } - - push(sessionA, "msg_a", tokenA[0]) - push(sessionB, "msg_b", tokenB[0]) - push(sessionA, "msg_a", tokenA[1]) - push(sessionB, "msg_b", tokenB[1]) - push(sessionA, "msg_a", tokenA[2]) - push(sessionB, "msg_b", tokenB[2]) - - await pollUntil( - () => - (chunks.get(sessionA) ?? "").includes(tokenA.join("")) && - (chunks.get(sessionB) ?? "").includes(tokenB.join("")), - "interleaved chunks never fully arrived", - ) - - const a = chunks.get(sessionA) ?? "" - const b = chunks.get(sessionB) ?? "" - - expect(a).toContain(tokenA.join("")) - expect(b).toContain(tokenB.join("")) - for (const part of tokenB) expect(a).not.toContain(part) - for (const part of tokenA) expect(b).not.toContain(part) - - stop() - }, - }) - }) - - test("does not create additional event subscriptions on repeated loadSession()", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, calls, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - - expect(calls.eventSubscribe).toBe(1) - - stop() - }, - }) - }) - - test("permission.asked events are handled and replied", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const permissionReplies: string[] = [] - const { agent, controller, stop, sdk } = createFakeAgent() - sdk.permission.reply = async (params: any) => { - permissionReplies.push(params.requestID) - return { data: true } - } - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push({ - directory: cwd, - payload: { - type: "permission.asked", - properties: { - id: "perm_1", - sessionID: sessionA, - permission: "bash", - patterns: ["*"], - metadata: {}, - always: [], - }, - }, - } as any) - - await pollUntil(() => permissionReplies.includes("perm_1"), "perm_1 was never replied") - - expect(permissionReplies).toContain("perm_1") - - stop() - }, - }) - }) - - test("permission prompt on session A does not block message updates for session B", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const permissionReplies: string[] = [] - let resolvePermissionA: (() => void) | undefined - const permissionABlocking = new Promise((r) => { - resolvePermissionA = r - }) - - const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent() - - // Make permission request for session A block until we release it - const originalRequestPermission = connection.requestPermission.bind(connection) - let _permissionCalls = 0 - connection.requestPermission = async (params: RequestPermissionParams) => { - _permissionCalls++ - if (params.sessionId.endsWith("1")) { - await permissionABlocking - } - return originalRequestPermission(params) - } - - sdk.permission.reply = async (params: any) => { - permissionReplies.push(params.requestID) - return { data: true } - } - - const cwd = "/tmp/opencode-acp-test" - - const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - // Push permission.asked for session A (will block) - controller.push({ - directory: cwd, - payload: { - type: "permission.asked", - properties: { - id: "perm_a", - sessionID: sessionA, - permission: "bash", - patterns: ["*"], - metadata: {}, - always: [], - }, - }, - } as any) - - await pollUntil(() => _permissionCalls > 0, "permission handling for A never started") - - controller.push({ - directory: cwd, - payload: { - type: "message.part.delta", - properties: { - sessionID: sessionB, - messageID: "msg_b", - partID: "msg_b_part", - field: "text", - delta: "session_b_message", - }, - }, - } as any) - - await pollUntil( - () => (chunks.get(sessionB) ?? "").includes("session_b_message"), - "session B never received its message", - ) - - expect(chunks.get(sessionB) ?? "").toContain("session_b_message") - expect(permissionReplies).not.toContain("perm_a") - - resolvePermissionA!() - await pollUntil(() => permissionReplies.includes("perm_a"), "perm_a was never replied after release") - - expect(permissionReplies).toContain("perm_a") - - stop() - }, - }) - }) - - test("streams running bash output snapshots and de-dupes identical snapshots", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const input = { command: "echo hello", description: "run command" } - - for (const output of ["a", "a", "ab"]) { - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output }, - }), - ) - } - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - .filter((t) => t === "ab").length > 0, - "final bash snapshot 'ab' never arrived", - ) - - const snapshots = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - - expect(snapshots).toEqual(["a", undefined, "ab"]) - stop() - }, - }) - }) - - test("emits synthetic pending before first running update for any tool", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_bash", - tool: "bash", - status: "running", - input: { command: "echo hi", description: "run command" }, - metadata: { output: "hi\n" }, - }), - ) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_read", - tool: "read", - status: "running", - input: { filePath: "/tmp/example.txt" }, - }), - ) - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update").length >= 4, - "expected 4 tool_call/tool_call_update events", - ) - - const types = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update") - expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"]) - - const pendings = sessionUpdates.filter( - (u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call", - ) - expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe( - true, - ) - stop() - }, - }) - }) - - test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const data = Buffer.from("image-data").toString("base64") - - controller.push( - completedToolEvent(sessionId, cwd, { - callID: "call_image", - tool: "read", - input: { filePath: "/tmp/image.png" }, - output: "Image read successfully", - attachments: [ - { - id: "part_image", - sessionID: sessionId, - messageID: "msg_image", - type: "file", - mime: "image/png", - filename: "image.png", - url: `data:image/png;base64,${data}`, - }, - { - id: "part_text", - sessionID: sessionId, - messageID: "msg_image", - type: "file", - mime: "text/plain", - filename: "note.txt", - url: "data:text/plain;base64,Zm9v", - }, - ], - }), - ) - await pollUntil( - () => completedToolUpdate(sessionUpdates, sessionId, "call_image"), - "completed tool update for call_image never arrived", - ) - - const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "text", text: "Image read successfully" }, - }) - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "image", mimeType: "image/png", data }, - }) - expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) - expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) - - stop() - }, - }) - }) - - test("replays completed tool image attachments as ACP tool content blocks", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, sessionUpdates, stop, sdk } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const data = Buffer.from("replay-image").toString("base64") - - sdk.session.messages = async () => ({ - data: [ - { - info: { - role: "assistant", - sessionID: sessionId, - }, - parts: [ - { - id: "part_replay", - sessionID: sessionId, - messageID: "msg_replay", - type: "tool", - callID: "call_replay_image", - tool: "webfetch", - state: { - status: "completed", - input: { url: "https://example.com/image.png" }, - output: "Image fetched successfully", - title: "webfetch", - metadata: {}, - time: { start: Date.now() - 1, end: Date.now() }, - attachments: [ - { - id: "part_replay_image", - sessionID: sessionId, - messageID: "msg_replay", - type: "file", - mime: "image/jpeg", - filename: "image.jpg", - url: `data:image/jpeg;base64,${data}`, - }, - ], - }, - }, - ], - }, - ], - }) - - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - - const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "text", text: "Image fetched successfully" }, - }) - expect(update?.content).toContainEqual({ - type: "content", - content: { type: "image", mimeType: "image/jpeg", data }, - }) - - stop() - }, - }) - }) - - test("does not emit duplicate synthetic pending after replayed running tool", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const input = { command: "echo hi", description: "run command" } - - sdk.session.messages = async () => ({ - data: [ - { - info: { - role: "assistant", - sessionID: sessionId, - }, - parts: [ - { - type: "tool", - callID: "call_1", - tool: "bash", - state: { - status: "running", - input, - metadata: { output: "hi\n" }, - time: { start: Date.now() }, - }, - }, - ], - }, - ], - }) - - await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output: "hi\nthere\n" }, - }), - ) - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update) - .filter((u) => "toolCallId" in u && u.toolCallId === "call_1") - .map((u) => u.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update").length >= 3, - "expected 3 tool events for call_1", - ) - - const types = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .map((u) => u.update) - .filter((u) => "toolCallId" in u && u.toolCallId === "call_1") - .map((u) => u.sessionUpdate) - .filter((u) => u === "tool_call" || u === "tool_call_update") - - expect(types).toEqual(["tool_call", "tool_call_update", "tool_call_update"]) - stop() - }, - }) - }) - - test("clears bash snapshot marker on pending state", async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const { agent, controller, sessionUpdates, stop } = createFakeAgent() - const cwd = "/tmp/opencode-acp-test" - const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) - const input = { command: "echo hello", description: "run command" } - - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output: "a" }, - }), - ) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "pending", - input, - raw: '{"command":"echo hello"}', - }), - ) - controller.push( - toolEvent(sessionId, cwd, { - callID: "call_1", - tool: "bash", - status: "running", - input, - metadata: { output: "a" }, - }), - ) - await pollUntil( - () => - sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - .filter((t) => t === "a").length >= 2, - "expected two 'a' bash snapshots after pending reset", - ) - - const snapshots = sessionUpdates - .filter((u) => u.sessionId === sessionId) - .filter((u) => isToolCallUpdate(u.update)) - .map((u) => inProgressText(u.update)) - - expect(snapshots).toEqual(["a", "a"]) - stop() - }, - }) - }) -}) diff --git a/packages/opencode/test/acp-next/event.test.ts b/packages/opencode/test/acp/event.test.ts similarity index 96% rename from packages/opencode/test/acp-next/event.test.ts rename to packages/opencode/test/acp/event.test.ts index 92b65b8881a2..c79baf035e0a 100644 --- a/packages/opencode/test/acp-next/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" -import { ACPNextEvent } from "@/acp-next/event" -import * as ACPNextService from "@/acp-next/service" -import { Directory } from "@/acp-next/directory" -import { ACPNextSession } from "@/acp-next/session" +import { ACPEvent } from "@/acp/event" +import * as ACPService from "@/acp/service" +import { Directory } from "@/acp/directory" +import { ACPSession } from "@/acp/session" type SessionUpdateParams = Parameters[0] type ToolSessionUpdateParams = SessionUpdateParams & { @@ -30,8 +30,8 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -107,7 +107,7 @@ function createHarness(messages: Record = {}) { }, } satisfies Pick const session = makeSessionService() - const subscription = new ACPNextEvent.Subscription({ sdk, connection, session }) + const subscription = new ACPEvent.Subscription({ sdk, connection, session }) return { calls, connection, events, sdk, session, subscription, updates } } @@ -296,7 +296,7 @@ function toolUpdates(updates: SessionUpdateParams[]) { } async function createKnownSession( - session: ACPNextSession.Interface, + session: ACPSession.Interface, sessionId: string, part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] }, ) { @@ -312,7 +312,7 @@ async function createKnownSession( ) } -describe("acp-next event routing", () => { +describe("acp event routing", () => { it("routes message.part.delta by sessionID without cross-session pollution", async () => { const harness = createHarness() await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" }) @@ -348,8 +348,8 @@ describe("acp-next event routing", () => { it("does not create extra subscriptions on repeated loadSession", async () => { const harness = createHarness() - let subscription: ACPNextEvent.Subscription | undefined - const service = ACPNextService.make({ + let subscription: ACPEvent.Subscription | undefined + const service = ACPService.make({ sdk: harness.sdk, connection: harness.connection, directory: { @@ -439,8 +439,8 @@ describe("acp-next event routing", () => { return Promise.resolve() }, } satisfies Pick - let subscription: ACPNextEvent.Subscription | undefined - const service = ACPNextService.make({ + let subscription: ACPEvent.Subscription | undefined + const service = ACPService.make({ sdk: { global: { event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }), diff --git a/packages/opencode/test/acp-next/permission.test.ts b/packages/opencode/test/acp/permission.test.ts similarity index 94% rename from packages/opencode/test/acp-next/permission.test.ts rename to packages/opencode/test/acp/permission.test.ts index ec9712d72d80..fb86026af6c4 100644 --- a/packages/opencode/test/acp-next/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -7,8 +7,8 @@ import type { } from "@agentclientprotocol/sdk" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" -import { ACPNextEvent } from "@/acp-next/event" -import { ACPNextSession } from "@/acp-next/session" +import { ACPEvent } from "@/acp/event" +import { ACPSession } from "@/acp/session" type PermissionEvent = Extract type PermissionReplyParams = Parameters[0] @@ -28,8 +28,8 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) } @@ -62,17 +62,17 @@ function createHarness( return Promise.resolve() }, } satisfies Pick - const subscription = new ACPNextEvent.Subscription({ sdk, connection, session }) + const subscription = new ACPEvent.Subscription({ sdk, connection, session }) return { connection, replies, requests, sdk, session, subscription, updates } } -async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") { +async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") { await Effect.runPromise(session.create({ id: sessionId, cwd })) } async function createKnownTextPart( - session: ACPNextSession.Interface, + session: ACPSession.Interface, sessionId: string, messageId: string, partId: string, @@ -137,7 +137,7 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) { .join("") } -describe("acp-next permissions", () => { +describe("acp permissions", () => { it("sends requestPermission and replies with the selected outcome", async () => { const harness = createHarness() await createSession(harness.session, "ses_a") diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts similarity index 96% rename from packages/opencode/test/acp-next/service-session.test.ts rename to packages/opencode/test/acp/service-session.test.ts index a35e6aa0ae80..7a09fd1cc910 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -12,10 +12,10 @@ import type { } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" -import * as ACPNextService from "@/acp-next/service" -import * as ACPNextError from "@/acp-next/error" -import { ACPNextSession } from "@/acp-next/session" -import { UsageService } from "@/acp-next/usage" +import * as ACPService from "@/acp/service" +import * as ACPError from "@/acp/error" +import { ACPSession } from "@/acp/session" +import { UsageService } from "@/acp/usage" import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" @@ -141,7 +141,7 @@ const provider: Provider.Info = { }, } -describe("ACP next service sessions", () => { +describe("ACP service sessions", () => { const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => { const updates: SessionNotification[] = [] const mcpAdds: string[] = [] @@ -254,7 +254,7 @@ describe("ACP next service sessions", () => { }) return { - service: ACPNextService.make({ sdk, connection, usage }), + service: ACPService.make({ sdk, connection, usage }), updates, mcpAdds, aborts, @@ -374,7 +374,7 @@ describe("ACP next service sessions", () => { const missing = await Effect.runPromise( service .setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) expect(missing.code).toBe(-32602) expect(aborts).toEqual([created.sessionId]) @@ -382,8 +382,8 @@ describe("ACP next service sessions", () => { }) it("does not fail close when backing abort fails", async () => { - const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( - ACPNextSession.Service.use((service) => Effect.succeed(service)), + const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync( + ACPSession.Service.use((service) => Effect.succeed(service)), ) const { service } = makeService() const sdk = { @@ -405,7 +405,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const closing = ACPNextService.make({ sdk, session: sessionService }) + const closing = ACPService.make({ sdk, session: sessionService }) await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" })) expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({}) @@ -467,7 +467,7 @@ describe("ACP next service sessions", () => { }) it("maps provider auth failures to auth-required request errors", async () => { - const service = ACPNextService.make({ + const service = ACPService.make({ sdk: { config: { providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }), @@ -485,7 +485,7 @@ describe("ACP next service sessions", () => { const error = await Effect.runPromise( service .newSession({ cwd: "/workspace", mcpServers: [] }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) expect(error.code).toBe(-32000) @@ -519,12 +519,12 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const first = await Effect.runPromise( service .newSession({ cwd: "/workspace", mcpServers: [] }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -562,7 +562,7 @@ describe("ACP next service sessions", () => { }, }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) await Effect.runPromise( service.newSession({ @@ -603,7 +603,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -642,7 +642,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -709,7 +709,7 @@ describe("ACP next service sessions", () => { Effect.runPromise( service .setSessionConfigOption({ sessionId: session.sessionId, ...input }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ), ), ) @@ -759,7 +759,7 @@ describe("ACP next service sessions", () => { }, }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 }) @@ -814,7 +814,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const updated = await Effect.runPromise( service.setSessionConfigOption({ @@ -884,7 +884,7 @@ describe("ACP next service sessions", () => { add: () => Promise.resolve({ data: {} }), }, } as unknown as OpencodeClient - const service = ACPNextService.make({ sdk }) + const service = ACPService.make({ sdk }) const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) @@ -1065,7 +1065,7 @@ describe("ACP next service sessions", () => { it("maps prompt auth failures to auth-required request errors", async () => { const { service } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) - const failing = ACPNextService.make({ + const failing = ACPService.make({ sdk: { config: { providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), @@ -1099,7 +1099,7 @@ describe("ACP next service sessions", () => { const error = await Effect.runPromise( failing .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }) - .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), + .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip), ) expect(error.code).toBe(-32000) diff --git a/packages/opencode/test/acp-next/session.test.ts b/packages/opencode/test/acp/session.test.ts similarity index 64% rename from packages/opencode/test/acp-next/session.test.ts rename to packages/opencode/test/acp/session.test.ts index 0c1cb16cc784..c03388037547 100644 --- a/packages/opencode/test/acp-next/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -1,14 +1,14 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" import { Effect } from "effect" -import * as ACPNextError from "@/acp-next/error" -import * as ACPNextSession from "@/acp-next/session" +import * as ACPError from "@/acp/error" +import * as ACPSession from "@/acp/session" import { ModelID, ProviderID } from "@/provider/schema" import { testEffect } from "../lib/effect" -const sessionTest = testEffect(ACPNextSession.defaultLayer) +const sessionTest = testEffect(ACPSession.defaultLayer) -const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({ +const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ providerID: ProviderID.make(providerID), modelID: ModelID.make(modelID), }) @@ -20,11 +20,11 @@ const mcpServer: McpServer = { env: [], } -describe("acp-next session state", () => { +describe("acp session state", () => { sessionTest.effect("creates and retrieves session state", () => Effect.gen(function* () { const createdAt = new Date("2026-05-25T00:00:00.000Z") - const created = yield* ACPNextSession.Service.use((session) => + const created = yield* ACPSession.Service.use((session) => session.create({ id: "ses_1", cwd: "/workspace", @@ -35,7 +35,7 @@ describe("acp-next session state", () => { modeId: "build", }), ) - const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1")) + const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1")) expect(created).toMatchObject({ id: "ses_1", @@ -52,17 +52,17 @@ describe("acp-next session state", () => { sessionTest.effect("fails required lookups with typed SessionNotFound", () => Effect.gen(function* () { - const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip) + const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip) - expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError) + expect(error).toBeInstanceOf(ACPError.SessionNotFoundError) expect(error.sessionId).toBe("ses_missing") }), ) sessionTest.effect("tryGet lets event routing ignore unknown sessions", () => Effect.gen(function* () { - const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing")) - const missingPart = yield* ACPNextSession.Service.use((session) => + const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing")) + const missingPart = yield* ACPSession.Service.use((session) => session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }), ) @@ -73,7 +73,7 @@ describe("acp-next session state", () => { sessionTest.effect("updates selected model while preserving session identity and inputs", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.create({ id: "ses_model", cwd: "/workspace", @@ -84,7 +84,7 @@ describe("acp-next session state", () => { }), ) - const updated = yield* ACPNextSession.Service.use((session) => + const updated = yield* ACPSession.Service.use((session) => session.setModel("ses_model", model("openai", "gpt-5")), ) @@ -99,7 +99,7 @@ describe("acp-next session state", () => { sessionTest.effect("updates selected variant and mode independently", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.load({ id: "ses_config", cwd: "/workspace", @@ -109,21 +109,21 @@ describe("acp-next session state", () => { }), ) - yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high")) - expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") - expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan") + yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high")) + expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") + expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan") - yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build")) - expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") - expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build") + yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build")) + expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") + expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build") }), ) sessionTest.effect("records known message part metadata for delta routing", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" })) + yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" })) - const metadata = yield* ACPNextSession.Service.use((session) => + const metadata = yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", @@ -132,7 +132,7 @@ describe("acp-next session state", () => { metadata: { output: "first chunk" }, }), ) - const routed = yield* ACPNextSession.Service.use((session) => + const routed = yield* ACPSession.Service.use((session) => session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }), ) @@ -148,8 +148,8 @@ describe("acp-next session state", () => { sessionTest.effect("keeps repeated part ids distinct across messages", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" })) - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" })) + yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", @@ -157,7 +157,7 @@ describe("acp-next session state", () => { metadata: { output: "from first message" }, }), ) - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", @@ -166,10 +166,10 @@ describe("acp-next session state", () => { }), ) - const first = yield* ACPNextSession.Service.use((session) => + const first = yield* ACPSession.Service.use((session) => session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }), ) - const second = yield* ACPNextSession.Service.use((session) => + const second = yield* ACPSession.Service.use((session) => session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }), ) @@ -180,14 +180,14 @@ describe("acp-next session state", () => { sessionTest.effect("removing a session clears its known part metadata", () => Effect.gen(function* () { - yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" })) - yield* ACPNextSession.Service.use((session) => + yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" })) + yield* ACPSession.Service.use((session) => session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), ) - const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove")) - const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove")) - const missingPart = yield* ACPNextSession.Service.use((session) => + const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove")) + const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove")) + const missingPart = yield* ACPSession.Service.use((session) => session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), ) diff --git a/packages/opencode/test/acp-next/tool.test.ts b/packages/opencode/test/acp/tool.test.ts similarity index 98% rename from packages/opencode/test/acp-next/tool.test.ts rename to packages/opencode/test/acp/tool.test.ts index 0e0cc1e3ffc6..7587f7bbd91b 100644 --- a/packages/opencode/test/acp-next/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -7,9 +7,9 @@ import { shellOutputSnapshot, toLocations, toToolKind, -} from "../../src/acp-next/tool" +} from "../../src/acp/tool" -describe("acp-next tool conversion", () => { +describe("acp tool conversion", () => { test("maps OpenCode tool ids to ACP tool kinds", () => { expect(toToolKind("bash")).toBe("execute") expect(toToolKind("shell")).toBe("execute") diff --git a/packages/opencode/test/acp-next/usage.test.ts b/packages/opencode/test/acp/usage.test.ts similarity index 99% rename from packages/opencode/test/acp-next/usage.test.ts rename to packages/opencode/test/acp/usage.test.ts index 77c17d4f72ea..343a27013804 100644 --- a/packages/opencode/test/acp-next/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" -import { UsageService } from "@/acp-next/usage" +import { UsageService } from "@/acp/usage" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Effect, Layer } from "effect" @@ -122,7 +122,7 @@ const connection = (updates: SessionNotification[]) => ({ }, }) -describe("acp-next usage", () => { +describe("acp usage", () => { test("builds ACP Usage from assistant token shape", () => { expect( UsageService.buildUsage({ diff --git a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts b/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts deleted file mode 100644 index a805bfef29f1..000000000000 --- a/packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, expect } from "bun:test" -import type { - CloseSessionResponse, - InitializeResponse, - NewSessionResponse, - ResumeSessionResponse, - SessionNotification, - SetSessionConfigOptionResponse, -} from "@agentclientprotocol/sdk" -import { Effect } from "effect" -import { mkdir } from "node:fs/promises" -import path from "node:path" -import { cliIt } from "../../lib/cli-process" -import { testProviderConfig } from "../../lib/test-provider" -import { - createAcpClient, - expectOk, - firstAlternateValue, - flattenSelectOptions, - selectConfigOption, -} from "./acp-test-client" - -describe("opencode acp verifier compatibility baseline", () => { - cliIt.live( - "initialize advertises close and resume capabilities", - ({ opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - const initialized = expectOk( - yield* acp.request("initialize", { - protocolVersion: 1, - }), - ) - - expect(initialized.protocolVersion).toBe(1) - expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) - expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) - }), - 60_000, - ) - - cliIt.live( - "first session returns model options", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { - protocolVersion: 1, - clientCapabilities: {}, - clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" }, - }) - const session = expectOk( - yield* acp.request("session/new", { - cwd: home, - mcpServers: [], - }), - ) - const model = selectConfigOption(session.configOptions, "model") - expect(model?.category).toBe("model") - expect(model?.currentValue).toBe("test/test-model") - expect(model ? flattenSelectOptions(model).length : 0).toBeGreaterThanOrEqual(2) - }), - 60_000, - ) - - cliIt.live( - "newSession can be called repeatedly", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - yield* acp.request("session/new", { cwd: home, mcpServers: [] }) - - const session = expectOk( - yield* acp.request("session/new", { - cwd: home, - mcpServers: [], - }), - ) - expect(session.sessionId).toBeTruthy() - }), - 60_000, - ) - - cliIt.live( - "model switch updates currentValue", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const model = selectConfigOption(session.configOptions, "model") - expect(model).toBeDefined() - const nextModel = model - ? flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value - : undefined - expect(nextModel).toBe("test/second-model") - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "model", - value: nextModel, - }), - ) - - expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel) - }), - 60_000, - ) - - cliIt.live( - "effort option is listed for variant-capable models and can switch", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - const effort = selectConfigOption(session.configOptions, "effort") - expect(effort?.category).toBe("thought_level") - const nextEffort = effort ? firstAlternateValue(effort) : undefined - expect(nextEffort).toBe("high") - - const updated = expectOk( - yield* acp.request("session/set_config_option", { - sessionId: session.sessionId, - configId: "effort", - value: nextEffort, - }), - ) - - expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort) - }), - 60_000, - ) - - cliIt.live( - "default test provider documents missing effort option when the model has no variants", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(noVariantConfig(llm.url)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - expect(selectConfigOption(session.configOptions, "model")?.currentValue).toBe("test/test-model") - expect(selectConfigOption(session.configOptions, "effort")).toBeUndefined() - }), - 60_000, - ) - - cliIt.live( - "skill slash command appears through available_commands_update", - ({ home, llm, opencode }) => - Effect.gen(function* () { - const skills = path.join(home, "skills") - yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) - yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) - const acp = createAcpClient( - yield* opencode.acp({ - env: { - OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)), - }, - }), - ) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - const update = yield* acp.waitForNotification( - "session/update", - (params) => - params.sessionId === session.sessionId && - params.update.sessionUpdate === "available_commands_update" && - params.update.availableCommands.some((command) => command.name === "verifier-skill"), - ) - - expect(update.params?.sessionId).toBe(session.sessionId) - }), - 60_000, - ) - - cliIt.live( - "close request succeeds for a live session", - ({ home, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - expectOk(yield* acp.request("session/close", { sessionId: session.sessionId })) - }), - 60_000, - ) - - cliIt.live( - "resume request succeeds for a created session", - ({ home, opencode }) => - Effect.gen(function* () { - const acp = createAcpClient(yield* opencode.acp()) - yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) - - const resumed = expectOk( - yield* acp.request("session/resume", { - sessionId: session.sessionId, - cwd: home, - mcpServers: [], - }), - ) - expect(resumed.configOptions?.length).toBeGreaterThan(0) - }), - 60_000, - ) -}) - -function verifierConfig(llmUrl: string, skills?: string) { - const config = testProviderConfig(llmUrl) - return { - ...config, - model: "test/test-model", - ...(skills ? { skills: { paths: [skills] } } : {}), - provider: { - test: { - ...config.provider.test, - models: { - "test-model": { - ...config.provider.test.models["test-model"], - variants: { - low: {}, - high: {}, - }, - }, - "second-model": { - ...config.provider.test.models["test-model"], - id: "second-model", - name: "Second Test Model", - }, - }, - }, - }, - } -} - -function noVariantConfig(llmUrl: string) { - const config = verifierConfig(llmUrl) - return { - ...config, - provider: { - test: { - ...config.provider.test, - models: { - "test-model": { - ...config.provider.test.models["test-model"], - variants: undefined, - }, - "second-model": config.provider.test.models["second-model"], - }, - }, - }, - } -} - -const verifierSkill = `--- -name: verifier-skill -description: Verifier compatibility skill. ---- - -# Verifier Skill -` diff --git a/packages/opencode/test/cli/acp/acp-process.test.ts b/packages/opencode/test/cli/acp/acp-process.test.ts deleted file mode 100644 index a3c244c6c8ce..000000000000 --- a/packages/opencode/test/cli/acp/acp-process.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC -// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md. -// This is the only test tier that exercises the full pipe of bun startup → -// server boot → ACP agent init → stdio framing → graceful shutdown. -import { describe, expect } from "bun:test" -import { Duration, Effect } from "effect" -import { cliIt } from "../../lib/cli-process" - -describe("opencode acp (subprocess)", () => { - // Smoke test: send the `initialize` request from src/acp/README.md and - // assert the response advertises the same protocol version and a non-empty - // capabilities block. If this fails, every other ACP test will too — start - // debugging here. - cliIt.live( - "responds to initialize with protocolVersion 1 and capabilities", - ({ opencode }) => - Effect.gen(function* () { - const acp = yield* opencode.acp() - - yield* acp.send({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { protocolVersion: 1 }, - }) - - // Tight deadline — the response should arrive within a few seconds - // once startup completes. A hang means the agent never finished init, - // which is a real regression and not a tuning issue. - const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as { - jsonrpc: string - id: number - result?: { protocolVersion: number; agentCapabilities: Record } - error?: unknown - } - - expect(response.jsonrpc).toBe("2.0") - expect(response.id).toBe(1) - expect(response.error).toBeUndefined() - expect(response.result?.protocolVersion).toBe(1) - expect(response.result?.agentCapabilities).toBeDefined() - }), - 60_000, - ) - - // Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin" - // — if a future refactor breaks the stdin-end branch in the handler, the - // process would only exit on SIGTERM fallback (2s in the harness). This - // test passing within the inner-scope assertion proves the EOF path works. - cliIt.live( - "exits cleanly when stdin is closed (scope close)", - ({ opencode }) => - Effect.gen(function* () { - const exitedPromise = yield* Effect.scoped( - Effect.gen(function* () { - const acp = yield* opencode.acp() - // Capture the Promise — scope-close fires the finalizer which - // ends stdin, and ACP should exit gracefully. - return acp.exited - }), - ) - - const code = yield* Effect.promise(() => exitedPromise) - // Bun returns a number for normal exit. Anything goes for SIGTERM, - // but we still require resolution within the test timeout. - expect(typeof code === "number" || code === null).toBe(true) - }), - 60_000, - ) -}) diff --git a/packages/opencode/test/cli/acp-next/config-options.test.ts b/packages/opencode/test/cli/acp/config-options.test.ts similarity index 91% rename from packages/opencode/test/cli/acp-next/config-options.test.ts rename to packages/opencode/test/cli/acp/config-options.test.ts index 1fb69d5ef88d..0c712f0f2331 100644 --- a/packages/opencode/test/cli/acp-next/config-options.test.ts +++ b/packages/opencode/test/cli/acp/config-options.test.ts @@ -2,9 +2,9 @@ import { describe, expect } from "bun:test" import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk" import { Effect } from "effect" import { cliIt } from "../../lib/cli-process" -import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client" +import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client" import { - createAcpNextClient, + createAcpClient, expectAlternateValue, expectSelectOption, initialize, @@ -12,12 +12,12 @@ import { verifierConfig, } from "./helpers" -describe("opencode acp-next config option subprocess", () => { +describe("opencode acp config option subprocess", () => { cliIt.live( 'model option is listed with category "model"', ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) @@ -35,7 +35,7 @@ describe("opencode acp-next config option subprocess", () => { "model switch updates currentValue", ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) @@ -62,7 +62,7 @@ describe("opencode acp-next config option subprocess", () => { 'effort option is listed with category "thought_level" when selected model supports variants', ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) @@ -80,7 +80,7 @@ describe("opencode acp-next config option subprocess", () => { "effort switch updates currentValue", ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) diff --git a/packages/opencode/test/cli/acp-next/helpers.ts b/packages/opencode/test/cli/acp/helpers.ts similarity index 86% rename from packages/opencode/test/cli/acp-next/helpers.ts rename to packages/opencode/test/cli/acp/helpers.ts index 6e92a73ea34f..92c560d941ca 100644 --- a/packages/opencode/test/cli/acp-next/helpers.ts +++ b/packages/opencode/test/cli/acp/helpers.ts @@ -4,23 +4,16 @@ import { Effect } from "effect" import type { CliFixture } from "../../lib/cli-process" import { testProviderConfig } from "../../lib/test-provider" import { - createAcpClient, + createAcpClient as createJsonRpcAcpClient, expectOk, flattenSelectOptions, selectConfigOption, type AcpClient, -} from "../acp/acp-test-client" +} from "./acp-test-client" -export function createAcpNextClient(input: Pick, env?: Record) { +export function createAcpClient(input: Pick, env?: Record) { return Effect.gen(function* () { - return createAcpClient( - yield* input.opencode.acp({ - env: { - OPENCODE_ACP_NEXT: "1", - ...env, - }, - }), - ) + return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined)) }) } @@ -30,7 +23,7 @@ export function initialize(acp: AcpClient) { yield* acp.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { "terminal-auth": true } }, - clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" }, + clientInfo: { name: "opencode-local-acp", version: "0.1.0" }, }), ) }) diff --git a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts b/packages/opencode/test/cli/acp/initialize-auth.test.ts similarity index 87% rename from packages/opencode/test/cli/acp-next/initialize-auth.test.ts rename to packages/opencode/test/cli/acp/initialize-auth.test.ts index 9db7c75c8ac6..709c27f3a319 100644 --- a/packages/opencode/test/cli/acp-next/initialize-auth.test.ts +++ b/packages/opencode/test/cli/acp/initialize-auth.test.ts @@ -2,14 +2,14 @@ import { describe, expect } from "bun:test" import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk" import { Effect } from "effect" import { cliIt } from "../../lib/cli-process" -import { createAcpNextClient, expectErrorCode, initialize } from "./helpers" +import { createAcpClient, expectErrorCode, initialize } from "./helpers" -describe("opencode acp-next initialize/auth subprocess", () => { +describe("opencode acp initialize/auth subprocess", () => { cliIt.live( "initialize responds with capabilities", ({ opencode }) => Effect.gen(function* () { - const initialized = yield* initialize(yield* createAcpNextClient({ opencode })) + const initialized = yield* initialize(yield* createAcpClient({ opencode })) expect(initialized.protocolVersion).toBe(1) expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) @@ -30,7 +30,7 @@ describe("opencode acp-next initialize/auth subprocess", () => { "auth negotiation is explicit and safe", ({ opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient({ opencode }) + const acp = yield* createAcpClient({ opencode }) const initialized = yield* initialize(acp) expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") @@ -50,7 +50,7 @@ describe("opencode acp-next initialize/auth subprocess", () => { "initialize without terminal-auth metadata keeps auth command implicit", ({ opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient({ opencode }) + const acp = yield* createAcpClient({ opencode }) const initialized = yield* acp.request("initialize", { protocolVersion: 1 }) expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login") diff --git a/packages/opencode/test/cli/acp-next/lifecycle.test.ts b/packages/opencode/test/cli/acp/lifecycle.test.ts similarity index 85% rename from packages/opencode/test/cli/acp-next/lifecycle.test.ts rename to packages/opencode/test/cli/acp/lifecycle.test.ts index a1b54037af40..9f2558ea2f58 100644 --- a/packages/opencode/test/cli/acp-next/lifecycle.test.ts +++ b/packages/opencode/test/cli/acp/lifecycle.test.ts @@ -7,15 +7,15 @@ import type { } from "@agentclientprotocol/sdk" import { Duration, Effect } from "effect" import { cliIt } from "../../lib/cli-process" -import { expectOk, selectConfigOption } from "../acp/acp-test-client" -import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" +import { expectOk, selectConfigOption } from "./acp-test-client" +import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers" -describe("opencode acp-next lifecycle subprocess", () => { +describe("opencode acp lifecycle subprocess", () => { cliIt.live( "stdin EOF exits cleanly", ({ opencode }) => Effect.gen(function* () { - const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }) + const acp = yield* opencode.acp() acp.close() const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5))) @@ -28,7 +28,7 @@ describe("opencode acp-next lifecycle subprocess", () => { "close capability and close request", ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) @@ -45,7 +45,7 @@ describe("opencode acp-next lifecycle subprocess", () => { "loadSession capability and load request return session config options", ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) @@ -69,7 +69,7 @@ describe("opencode acp-next lifecycle subprocess", () => { "list request includes a live ACP-created session", ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) @@ -86,7 +86,7 @@ describe("opencode acp-next lifecycle subprocess", () => { "resume capability advertisement", ({ opencode }) => Effect.gen(function* () { - const initialized = yield* initialize(yield* createAcpNextClient({ opencode })) + const initialized = yield* initialize(yield* createAcpClient({ opencode })) expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) }), @@ -97,7 +97,7 @@ describe("opencode acp-next lifecycle subprocess", () => { "resume request returns session config options", ({ home, llm, opencode }) => Effect.gen(function* () { - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, ) diff --git a/packages/opencode/test/cli/acp-next/prompt-content.test.ts b/packages/opencode/test/cli/acp/prompt-content.test.ts similarity index 91% rename from packages/opencode/test/cli/acp-next/prompt-content.test.ts rename to packages/opencode/test/cli/acp/prompt-content.test.ts index 2e658f70ee21..6b6f6ddeb67e 100644 --- a/packages/opencode/test/cli/acp-next/prompt-content.test.ts +++ b/packages/opencode/test/cli/acp/prompt-content.test.ts @@ -5,18 +5,18 @@ import { writeFile } from "node:fs/promises" import path from "node:path" import { pathToFileURL } from "node:url" import { cliIt } from "../../lib/cli-process" -import { expectOk } from "../acp/acp-test-client" -import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" +import { expectOk } from "./acp-test-client" +import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers" const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" -describe("opencode acp-next prompt content subprocess", () => { +describe("opencode acp prompt content subprocess", () => { cliIt.live( "accepts embedded text resource image and file resource link prompt content", ({ home, llm, opencode }) => Effect.gen(function* () { yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n")) - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) }, ) diff --git a/packages/opencode/test/cli/acp-next/skills.test.ts b/packages/opencode/test/cli/acp/skills.test.ts similarity index 87% rename from packages/opencode/test/cli/acp-next/skills.test.ts rename to packages/opencode/test/cli/acp/skills.test.ts index 0485fa56d439..b423493cfa39 100644 --- a/packages/opencode/test/cli/acp-next/skills.test.ts +++ b/packages/opencode/test/cli/acp/skills.test.ts @@ -4,9 +4,9 @@ import { Effect } from "effect" import { mkdir } from "node:fs/promises" import path from "node:path" import { cliIt } from "../../lib/cli-process" -import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers" +import { createAcpClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers" -describe("opencode acp-next skills subprocess", () => { +describe("opencode acp skills subprocess", () => { cliIt.live( "skill slash command appears through available_commands_update", ({ home, llm, opencode }) => @@ -14,7 +14,7 @@ describe("opencode acp-next skills subprocess", () => { const skills = path.join(home, "skills") yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) - const acp = yield* createAcpNextClient( + const acp = yield* createAcpClient( { opencode }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) }, )