Просмотр исходного кода

feat(acp): promote next implementation (#29929)

Shoubhit Dash 3 месяцев назад
Родитель
Сommit
4cc166a400
39 измененных файлов с 506 добавлено и 4224 удалено
  1. 0 95
      packages/opencode/src/acp-next/agent.ts
  2. 0 232
      packages/opencode/src/acp-next/session.ts
  3. 0 174
      packages/opencode/src/acp/README.md
  4. 38 1909
      packages/opencode/src/acp/agent.ts
  5. 0 0
      packages/opencode/src/acp/config-option.ts
  6. 0 0
      packages/opencode/src/acp/content.ts
  7. 10 10
      packages/opencode/src/acp/directory.ts
  8. 18 18
      packages/opencode/src/acp/error.ts
  9. 8 8
      packages/opencode/src/acp/event.ts
  10. 4 4
      packages/opencode/src/acp/permission.ts
  11. 1 1
      packages/opencode/src/acp/profile.ts
  12. 0 22
      packages/opencode/src/acp/runtime.ts
  13. 56 56
      packages/opencode/src/acp/service.ts
  14. 219 109
      packages/opencode/src/acp/session.ts
  15. 0 0
      packages/opencode/src/acp/tool.ts
  16. 0 24
      packages/opencode/src/acp/types.ts
  17. 7 7
      packages/opencode/src/acp/usage.ts
  18. 6 11
      packages/opencode/src/cli/cmd/acp.ts
  19. 0 1
      packages/opencode/src/effect/runtime-flags.ts
  20. 0 52
      packages/opencode/test/acp/agent-interface.test.ts
  21. 2 2
      packages/opencode/test/acp/config-option.test.ts
  22. 3 3
      packages/opencode/test/acp/content.test.ts
  23. 2 2
      packages/opencode/test/acp/directory.test.ts
  24. 18 18
      packages/opencode/test/acp/error.test.ts
  25. 0 977
      packages/opencode/test/acp/event-subscription.test.ts
  26. 13 13
      packages/opencode/test/acp/event.test.ts
  27. 8 8
      packages/opencode/test/acp/permission.test.ts
  28. 23 23
      packages/opencode/test/acp/service-session.test.ts
  29. 33 33
      packages/opencode/test/acp/session.test.ts
  30. 2 2
      packages/opencode/test/acp/tool.test.ts
  31. 2 2
      packages/opencode/test/acp/usage.test.ts
  32. 0 298
      packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts
  33. 0 70
      packages/opencode/test/cli/acp/acp-process.test.ts
  34. 7 7
      packages/opencode/test/cli/acp/config-options.test.ts
  35. 5 12
      packages/opencode/test/cli/acp/helpers.ts
  36. 5 5
      packages/opencode/test/cli/acp/initialize-auth.test.ts
  37. 9 9
      packages/opencode/test/cli/acp/lifecycle.test.ts
  38. 4 4
      packages/opencode/test/cli/acp/prompt-content.test.ts
  39. 3 3
      packages/opencode/test/cli/acp/skills.test.ts

+ 0 - 95
packages/opencode/src/acp-next/agent.ts

@@ -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<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
-  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"

+ 0 - 232
packages/opencode/src/acp-next/session.ts

@@ -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<string, KnownMessagePartMetadata>
-}
-
-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<Info>
-  readonly load: (input: StoreInput) => Effect.Effect<Info>
-  readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
-  readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
-  readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
-  readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
-  readonly setModel: (
-    sessionId: string,
-    model: SelectedModel | undefined,
-  ) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
-  readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPNextError.SessionNotFoundError>
-  readonly setVariant: (
-    sessionId: string,
-    variant: string | undefined,
-  ) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
-  readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
-  readonly setMode: (
-    sessionId: string,
-    modeId: string | undefined,
-  ) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
-  readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
-  readonly recordPartMetadata: (
-    input: RecordPartMetadataInput,
-  ) => Effect.Effect<KnownMessagePartMetadata, ACPNextError.SessionNotFoundError>
-  readonly getPartMetadata: (
-    input: PartMetadataLookupInput,
-  ) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPNextError.SessionNotFoundError>
-  readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Session") {}
-
-type State = Map<string, Info>
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const sessions = yield* Ref.make<State>(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"

+ 0 - 174
packages/opencode/src/acp/README.md

@@ -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)

+ 38 - 1909
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<number | null> {
-  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<void> {
-  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<string, string>()
-  private toolStarts = new Set<string>()
-  private permissionQueues = new Map<string, Promise<void>>()
-  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<InitializeResponse> {
-    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<ListSessionsResponse> {
-    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<ForkSessionResponse> {
-    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<ResumeSessionResponse> {
-    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<CloseSessionResponse> {
-    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<ModeOption[]> {
-    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<string, ConfigMCP.Info> = {}
-    for (const server of params.mcpServers) {
-      if ("type" in server) {
-        mcpServers[server.name] = {
-          url: server.url,
-          headers: server.headers.reduce<Record<string, string>>((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<Record<string, string>>((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<SetSessionModeResponse | void> {
-    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<SetSessionConfigOptionResponse> {
-    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<string, any>): { 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<A>(effect: Effect.Effect<A, ACPService.Error>) {
+  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<string, unknown> }>,
-): 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<T extends { name: string }>(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<string, { variants?: Record<string, any> }> }>,
-  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<string, any> }>,
-  options: { includeVariants?: boolean } = {},
-): ModelOption[] {
-  const includeVariants = options.includeVariants ?? false
-  return providers.flatMap((provider) => {
-    const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = 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<string, { variants?: Record<string, any> }> }>,
-): { 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"

+ 0 - 0
packages/opencode/src/acp-next/config-option.ts → packages/opencode/src/acp/config-option.ts


+ 0 - 0
packages/opencode/src/acp-next/content.ts → packages/opencode/src/acp/content.ts


+ 10 - 10
packages/opencode/src/acp-next/directory.ts → 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<Snapshot, ACPNextError.Error>
+  readonly load: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
 }
 
 export interface Interface {
-  readonly get: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
-  readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
+  readonly get: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
+  readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
   readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
 }
 
-export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPNextDirectoryLoader") {}
+export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPDirectoryLoader") {}
 
-export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextDirectory") {}
+export class Service extends Context.Service<Service, Interface>()("@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<string, Effect.Effect<Snapshot, ACPNextError.Error>>())
+    const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPError.Error>>())
 
     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) {

+ 18 - 18
packages/opencode/src/acp-next/error.ts → 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<SessionNotFoundError>()(
-  "ACPNextSessionNotFoundError",
+  "ACPSessionNotFoundError",
   {
     sessionId: Schema.String,
   },
 ) {}
 
 export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
-  "ACPNextInvalidConfigOptionError",
+  "ACPInvalidConfigOptionError",
   {
     configId: Schema.String,
   },
 ) {}
 
-export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", {
+export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
   modelId: Schema.String,
   providerId: Schema.optional(Schema.String),
 }) {}
 
-export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPNextInvalidEffortError", {
+export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
   effort: Schema.String,
 }) {}
 
-export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", {
+export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
   mode: Schema.String,
 }) {}
 
-export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", {
+export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
   providerId: Schema.optional(Schema.String),
 }) {}
 
 export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
-  "ACPNextUnknownAuthMethodError",
+  "ACPUnknownAuthMethodError",
   {
     methodId: Schema.String,
   },
 ) {}
 
 export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
-  "ACPNextUnsupportedOperationError",
+  "ACPUnsupportedOperationError",
   {
     method: Schema.String,
   },
 ) {}
 
-export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", {
+export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("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)
   }
 }

+ 8 - 8
packages/opencode/src/acp-next/event.ts → 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<AgentSideConnection, "sessionUpdate"> &
   Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@@ -32,7 +32,7 @@ type GlobalEventStream = {
   stream: AsyncIterable<GlobalEventEnvelope>
 }
 
-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<string, string>()
   private readonly toolStarts = new Set<string>()
-  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"

+ 4 - 4
packages/opencode/src/acp-next/permission.ts → 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<Event, { type: "permission.asked" }>
 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"

+ 1 - 1
packages/opencode/src/acp-next/profile.ts → packages/opencode/src/acp/profile.ts

@@ -39,4 +39,4 @@ function write(name: string, durationMs: number, fields?: Record<string, string
   console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
 }
 
-export * as ACPNextProfile from "./profile"
+export * as ACPProfile from "./profile"

+ 0 - 22
packages/opencode/src/acp/runtime.ts

@@ -1,22 +0,0 @@
-import { Agent } from "@/agent/agent"
-import { AppRuntime, type AppServices } from "@/effect/app-runtime"
-import { InstanceRef } from "@/effect/instance-ref"
-import { InstanceRuntime } from "@/project/instance-runtime"
-import { Effect } from "effect"
-
-// Global ACP Effect re-entry: no project InstanceRef is provided.
-export const runGlobal = AppRuntime.runPromise
-
-// Directory-scoped ACP Effect re-entry: load the project instance and provide InstanceRef.
-export async function runDirectory<A, E>(input: { directory: string; effect: Effect.Effect<A, E, AppServices> }) {
-  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"

+ 56 - 56
packages/opencode/src/acp-next/service.ts → 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<AgentSideConnection, "sessionUpdate"> &
   Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
 
@@ -70,26 +70,26 @@ export type Interface = {
   readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
 }
 
-export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
+export class Service extends Context.Service<Service, Interface>()("@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<string, Set<string>>()
   const sessionSnapshots = new Map<string, Directory.Snapshot>()
   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<string, Promise<number | undefined>>()
-  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<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
 }
 
 function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, 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<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
 }
 
 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")
   )
 }
 

+ 219 - 109
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<string, ACPSessionState>()
-  private sdk: OpencodeClient
+export type Info = {
+  id: string
+  cwd: string
+  mcpServers: readonly McpServer[]
+  createdAt: Date
+  model?: SelectedModel
+  variant?: string
+  modeId?: string
+  knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
+}
 
-  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<ACPSessionState> {
-    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<Info>
+  readonly load: (input: StoreInput) => Effect.Effect<Info>
+  readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
+  readonly get: (sessionId: string) => Effect.Effect<Info, ACPError.SessionNotFoundError>
+  readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
+  readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
+  readonly setModel: (
     sessionId: string,
-    cwd: string,
-    mcpServers: McpServer[],
-    model?: ACPSessionState["model"],
-  ): Promise<ACPSessionState> {
-    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<Info, ACPError.SessionNotFoundError>
+  readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPError.SessionNotFoundError>
+  readonly setVariant: (
+    sessionId: string,
+    variant: string | undefined,
+  ) => Effect.Effect<Info, ACPError.SessionNotFoundError>
+  readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
+  readonly setMode: (
+    sessionId: string,
+    modeId: string | undefined,
+  ) => Effect.Effect<Info, ACPError.SessionNotFoundError>
+  readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
+  readonly recordPartMetadata: (
+    input: RecordPartMetadataInput,
+  ) => Effect.Effect<KnownMessagePartMetadata, ACPError.SessionNotFoundError>
+  readonly getPartMetadata: (
+    input: PartMetadataLookupInput,
+  ) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPError.SessionNotFoundError>
+  readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
+}
 
-  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<Service, Interface>()("@opencode/ACP/Session") {}
+
+type State = Map<string, Info>
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const sessions = yield* Ref.make<State>(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"

+ 0 - 0
packages/opencode/src/acp-next/tool.ts → packages/opencode/src/acp/tool.ts


+ 0 - 24
packages/opencode/src/acp/types.ts

@@ -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
-  }
-}

+ 7 - 7
packages/opencode/src/acp-next/usage.ts → 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<OpenCodeAssistantMessage, "cost" | "tokens">
 
@@ -60,14 +60,14 @@ export interface Interface {
 }
 
 export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
-  "@opencode/ACPNextUsageMessageLoader",
+  "@opencode/ACPUsageMessageLoader",
 ) {}
 
 export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
