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

This commit is contained in:
Shoubhit Dash 2026-05-30 01:34:44 +05:30 committed by GitHub
parent 0733c080c0
commit 4cc166a400
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 499 additions and 4217 deletions

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
const log = Log.create({ service: "acp-session-manager" })
export type SelectedModel = {
providerID: ProviderID
modelID: ModelID
}
export class ACPSessionManager {
private sessions = new Map<string, ACPSessionState>()
private sdk: OpencodeClient
export type KnownMessagePartMetadata = {
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
constructor(sdk: OpencodeClient) {
this.sdk = sdk
}
export type Info = {
id: string
cwd: string
mcpServers: readonly McpServer[]
createdAt: Date
model?: SelectedModel
variant?: string
modeId?: string
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
}
tryGet(sessionId: string): ACPSessionState | undefined {
return this.sessions.get(sessionId)
}
export type StoreInput = {
id: string
cwd: string
mcpServers?: readonly McpServer[]
createdAt?: Date
model?: SelectedModel
variant?: string
modeId?: string
}
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!)
export type RecordPartMetadataInput = {
sessionId: string
messageId: string
partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
const sessionId = session.id
const resolvedModel = model
export type PartMetadataLookupInput = {
sessionId: string
messageId: string
partId: string
}
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
createdAt: new Date(),
model: resolvedModel,
}
log.info("creating_session", { state })
this.sessions.set(sessionId, state)
return state
}
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!)
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>
}
const resolvedModel = model
export class Service extends Context.Service<Service, Interface>()("@opencode/ACP/Session") {}
const state: ACPSessionState = {
id: sessionId,
cwd,
mcpServers,
createdAt: new Date(session.time.created),
model: resolvedModel,
}
log.info("loading_session", { state })
type State = Map<string, Info>
this.sessions.set(sessionId, state)
return state
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Ref.make<State>(new Map())
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
}
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)
})
getModel(sessionId: string) {
const session = this.get(sessionId)
return session.model
}
const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) {
const session = (yield* Ref.get(sessions)).get(sessionId)
if (!session) return
return snapshot(session)
})
setModel(sessionId: string, model: ACPSessionState["model"]) {
const session = this.get(sessionId)
session.model = model
this.sessions.set(sessionId, session)
return 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 })
})
getVariant(sessionId: string) {
const session = this.get(sessionId)
return session.variant
}
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 })
})
setVariant(sessionId: string, variant?: string) {
const session = this.get(sessionId)
session.variant = variant
this.sessions.set(sessionId, session)
return session
}
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
})
})
setMode(sessionId: string, modeId: string) {
const session = this.get(sessionId)
session.modeId = modeId
this.sessions.set(sessionId, session)
return session
}
const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) =>
update(sessionId, (session) => ({ ...session, model })),
)
remove(sessionId: string): ACPSessionState | undefined {
const session = this.sessions.get(sessionId)
this.sessions.delete(sessionId)
return session
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(),
}
}
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 ACPSession from "./session"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

@ -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([
{

View file

@ -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* () {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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