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

This commit is contained in:
Shoubhit Dash 2026-05-30 01:34:44 +05:30 committed by GitHub
commit 4cc166a400
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 506 additions and 4224 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 { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect" import { Context, Effect, Layer, SynchronizedRef } from "effect"
import type * as ACPNextError from "./error" import type * as ACPError from "./error"
export type ModelOption = { export type ModelOption = {
readonly providerID: ProviderID readonly providerID: ProviderID
@ -39,18 +39,18 @@ export type Snapshot = {
} }
export interface LoaderInterface { export interface LoaderInterface {
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error> readonly load: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
} }
export interface Interface { export interface Interface {
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error> readonly get: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error> readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined 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}` export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
@ -110,7 +110,7 @@ export const loaderLayer = Layer.effect(
const command = yield* Command.Service const command = yield* Command.Service
return Loader.of({ return Loader.of({
load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) { load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) {
const ctx = yield* store.load({ directory }) const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const providers = yield* provider.list() const providers = yield* provider.list()
@ -142,7 +142,7 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const loader = yield* Loader 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) { const cached = Effect.fnUntraced(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect( 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) 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( return yield* SynchronizedRef.modifyEffect(
snapshots, snapshots,
Effect.fnUntraced(function* (items) { Effect.fnUntraced(function* (items) {

View file

@ -2,51 +2,51 @@ import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect" import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()( export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"ACPNextSessionNotFoundError", "ACPSessionNotFoundError",
{ {
sessionId: Schema.String, sessionId: Schema.String,
}, },
) {} ) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()( export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPNextInvalidConfigOptionError", "ACPInvalidConfigOptionError",
{ {
configId: Schema.String, configId: Schema.String,
}, },
) {} ) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", { export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
modelId: Schema.String, modelId: Schema.String,
providerId: Schema.optional(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, effort: Schema.String,
}) {} }) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", { export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
mode: Schema.String, mode: Schema.String,
}) {} }) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", { export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
providerId: Schema.optional(Schema.String), providerId: Schema.optional(Schema.String),
}) {} }) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()( export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPNextUnknownAuthMethodError", "ACPUnknownAuthMethodError",
{ {
methodId: Schema.String, methodId: Schema.String,
}, },
) {} ) {}
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()( export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
"ACPNextUnsupportedOperationError", "ACPUnsupportedOperationError",
{ {
method: Schema.String, method: Schema.String,
}, },
) {} ) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", { export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
safeMessage: Schema.String, safeMessage: Schema.String,
service: Schema.optional(Schema.String), service: Schema.optional(Schema.String),
}) {} }) {}
@ -64,26 +64,26 @@ export type Error =
export function toRequestError(error: Error) { export function toRequestError(error: Error) {
switch (error._tag) { switch (error._tag) {
case "ACPNextSessionNotFoundError": case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`) 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}`) return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPNextInvalidModelError": case "ACPInvalidModelError":
return RequestError.invalidParams( return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId }, { providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`, `model not found: ${error.modelId}`,
) )
case "ACPNextInvalidEffortError": case "ACPInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`) 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}`) return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPNextAuthRequiredError": case "ACPAuthRequiredError":
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required") return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
case "ACPNextUnknownAuthMethodError": case "ACPUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`) return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPNextUnsupportedOperationError": case "ACPUnsupportedOperationError":
return RequestError.methodNotFound(error.method) return RequestError.methodNotFound(error.method)
case "ACPNextServiceFailureError": case "ACPServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage) return RequestError.internalError({ service: error.service }, error.safeMessage)
} }
} }

View file

@ -10,8 +10,8 @@ import type {
ToolPart, ToolPart,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { Effect } from "effect" import { Effect } from "effect"
import { ACPNextSession } from "./session" import { ACPSession } from "./session"
import { ACPNextPermission } from "./permission" import { ACPPermission } from "./permission"
import { import {
duplicateRunningToolUpdate, duplicateRunningToolUpdate,
errorToolUpdate, errorToolUpdate,
@ -21,7 +21,7 @@ import {
completedToolUpdate, completedToolUpdate,
} from "./tool" } from "./tool"
const log = Log.create({ service: "acp-next-event" }) const log = Log.create({ service: "acp-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate"> & type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">> Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@ -32,7 +32,7 @@ type GlobalEventStream = {
stream: AsyncIterable<GlobalEventEnvelope> 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) const subscription = new Subscription(input)
subscription.start() subscription.start()
return subscription return subscription
@ -42,17 +42,17 @@ export class Subscription {
private readonly abort = new AbortController() private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>() private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>() private readonly toolStarts = new Set<string>()
private readonly permission: ACPNextPermission.Handler private readonly permission: ACPPermission.Handler
private started = false private started = false
constructor( constructor(
private readonly input: { private readonly input: {
sdk: OpencodeClient sdk: OpencodeClient
connection: Connection connection: Connection
session: ACPNextSession.Interface session: ACPSession.Interface
}, },
) { ) {
this.permission = new ACPNextPermission.Handler(input) this.permission = new ACPPermission.Handler(input)
} }
start() { 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 type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff" import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem" import { exists, readText } from "@/util/filesystem"
import type { ACPNextSession } from "./session" import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool" import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect" 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 PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject" type Reply = "once" | "always" | "reject"
@ -26,7 +26,7 @@ export class Handler {
private readonly input: { private readonly input: {
sdk: OpencodeClient sdk: OpencodeClient
connection: Connection connection: Connection
session: ACPNextSession.Interface session: ACPSession.Interface
}, },
) {} ) {}
@ -142,4 +142,4 @@ function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined 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}` : ""}`) 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 * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect" import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error" import * as ACPError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option" import { buildConfigOptions, parseModelSelection } from "./config-option"
import { promptContentToParts } from "./content" import { promptContentToParts } from "./content"
import { Directory } from "./directory" import { Directory } from "./directory"
import { ACPNextEvent } from "./event" import { ACPEvent } from "./event"
import { ACPNextSession } from "./session" import { ACPSession } from "./session"
import { UsageService } from "./usage" import { UsageService } from "./usage"
import { ACPNextProfile } from "./profile" import { ACPProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import type { Command } from "@/command" import type { Command } from "@/command"
export const AuthMethodID = "opencode-login" 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"> & type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">> Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@ -70,26 +70,26 @@ export type Interface = {
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error> 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: { export function make(input: {
sdk: OpencodeClient sdk: OpencodeClient
connection?: ServiceConnection connection?: ServiceConnection
directory?: Directory.Interface directory?: Directory.Interface
session?: ACPNextSession.Interface session?: ACPSession.Interface
usage?: UsageService.Interface usage?: UsageService.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void eventSubscription?: (subscription: ACPEvent.Subscription) => void
}): Interface { }): Interface {
const session = input.session ?? makeSessionService() const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk) const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>() const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>() const sessionSnapshots = new Map<string, Directory.Snapshot>()
const events = input.connection const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session }) ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined : undefined
if (events) input.eventSubscription?.(events) 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 started = performance.now()
const authMethod: AuthMethod = { const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal", description: "Run `opencode auth login` in the terminal",
@ -132,25 +132,25 @@ export function make(input: {
version: InstallationVersion, version: InstallationVersion,
}, },
} }
ACPNextProfile.duration("acp.initialize", started) ACPProfile.duration("acp.initialize", started)
return response return response
}) })
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) { const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) {
if (params.methodId !== AuthMethodID) { if (params.methodId !== AuthMethodID) {
return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId }) return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
} }
return {} return {}
}) })
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) { const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) {
const started = performance.now() const started = performance.now()
const snapshot = yield* directoryService.get(cwd) const snapshot = yield* directoryService.get(cwd)
ACPNextProfile.duration("acp.directory.snapshot", started) ACPProfile.duration("acp.directory.snapshot", started)
return snapshot 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) const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd) const loaded = yield* directorySnapshot(state.cwd)
@ -158,7 +158,7 @@ export function make(input: {
return loaded 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 started = performance.now()
const snapshot = yield* directorySnapshot(params.cwd) const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot) const selected = selectDefaultModel(snapshot)
@ -202,11 +202,11 @@ export function make(input: {
modeId: state.modeId, modeId: state.modeId,
}), }),
} }
ACPNextProfile.duration("acp.newSession", started) ACPProfile.duration("acp.newSession", started)
return response 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) const snapshot = yield* directorySnapshot(params.cwd)
yield* request( yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), () => 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 cursor = params.cursor ? Number(params.cursor) : undefined
const limit = 100 const limit = 100
const sessions = yield* request( 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) const snapshot = yield* directorySnapshot(params.cwd)
yield* request( yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), () => 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) const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId) registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId) sessionSnapshots.delete(params.sessionId)
@ -349,7 +349,7 @@ export function make(input: {
return {} 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 snapshot = yield* directorySnapshot(params.cwd)
const forked = yield* request( 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, params: SetSessionConfigOptionRequest,
) { ) {
const current = yield* session.get(params.sessionId) const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current) const snapshot = yield* configSnapshot(current)
if (typeof params.value !== "string") { 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") { if (params.configId === "model") {
@ -421,7 +421,7 @@ export function make(input: {
const model = current.model ?? selectDefaultModel(snapshot) const model = current.model ?? selectDefaultModel(snapshot)
const variants = Directory.variants(snapshot, model) const variants = Directory.variants(snapshot, model)
if (!variants || !Object.keys(variants).includes(params.value)) { 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) const state = yield* session.setVariant(params.sessionId, params.value)
return { return {
@ -435,7 +435,7 @@ export function make(input: {
if (params.configId === "mode") { if (params.configId === "mode") {
if (!snapshot.availableModes.some((mode) => mode.id === params.value)) { 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) const state = yield* session.setMode(params.sessionId, params.value)
return { 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 current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current) const snapshot = yield* configSnapshot(current)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) { 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) yield* session.setMode(params.sessionId, params.modeId)
return {} 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 current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current) const snapshot = yield* configSnapshot(current)
const selected = yield* parseSelectedModel(snapshot, params.modelId) const selected = yield* parseSelectedModel(snapshot, params.modelId)
@ -487,7 +487,7 @@ export function make(input: {
setSessionConfigOption, setSessionConfigOption,
setSessionMode, setSessionMode,
setSessionModel, 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 current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd) const snapshot = yield* directorySnapshot(current.cwd)
const selected = current.model ?? selectDefaultModel(snapshot) 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) yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId) return promptResponse(undefined, params.messageId)
}), }),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) { cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" }) return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" })
}), }),
} }
} }
function makeSessionService() { function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)), ACPSession.Service.use((service) => Effect.succeed(service)),
) )
} }
@ -592,7 +592,7 @@ function makeDirectoryService(sdk: OpencodeClient) {
function makeUsageService(sdk: OpencodeClient) { function makeUsageService(sdk: OpencodeClient) {
const limits = new Map<string, Promise<number | undefined>>() 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) { function* (params) {
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}` const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
const current = limits.get(key) 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) { function* (params) {
const messages = yield* request( 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 if (!subscription) return Effect.void
return Effect.promise(async () => { return Effect.promise(async () => {
for (const message of messages) { 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) { 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) { 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([ 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 }), 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 }), sdk.app.agents({ directory }, { throwOnError: true }),
), ),
ACPNextProfile.measure("acp.directory.command.list", () => ACPProfile.measure("acp.directory.command.list", () =>
sdk.command.list({ directory }, { throwOnError: true }), sdk.command.list({ directory }, { throwOnError: true }),
), ),
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })), ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
ACPNextProfile.measure("acp.directory.defaultModel.config", () => ACPProfile.measure("acp.directory.defaultModel.config", () =>
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined), sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
), ),
]) ])
@ -754,7 +754,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
> >
const defaultModelStarted = performance.now() const defaultModelStarted = performance.now()
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers) 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 const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true) .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({ .map((agent) => ({
@ -872,14 +872,14 @@ function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
const model = provider?.models[ModelID.make(selected.model.modelID)] const model = provider?.models[ModelID.make(selected.model.modelID)]
if (!model) { if (!model) {
return Effect.fail( return Effect.fail(
new ACPNextError.InvalidModelError({ new ACPError.InvalidModelError({
providerId: selected.model.providerID, providerId: selected.model.providerID,
modelId, modelId,
}), }),
) )
} }
if (selected.variant && !model.variants?.[selected.variant]) { 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({ return Effect.succeed({
model: { model: {
@ -954,7 +954,7 @@ function registerMcpServers(
).pipe( ).pipe(
Effect.tap(() => Effect.tap(() =>
Effect.sync(() => Effect.sync(() =>
ACPNextProfile.duration("acp.mcp.register", started, { ACPProfile.duration("acp.mcp.register", started, {
count: pending.size, 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 { function fromUnknownError(error: unknown, service?: string): Error {
if (isACPNextError(error)) return error if (isACPError(error)) return error
if (isAuthRequired(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 ( return (
typeof error === "object" && typeof error === "object" &&
error !== null && error !== null &&
"_tag" in error && "_tag" in error &&
typeof error._tag === "string" && 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 { McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types" import type { Message, Part } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer, Ref } from "effect"
import type { OpencodeClient } from "@opencode-ai/sdk/v2" 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 { export type KnownMessagePartMetadata = {
private sessions = new Map<string, ACPSessionState>() messageId: string
private sdk: OpencodeClient partId: string
partType?: Part["type"]
role?: Message["role"]
ignored?: boolean
toolCallId?: string
metadata?: unknown
}
constructor(sdk: OpencodeClient) { export type Info = {
this.sdk = sdk id: string
} cwd: string
mcpServers: readonly McpServer[]
createdAt: Date
model?: SelectedModel
variant?: string
modeId?: string
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
}
tryGet(sessionId: string): ACPSessionState | undefined { export type StoreInput = {
return this.sessions.get(sessionId) 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> { export type RecordPartMetadataInput = {
const session = await this.sdk.session sessionId: string
.create( messageId: string
{ partId: string
directory: cwd, partType?: Part["type"]
}, role?: Message["role"]
{ throwOnError: true }, ignored?: boolean
) toolCallId?: string
.then((x) => x.data!) metadata?: unknown
}
const sessionId = session.id export type PartMetadataLookupInput = {
const resolvedModel = model sessionId: string
messageId: string
partId: string
}
const state: ACPSessionState = { export type Interface = {
id: sessionId, readonly create: (input: StoreInput) => Effect.Effect<Info>
cwd, readonly load: (input: StoreInput) => Effect.Effect<Info>
mcpServers, readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
createdAt: new Date(), readonly get: (sessionId: string) => Effect.Effect<Info, ACPError.SessionNotFoundError>
model: resolvedModel, readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
} readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
log.info("creating_session", { state }) readonly setModel: (
this.sessions.set(sessionId, state)
return state
}
async load(
sessionId: string, sessionId: string,
cwd: string, model: SelectedModel | undefined,
mcpServers: McpServer[], ) => Effect.Effect<Info, ACPError.SessionNotFoundError>
model?: ACPSessionState["model"], readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPError.SessionNotFoundError>
): Promise<ACPSessionState> { readonly setVariant: (
const session = await this.sdk.session sessionId: string,
.get( variant: string | undefined,
{ ) => Effect.Effect<Info, ACPError.SessionNotFoundError>
sessionID: sessionId, readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
directory: cwd, readonly setMode: (
}, sessionId: string,
{ throwOnError: true }, modeId: string | undefined,
) ) => Effect.Effect<Info, ACPError.SessionNotFoundError>
.then((x) => x.data!) 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 = { type State = Map<string, Info>
id: sessionId,
cwd,
mcpServers,
createdAt: new Date(session.time.created),
model: resolvedModel,
}
log.info("loading_session", { state })
this.sessions.set(sessionId, state) export const layer = Layer.effect(
return state Service,
} Effect.gen(function* () {
const sessions = yield* Ref.make<State>(new Map())
get(sessionId: string): ACPSessionState { const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) {
const session = this.sessions.get(sessionId) const session = makeSession(input)
if (!session) { yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
log.error("session not found", { sessionId }) return snapshot(session)
throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` })) })
}
return session
}
getModel(sessionId: string) { const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) {
const session = this.get(sessionId) const session = (yield* Ref.get(sessions)).get(sessionId)
return session.model if (!session) return
} return snapshot(session)
})
setModel(sessionId: string, model: ACPSessionState["model"]) { const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) {
const session = this.get(sessionId) const session = yield* tryGet(sessionId)
session.model = model if (session) return session
this.sessions.set(sessionId, session) return yield* new ACPError.SessionNotFoundError({ sessionId })
return session })
}
getVariant(sessionId: string) { const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
const session = this.get(sessionId) const result = yield* Ref.modify(sessions, (state) => {
return session.variant 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 remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) {
const session = this.get(sessionId) return yield* Ref.modify(sessions, (state) => {
session.variant = variant const session = state.get(sessionId)
this.sessions.set(sessionId, session) if (!session) return [undefined, state] as const
return session const next = new Map(state)
} next.delete(sessionId)
return [snapshot(session), next] as const
})
})
setMode(sessionId: string, modeId: string) { const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) =>
const session = this.get(sessionId) update(sessionId, (session) => ({ ...session, model })),
session.modeId = modeId )
this.sessions.set(sessionId, session)
return session
}
remove(sessionId: string): ACPSessionState | undefined { const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) =>
const session = this.sessions.get(sessionId) update(sessionId, (session) => ({ ...session, variant })),
this.sessions.delete(sessionId) )
return session
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 { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect" 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"> export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
@ -60,14 +60,14 @@ export interface Interface {
} }
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()( export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
"@opencode/ACPNextUsageMessageLoader", "@opencode/ACPUsageMessageLoader",
) {} ) {}
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()( 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 { export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
return MessageLoader.of({ return MessageLoader.of({
@ -124,7 +124,7 @@ export const contextLimitLoaderLayer = Layer.effect(
const provider = yield* Provider.Service const provider = yield* Provider.Service
return ContextLimitLoader.of({ return ContextLimitLoader.of({
providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) { providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) {
const ctx = yield* store.load({ directory }) const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
return yield* provider.list() 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 directory: string
readonly providerID: ProviderID readonly providerID: ProviderID
readonly modelID: ModelID readonly modelID: ModelID
@ -176,7 +176,7 @@ export const layer = Layer.effect(
return yield* yield* cachedLimit(input) 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 connection: UsageConnection
readonly sessionID: string readonly sessionID: string
readonly directory: string readonly directory: string

View file

@ -3,13 +3,11 @@ import { Effect } from "effect"
import { effectCmd } from "../effect-cmd" import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent" import { ACP } from "@/acp/agent"
import { ACPNext } from "@/acp-next/agent"
import { Server } from "@/server/server" import { Server } from "@/server/server"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network" import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags" import { ACPProfile } from "@/acp/profile"
import { ACPNextProfile } from "@/acp-next/profile"
const log = Log.create({ service: "acp-command" }) const log = Log.create({ service: "acp-command" })
@ -24,13 +22,10 @@ export const AcpCommand = effectCmd({
}) })
}, },
handler: Effect.fn("Cli.acp")(function* (args) { handler: Effect.fn("Cli.acp")(function* (args) {
ACPNextProfile.mark("cli.acp.handler") ACPProfile.mark("cli.acp.handler")
process.env.OPENCODE_CLIENT = "acp" process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args) const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts)))
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
)
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`, baseUrl: `http://${server.hostname}:${server.port}`,
@ -61,11 +56,11 @@ export const AcpCommand = effectCmd({
}) })
const stream = ndJsonStream(input, output) const stream = ndJsonStream(input, output)
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk }) const agent = ACP.init({ sdk })
new AgentSideConnection((conn) => { new AgentSideConnection((conn) => {
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext }) ACPProfile.mark("cli.acp.connection.create")
return agent.create(conn, { sdk }) return agent.create(conn)
}, stream) }, stream)
log.info("setup connection") 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"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
acpNext: bool("OPENCODE_ACP_NEXT"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), 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, formatVariantName,
parseModelSelection, parseModelSelection,
type ConfigOptionProvider, type ConfigOptionProvider,
} from "@/acp-next/config-option" } from "@/acp/config-option"
const providers: ConfigOptionProvider[] = [ 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", () => { test("builds the model select option with ACP verifier category", () => {
expect( expect(
buildModelSelectOption({ buildModelSelectOption({

View file

@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ContentBlock } from "@agentclientprotocol/sdk" import type { ContentBlock } from "@agentclientprotocol/sdk"
import { pathToFileURL } from "node:url" 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", () => { test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }]) 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", () => { test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([ expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{ {

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Directory } from "@/acp-next/directory" import { Directory } from "@/acp/directory"
import { Command } from "@/command" import { Command } from "@/command"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider" 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", () => { it.effect("two concurrent callers share one load", () => {
const calls: string[] = [] const calls: string[] = []
return Effect.gen(function* () { return Effect.gen(function* () {

View file

@ -1,35 +1,35 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk" 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", () => { test("maps validation failures to invalid params", () => {
const cases: ACPNextError.Error[] = [ const cases: ACPError.Error[] = [
new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }), new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }), new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }), new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPNextError.InvalidEffortError({ effort: "extreme" }), new ACPError.InvalidEffortError({ effort: "extreme" }),
new ACPNextError.InvalidModeError({ mode: "turbo" }), 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, -32602, -32602, -32602, -32602, -32602,
]) ])
}) })
test("includes safe validation details", () => { 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, code: -32602,
data: { sessionId: "ses_123" }, 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, code: -32602,
data: { modelId: "gpt-missing" }, data: { modelId: "gpt-missing" },
}) })
}) })
test("maps auth required to the SDK auth error", () => { 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).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000) expect(requestError.code).toBe(-32000)
@ -38,8 +38,8 @@ describe("acp-next.error", () => {
}) })
test("maps unsupported operations to method not found", () => { test("maps unsupported operations to method not found", () => {
const requestError = ACPNextError.toRequestError( const requestError = ACPError.toRequestError(
new ACPNextError.UnsupportedOperationError({ method: "session/new" }), new ACPError.UnsupportedOperationError({ method: "session/new" }),
) )
expect(requestError.code).toBe(-32601) expect(requestError.code).toBe(-32601)
@ -47,8 +47,8 @@ describe("acp-next.error", () => {
}) })
test("maps service failures to safe internal errors", () => { test("maps service failures to safe internal errors", () => {
const requestError = ACPNextError.toRequestError( const requestError = ACPError.toRequestError(
new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }), new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
) )
expect(requestError.code).toBe(-32603) expect(requestError.code).toBe(-32603)
@ -57,8 +57,8 @@ describe("acp-next.error", () => {
}) })
test("wraps unknown defects without leaking raw details", () => { test("wraps unknown defects without leaking raw details", () => {
const requestError = ACPNextError.toRequestError( const requestError = ACPError.toRequestError(
ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")), ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
) )
const serialized = JSON.stringify(requestError.toErrorResponse()) 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 { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect" import { Effect, ManagedRuntime } from "effect"
import { ACPNextEvent } from "@/acp-next/event" import { ACPEvent } from "@/acp/event"
import * as ACPNextService from "@/acp-next/service" import * as ACPService from "@/acp/service"
import { Directory } from "@/acp-next/directory" import { Directory } from "@/acp/directory"
import { ACPNextSession } from "@/acp-next/session" import { ACPSession } from "@/acp/session"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0] type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type ToolSessionUpdateParams = SessionUpdateParams & { type ToolSessionUpdateParams = SessionUpdateParams & {
@ -30,8 +30,8 @@ const pollUntil = async (
} }
function makeSessionService() { function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)), ACPSession.Service.use((service) => Effect.succeed(service)),
) )
} }
@ -107,7 +107,7 @@ function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
}, },
} satisfies Pick<AgentSideConnection, "sessionUpdate"> } satisfies Pick<AgentSideConnection, "sessionUpdate">
const session = makeSessionService() 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 } return { calls, connection, events, sdk, session, subscription, updates }
} }
@ -296,7 +296,7 @@ function toolUpdates(updates: SessionUpdateParams[]) {
} }
async function createKnownSession( async function createKnownSession(
session: ACPNextSession.Interface, session: ACPSession.Interface,
sessionId: string, sessionId: string,
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] }, 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 () => { it("routes message.part.delta by sessionID without cross-session pollution", async () => {
const harness = createHarness() const harness = createHarness()
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" }) 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 () => { it("does not create extra subscriptions on repeated loadSession", async () => {
const harness = createHarness() const harness = createHarness()
let subscription: ACPNextEvent.Subscription | undefined let subscription: ACPEvent.Subscription | undefined
const service = ACPNextService.make({ const service = ACPService.make({
sdk: harness.sdk, sdk: harness.sdk,
connection: harness.connection, connection: harness.connection,
directory: { directory: {
@ -439,8 +439,8 @@ describe("acp-next event routing", () => {
return Promise.resolve() return Promise.resolve()
}, },
} satisfies Pick<AgentSideConnection, "sessionUpdate"> } satisfies Pick<AgentSideConnection, "sessionUpdate">
let subscription: ACPNextEvent.Subscription | undefined let subscription: ACPEvent.Subscription | undefined
const service = ACPNextService.make({ const service = ACPService.make({
sdk: { sdk: {
global: { global: {
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }), event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),

View file

@ -7,8 +7,8 @@ import type {
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect" import { Effect, ManagedRuntime } from "effect"
import { ACPNextEvent } from "@/acp-next/event" import { ACPEvent } from "@/acp/event"
import { ACPNextSession } from "@/acp-next/session" import { ACPSession } from "@/acp/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }> type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0] type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
@ -28,8 +28,8 @@ const pollUntil = async (
} }
function makeSessionService() { function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)), ACPSession.Service.use((service) => Effect.succeed(service)),
) )
} }
@ -62,17 +62,17 @@ function createHarness(
return Promise.resolve() return Promise.resolve()
}, },
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate"> } 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 } 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 })) await Effect.runPromise(session.create({ id: sessionId, cwd }))
} }
async function createKnownTextPart( async function createKnownTextPart(
session: ACPNextSession.Interface, session: ACPSession.Interface,
sessionId: string, sessionId: string,
messageId: string, messageId: string,
partId: string, partId: string,
@ -137,7 +137,7 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
.join("") .join("")
} }
describe("acp-next permissions", () => { describe("acp permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => { it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness() const harness = createHarness()
await createSession(harness.session, "ses_a") await createSession(harness.session, "ses_a")

View file

@ -12,10 +12,10 @@ import type {
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2" import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect" import { Effect, ManagedRuntime } from "effect"
import * as ACPNextService from "@/acp-next/service" import * as ACPService from "@/acp/service"
import * as ACPNextError from "@/acp-next/error" import * as ACPError from "@/acp/error"
import { ACPNextSession } from "@/acp-next/session" import { ACPSession } from "@/acp/session"
import { UsageService } from "@/acp-next/usage" import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider" 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 makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: SessionNotification[] = [] const updates: SessionNotification[] = []
const mcpAdds: string[] = [] const mcpAdds: string[] = []
@ -254,7 +254,7 @@ describe("ACP next service sessions", () => {
}) })
return { return {
service: ACPNextService.make({ sdk, connection, usage }), service: ACPService.make({ sdk, connection, usage }),
updates, updates,
mcpAdds, mcpAdds,
aborts, aborts,
@ -374,7 +374,7 @@ describe("ACP next service sessions", () => {
const missing = await Effect.runPromise( const missing = await Effect.runPromise(
service service
.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }) .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(missing.code).toBe(-32602)
expect(aborts).toEqual([created.sessionId]) expect(aborts).toEqual([created.sessionId])
@ -382,8 +382,8 @@ describe("ACP next service sessions", () => {
}) })
it("does not fail close when backing abort fails", async () => { it("does not fail close when backing abort fails", async () => {
const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)), ACPSession.Service.use((service) => Effect.succeed(service)),
) )
const { service } = makeService() const { service } = makeService()
const sdk = { const sdk = {
@ -405,7 +405,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }), add: () => Promise.resolve({ data: {} }),
}, },
} as unknown as OpencodeClient } 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" })) await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" }))
expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({}) 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 () => { it("maps provider auth failures to auth-required request errors", async () => {
const service = ACPNextService.make({ const service = ACPService.make({
sdk: { sdk: {
config: { config: {
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }), providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
@ -485,7 +485,7 @@ describe("ACP next service sessions", () => {
const error = await Effect.runPromise( const error = await Effect.runPromise(
service service
.newSession({ cwd: "/workspace", mcpServers: [] }) .newSession({ cwd: "/workspace", mcpServers: [] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip), .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
) )
expect(error.code).toBe(-32000) expect(error.code).toBe(-32000)
@ -519,12 +519,12 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }), add: () => Promise.resolve({ data: {} }),
}, },
} as unknown as OpencodeClient } as unknown as OpencodeClient
const service = ACPNextService.make({ sdk }) const service = ACPService.make({ sdk })
const first = await Effect.runPromise( const first = await Effect.runPromise(
service service
.newSession({ cwd: "/workspace", mcpServers: [] }) .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: [] })) const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@ -562,7 +562,7 @@ describe("ACP next service sessions", () => {
}, },
}, },
} as unknown as OpencodeClient } as unknown as OpencodeClient
const service = ACPNextService.make({ sdk }) const service = ACPService.make({ sdk })
await Effect.runPromise( await Effect.runPromise(
service.newSession({ service.newSession({
@ -603,7 +603,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }), add: () => Promise.resolve({ data: {} }),
}, },
} as unknown as OpencodeClient } as unknown as OpencodeClient
const service = ACPNextService.make({ sdk }) const service = ACPService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@ -642,7 +642,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }), add: () => Promise.resolve({ data: {} }),
}, },
} as unknown as OpencodeClient } as unknown as OpencodeClient
const service = ACPNextService.make({ sdk }) const service = ACPService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@ -709,7 +709,7 @@ describe("ACP next service sessions", () => {
Effect.runPromise( Effect.runPromise(
service service
.setSessionConfigOption({ sessionId: session.sessionId, ...input }) .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 } 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 session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 }) 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: {} }), add: () => Promise.resolve({ data: {} }),
}, },
} as unknown as OpencodeClient } 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 session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise( const updated = await Effect.runPromise(
service.setSessionConfigOption({ service.setSessionConfigOption({
@ -884,7 +884,7 @@ describe("ACP next service sessions", () => {
add: () => Promise.resolve({ data: {} }), add: () => Promise.resolve({ data: {} }),
}, },
} as unknown as OpencodeClient } 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 first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const second = 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 () => { it("maps prompt auth failures to auth-required request errors", async () => {
const { service } = makeService() const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const failing = ACPNextService.make({ const failing = ACPService.make({
sdk: { sdk: {
config: { config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
@ -1099,7 +1099,7 @@ describe("ACP next service sessions", () => {
const error = await Effect.runPromise( const error = await Effect.runPromise(
failing failing
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }) .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) expect(error.code).toBe(-32000)

View file

@ -1,14 +1,14 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk" import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect" import { Effect } from "effect"
import * as ACPNextError from "@/acp-next/error" import * as ACPError from "@/acp/error"
import * as ACPNextSession from "@/acp-next/session" import * as ACPSession from "@/acp/session"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { testEffect } from "../lib/effect" 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), providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID), modelID: ModelID.make(modelID),
}) })
@ -20,11 +20,11 @@ const mcpServer: McpServer = {
env: [], env: [],
} }
describe("acp-next session state", () => { describe("acp session state", () => {
sessionTest.effect("creates and retrieves session state", () => sessionTest.effect("creates and retrieves session state", () =>
Effect.gen(function* () { Effect.gen(function* () {
const createdAt = new Date("2026-05-25T00:00:00.000Z") 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({ session.create({
id: "ses_1", id: "ses_1",
cwd: "/workspace", cwd: "/workspace",
@ -35,7 +35,7 @@ describe("acp-next session state", () => {
modeId: "build", 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({ expect(created).toMatchObject({
id: "ses_1", id: "ses_1",
@ -52,17 +52,17 @@ describe("acp-next session state", () => {
sessionTest.effect("fails required lookups with typed SessionNotFound", () => sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
Effect.gen(function* () { 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") expect(error.sessionId).toBe("ses_missing")
}), }),
) )
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () => sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
Effect.gen(function* () { Effect.gen(function* () {
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing")) const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing"))
const missingPart = yield* ACPNextSession.Service.use((session) => const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }), 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", () => sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => yield* ACPSession.Service.use((session) =>
session.create({ session.create({
id: "ses_model", id: "ses_model",
cwd: "/workspace", 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")), 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", () => sessionTest.effect("updates selected variant and mode independently", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => yield* ACPSession.Service.use((session) =>
session.load({ session.load({
id: "ses_config", id: "ses_config",
cwd: "/workspace", cwd: "/workspace",
@ -109,21 +109,21 @@ describe("acp-next session state", () => {
}), }),
) )
yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high")) yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high"))
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan") expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build")) yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build"))
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high") expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build") expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
}), }),
) )
sessionTest.effect("records known message part metadata for delta routing", () => sessionTest.effect("records known message part metadata for delta routing", () =>
Effect.gen(function* () { 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({ session.recordPartMetadata({
sessionId: "ses_parts", sessionId: "ses_parts",
messageId: "msg_1", messageId: "msg_1",
@ -132,7 +132,7 @@ describe("acp-next session state", () => {
metadata: { output: "first chunk" }, 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" }), 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", () => sessionTest.effect("keeps repeated part ids distinct across messages", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" })) yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
yield* ACPNextSession.Service.use((session) => yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({ session.recordPartMetadata({
sessionId: "ses_duplicate_parts", sessionId: "ses_duplicate_parts",
messageId: "msg_1", messageId: "msg_1",
@ -157,7 +157,7 @@ describe("acp-next session state", () => {
metadata: { output: "from first message" }, metadata: { output: "from first message" },
}), }),
) )
yield* ACPNextSession.Service.use((session) => yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({ session.recordPartMetadata({
sessionId: "ses_duplicate_parts", sessionId: "ses_duplicate_parts",
messageId: "msg_2", 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" }), 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" }), 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", () => sessionTest.effect("removing a session clears its known part metadata", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" })) yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
yield* ACPNextSession.Service.use((session) => yield* ACPSession.Service.use((session) =>
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
) )
const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove")) const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove"))
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove")) const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove"))
const missingPart = yield* ACPNextSession.Service.use((session) => const missingPart = yield* ACPSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }), session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
) )

View file

@ -7,9 +7,9 @@ import {
shellOutputSnapshot, shellOutputSnapshot,
toLocations, toLocations,
toToolKind, 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", () => { test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute") expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute") expect(toToolKind("shell")).toBe("execute")

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk" import type { SessionNotification } from "@agentclientprotocol/sdk"
import { UsageService } from "@/acp-next/usage" import { UsageService } from "@/acp/usage"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect" 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", () => { test("builds ACP Usage from assistant token shape", () => {
expect( expect(
UsageService.buildUsage({ 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 type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect" import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process" import { cliIt } from "../../lib/cli-process"
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client" import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client"
import { import {
createAcpNextClient, createAcpClient,
expectAlternateValue, expectAlternateValue,
expectSelectOption, expectSelectOption,
initialize, initialize,
@ -12,12 +12,12 @@ import {
verifierConfig, verifierConfig,
} from "./helpers" } from "./helpers"
describe("opencode acp-next config option subprocess", () => { describe("opencode acp config option subprocess", () => {
cliIt.live( cliIt.live(
'model option is listed with category "model"', 'model option is listed with category "model"',
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
) )
@ -35,7 +35,7 @@ describe("opencode acp-next config option subprocess", () => {
"model switch updates currentValue", "model switch updates currentValue",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { 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', 'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
) )
@ -80,7 +80,7 @@ describe("opencode acp-next config option subprocess", () => {
"effort switch updates currentValue", "effort switch updates currentValue",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { 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 type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider" import { testProviderConfig } from "../../lib/test-provider"
import { import {
createAcpClient, createAcpClient as createJsonRpcAcpClient,
expectOk, expectOk,
flattenSelectOptions, flattenSelectOptions,
selectConfigOption, selectConfigOption,
type AcpClient, 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 Effect.gen(function* () {
return createAcpClient( return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined))
yield* input.opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
...env,
},
}),
)
}) })
} }
@ -30,7 +23,7 @@ export function initialize(acp: AcpClient) {
yield* acp.request<InitializeResponse>("initialize", { yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1, protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } }, 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 type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect" import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process" 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( cliIt.live(
"initialize responds with capabilities", "initialize responds with capabilities",
({ opencode }) => ({ opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode })) const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.protocolVersion).toBe(1) expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true) expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
@ -30,7 +30,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
"auth negotiation is explicit and safe", "auth negotiation is explicit and safe",
({ opencode }) => ({ opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode }) const acp = yield* createAcpClient({ opencode })
const initialized = yield* initialize(acp) const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login") 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", "initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) => ({ opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode }) const acp = yield* createAcpClient({ opencode })
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }) const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login") expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")

View file

@ -7,15 +7,15 @@ import type {
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect" import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process" import { cliIt } from "../../lib/cli-process"
import { expectOk, selectConfigOption } from "../acp/acp-test-client" import { expectOk, selectConfigOption } from "./acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
describe("opencode acp-next lifecycle subprocess", () => { describe("opencode acp lifecycle subprocess", () => {
cliIt.live( cliIt.live(
"stdin EOF exits cleanly", "stdin EOF exits cleanly",
({ opencode }) => ({ opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }) const acp = yield* opencode.acp()
acp.close() acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5))) 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", "close capability and close request",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { 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", "loadSession capability and load request return session config options",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { 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", "list request includes a live ACP-created session",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
) )
@ -86,7 +86,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"resume capability advertisement", "resume capability advertisement",
({ opencode }) => ({ opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode })) const initialized = yield* initialize(yield* createAcpClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}), }),
@ -97,7 +97,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
"resume request returns session config options", "resume request returns session config options",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) }, { 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 path from "node:path"
import { pathToFileURL } from "node:url" import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process" import { cliIt } from "../../lib/cli-process"
import { expectOk } from "../acp/acp-test-client" import { expectOk } from "./acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers" import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=" const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("opencode acp-next prompt content subprocess", () => { describe("opencode acp prompt content subprocess", () => {
cliIt.live( cliIt.live(
"accepts embedded text resource image and file resource link prompt content", "accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n")) yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) }, { 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 { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { cliIt } from "../../lib/cli-process" 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( cliIt.live(
"skill slash command appears through available_commands_update", "skill slash command appears through available_commands_update",
({ home, llm, opencode }) => ({ home, llm, opencode }) =>
@ -14,7 +14,7 @@ describe("opencode acp-next skills subprocess", () => {
const skills = path.join(home, "skills") const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true })) yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill)) yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = yield* createAcpNextClient( const acp = yield* createAcpClient(
{ opencode }, { opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) }, { OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
) )