-  "@opencode/ACPNextUsageContextLimitLoader",
+  "@opencode/ACPUsageContextLimitLoader",
 ) {}
 
-export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextUsage") {}
+export class Service extends Context.Service<Service, Interface>()("@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

+ 6 - 11
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")

+ 0 - 1
packages/opencode/src/effect/runtime-flags.ts

@@ -50,7 +50,6 @@ export class Service extends ConfigService.Service<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"),

+ 0 - 52
packages/opencode/test/acp/agent-interface.test.ts

@@ -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",
-      )
-    }
-  })
-})

+ 2 - 2
packages/opencode/test/acp-next/config-option.test.ts → 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({

+ 3 - 3
packages/opencode/test/acp-next/content.test.ts → 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([
       {

+ 2 - 2
packages/opencode/test/acp-next/directory.test.ts → 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* () {

+ 18 - 18
packages/opencode/test/acp-next/error.test.ts → 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())
 

+ 0 - 977
packages/opencode/test/acp/event-subscription.test.ts

@@ -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 <T>(
-  check: () => T | undefined | false | Promise<T | undefined | false>,
-  message: string,
-  opts?: { timeoutMs?: number; intervalMs?: number },
-): Promise<T> => {
-  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<AgentSideConnection["sessionUpdate"]>[0]
-type RequestPermissionParams = Parameters<AgentSideConnection["requestPermission"]>[0]
-type RequestPermissionResult = Awaited<ReturnType<AgentSideConnection["requestPermission"]>>
-
-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<SessionUpdateParams["update"], { sessionUpdate: "tool_call_update" }> {
-  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<string, unknown>
-  } & ({ status: "running"; metadata?: Record<string, unknown> } | { 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<string, unknown>
-    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<GlobalEventEnvelope | undefined>((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<string, string[]>()
-  const chunks = new Map<string, string>()
-  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<RequestPermissionResult> {
-      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<void>((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()
-      },
-    })
-  })
-})

+ 13 - 13
packages/opencode/test/acp-next/event.test.ts → 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<AgentSideConnection["sessionUpdate"]>[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<string, SessionMessageResponse> = {}) {
     },
   } satisfies Pick<AgentSideConnection, "sessionUpdate">
   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<AgentSideConnection, "sessionUpdate">
-    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) }),

+ 8 - 8
packages/opencode/test/acp-next/permission.test.ts → 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<Event, { type: "permission.asked" }>
 type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[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<AgentSideConnection, "requestPermission" | "sessionUpdate">
-  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")

+ 23 - 23
packages/opencode/test/acp-next/service-session.test.ts → 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)

+ 33 - 33
packages/opencode/test/acp-next/session.test.ts → 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" }),
       )
 

+ 2 - 2
packages/opencode/test/acp-next/tool.test.ts → 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")

+ 2 - 2
packages/opencode/test/acp-next/usage.test.ts → 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({

+ 0 - 298
packages/opencode/test/cli/acp/acp-compatibility-baseline.test.ts

@@ -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<InitializeResponse>("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<InitializeResponse>("initialize", {
-          protocolVersion: 1,
-          clientCapabilities: {},
-          clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" },
-        })
-        const session = expectOk(
-          yield* acp.request<NewSessionResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] })
-
-        const session = expectOk(
-          yield* acp.request<NewSessionResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        const session = expectOk(yield* acp.request<NewSessionResponse>("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<SetSessionConfigOptionResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        const session = expectOk(yield* acp.request<NewSessionResponse>("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<SetSessionConfigOptionResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        const session = expectOk(yield* acp.request<NewSessionResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
-
-        const update = yield* acp.waitForNotification<SessionNotification>(
-          "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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
-
-        expectOk(yield* acp.request<CloseSessionResponse>("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<InitializeResponse>("initialize", { protocolVersion: 1 })
-        const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
-
-        const resumed = expectOk(
-          yield* acp.request<ResumeSessionResponse>("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
-`

+ 0 - 70
packages/opencode/test/cli/acp/acp-process.test.ts

@@ -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<string, unknown> }
-          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,
-  )
-})

+ 7 - 7
packages/opencode/test/cli/acp-next/config-options.test.ts → 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)) },
         )

+ 5 - 12
packages/opencode/test/cli/acp-next/helpers.ts → 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<CliFixture, "opencode">, env?: Record<string, string>) {
+export function createAcpClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
   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<InitializeResponse>("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" },
       }),
     )
   })

+ 5 - 5
packages/opencode/test/cli/acp-next/initialize-auth.test.ts → 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<InitializeResponse>("initialize", { protocolVersion: 1 })
 
         expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")

+ 9 - 9
packages/opencode/test/cli/acp-next/lifecycle.test.ts → 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)) },
         )

+ 4 - 4
packages/opencode/test/cli/acp-next/prompt-content.test.ts → 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)) },
         )

+ 3 - 3
packages/opencode/test/cli/acp-next/skills.test.ts → 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)) },
         )