mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 14:40:32 +00:00
feat(acp): promote next implementation (#29929)
This commit is contained in:
parent
0733c080c0
commit
4cc166a400
39 changed files with 499 additions and 4217 deletions
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
|
@ -5,7 +5,7 @@ import { InstanceStore } from "@/project/instance-store"
|
|||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
import type * as ACPNextError from "./error"
|
||||
import type * as ACPError from "./error"
|
||||
|
||||
export type ModelOption = {
|
||||
readonly providerID: ProviderID
|
||||
|
|
@ -39,18 +39,18 @@ export type Snapshot = {
|
|||
}
|
||||
|
||||
export interface LoaderInterface {
|
||||
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
|
||||
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
|
||||
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
|
||||
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
|
||||
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPError.Error>
|
||||
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
|
||||
}
|
||||
|
||||
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPNextDirectoryLoader") {}
|
||||
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPDirectoryLoader") {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextDirectory") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPDirectory") {}
|
||||
|
||||
export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ export const loaderLayer = Layer.effect(
|
|||
const command = yield* Command.Service
|
||||
|
||||
return Loader.of({
|
||||
load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) {
|
||||
load: Effect.fn("ACPDirectoryLoader.load")(function* (directory) {
|
||||
const ctx = yield* store.load({ directory })
|
||||
return yield* Effect.gen(function* () {
|
||||
const providers = yield* provider.list()
|
||||
|
|
@ -142,7 +142,7 @@ export const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const loader = yield* Loader
|
||||
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPNextError.Error>>())
|
||||
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPError.Error>>())
|
||||
|
||||
const cached = Effect.fnUntraced(function* (directory: string) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
|
|
@ -166,11 +166,11 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) {
|
||||
const get = Effect.fn("ACPDirectory.get")(function* (directory: string) {
|
||||
return yield* yield* cached(directory)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) {
|
||||
const refresh = Effect.fn("ACPDirectory.refresh")(function* (directory: string) {
|
||||
return yield* SynchronizedRef.modifyEffect(
|
||||
snapshots,
|
||||
Effect.fnUntraced(function* (items) {
|
||||
|
|
@ -2,51 +2,51 @@ import { RequestError } from "@agentclientprotocol/sdk"
|
|||
import { Schema } from "effect"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
|
||||
"ACPNextSessionNotFoundError",
|
||||
"ACPSessionNotFoundError",
|
||||
{
|
||||
sessionId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
|
||||
"ACPNextInvalidConfigOptionError",
|
||||
"ACPInvalidConfigOptionError",
|
||||
{
|
||||
configId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", {
|
||||
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPInvalidModelError", {
|
||||
modelId: Schema.String,
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPNextInvalidEffortError", {
|
||||
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPInvalidEffortError", {
|
||||
effort: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", {
|
||||
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPInvalidModeError", {
|
||||
mode: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", {
|
||||
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPAuthRequiredError", {
|
||||
providerId: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
|
||||
"ACPNextUnknownAuthMethodError",
|
||||
"ACPUnknownAuthMethodError",
|
||||
{
|
||||
methodId: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
|
||||
"ACPNextUnsupportedOperationError",
|
||||
"ACPUnsupportedOperationError",
|
||||
{
|
||||
method: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", {
|
||||
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
|
@ -64,26 +64,26 @@ export type Error =
|
|||
|
||||
export function toRequestError(error: Error) {
|
||||
switch (error._tag) {
|
||||
case "ACPNextSessionNotFoundError":
|
||||
case "ACPSessionNotFoundError":
|
||||
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
|
||||
case "ACPNextInvalidConfigOptionError":
|
||||
case "ACPInvalidConfigOptionError":
|
||||
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
|
||||
case "ACPNextInvalidModelError":
|
||||
case "ACPInvalidModelError":
|
||||
return RequestError.invalidParams(
|
||||
{ providerId: error.providerId, modelId: error.modelId },
|
||||
`model not found: ${error.modelId}`,
|
||||
)
|
||||
case "ACPNextInvalidEffortError":
|
||||
case "ACPInvalidEffortError":
|
||||
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
|
||||
case "ACPNextInvalidModeError":
|
||||
case "ACPInvalidModeError":
|
||||
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
||||
case "ACPNextAuthRequiredError":
|
||||
case "ACPAuthRequiredError":
|
||||
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
|
||||
case "ACPNextUnknownAuthMethodError":
|
||||
case "ACPUnknownAuthMethodError":
|
||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||
case "ACPNextUnsupportedOperationError":
|
||||
case "ACPUnsupportedOperationError":
|
||||
return RequestError.methodNotFound(error.method)
|
||||
case "ACPNextServiceFailureError":
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError({ service: error.service }, error.safeMessage)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import type {
|
|||
ToolPart,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { Effect } from "effect"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ACPNextPermission } from "./permission"
|
||||
import { ACPSession } from "./session"
|
||||
import { ACPPermission } from "./permission"
|
||||
import {
|
||||
duplicateRunningToolUpdate,
|
||||
errorToolUpdate,
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
completedToolUpdate,
|
||||
} from "./tool"
|
||||
|
||||
const log = Log.create({ service: "acp-next-event" })
|
||||
const log = Log.create({ service: "acp-event" })
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
|
@ -32,7 +32,7 @@ type GlobalEventStream = {
|
|||
stream: AsyncIterable<GlobalEventEnvelope>
|
||||
}
|
||||
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPNextSession.Interface }) {
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
|
||||
const subscription = new Subscription(input)
|
||||
subscription.start()
|
||||
return subscription
|
||||
|
|
@ -42,17 +42,17 @@ export class Subscription {
|
|||
private readonly abort = new AbortController()
|
||||
private readonly shellSnapshots = new Map<string, string>()
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly permission: ACPNextPermission.Handler
|
||||
private readonly permission: ACPPermission.Handler
|
||||
private started = false
|
||||
|
||||
constructor(
|
||||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
session: ACPSession.Interface
|
||||
},
|
||||
) {
|
||||
this.permission = new ACPNextPermission.Handler(input)
|
||||
this.permission = new ACPPermission.Handler(input)
|
||||
}
|
||||
|
||||
start() {
|
||||
|
|
@ -316,4 +316,4 @@ export class Subscription {
|
|||
}
|
||||
}
|
||||
|
||||
export * as ACPNextEvent from "./event"
|
||||
export * as ACPEvent from "./event"
|
||||
|
|
@ -3,11 +3,11 @@ import * as Log from "@opencode-ai/core/util/log"
|
|||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { exists, readText } from "@/util/filesystem"
|
||||
import type { ACPNextSession } from "./session"
|
||||
import type { ACPSession } from "./session"
|
||||
import { toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-permission" })
|
||||
const log = Log.create({ service: "acp-permission" })
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
|
|
@ -26,7 +26,7 @@ export class Handler {
|
|||
private readonly input: {
|
||||
sdk: OpencodeClient
|
||||
connection: Connection
|
||||
session: ACPNextSession.Interface
|
||||
session: ACPSession.Interface
|
||||
},
|
||||
) {}
|
||||
|
||||
|
|
@ -142,4 +142,4 @@ function stringValue(value: unknown) {
|
|||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as ACPNextPermission from "./permission"
|
||||
export * as ACPPermission from "./permission"
|
||||
|
|
@ -39,4 +39,4 @@ function write(name: string, durationMs: number, fields?: Record<string, string
|
|||
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
|
||||
}
|
||||
|
||||
export * as ACPNextProfile from "./profile"
|
||||
export * as ACPProfile from "./profile"
|
||||
|
|
@ -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"
|
||||
|
|
@ -33,22 +33,22 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ACPNextError from "./error"
|
||||
import * as ACPError from "./error"
|
||||
import { buildConfigOptions, parseModelSelection } from "./config-option"
|
||||
import { promptContentToParts } from "./content"
|
||||
import { Directory } from "./directory"
|
||||
import { ACPNextEvent } from "./event"
|
||||
import { ACPNextSession } from "./session"
|
||||
import { ACPEvent } from "./event"
|
||||
import { ACPSession } from "./session"
|
||||
import { UsageService } from "./usage"
|
||||
import { ACPNextProfile } from "./profile"
|
||||
import { ACPProfile } from "./profile"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import type { Command } from "@/command"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
const log = Log.create({ service: "acp-next-service" })
|
||||
const log = Log.create({ service: "acp-service" })
|
||||
|
||||
export type Error = ACPNextError.Error
|
||||
export type Error = ACPError.Error
|
||||
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
|
||||
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
|
||||
|
|
@ -70,26 +70,26 @@ export type Interface = {
|
|||
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACP/Service") {}
|
||||
|
||||
export function make(input: {
|
||||
sdk: OpencodeClient
|
||||
connection?: ServiceConnection
|
||||
directory?: Directory.Interface
|
||||
session?: ACPNextSession.Interface
|
||||
session?: ACPSession.Interface
|
||||
usage?: UsageService.Interface
|
||||
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
|
||||
eventSubscription?: (subscription: ACPEvent.Subscription) => void
|
||||
}): Interface {
|
||||
const session = input.session ?? makeSessionService()
|
||||
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const sessionSnapshots = new Map<string, Directory.Snapshot>()
|
||||
const events = input.connection
|
||||
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
|
||||
? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session })
|
||||
: undefined
|
||||
if (events) input.eventSubscription?.(events)
|
||||
|
||||
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
|
||||
const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) {
|
||||
const started = performance.now()
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
|
|
@ -132,25 +132,25 @@ export function make(input: {
|
|||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
ACPNextProfile.duration("acp.initialize", started)
|
||||
ACPProfile.duration("acp.initialize", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
|
||||
const authenticate = Effect.fn("ACP.authenticate")(function* (params: AuthenticateRequest) {
|
||||
if (params.methodId !== AuthMethodID) {
|
||||
return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId })
|
||||
return yield* new ACPError.UnknownAuthMethodError({ methodId: params.methodId })
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
|
||||
const directorySnapshot = Effect.fn("ACP.directorySnapshot")(function* (cwd: string) {
|
||||
const started = performance.now()
|
||||
const snapshot = yield* directoryService.get(cwd)
|
||||
ACPNextProfile.duration("acp.directory.snapshot", started)
|
||||
ACPProfile.duration("acp.directory.snapshot", started)
|
||||
return snapshot
|
||||
})
|
||||
|
||||
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
|
||||
const configSnapshot = Effect.fn("ACP.configSnapshot")(function* (state: ACPSession.Info) {
|
||||
const snapshot = sessionSnapshots.get(state.id)
|
||||
if (snapshot) return snapshot
|
||||
const loaded = yield* directorySnapshot(state.cwd)
|
||||
|
|
@ -158,7 +158,7 @@ export function make(input: {
|
|||
return loaded
|
||||
})
|
||||
|
||||
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
|
||||
const newSession = Effect.fn("ACP.newSession")(function* (params: NewSessionRequest) {
|
||||
const started = performance.now()
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const selected = selectDefaultModel(snapshot)
|
||||
|
|
@ -202,11 +202,11 @@ export function make(input: {
|
|||
modeId: state.modeId,
|
||||
}),
|
||||
}
|
||||
ACPNextProfile.duration("acp.newSession", started)
|
||||
ACPProfile.duration("acp.newSession", started)
|
||||
return response
|
||||
})
|
||||
|
||||
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
|
||||
const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
yield* request(
|
||||
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
|
|
@ -245,7 +245,7 @@ export function make(input: {
|
|||
}
|
||||
})
|
||||
|
||||
const listSessions = Effect.fn("ACPNext.listSessions")(function* (params: ListSessionsRequest) {
|
||||
const listSessions = Effect.fn("ACP.listSessions")(function* (params: ListSessionsRequest) {
|
||||
const cursor = params.cursor ? Number(params.cursor) : undefined
|
||||
const limit = 100
|
||||
const sessions = yield* request(
|
||||
|
|
@ -291,7 +291,7 @@ export function make(input: {
|
|||
}
|
||||
})
|
||||
|
||||
const resumeSession = Effect.fn("ACPNext.resumeSession")(function* (params: ResumeSessionRequest) {
|
||||
const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
yield* request(
|
||||
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
|
||||
|
|
@ -330,7 +330,7 @@ export function make(input: {
|
|||
}
|
||||
})
|
||||
|
||||
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const closeSession = Effect.fn("ACP.closeSession")(function* (params: CloseSessionRequest) {
|
||||
const removed = yield* session.remove(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
sessionSnapshots.delete(params.sessionId)
|
||||
|
|
@ -349,7 +349,7 @@ export function make(input: {
|
|||
return {}
|
||||
})
|
||||
|
||||
const forkSession = Effect.fn("ACPNext.forkSession")(function* (params: ForkSessionRequest) {
|
||||
const forkSession = Effect.fn("ACP.forkSession")(function* (params: ForkSessionRequest) {
|
||||
const snapshot = yield* directorySnapshot(params.cwd)
|
||||
const forked = yield* request(
|
||||
() =>
|
||||
|
|
@ -393,13 +393,13 @@ export function make(input: {
|
|||
}
|
||||
})
|
||||
|
||||
const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* (
|
||||
const setSessionConfigOption = Effect.fn("ACP.setSessionConfigOption")(function* (
|
||||
params: SetSessionConfigOptionRequest,
|
||||
) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (typeof params.value !== "string") {
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
|
||||
if (params.configId === "model") {
|
||||
|
|
@ -421,7 +421,7 @@ export function make(input: {
|
|||
const model = current.model ?? selectDefaultModel(snapshot)
|
||||
const variants = Directory.variants(snapshot, model)
|
||||
if (!variants || !Object.keys(variants).includes(params.value)) {
|
||||
return yield* new ACPNextError.InvalidEffortError({ effort: params.value })
|
||||
return yield* new ACPError.InvalidEffortError({ effort: params.value })
|
||||
}
|
||||
const state = yield* session.setVariant(params.sessionId, params.value)
|
||||
return {
|
||||
|
|
@ -435,7 +435,7 @@ export function make(input: {
|
|||
|
||||
if (params.configId === "mode") {
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.value)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.value })
|
||||
return yield* new ACPError.InvalidModeError({ mode: params.value })
|
||||
}
|
||||
const state = yield* session.setMode(params.sessionId, params.value)
|
||||
return {
|
||||
|
|
@ -447,20 +447,20 @@ export function make(input: {
|
|||
}
|
||||
}
|
||||
|
||||
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
})
|
||||
|
||||
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
|
||||
const setSessionMode = Effect.fn("ACP.setSessionMode")(function* (params: SetSessionModeRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
|
||||
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
|
||||
return yield* new ACPError.InvalidModeError({ mode: params.modeId })
|
||||
}
|
||||
yield* session.setMode(params.sessionId, params.modeId)
|
||||
return {}
|
||||
})
|
||||
|
||||
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
|
||||
const setSessionModel = Effect.fn("ACP.setSessionModel")(function* (params: SetSessionModelRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* configSnapshot(current)
|
||||
const selected = yield* parseSelectedModel(snapshot, params.modelId)
|
||||
|
|
@ -487,7 +487,7 @@ export function make(input: {
|
|||
setSessionConfigOption,
|
||||
setSessionMode,
|
||||
setSessionModel,
|
||||
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
|
||||
prompt: Effect.fn("ACP.prompt")(function* (params: PromptRequest) {
|
||||
const current = yield* session.get(params.sessionId)
|
||||
const snapshot = yield* directorySnapshot(current.cwd)
|
||||
const selected = current.model ?? selectDefaultModel(snapshot)
|
||||
|
|
@ -563,15 +563,15 @@ export function make(input: {
|
|||
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
|
||||
return promptResponse(undefined, params.messageId)
|
||||
}),
|
||||
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
cancel: Effect.fn("ACP.cancel")(function* (_input: CancelNotification) {
|
||||
return yield* new ACPError.UnsupportedOperationError({ method: "session/cancel" })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionService() {
|
||||
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
|
||||
ACPSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -592,7 +592,7 @@ function makeDirectoryService(sdk: OpencodeClient) {
|
|||
|
||||
function makeUsageService(sdk: OpencodeClient) {
|
||||
const limits = new Map<string, Promise<number | undefined>>()
|
||||
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
|
||||
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACP.promptUsage.contextLimit")(
|
||||
function* (params) {
|
||||
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
|
||||
const current = limits.get(key)
|
||||
|
|
@ -615,7 +615,7 @@ function makeUsageService(sdk: OpencodeClient) {
|
|||
},
|
||||
)
|
||||
|
||||
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
|
||||
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")(
|
||||
function* (params) {
|
||||
const messages = yield* request(
|
||||
() =>
|
||||
|
|
@ -675,7 +675,7 @@ function makeUsageService(sdk: OpencodeClient) {
|
|||
})
|
||||
}
|
||||
|
||||
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
||||
function replayMessages(subscription: ACPEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
|
||||
if (!subscription) return Effect.void
|
||||
return Effect.promise(async () => {
|
||||
for (const message of messages) {
|
||||
|
|
@ -724,23 +724,23 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
|||
}
|
||||
|
||||
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
|
||||
return request(() => ACPNextProfile.measure(name, fn), service)
|
||||
return request(() => ACPProfile.measure(name, fn), service)
|
||||
}
|
||||
|
||||
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
||||
return ACPNextProfile.measure("acp.directory.load", async () => {
|
||||
return ACPProfile.measure("acp.directory.load", async () => {
|
||||
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
|
||||
ACPNextProfile.measure("acp.directory.provider.list", () =>
|
||||
ACPProfile.measure("acp.directory.provider.list", () =>
|
||||
sdk.config.providers({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
|
||||
ACPProfile.measure("acp.directory.mode.defaultAgent.load", () =>
|
||||
sdk.app.agents({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.command.list", () =>
|
||||
ACPProfile.measure("acp.directory.command.list", () =>
|
||||
sdk.command.list({ directory }, { throwOnError: true }),
|
||||
),
|
||||
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
|
||||
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
|
||||
ACPProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
|
||||
ACPProfile.measure("acp.directory.defaultModel.config", () =>
|
||||
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
|
||||
),
|
||||
])
|
||||
|
|
@ -754,7 +754,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
|
|||
>
|
||||
const defaultModelStarted = performance.now()
|
||||
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
|
||||
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
|
||||
ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
|
||||
const modes = agents
|
||||
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => ({
|
||||
|
|
@ -872,14 +872,14 @@ function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
|
|||
const model = provider?.models[ModelID.make(selected.model.modelID)]
|
||||
if (!model) {
|
||||
return Effect.fail(
|
||||
new ACPNextError.InvalidModelError({
|
||||
new ACPError.InvalidModelError({
|
||||
providerId: selected.model.providerID,
|
||||
modelId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (selected.variant && !model.variants?.[selected.variant]) {
|
||||
return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant }))
|
||||
return Effect.fail(new ACPError.InvalidEffortError({ effort: selected.variant }))
|
||||
}
|
||||
return Effect.succeed({
|
||||
model: {
|
||||
|
|
@ -954,7 +954,7 @@ function registerMcpServers(
|
|||
).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() =>
|
||||
ACPNextProfile.duration("acp.mcp.register", started, {
|
||||
ACPProfile.duration("acp.mcp.register", started, {
|
||||
count: pending.size,
|
||||
}),
|
||||
),
|
||||
|
|
@ -1020,20 +1020,20 @@ function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
|
|||
}
|
||||
|
||||
function fromUnknownError(error: unknown, service?: string): Error {
|
||||
if (isACPNextError(error)) return error
|
||||
if (isACPError(error)) return error
|
||||
if (isAuthRequired(error)) {
|
||||
return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) })
|
||||
return new ACPError.AuthRequiredError({ providerId: findProviderID(error) })
|
||||
}
|
||||
return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
|
||||
return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
|
||||
}
|
||||
|
||||
function isACPNextError(error: unknown): error is Error {
|
||||
function isACPError(error: unknown): error is Error {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"_tag" in error &&
|
||||
typeof error._tag === "string" &&
|
||||
error._tag.startsWith("ACPNext")
|
||||
error._tag.startsWith("ACP")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,122 +1,232 @@
|
|||
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { ACPSessionState } from "./types"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
||||
import { Context, Effect, Layer, Ref } from "effect"
|
||||
import type { ModelID, ProviderID } from "../provider/schema"
|
||||
import * as ACPError from "./error"
|
||||
|
||||
const log = Log.create({ service: "acp-session-manager" })
|
||||
export type SelectedModel = {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
}
|
||||
|
||||
export class ACPSessionManager {
|
||||
private sessions = new Map<string, ACPSessionState>()
|
||||
private sdk: OpencodeClient
|
||||
export type KnownMessagePartMetadata = {
|
||||
messageId: string
|
||||
partId: string
|
||||
partType?: Part["type"]
|
||||
role?: Message["role"]
|
||||
ignored?: boolean
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
constructor(sdk: OpencodeClient) {
|
||||
this.sdk = sdk
|
||||
}
|
||||
export type Info = {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers: readonly McpServer[]
|
||||
createdAt: Date
|
||||
model?: SelectedModel
|
||||
variant?: string
|
||||
modeId?: string
|
||||
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
|
||||
}
|
||||
|
||||
tryGet(sessionId: string): ACPSessionState | undefined {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
export type StoreInput = {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers?: readonly McpServer[]
|
||||
createdAt?: Date
|
||||
model?: SelectedModel
|
||||
variant?: string
|
||||
modeId?: string
|
||||
}
|
||||
|
||||
async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise<ACPSessionState> {
|
||||
const session = await this.sdk.session
|
||||
.create(
|
||||
{
|
||||
directory: cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data!)
|
||||
export type RecordPartMetadataInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
partType?: Part["type"]
|
||||
role?: Message["role"]
|
||||
ignored?: boolean
|
||||
toolCallId?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
const sessionId = session.id
|
||||
const resolvedModel = model
|
||||
export type PartMetadataLookupInput = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
partId: string
|
||||
}
|
||||
|
||||
const state: ACPSessionState = {
|
||||
id: sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
createdAt: new Date(),
|
||||
model: resolvedModel,
|
||||
}
|
||||
log.info("creating_session", { state })
|
||||
|
||||
this.sessions.set(sessionId, state)
|
||||
return state
|
||||
}
|
||||
|
||||
async load(
|
||||
export type Interface = {
|
||||
readonly create: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly load: (input: StoreInput) => Effect.Effect<Info>
|
||||
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
|
||||
readonly get: (sessionId: string) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
|
||||
readonly setModel: (
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: McpServer[],
|
||||
model?: ACPSessionState["model"],
|
||||
): Promise<ACPSessionState> {
|
||||
const session = await this.sdk.session
|
||||
.get(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory: cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data!)
|
||||
model: SelectedModel | undefined,
|
||||
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPError.SessionNotFoundError>
|
||||
readonly setVariant: (
|
||||
sessionId: string,
|
||||
variant: string | undefined,
|
||||
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
|
||||
readonly setMode: (
|
||||
sessionId: string,
|
||||
modeId: string | undefined,
|
||||
) => Effect.Effect<Info, ACPError.SessionNotFoundError>
|
||||
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPError.SessionNotFoundError>
|
||||
readonly recordPartMetadata: (
|
||||
input: RecordPartMetadataInput,
|
||||
) => Effect.Effect<KnownMessagePartMetadata, ACPError.SessionNotFoundError>
|
||||
readonly getPartMetadata: (
|
||||
input: PartMetadataLookupInput,
|
||||
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPError.SessionNotFoundError>
|
||||
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
|
||||
}
|
||||
|
||||
const resolvedModel = model
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACP/Session") {}
|
||||
|
||||
const state: ACPSessionState = {
|
||||
id: sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
createdAt: new Date(session.time.created),
|
||||
model: resolvedModel,
|
||||
}
|
||||
log.info("loading_session", { state })
|
||||
type State = Map<string, Info>
|
||||
|
||||
this.sessions.set(sessionId, state)
|
||||
return state
|
||||
}
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Ref.make<State>(new Map())
|
||||
|
||||
get(sessionId: string): ACPSessionState {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
log.error("session not found", { sessionId })
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` }))
|
||||
}
|
||||
return session
|
||||
}
|
||||
const store = Effect.fn("ACP.Session.store")(function* (input: StoreInput) {
|
||||
const session = makeSession(input)
|
||||
yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
|
||||
return snapshot(session)
|
||||
})
|
||||
|
||||
getModel(sessionId: string) {
|
||||
const session = this.get(sessionId)
|
||||
return session.model
|
||||
}
|
||||
const tryGet = Effect.fn("ACP.Session.tryGet")(function* (sessionId: string) {
|
||||
const session = (yield* Ref.get(sessions)).get(sessionId)
|
||||
if (!session) return
|
||||
return snapshot(session)
|
||||
})
|
||||
|
||||
setModel(sessionId: string, model: ACPSessionState["model"]) {
|
||||
const session = this.get(sessionId)
|
||||
session.model = model
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
const get = Effect.fn("ACP.Session.get")(function* (sessionId: string) {
|
||||
const session = yield* tryGet(sessionId)
|
||||
if (session) return session
|
||||
return yield* new ACPError.SessionNotFoundError({ sessionId })
|
||||
})
|
||||
|
||||
getVariant(sessionId: string) {
|
||||
const session = this.get(sessionId)
|
||||
return session.variant
|
||||
}
|
||||
const update = Effect.fn("ACP.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
|
||||
const result = yield* Ref.modify(sessions, (state) => {
|
||||
const session = state.get(sessionId)
|
||||
if (!session) return [undefined, state] as const
|
||||
const next = fn(session)
|
||||
return [snapshot(next), new Map(state).set(sessionId, next)] as const
|
||||
})
|
||||
if (result) return result
|
||||
return yield* new ACPError.SessionNotFoundError({ sessionId })
|
||||
})
|
||||
|
||||
setVariant(sessionId: string, variant?: string) {
|
||||
const session = this.get(sessionId)
|
||||
session.variant = variant
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
const remove = Effect.fn("ACP.Session.remove")(function* (sessionId: string) {
|
||||
return yield* Ref.modify(sessions, (state) => {
|
||||
const session = state.get(sessionId)
|
||||
if (!session) return [undefined, state] as const
|
||||
const next = new Map(state)
|
||||
next.delete(sessionId)
|
||||
return [snapshot(session), next] as const
|
||||
})
|
||||
})
|
||||
|
||||
setMode(sessionId: string, modeId: string) {
|
||||
const session = this.get(sessionId)
|
||||
session.modeId = modeId
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
const setModel: Interface["setModel"] = Effect.fn("ACP.Session.setModel")((sessionId, model) =>
|
||||
update(sessionId, (session) => ({ ...session, model })),
|
||||
)
|
||||
|
||||
remove(sessionId: string): ACPSessionState | undefined {
|
||||
const session = this.sessions.get(sessionId)
|
||||
this.sessions.delete(sessionId)
|
||||
return session
|
||||
const setVariant: Interface["setVariant"] = Effect.fn("ACP.Session.setVariant")((sessionId, variant) =>
|
||||
update(sessionId, (session) => ({ ...session, variant })),
|
||||
)
|
||||
|
||||
const setMode: Interface["setMode"] = Effect.fn("ACP.Session.setMode")((sessionId, modeId) =>
|
||||
update(sessionId, (session) => ({ ...session, modeId })),
|
||||
)
|
||||
|
||||
const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACP.Session.recordPartMetadata")((
|
||||
input,
|
||||
) => {
|
||||
const metadata = {
|
||||
messageId: input.messageId,
|
||||
partId: input.partId,
|
||||
partType: input.partType,
|
||||
role: input.role,
|
||||
ignored: input.ignored,
|
||||
toolCallId: input.toolCallId,
|
||||
metadata: input.metadata,
|
||||
}
|
||||
return update(input.sessionId, (session) => ({
|
||||
...session,
|
||||
knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata),
|
||||
})).pipe(Effect.as(metadata))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create: store,
|
||||
load: store,
|
||||
list: Effect.fn("ACP.Session.list")(function* (cwd?: string) {
|
||||
return [...(yield* Ref.get(sessions)).values()]
|
||||
.filter((session) => !cwd || session.cwd === cwd)
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
}),
|
||||
get,
|
||||
tryGet,
|
||||
remove,
|
||||
setModel,
|
||||
getModel: Effect.fn("ACP.Session.getModel")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).model
|
||||
}),
|
||||
setVariant,
|
||||
getVariant: Effect.fn("ACP.Session.getVariant")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).variant
|
||||
}),
|
||||
setMode,
|
||||
getMode: Effect.fn("ACP.Session.getMode")(function* (sessionId) {
|
||||
return (yield* get(sessionId)).modeId
|
||||
}),
|
||||
recordPartMetadata,
|
||||
getPartMetadata: Effect.fn("ACP.Session.getPartMetadata")(function* (input) {
|
||||
return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input))
|
||||
}),
|
||||
tryGetPartMetadata: Effect.fn("ACP.Session.tryGetPartMetadata")(function* (input) {
|
||||
return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
function makeSession(input: StoreInput): Info {
|
||||
return {
|
||||
id: input.id,
|
||||
cwd: input.cwd,
|
||||
mcpServers: [...(input.mcpServers ?? [])],
|
||||
createdAt: input.createdAt ? new Date(input.createdAt) : new Date(),
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
modeId: input.modeId,
|
||||
knownParts: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(session: Info): Info {
|
||||
return {
|
||||
...session,
|
||||
mcpServers: [...session.mcpServers],
|
||||
createdAt: new Date(session.createdAt),
|
||||
knownParts: new Map(session.knownParts),
|
||||
}
|
||||
}
|
||||
|
||||
function partMetadataKey(input: { messageId: string; partId: string }) {
|
||||
return `${input.messageId}:${input.partId}`
|
||||
}
|
||||
|
||||
export * as ACPSession from "./session"
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { ProviderID, ModelID } from "../provider/schema"
|
||||
|
||||
export interface ACPSessionState {
|
||||
id: string
|
||||
cwd: string
|
||||
mcpServers: McpServer[]
|
||||
createdAt: Date
|
||||
model?: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
}
|
||||
variant?: string
|
||||
modeId?: string
|
||||
}
|
||||
|
||||
export interface ACPConfig {
|
||||
sdk: OpencodeClient
|
||||
defaultModel?: {
|
||||
providerID: ProviderID
|
||||
modelID: ModelID
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
|
|||
import { Provider } from "@/provider/provider"
|
||||
import { Context, Effect, Layer, SynchronizedRef } from "effect"
|
||||
|
||||
const log = Log.create({ service: "acp-next-usage" })
|
||||
const log = Log.create({ service: "acp-usage" })
|
||||
|
||||
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
|
||||
|
||||
|
|
@ -60,14 +60,14 @@ export interface Interface {
|
|||
}
|
||||
|
||||
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
|
||||
"@opencode/ACPNextUsageMessageLoader",
|
||||
"@opencode/ACPUsageMessageLoader",
|
||||
) {}
|
||||
|
||||
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
|
||||
"@opencode/ACPNextUsageContextLimitLoader",
|
||||
"@opencode/ACPUsageContextLimitLoader",
|
||||
) {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextUsage") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPUsage") {}
|
||||
|
||||
export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
|
||||
return MessageLoader.of({
|
||||
|
|
@ -124,7 +124,7 @@ export const contextLimitLoaderLayer = Layer.effect(
|
|||
const provider = yield* Provider.Service
|
||||
|
||||
return ContextLimitLoader.of({
|
||||
providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) {
|
||||
providers: Effect.fn("ACPUsageContextLimitLoader.providers")(function* (directory) {
|
||||
const ctx = yield* store.load({ directory })
|
||||
return yield* Effect.gen(function* () {
|
||||
return yield* provider.list()
|
||||
|
|
@ -168,7 +168,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
|
||||
const contextLimit = Effect.fn("ACPNextUsage.contextLimit")(function* (input: {
|
||||
const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
|
||||
readonly directory: string
|
||||
readonly providerID: ProviderID
|
||||
readonly modelID: ModelID
|
||||
|
|
@ -176,7 +176,7 @@ export const layer = Layer.effect(
|
|||
return yield* yield* cachedLimit(input)
|
||||
})
|
||||
|
||||
const sendUpdate = Effect.fn("ACPNextUsage.sendUpdate")(function* (input: {
|
||||
const sendUpdate = Effect.fn("ACPUsage.sendUpdate")(function* (input: {
|
||||
readonly connection: UsageConnection
|
||||
readonly sessionID: string
|
||||
readonly directory: string
|
||||
|
|
@ -3,13 +3,11 @@ import { Effect } from "effect"
|
|||
import { effectCmd } from "../effect-cmd"
|
||||
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ACP } from "@/acp/agent"
|
||||
import { ACPNext } from "@/acp-next/agent"
|
||||
import { Server } from "@/server/server"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ACPNextProfile } from "@/acp-next/profile"
|
||||
import { ACPProfile } from "@/acp/profile"
|
||||
|
||||
const log = Log.create({ service: "acp-command" })
|
||||
|
||||
|
|
@ -24,13 +22,10 @@ export const AcpCommand = effectCmd({
|
|||
})
|
||||
},
|
||||
handler: Effect.fn("Cli.acp")(function* (args) {
|
||||
ACPNextProfile.mark("cli.acp.handler")
|
||||
ACPProfile.mark("cli.acp.handler")
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const opts = yield* resolveNetworkOptions(args)
|
||||
const server = yield* Effect.promise(() =>
|
||||
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
|
||||
)
|
||||
const server = yield* Effect.promise(() => ACPProfile.measure("cli.acp.server.listen", () => Server.listen(opts)))
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
|
@ -61,11 +56,11 @@ export const AcpCommand = effectCmd({
|
|||
})
|
||||
|
||||
const stream = ndJsonStream(input, output)
|
||||
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
|
||||
const agent = ACP.init({ sdk })
|
||||
|
||||
new AgentSideConnection((conn) => {
|
||||
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
|
||||
return agent.create(conn, { sdk })
|
||||
ACPProfile.mark("cli.acp.connection.create")
|
||||
return agent.create(conn)
|
||||
}, stream)
|
||||
|
||||
log.info("setup connection")
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
|
||||
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
|
||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||
acpNext: bool("OPENCODE_ACP_NEXT"),
|
||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
formatVariantName,
|
||||
parseModelSelection,
|
||||
type ConfigOptionProvider,
|
||||
} from "@/acp-next/config-option"
|
||||
} from "@/acp/config-option"
|
||||
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
|
|
@ -46,7 +46,7 @@ const providers: ConfigOptionProvider[] = [
|
|||
},
|
||||
]
|
||||
|
||||
describe("acp-next config options", () => {
|
||||
describe("acp config options", () => {
|
||||
test("builds the model select option with ACP verifier category", () => {
|
||||
expect(
|
||||
buildModelSelectOption({
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content"
|
||||
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
|
||||
|
||||
describe("acp-next content conversion", () => {
|
||||
describe("acp content conversion", () => {
|
||||
test("plain text block becomes a text part", () => {
|
||||
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
|
||||
})
|
||||
|
|
@ -158,7 +158,7 @@ describe("acp-next content conversion", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("acp-next replay conversion", () => {
|
||||
describe("acp replay conversion", () => {
|
||||
test("replays text audience annotations", () => {
|
||||
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
|
||||
{
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Directory } from "@/acp-next/directory"
|
||||
import { Directory } from "@/acp/directory"
|
||||
import { Command } from "@/command"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
|
@ -97,7 +97,7 @@ const fakeLayer = (calls: string[]) =>
|
|||
),
|
||||
)
|
||||
|
||||
describe("ACP next directory snapshot", () => {
|
||||
describe("ACP directory snapshot", () => {
|
||||
it.effect("two concurrent callers share one load", () => {
|
||||
const calls: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
|
|
@ -1,35 +1,35 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import * as ACPNextError from "../../src/acp-next/error"
|
||||
import * as ACPError from "../../src/acp/error"
|
||||
|
||||
describe("acp-next.error", () => {
|
||||
describe("acp.error", () => {
|
||||
test("maps validation failures to invalid params", () => {
|
||||
const cases: ACPNextError.Error[] = [
|
||||
new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
||||
new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }),
|
||||
new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
|
||||
new ACPNextError.InvalidEffortError({ effort: "extreme" }),
|
||||
new ACPNextError.InvalidModeError({ mode: "turbo" }),
|
||||
const cases: ACPError.Error[] = [
|
||||
new ACPError.SessionNotFoundError({ sessionId: "ses_missing" }),
|
||||
new ACPError.InvalidConfigOptionError({ configId: "temperature" }),
|
||||
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
|
||||
new ACPError.InvalidEffortError({ effort: "extreme" }),
|
||||
new ACPError.InvalidModeError({ mode: "turbo" }),
|
||||
]
|
||||
|
||||
expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
|
||||
-32602, -32602, -32602, -32602, -32602,
|
||||
])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
|
||||
expect(ACPError.toRequestError(new ACPError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: "ses_123" },
|
||||
})
|
||||
expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
|
||||
expect(ACPError.toRequestError(new ACPError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { modelId: "gpt-missing" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps auth required to the SDK auth error", () => {
|
||||
const requestError = ACPNextError.toRequestError(new ACPNextError.AuthRequiredError({ providerId: "anthropic" }))
|
||||
const requestError = ACPError.toRequestError(new ACPError.AuthRequiredError({ providerId: "anthropic" }))
|
||||
|
||||
expect(requestError).toBeInstanceOf(RequestError)
|
||||
expect(requestError.code).toBe(-32000)
|
||||
|
|
@ -38,8 +38,8 @@ describe("acp-next.error", () => {
|
|||
})
|
||||
|
||||
test("maps unsupported operations to method not found", () => {
|
||||
const requestError = ACPNextError.toRequestError(
|
||||
new ACPNextError.UnsupportedOperationError({ method: "session/new" }),
|
||||
const requestError = ACPError.toRequestError(
|
||||
new ACPError.UnsupportedOperationError({ method: "session/new" }),
|
||||
)
|
||||
|
||||
expect(requestError.code).toBe(-32601)
|
||||
|
|
@ -47,8 +47,8 @@ describe("acp-next.error", () => {
|
|||
})
|
||||
|
||||
test("maps service failures to safe internal errors", () => {
|
||||
const requestError = ACPNextError.toRequestError(
|
||||
new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
|
||||
const requestError = ACPError.toRequestError(
|
||||
new ACPError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
|
||||
)
|
||||
|
||||
expect(requestError.code).toBe(-32603)
|
||||
|
|
@ -57,8 +57,8 @@ describe("acp-next.error", () => {
|
|||
})
|
||||
|
||||
test("wraps unknown defects without leaking raw details", () => {
|
||||
const requestError = ACPNextError.toRequestError(
|
||||
ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
|
||||
const requestError = ACPError.toRequestError(
|
||||
ACPError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
|
||||
)
|
||||
const serialized = JSON.stringify(requestError.toErrorResponse())
|
||||
|
||||
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2,10 +2,10 @@ import { describe, expect, it } from "bun:test"
|
|||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { ACPNextEvent } from "@/acp-next/event"
|
||||
import * as ACPNextService from "@/acp-next/service"
|
||||
import { Directory } from "@/acp-next/directory"
|
||||
import { ACPNextSession } from "@/acp-next/session"
|
||||
import { ACPEvent } from "@/acp/event"
|
||||
import * as ACPService from "@/acp/service"
|
||||
import { Directory } from "@/acp/directory"
|
||||
import { ACPSession } from "@/acp/session"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
type ToolSessionUpdateParams = SessionUpdateParams & {
|
||||
|
|
@ -30,8 +30,8 @@ const pollUntil = async (
|
|||
}
|
||||
|
||||
function makeSessionService() {
|
||||
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
|
||||
ACPSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
|
|||
},
|
||||
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
||||
const session = makeSessionService()
|
||||
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
|
||||
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
|
||||
|
||||
return { calls, connection, events, sdk, session, subscription, updates }
|
||||
}
|
||||
|
|
@ -296,7 +296,7 @@ function toolUpdates(updates: SessionUpdateParams[]) {
|
|||
}
|
||||
|
||||
async function createKnownSession(
|
||||
session: ACPNextSession.Interface,
|
||||
session: ACPSession.Interface,
|
||||
sessionId: string,
|
||||
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
|
||||
) {
|
||||
|
|
@ -312,7 +312,7 @@ async function createKnownSession(
|
|||
)
|
||||
}
|
||||
|
||||
describe("acp-next event routing", () => {
|
||||
describe("acp event routing", () => {
|
||||
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
|
||||
const harness = createHarness()
|
||||
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
|
||||
|
|
@ -348,8 +348,8 @@ describe("acp-next event routing", () => {
|
|||
|
||||
it("does not create extra subscriptions on repeated loadSession", async () => {
|
||||
const harness = createHarness()
|
||||
let subscription: ACPNextEvent.Subscription | undefined
|
||||
const service = ACPNextService.make({
|
||||
let subscription: ACPEvent.Subscription | undefined
|
||||
const service = ACPService.make({
|
||||
sdk: harness.sdk,
|
||||
connection: harness.connection,
|
||||
directory: {
|
||||
|
|
@ -439,8 +439,8 @@ describe("acp-next event routing", () => {
|
|||
return Promise.resolve()
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
||||
let subscription: ACPNextEvent.Subscription | undefined
|
||||
const service = ACPNextService.make({
|
||||
let subscription: ACPEvent.Subscription | undefined
|
||||
const service = ACPService.make({
|
||||
sdk: {
|
||||
global: {
|
||||
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
|
||||
|
|
@ -7,8 +7,8 @@ import type {
|
|||
} from "@agentclientprotocol/sdk"
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import { ACPNextEvent } from "@/acp-next/event"
|
||||
import { ACPNextSession } from "@/acp-next/session"
|
||||
import { ACPEvent } from "@/acp/event"
|
||||
import { ACPSession } from "@/acp/session"
|
||||
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
|
|
@ -28,8 +28,8 @@ const pollUntil = async (
|
|||
}
|
||||
|
||||
function makeSessionService() {
|
||||
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
return ManagedRuntime.make(ACPSession.defaultLayer).runSync(
|
||||
ACPSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -62,17 +62,17 @@ function createHarness(
|
|||
return Promise.resolve()
|
||||
},
|
||||
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
|
||||
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
|
||||
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
|
||||
|
||||
return { connection, replies, requests, sdk, session, subscription, updates }
|
||||
}
|
||||
|
||||
async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
|
||||
async function createSession(session: ACPSession.Interface, sessionId: string, cwd = "/workspace") {
|
||||
await Effect.runPromise(session.create({ id: sessionId, cwd }))
|
||||
}
|
||||
|
||||
async function createKnownTextPart(
|
||||
session: ACPNextSession.Interface,
|
||||
session: ACPSession.Interface,
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
|
|
@ -137,7 +137,7 @@ function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
|
|||
.join("")
|
||||
}
|
||||
|
||||
describe("acp-next permissions", () => {
|
||||
describe("acp permissions", () => {
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
|
@ -12,10 +12,10 @@ import type {
|
|||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { Effect, ManagedRuntime } from "effect"
|
||||
import * as ACPNextService from "@/acp-next/service"
|
||||
import * as ACPNextError from "@/acp-next/error"
|
||||
import { ACPNextSession } from "@/acp-next/session"
|
||||
import { UsageService } from "@/acp-next/usage"
|
||||
import * as ACPService from "@/acp/service"
|
||||
import * as ACPError from "@/acp/error"
|
||||
import { ACPSession } from "@/acp/session"
|
||||
import { UsageService } from "@/acp/usage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ const provider: Provider.Info = {
|
|||
},
|
||||
}
|
||||
|
||||
describe("ACP next service sessions", () => {
|
||||
describe("ACP service sessions", () => {
|
||||
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
|
||||
const updates: SessionNotification[] = []
|
||||
const mcpAdds: string[] = []
|
||||
|
|
@ -254,7 +254,7 @@ describe("ACP next service sessions", () => {
|
|||
})
|
||||
|
||||
return {
|
||||
service: ACPNextService.make({ sdk, connection, usage }),
|
||||
service: ACPService.make({ sdk, connection, usage }),
|
||||
updates,
|
||||
mcpAdds,
|
||||
aborts,
|
||||
|
|
@ -374,7 +374,7 @@ describe("ACP next service sessions", () => {
|
|||
const missing = await Effect.runPromise(
|
||||
service
|
||||
.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
)
|
||||
expect(missing.code).toBe(-32602)
|
||||
expect(aborts).toEqual([created.sessionId])
|
||||
|
|
@ -382,8 +382,8 @@ describe("ACP next service sessions", () => {
|
|||
})
|
||||
|
||||
it("does not fail close when backing abort fails", async () => {
|
||||
const sessionService = ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
|
||||
ACPNextSession.Service.use((service) => Effect.succeed(service)),
|
||||
const sessionService = ManagedRuntime.make(ACPSession.defaultLayer).runSync(
|
||||
ACPSession.Service.use((service) => Effect.succeed(service)),
|
||||
)
|
||||
const { service } = makeService()
|
||||
const sdk = {
|
||||
|
|
@ -405,7 +405,7 @@ describe("ACP next service sessions", () => {
|
|||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const closing = ACPNextService.make({ sdk, session: sessionService })
|
||||
const closing = ACPService.make({ sdk, session: sessionService })
|
||||
await Effect.runPromise(sessionService.create({ id: "ses_close", cwd: "/workspace" }))
|
||||
|
||||
expect(await Effect.runPromise(closing.closeSession({ sessionId: "ses_close" }))).toEqual({})
|
||||
|
|
@ -467,7 +467,7 @@ describe("ACP next service sessions", () => {
|
|||
})
|
||||
|
||||
it("maps provider auth failures to auth-required request errors", async () => {
|
||||
const service = ACPNextService.make({
|
||||
const service = ACPService.make({
|
||||
sdk: {
|
||||
config: {
|
||||
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
|
||||
|
|
@ -485,7 +485,7 @@ describe("ACP next service sessions", () => {
|
|||
const error = await Effect.runPromise(
|
||||
service
|
||||
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
)
|
||||
|
||||
expect(error.code).toBe(-32000)
|
||||
|
|
@ -519,12 +519,12 @@ describe("ACP next service sessions", () => {
|
|||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
|
||||
const first = await Effect.runPromise(
|
||||
service
|
||||
.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
)
|
||||
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
|
|
@ -562,7 +562,7 @@ describe("ACP next service sessions", () => {
|
|||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
|
||||
await Effect.runPromise(
|
||||
service.newSession({
|
||||
|
|
@ -603,7 +603,7 @@ describe("ACP next service sessions", () => {
|
|||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
|
||||
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
|
|
@ -642,7 +642,7 @@ describe("ACP next service sessions", () => {
|
|||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
|
||||
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
|
|
@ -709,7 +709,7 @@ describe("ACP next service sessions", () => {
|
|||
Effect.runPromise(
|
||||
service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, ...input })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -759,7 +759,7 @@ describe("ACP next service sessions", () => {
|
|||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
||||
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
|
||||
|
|
@ -814,7 +814,7 @@ describe("ACP next service sessions", () => {
|
|||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const updated = await Effect.runPromise(
|
||||
service.setSessionConfigOption({
|
||||
|
|
@ -884,7 +884,7 @@ describe("ACP next service sessions", () => {
|
|||
add: () => Promise.resolve({ data: {} }),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const service = ACPNextService.make({ sdk })
|
||||
const service = ACPService.make({ sdk })
|
||||
|
||||
const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
|
|
@ -1065,7 +1065,7 @@ describe("ACP next service sessions", () => {
|
|||
it("maps prompt auth failures to auth-required request errors", async () => {
|
||||
const { service } = makeService()
|
||||
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
|
||||
const failing = ACPNextService.make({
|
||||
const failing = ACPService.make({
|
||||
sdk: {
|
||||
config: {
|
||||
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
|
||||
|
|
@ -1099,7 +1099,7 @@ describe("ACP next service sessions", () => {
|
|||
const error = await Effect.runPromise(
|
||||
failing
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
|
||||
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
|
||||
.pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
|
||||
)
|
||||
|
||||
expect(error.code).toBe(-32000)
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import type { McpServer } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import * as ACPNextError from "@/acp-next/error"
|
||||
import * as ACPNextSession from "@/acp-next/session"
|
||||
import * as ACPError from "@/acp/error"
|
||||
import * as ACPSession from "@/acp/session"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const sessionTest = testEffect(ACPNextSession.defaultLayer)
|
||||
const sessionTest = testEffect(ACPSession.defaultLayer)
|
||||
|
||||
const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({
|
||||
const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
})
|
||||
|
|
@ -20,11 +20,11 @@ const mcpServer: McpServer = {
|
|||
env: [],
|
||||
}
|
||||
|
||||
describe("acp-next session state", () => {
|
||||
describe("acp session state", () => {
|
||||
sessionTest.effect("creates and retrieves session state", () =>
|
||||
Effect.gen(function* () {
|
||||
const createdAt = new Date("2026-05-25T00:00:00.000Z")
|
||||
const created = yield* ACPNextSession.Service.use((session) =>
|
||||
const created = yield* ACPSession.Service.use((session) =>
|
||||
session.create({
|
||||
id: "ses_1",
|
||||
cwd: "/workspace",
|
||||
|
|
@ -35,7 +35,7 @@ describe("acp-next session state", () => {
|
|||
modeId: "build",
|
||||
}),
|
||||
)
|
||||
const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1"))
|
||||
const loaded = yield* ACPSession.Service.use((session) => session.get("ses_1"))
|
||||
|
||||
expect(created).toMatchObject({
|
||||
id: "ses_1",
|
||||
|
|
@ -52,17 +52,17 @@ describe("acp-next session state", () => {
|
|||
|
||||
sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
|
||||
const error = yield* ACPSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError)
|
||||
expect(error).toBeInstanceOf(ACPError.SessionNotFoundError)
|
||||
expect(error.sessionId).toBe("ses_missing")
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing"))
|
||||
const missingPart = yield* ACPNextSession.Service.use((session) =>
|
||||
const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_missing"))
|
||||
const missingPart = yield* ACPSession.Service.use((session) =>
|
||||
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ describe("acp-next session state", () => {
|
|||
|
||||
sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
yield* ACPSession.Service.use((session) =>
|
||||
session.create({
|
||||
id: "ses_model",
|
||||
cwd: "/workspace",
|
||||
|
|
@ -84,7 +84,7 @@ describe("acp-next session state", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const updated = yield* ACPNextSession.Service.use((session) =>
|
||||
const updated = yield* ACPSession.Service.use((session) =>
|
||||
session.setModel("ses_model", model("openai", "gpt-5")),
|
||||
)
|
||||
|
||||
|
|
@ -99,7 +99,7 @@ describe("acp-next session state", () => {
|
|||
|
||||
sessionTest.effect("updates selected variant and mode independently", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
yield* ACPSession.Service.use((session) =>
|
||||
session.load({
|
||||
id: "ses_config",
|
||||
cwd: "/workspace",
|
||||
|
|
@ -109,21 +109,21 @@ describe("acp-next session state", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high"))
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
|
||||
yield* ACPSession.Service.use((session) => session.setVariant("ses_config", "high"))
|
||||
expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
|
||||
expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
|
||||
|
||||
yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build"))
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
|
||||
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
|
||||
yield* ACPSession.Service.use((session) => session.setMode("ses_config", "build"))
|
||||
expect(yield* ACPSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
|
||||
expect(yield* ACPSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
|
||||
}),
|
||||
)
|
||||
|
||||
sessionTest.effect("records known message part metadata for delta routing", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
|
||||
yield* ACPSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
|
||||
|
||||
const metadata = yield* ACPNextSession.Service.use((session) =>
|
||||
const metadata = yield* ACPSession.Service.use((session) =>
|
||||
session.recordPartMetadata({
|
||||
sessionId: "ses_parts",
|
||||
messageId: "msg_1",
|
||||
|
|
@ -132,7 +132,7 @@ describe("acp-next session state", () => {
|
|||
metadata: { output: "first chunk" },
|
||||
}),
|
||||
)
|
||||
const routed = yield* ACPNextSession.Service.use((session) =>
|
||||
const routed = yield* ACPSession.Service.use((session) =>
|
||||
session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
|
|
@ -148,8 +148,8 @@ describe("acp-next session state", () => {
|
|||
|
||||
sessionTest.effect("keeps repeated part ids distinct across messages", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
yield* ACPSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
|
||||
yield* ACPSession.Service.use((session) =>
|
||||
session.recordPartMetadata({
|
||||
sessionId: "ses_duplicate_parts",
|
||||
messageId: "msg_1",
|
||||
|
|
@ -157,7 +157,7 @@ describe("acp-next session state", () => {
|
|||
metadata: { output: "from first message" },
|
||||
}),
|
||||
)
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
yield* ACPSession.Service.use((session) =>
|
||||
session.recordPartMetadata({
|
||||
sessionId: "ses_duplicate_parts",
|
||||
messageId: "msg_2",
|
||||
|
|
@ -166,10 +166,10 @@ describe("acp-next session state", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
const first = yield* ACPNextSession.Service.use((session) =>
|
||||
const first = yield* ACPSession.Service.use((session) =>
|
||||
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
const second = yield* ACPNextSession.Service.use((session) =>
|
||||
const second = yield* ACPSession.Service.use((session) =>
|
||||
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
|
||||
)
|
||||
|
||||
|
|
@ -180,14 +180,14 @@ describe("acp-next session state", () => {
|
|||
|
||||
sessionTest.effect("removing a session clears its known part metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
|
||||
yield* ACPNextSession.Service.use((session) =>
|
||||
yield* ACPSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
|
||||
yield* ACPSession.Service.use((session) =>
|
||||
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove"))
|
||||
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove"))
|
||||
const missingPart = yield* ACPNextSession.Service.use((session) =>
|
||||
const removed = yield* ACPSession.Service.use((session) => session.remove("ses_remove"))
|
||||
const missing = yield* ACPSession.Service.use((session) => session.tryGet("ses_remove"))
|
||||
const missingPart = yield* ACPSession.Service.use((session) =>
|
||||
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
|
||||
)
|
||||
|
||||
|
|
@ -7,9 +7,9 @@ import {
|
|||
shellOutputSnapshot,
|
||||
toLocations,
|
||||
toToolKind,
|
||||
} from "../../src/acp-next/tool"
|
||||
} from "../../src/acp/tool"
|
||||
|
||||
describe("acp-next tool conversion", () => {
|
||||
describe("acp tool conversion", () => {
|
||||
test("maps OpenCode tool ids to ACP tool kinds", () => {
|
||||
expect(toToolKind("bash")).toBe("execute")
|
||||
expect(toToolKind("shell")).toBe("execute")
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { UsageService } from "@/acp-next/usage"
|
||||
import { UsageService } from "@/acp/usage"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
|
@ -122,7 +122,7 @@ const connection = (updates: SessionNotification[]) => ({
|
|||
},
|
||||
})
|
||||
|
||||
describe("acp-next usage", () => {
|
||||
describe("acp usage", () => {
|
||||
test("builds ACP Usage from assistant token shape", () => {
|
||||
expect(
|
||||
UsageService.buildUsage({
|
||||
|
|
@ -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
|
||||
`
|
||||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
|
|
@ -2,9 +2,9 @@ import { describe, expect } from "bun:test"
|
|||
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
|
||||
import { expectOk, flattenSelectOptions, selectConfigOption } from "./acp-test-client"
|
||||
import {
|
||||
createAcpNextClient,
|
||||
createAcpClient,
|
||||
expectAlternateValue,
|
||||
expectSelectOption,
|
||||
initialize,
|
||||
|
|
@ -12,12 +12,12 @@ import {
|
|||
verifierConfig,
|
||||
} from "./helpers"
|
||||
|
||||
describe("opencode acp-next config option subprocess", () => {
|
||||
describe("opencode acp config option subprocess", () => {
|
||||
cliIt.live(
|
||||
'model option is listed with category "model"',
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -35,7 +35,7 @@ describe("opencode acp-next config option subprocess", () => {
|
|||
"model switch updates currentValue",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -62,7 +62,7 @@ describe("opencode acp-next config option subprocess", () => {
|
|||
'effort option is listed with category "thought_level" when selected model supports variants',
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -80,7 +80,7 @@ describe("opencode acp-next config option subprocess", () => {
|
|||
"effort switch updates currentValue",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -4,23 +4,16 @@ import { Effect } from "effect"
|
|||
import type { CliFixture } from "../../lib/cli-process"
|
||||
import { testProviderConfig } from "../../lib/test-provider"
|
||||
import {
|
||||
createAcpClient,
|
||||
createAcpClient as createJsonRpcAcpClient,
|
||||
expectOk,
|
||||
flattenSelectOptions,
|
||||
selectConfigOption,
|
||||
type AcpClient,
|
||||
} from "../acp/acp-test-client"
|
||||
} from "./acp-test-client"
|
||||
|
||||
export function createAcpNextClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
|
||||
export function createAcpClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
|
||||
return Effect.gen(function* () {
|
||||
return createAcpClient(
|
||||
yield* input.opencode.acp({
|
||||
env: {
|
||||
OPENCODE_ACP_NEXT: "1",
|
||||
...env,
|
||||
},
|
||||
}),
|
||||
)
|
||||
return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +23,7 @@ export function initialize(acp: AcpClient) {
|
|||
yield* acp.request<InitializeResponse>("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { "terminal-auth": true } },
|
||||
clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
|
||||
clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -2,14 +2,14 @@ import { describe, expect } from "bun:test"
|
|||
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
|
||||
import { Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
|
||||
import { createAcpClient, expectErrorCode, initialize } from "./helpers"
|
||||
|
||||
describe("opencode acp-next initialize/auth subprocess", () => {
|
||||
describe("opencode acp initialize/auth subprocess", () => {
|
||||
cliIt.live(
|
||||
"initialize responds with capabilities",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
|
||||
const initialized = yield* initialize(yield* createAcpClient({ opencode }))
|
||||
|
||||
expect(initialized.protocolVersion).toBe(1)
|
||||
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
|
||||
|
|
@ -30,7 +30,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
|
|||
"auth negotiation is explicit and safe",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient({ opencode })
|
||||
const acp = yield* createAcpClient({ opencode })
|
||||
const initialized = yield* initialize(acp)
|
||||
|
||||
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
|
|
@ -50,7 +50,7 @@ describe("opencode acp-next initialize/auth subprocess", () => {
|
|||
"initialize without terminal-auth metadata keeps auth command implicit",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient({ opencode })
|
||||
const acp = yield* createAcpClient({ opencode })
|
||||
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
|
||||
|
||||
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
|
||||
|
|
@ -7,15 +7,15 @@ import type {
|
|||
} from "@agentclientprotocol/sdk"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
|
||||
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
|
||||
import { expectOk, selectConfigOption } from "./acp-test-client"
|
||||
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
|
||||
|
||||
describe("opencode acp-next lifecycle subprocess", () => {
|
||||
describe("opencode acp lifecycle subprocess", () => {
|
||||
cliIt.live(
|
||||
"stdin EOF exits cleanly",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
|
||||
const acp = yield* opencode.acp()
|
||||
acp.close()
|
||||
|
||||
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
|
||||
|
|
@ -28,7 +28,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
|
|||
"close capability and close request",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -45,7 +45,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
|
|||
"loadSession capability and load request return session config options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -69,7 +69,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
|
|||
"list request includes a live ACP-created session",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -86,7 +86,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
|
|||
"resume capability advertisement",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
|
||||
const initialized = yield* initialize(yield* createAcpClient({ opencode }))
|
||||
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
}),
|
||||
|
|
@ -97,7 +97,7 @@ describe("opencode acp-next lifecycle subprocess", () => {
|
|||
"resume request returns session config options",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -5,18 +5,18 @@ import { writeFile } from "node:fs/promises"
|
|||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { expectOk } from "../acp/acp-test-client"
|
||||
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
|
||||
import { expectOk } from "./acp-test-client"
|
||||
import { createAcpClient, initialize, newSession, verifierConfig } from "./helpers"
|
||||
|
||||
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
|
||||
|
||||
describe("opencode acp-next prompt content subprocess", () => {
|
||||
describe("opencode acp prompt content subprocess", () => {
|
||||
cliIt.live(
|
||||
"accepts embedded text resource image and file resource link prompt content",
|
||||
({ home, llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
|
||||
)
|
||||
|
|
@ -4,9 +4,9 @@ import { Effect } from "effect"
|
|||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { cliIt } from "../../lib/cli-process"
|
||||
import { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
|
||||
import { createAcpClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
|
||||
|
||||
describe("opencode acp-next skills subprocess", () => {
|
||||
describe("opencode acp skills subprocess", () => {
|
||||
cliIt.live(
|
||||
"skill slash command appears through available_commands_update",
|
||||
({ home, llm, opencode }) =>
|
||||
|
|
@ -14,7 +14,7 @@ describe("opencode acp-next skills subprocess", () => {
|
|||
const skills = path.join(home, "skills")
|
||||
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
|
||||
const acp = yield* createAcpNextClient(
|
||||
const acp = yield* createAcpClient(
|
||||
{ opencode },
|
||||
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue