feat(acp): promote next implementation (#29929)
This commit is contained in:
@@ -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)
|
||||
+38
-1909
@@ -3,1964 +3,93 @@ import {
|
||||
type Agent as ACPAgent,
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type AuthMethod,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type CloseSessionResponse,
|
||||
type ForkSessionRequest,
|
||||
type ForkSessionResponse,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
type LoadSessionRequest,
|
||||
type NewSessionRequest,
|
||||
type PermissionOption,
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type ResumeSessionRequest,
|
||||
type ResumeSessionResponse,
|
||||
type Role,
|
||||
type SessionInfo,
|
||||
type SetSessionModelRequest,
|
||||
type SessionConfigOption,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
type ToolCallContent,
|
||||
type ToolKind,
|
||||
type Usage,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { ACPRuntime } from "./runtime"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Result, Schema } from "effect"
|
||||
import { LoadAPIKeyError } from "ai"
|
||||
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { applyPatch } from "diff"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ShellID } from "@/tool/shell/id"
|
||||
|
||||
type ModeOption = { id: string; name: string; description?: string }
|
||||
type ModelOption = { modelId: string; name: string }
|
||||
const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info)))
|
||||
|
||||
const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
const log = Log.create({ service: "acp-agent" })
|
||||
|
||||
async function getContextLimit(
|
||||
sdk: OpencodeClient,
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
directory: string,
|
||||
): Promise<number | null> {
|
||||
const providers = await sdk.config
|
||||
.providers({ directory })
|
||||
.then((x) => x.data?.providers ?? [])
|
||||
.catch((error) => {
|
||||
log.error("failed to get providers for context limit", { error })
|
||||
return []
|
||||
})
|
||||
|
||||
const provider = providers.find((p) => p.id === providerID)
|
||||
const model = provider?.models[modelID]
|
||||
return model?.limit.context ?? null
|
||||
}
|
||||
|
||||
async function sendUsageUpdate(
|
||||
connection: AgentSideConnection,
|
||||
sdk: OpencodeClient,
|
||||
sessionID: string,
|
||||
directory: string,
|
||||
): Promise<void> {
|
||||
const messages = await sdk.session
|
||||
.messages({ sessionID, directory }, { throwOnError: true })
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("failed to fetch messages for usage update", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!messages) return
|
||||
|
||||
const assistantMessages = messages.filter(
|
||||
(m): m is { info: AssistantMessage; parts: SessionMessageResponse["parts"] } => m.info.role === "assistant",
|
||||
)
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (!lastAssistant) return
|
||||
|
||||
const msg = lastAssistant.info
|
||||
if (!msg.providerID || !msg.modelID) return
|
||||
const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory)
|
||||
|
||||
if (!size) {
|
||||
// Cannot calculate usage without known context size
|
||||
return
|
||||
}
|
||||
|
||||
const used = msg.tokens.input + (msg.tokens.cache?.read ?? 0)
|
||||
const totalCost = assistantMessages.reduce((sum, m) => sum + m.info.cost, 0)
|
||||
|
||||
await connection
|
||||
.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used,
|
||||
size,
|
||||
cost: { amount: totalCost, currency: "USD" },
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send usage update", { error })
|
||||
})
|
||||
}
|
||||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import * as ACPError from "./error"
|
||||
import * as ACPService from "./service"
|
||||
|
||||
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
|
||||
return {
|
||||
create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
|
||||
return new Agent(connection, fullConfig)
|
||||
create: (connection: AgentSideConnection) => {
|
||||
return new Agent(ACPService.make({ sdk: _sdk, connection }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent implements ACPAgent {
|
||||
private connection: AgentSideConnection
|
||||
private config: ACPConfig
|
||||
private sdk: OpencodeClient
|
||||
private sessionManager: ACPSessionManager
|
||||
private eventAbort = new AbortController()
|
||||
private eventStarted = false
|
||||
private shellSnapshots = new Map<string, string>()
|
||||
private toolStarts = new Set<string>()
|
||||
private permissionQueues = new Map<string, Promise<void>>()
|
||||
private permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
constructor(private readonly service: ACPService.Interface) {}
|
||||
|
||||
constructor(connection: AgentSideConnection, config: ACPConfig) {
|
||||
this.connection = connection
|
||||
this.config = config
|
||||
this.sdk = config.sdk
|
||||
this.sessionManager = new ACPSessionManager(this.sdk)
|
||||
this.startEventSubscription()
|
||||
initialize(params: InitializeRequest) {
|
||||
return run(this.service.initialize(params))
|
||||
}
|
||||
|
||||
private startEventSubscription() {
|
||||
if (this.eventStarted) return
|
||||
this.eventStarted = true
|
||||
this.runEventSubscription().catch((error) => {
|
||||
if (this.eventAbort.signal.aborted) return
|
||||
log.error("event subscription failed", { error })
|
||||
})
|
||||
authenticate(params: AuthenticateRequest) {
|
||||
return run(this.service.authenticate(params))
|
||||
}
|
||||
|
||||
private async runEventSubscription() {
|
||||
while (true) {
|
||||
if (this.eventAbort.signal.aborted) return
|
||||
const events = await this.sdk.global.event({
|
||||
signal: this.eventAbort.signal,
|
||||
})
|
||||
for await (const event of events.stream) {
|
||||
if (this.eventAbort.signal.aborted) return
|
||||
const payload = event?.payload
|
||||
if (!payload) continue
|
||||
await this.handleEvent(payload as Event).catch((error) => {
|
||||
log.error("failed to handle event", { error, type: payload.type })
|
||||
})
|
||||
}
|
||||
}
|
||||
newSession(params: NewSessionRequest) {
|
||||
return run(this.service.newSession(params))
|
||||
}
|
||||
|
||||
private async handleEvent(event: Event) {
|
||||
switch (event.type) {
|
||||
case "permission.asked": {
|
||||
const permission = event.properties
|
||||
const session = this.sessionManager.tryGet(permission.sessionID)
|
||||
if (!session) return
|
||||
|
||||
const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.then(async () => {
|
||||
const directory = session.cwd
|
||||
|
||||
const res = await this.connection
|
||||
.requestPermission({
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: {
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
status: "pending",
|
||||
title: permission.permission,
|
||||
rawInput: permission.metadata,
|
||||
kind: toToolKind(permission.permission),
|
||||
locations: toLocations(permission.permission, permission.metadata),
|
||||
},
|
||||
options: this.permissionOptions,
|
||||
})
|
||||
.catch(async (error) => {
|
||||
log.error("failed to request permission from ACP", {
|
||||
error,
|
||||
permissionID: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
})
|
||||
await this.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
directory,
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
await this.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
directory,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
|
||||
const metadata = permission.metadata || {}
|
||||
const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
|
||||
const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
|
||||
const content = (await Filesystem.exists(filepath)) ? await Filesystem.readText(filepath) : ""
|
||||
const newContent = getNewContent(content, diff)
|
||||
|
||||
if (newContent) {
|
||||
void this.connection.writeTextFile({
|
||||
sessionId: session.id,
|
||||
path: filepath,
|
||||
content: newContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: res.outcome.optionId as "once" | "always" | "reject",
|
||||
directory,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to handle permission", { error, permissionID: permission.id })
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.permissionQueues.get(permission.sessionID) === next) {
|
||||
this.permissionQueues.delete(permission.sessionID)
|
||||
}
|
||||
})
|
||||
this.permissionQueues.set(permission.sessionID, next)
|
||||
return
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
log.info("message part updated", { event: event.properties })
|
||||
const props = event.properties
|
||||
const part = props.part
|
||||
const session = this.sessionManager.tryGet(part.sessionID)
|
||||
if (!session) return
|
||||
const sessionId = session.id
|
||||
|
||||
if (part.type === "tool") {
|
||||
await this.toolStart(sessionId, part)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
const output = this.shellOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = Hash.fast(output)
|
||||
if (part.tool === ShellID.ToolID) {
|
||||
if (this.shellSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(part.callID, hash)
|
||||
}
|
||||
content.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: output,
|
||||
},
|
||||
})
|
||||
}
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
...(content.length > 0 && { content }),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error })
|
||||
})
|
||||
return
|
||||
|
||||
case "completed": {
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content = completedToolContent(part, kind)
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = decodeTodos(part.state.output)
|
||||
if (Result.isSuccess(parsedTodos)) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.success.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
content: todo.content,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send session update for todo", { error })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.failure })
|
||||
}
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: completedToolRawOutput(part),
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool completed to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
rawInput: part.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.error,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool error to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ACP clients already know the prompt they just submitted, so replaying
|
||||
// live user parts duplicates the message. We still replay user history in
|
||||
// loadSession() and forkSession() via processMessage().
|
||||
if (part.type !== "text" && part.type !== "file") return
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case "message.part.delta": {
|
||||
const props = event.properties
|
||||
const session = this.sessionManager.tryGet(props.sessionID)
|
||||
if (!session) return
|
||||
const sessionId = session.id
|
||||
|
||||
const message = await this.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: props.sessionID,
|
||||
messageID: props.messageID,
|
||||
directory: session.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!message || message.info.role !== "assistant") return
|
||||
|
||||
const part = message.parts.find((p) => p.id === props.partID)
|
||||
if (!part) return
|
||||
|
||||
if (part.type === "text" && props.field === "text" && part.ignored !== true) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
type: "text",
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send text delta to ACP", { error })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && props.field === "text") {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
type: "text",
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send reasoning delta to ACP", { error })
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
loadSession(params: LoadSessionRequest) {
|
||||
return run(this.service.loadSession(params))
|
||||
}
|
||||
|
||||
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||
log.info("initialize", { protocolVersion: params.protocolVersion })
|
||||
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
id: "opencode-login",
|
||||
}
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
authMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: "opencode",
|
||||
args: ["auth", "login"],
|
||||
label: "OpenCode Login",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
promptCapabilities: {
|
||||
embeddedContext: true,
|
||||
image: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
close: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
},
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: {
|
||||
name: "OpenCode",
|
||||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
listSessions(params: ListSessionsRequest) {
|
||||
return run(this.service.listSessions(params))
|
||||
}
|
||||
|
||||
async authenticate(_params: AuthenticateRequest) {
|
||||
throw new Error("Authentication not implemented")
|
||||
resumeSession(params: ResumeSessionRequest) {
|
||||
return run(this.service.resumeSession(params))
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest) {
|
||||
const directory = params.cwd
|
||||
try {
|
||||
const model = await defaultModel(this.config, directory)
|
||||
|
||||
// Store ACP session state
|
||||
const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
|
||||
const sessionId = state.id
|
||||
|
||||
log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
|
||||
|
||||
const load = await this.loadSessionMode({
|
||||
cwd: directory,
|
||||
mcpServers: params.mcpServers,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
configOptions: load.configOptions,
|
||||
models: load.models,
|
||||
modes: load.modes,
|
||||
_meta: load._meta,
|
||||
}
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
closeSession(params: CloseSessionRequest) {
|
||||
return run(this.service.closeSession(params))
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest) {
|
||||
const directory = params.cwd
|
||||
const sessionId = params.sessionId
|
||||
|
||||
try {
|
||||
const model = await defaultModel(this.config, directory)
|
||||
|
||||
// Store ACP session state
|
||||
await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
|
||||
|
||||
const result = await this.loadSessionMode({
|
||||
cwd: directory,
|
||||
mcpServers: params.mcpServers,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
}
|
||||
|
||||
await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
unstable_forkSession(params: ForkSessionRequest) {
|
||||
return run(this.service.forkSession(params))
|
||||
}
|
||||
|
||||
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
try {
|
||||
const cursor = params.cursor ? Number(params.cursor) : undefined
|
||||
const limit = 100
|
||||
|
||||
const sessions = await this.sdk.session
|
||||
.list(
|
||||
{
|
||||
directory: params.cwd ?? undefined,
|
||||
roots: true,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data ?? [])
|
||||
|
||||
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
const filtered = cursor ? sorted.filter((s) => s.time.updated < cursor) : sorted
|
||||
const page = filtered.slice(0, limit)
|
||||
|
||||
const entries: SessionInfo[] = page.map((session) => ({
|
||||
sessionId: session.id,
|
||||
cwd: session.directory,
|
||||
title: session.title,
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
}))
|
||||
|
||||
const last = page[page.length - 1]
|
||||
const next = filtered.length > limit && last ? String(last.time.updated) : undefined
|
||||
|
||||
const response: ListSessionsResponse = {
|
||||
sessions: entries,
|
||||
}
|
||||
if (next) response.nextCursor = next
|
||||
return response
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
|
||||
return run(this.service.setSessionConfigOption(params))
|
||||
}
|
||||
|
||||
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
||||
const directory = params.cwd
|
||||
const mcpServers = params.mcpServers ?? []
|
||||
|
||||
try {
|
||||
const model = await defaultModel(this.config, directory)
|
||||
|
||||
const forked = await this.sdk.session
|
||||
.fork(
|
||||
{
|
||||
sessionID: params.sessionId,
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
|
||||
if (!forked) {
|
||||
throw new Error("Fork session returned no data")
|
||||
}
|
||||
|
||||
const sessionId = forked.id
|
||||
await this.sessionManager.load(sessionId, directory, mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
|
||||
|
||||
const mode = await this.loadSessionMode({
|
||||
cwd: directory,
|
||||
mcpServers,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
log.debug("replay message", msg)
|
||||
await this.processMessage(msg)
|
||||
}
|
||||
|
||||
await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
|
||||
|
||||
return mode
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
setSessionMode(params: SetSessionModeRequest) {
|
||||
return run(this.service.setSessionMode(params))
|
||||
}
|
||||
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const directory = params.cwd
|
||||
const sessionId = params.sessionId
|
||||
const mcpServers = params.mcpServers ?? []
|
||||
|
||||
try {
|
||||
const model = await defaultModel(this.config, directory)
|
||||
await this.sessionManager.load(sessionId, directory, mcpServers, model)
|
||||
|
||||
const messages = await this.loadSessionMessages(directory, sessionId, 20)
|
||||
this.restoreSessionStateFromMessages(sessionId, messages)
|
||||
|
||||
log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
|
||||
|
||||
const result = await this.loadSessionMode({
|
||||
cwd: directory,
|
||||
mcpServers,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
await sendUsageUpdate(this.connection, this.sdk, sessionId, directory)
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
const error = MessageV2.fromError(e, {
|
||||
providerID: ProviderID.make(this.config.defaultModel?.providerID ?? "unknown"),
|
||||
})
|
||||
if (LoadAPIKeyError.isInstance(error)) {
|
||||
throw RequestError.authRequired()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
unstable_setSessionModel(params: SetSessionModelRequest) {
|
||||
return run(this.service.setSessionModel(params))
|
||||
}
|
||||
|
||||
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessionManager.remove(params.sessionId)
|
||||
if (!session) return {}
|
||||
|
||||
await this.sdk.session
|
||||
.abort(
|
||||
{
|
||||
sessionID: params.sessionId,
|
||||
directory: session.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
})
|
||||
|
||||
this.permissionQueues.delete(params.sessionId)
|
||||
log.info("close_session", { sessionId: params.sessionId })
|
||||
return {}
|
||||
prompt(params: PromptRequest) {
|
||||
return run(this.service.prompt(params))
|
||||
}
|
||||
|
||||
private async processMessage(message: SessionMessageResponse) {
|
||||
log.debug("process message", message)
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
const sessionId = message.info.sessionID
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "tool") {
|
||||
await this.toolStart(sessionId, part)
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
break
|
||||
case "running":
|
||||
const output = this.shellOutput(part)
|
||||
const runningContent: ToolCallContent[] = []
|
||||
if (output) {
|
||||
runningContent.push({
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: output,
|
||||
},
|
||||
})
|
||||
}
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "in_progress",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
locations: toLocations(part.tool, part.state.input),
|
||||
rawInput: part.state.input,
|
||||
...(runningContent.length > 0 && { content: runningContent }),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool in_progress to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
const kind = toToolKind(part.tool)
|
||||
const content = completedToolContent(part, kind)
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = decodeTodos(part.state.output)
|
||||
if (Result.isSuccess(parsedTodos)) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.success.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
content: todo.content,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send session update for todo", { error: err })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.failure })
|
||||
}
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawInput: part.state.input,
|
||||
rawOutput: completedToolRawOutput(part),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool completed to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
this.toolStarts.delete(part.callID)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "failed",
|
||||
kind: toToolKind(part.tool),
|
||||
title: part.tool,
|
||||
rawInput: part.state.input,
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.error,
|
||||
},
|
||||
},
|
||||
],
|
||||
rawOutput: {
|
||||
error: part.state.error,
|
||||
metadata: part.state.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send tool error to ACP", { error: err })
|
||||
})
|
||||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
if (part.text) {
|
||||
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
|
||||
messageId: message.info.id,
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
...(audience && { annotations: { audience } }),
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send text to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
} else if (part.type === "file") {
|
||||
// Replay file attachments as appropriate ACP content blocks.
|
||||
// OpenCode stores files internally as { type: "file", url, filename, mime }.
|
||||
// We convert these back to ACP blocks based on the URL scheme and MIME type:
|
||||
// - file:// URLs → resource_link
|
||||
// - data: URLs with image/* → image block
|
||||
// - data: URLs with text/* or application/json → resource with text
|
||||
// - data: URLs with other types → resource with blob
|
||||
const url = part.url
|
||||
const filename = part.filename ?? "file"
|
||||
const mime = part.mime || "application/octet-stream"
|
||||
const messageChunk = message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk"
|
||||
|
||||
if (url.startsWith("file://")) {
|
||||
// Local file reference - send as resource_link
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: messageChunk,
|
||||
messageId: message.info.id,
|
||||
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send resource_link to ACP", { error: err })
|
||||
})
|
||||
} else if (url.startsWith("data:")) {
|
||||
// Embedded content - parse data URL and send as appropriate block type
|
||||
const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
|
||||
const dataMime = base64Match?.[1]
|
||||
const base64Data = base64Match?.[2] ?? ""
|
||||
|
||||
const effectiveMime = dataMime || mime
|
||||
|
||||
if (effectiveMime.startsWith("image/")) {
|
||||
// Image - send as image block
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: messageChunk,
|
||||
messageId: message.info.id,
|
||||
content: {
|
||||
type: "image",
|
||||
mimeType: effectiveMime,
|
||||
data: base64Data,
|
||||
uri: pathToFileURL(filename).href,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send image to ACP", { error: err })
|
||||
})
|
||||
} else {
|
||||
// Non-image: text types get decoded, binary types stay as blob
|
||||
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
|
||||
const fileUri = pathToFileURL(filename).href
|
||||
const resource = isText
|
||||
? {
|
||||
uri: fileUri,
|
||||
mimeType: effectiveMime,
|
||||
text: Buffer.from(base64Data, "base64").toString("utf-8"),
|
||||
}
|
||||
: { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: messageChunk,
|
||||
messageId: message.info.id,
|
||||
content: { type: "resource", resource },
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send resource to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
// URLs that don't match file:// or data: are skipped (unsupported)
|
||||
} else if (part.type === "reasoning") {
|
||||
if (part.text) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: message.info.id,
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send reasoning to ACP", { error: err })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shellOutput(part: ToolPart) {
|
||||
if (part.tool !== ShellID.ToolID) return
|
||||
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||
const output = part.state.metadata["output"]
|
||||
if (typeof output !== "string") return
|
||||
return output
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, part: ToolPart) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: part.callID,
|
||||
title: part.tool,
|
||||
kind: toToolKind(part.tool),
|
||||
status: "pending",
|
||||
locations: [],
|
||||
rawInput: {},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to send tool pending to ACP", { error })
|
||||
})
|
||||
}
|
||||
|
||||
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
|
||||
const agents = await this.config.sdk.app
|
||||
.agents(
|
||||
{
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((resp) => resp.data!)
|
||||
|
||||
return agents
|
||||
.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
.map((agent) => ({
|
||||
id: agent.name,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
}))
|
||||
}
|
||||
|
||||
private async resolveModeState(
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
|
||||
const availableModes = await this.loadAvailableModes(directory)
|
||||
const storedModeId = this.sessionManager.get(sessionId).modeId
|
||||
if (storedModeId && availableModes.some((mode) => mode.id === storedModeId)) {
|
||||
return { availableModes, currentModeId: storedModeId }
|
||||
}
|
||||
|
||||
const currentModeId = await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgent = await ACPRuntime.defaultAgentInfo(directory)
|
||||
const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgent.name)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
return resolvedModeId
|
||||
})()
|
||||
|
||||
return { availableModes, currentModeId }
|
||||
}
|
||||
|
||||
private async loadSessionMode(params: LoadSessionRequest) {
|
||||
const directory = params.cwd
|
||||
const sessionId = params.sessionId
|
||||
const model = this.sessionManager.get(sessionId).model ?? (await defaultModel(this.config, directory))
|
||||
|
||||
const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
|
||||
const entries = sortProvidersByName(providers)
|
||||
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||
const currentVariant = this.sessionManager.getVariant(sessionId)
|
||||
if (currentVariant && !availableVariants.includes(currentVariant)) {
|
||||
this.sessionManager.setVariant(sessionId, undefined)
|
||||
}
|
||||
const availableModels = buildAvailableModels(entries)
|
||||
const modeState = await this.resolveModeState(directory, sessionId)
|
||||
const currentModeId = modeState.currentModeId
|
||||
const modes = currentModeId
|
||||
? {
|
||||
availableModes: modeState.availableModes,
|
||||
currentModeId,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const commands = await this.config.sdk.command
|
||||
.list(
|
||||
{
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((resp) => resp.data!)
|
||||
|
||||
const availableCommands = commands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
}))
|
||||
const names = new Set(availableCommands.map((c) => c.name))
|
||||
if (!names.has("compact"))
|
||||
availableCommands.push({
|
||||
name: "compact",
|
||||
description: "compact the session",
|
||||
})
|
||||
|
||||
const mcpServers: Record<string, ConfigMCP.Info> = {}
|
||||
for (const server of params.mcpServers) {
|
||||
if ("type" in server) {
|
||||
mcpServers[server.name] = {
|
||||
url: server.url,
|
||||
headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
|
||||
acc[name] = value
|
||||
return acc
|
||||
}, {}),
|
||||
type: "remote",
|
||||
}
|
||||
} else {
|
||||
mcpServers[server.name] = {
|
||||
type: "local",
|
||||
command: [server.command, ...server.args],
|
||||
environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
|
||||
acc[name] = value
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(mcpServers).map(async ([key, mcp]) => {
|
||||
await this.sdk.mcp
|
||||
.add(
|
||||
{
|
||||
directory,
|
||||
name: key,
|
||||
config: mcp,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
log.error("failed to add mcp server", { name: key, error })
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
void this.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands,
|
||||
},
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models: {
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
|
||||
availableModels,
|
||||
},
|
||||
modes,
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
|
||||
availableModels,
|
||||
currentVariant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
_meta: buildVariantMeta({
|
||||
model,
|
||||
variant: this.sessionManager.getVariant(sessionId),
|
||||
availableVariants,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(params: SetSessionModelRequest) {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
const providers = await this.sdk.config
|
||||
.providers({ directory: session.cwd }, { throwOnError: true })
|
||||
.then((x) => x.data!.providers)
|
||||
|
||||
const selection = parseModelSelection(params.modelId, providers)
|
||||
this.sessionManager.setModel(session.id, selection.model)
|
||||
this.sessionManager.setVariant(session.id, selection.variant)
|
||||
|
||||
const entries = sortProvidersByName(providers)
|
||||
const availableVariants = modelVariantsFromProviders(entries, selection.model)
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(selection.model, selection.variant, availableVariants, false),
|
||||
availableModels: buildAvailableModels(entries),
|
||||
currentVariant: selection.variant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
_meta: buildVariantMeta({
|
||||
model: selection.model,
|
||||
variant: selection.variant,
|
||||
availableVariants,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||
if (!availableModes.some((mode) => mode.id === params.modeId)) {
|
||||
throw new Error(`Agent not found: ${params.modeId}`)
|
||||
}
|
||||
this.sessionManager.setMode(params.sessionId, params.modeId)
|
||||
}
|
||||
|
||||
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
const providers = await this.sdk.config
|
||||
.providers({ directory: session.cwd }, { throwOnError: true })
|
||||
.then((x) => x.data!.providers)
|
||||
const entries = sortProvidersByName(providers)
|
||||
|
||||
if (params.configId === "model") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
|
||||
const selection = parseModelSelection(params.value, providers)
|
||||
this.sessionManager.setModel(session.id, selection.model)
|
||||
this.sessionManager.setVariant(session.id, selection.variant)
|
||||
} else if (params.configId === "effort") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("effort value must be a string")
|
||||
const current = session.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, current)
|
||||
if (!availableVariants.includes(params.value)) {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Effort not found: ${params.value}` }))
|
||||
}
|
||||
this.sessionManager.setVariant(session.id, params.value)
|
||||
} else if (params.configId === "mode") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
|
||||
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||
if (!availableModes.some((mode) => mode.id === params.value)) {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
|
||||
}
|
||||
this.sessionManager.setMode(session.id, params.value)
|
||||
} else {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
|
||||
}
|
||||
|
||||
const updatedSession = this.sessionManager.get(session.id)
|
||||
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, false)
|
||||
const availableModels = buildAvailableModels(entries)
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId,
|
||||
availableModels,
|
||||
currentVariant: updatedSession.variant,
|
||||
availableVariants,
|
||||
modes,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest) {
|
||||
const sessionID = params.sessionId
|
||||
const session = this.sessionManager.get(sessionID)
|
||||
const directory = session.cwd
|
||||
|
||||
const current = session.model
|
||||
const model = current ?? (await defaultModel(this.config, directory))
|
||||
if (!current) {
|
||||
this.sessionManager.setModel(session.id, model)
|
||||
}
|
||||
const agent = session.modeId ?? (await ACPRuntime.defaultAgentInfo(directory)).name
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
> = []
|
||||
for (const part of params.prompt) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
const audience = part.annotations?.audience
|
||||
const forAssistant = audience?.length === 1 && audience[0] === "assistant"
|
||||
const forUser = audience?.length === 1 && audience[0] === "user"
|
||||
parts.push({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
...(forAssistant && { synthetic: true }),
|
||||
...(forUser && { ignored: true }),
|
||||
})
|
||||
break
|
||||
case "image": {
|
||||
const parsed = parseUri(part.uri ?? "")
|
||||
const filename = parsed.type === "file" ? parsed.filename : "image"
|
||||
if (part.data) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `data:${part.mimeType};base64,${part.data}`,
|
||||
filename,
|
||||
mime: part.mimeType,
|
||||
})
|
||||
} else if (part.uri && part.uri.startsWith("http:")) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: part.uri,
|
||||
filename,
|
||||
mime: part.mimeType,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "resource_link":
|
||||
const parsed = parseUri(part.uri)
|
||||
// Use the name from resource_link if available
|
||||
if (part.name && parsed.type === "file") {
|
||||
parsed.filename = part.name
|
||||
}
|
||||
parts.push(parsed)
|
||||
|
||||
break
|
||||
|
||||
case "resource": {
|
||||
const resource = part.resource
|
||||
if ("text" in resource && resource.text) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
text: resource.text,
|
||||
})
|
||||
} else if ("blob" in resource && resource.blob && resource.mimeType) {
|
||||
// Binary resource (PDFs, etc.): store as file part with data URL
|
||||
const parsed = parseUri(resource.uri ?? "")
|
||||
const filename = parsed.type === "file" ? parsed.filename : "file"
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `data:${resource.mimeType};base64,${resource.blob}`,
|
||||
filename,
|
||||
mime: resource.mimeType,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.info("parts", { parts })
|
||||
|
||||
const cmd = (() => {
|
||||
const text = parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const [name, ...rest] = text.slice(1).split(/\s+/)
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
})()
|
||||
|
||||
const buildUsage = (msg: AssistantMessage): Usage => ({
|
||||
totalTokens:
|
||||
msg.tokens.input +
|
||||
msg.tokens.output +
|
||||
msg.tokens.reasoning +
|
||||
(msg.tokens.cache?.read ?? 0) +
|
||||
(msg.tokens.cache?.write ?? 0),
|
||||
inputTokens: msg.tokens.input,
|
||||
outputTokens: msg.tokens.output,
|
||||
thoughtTokens: msg.tokens.reasoning || undefined,
|
||||
cachedReadTokens: msg.tokens.cache?.read || undefined,
|
||||
cachedWriteTokens: msg.tokens.cache?.write || undefined,
|
||||
})
|
||||
|
||||
if (!cmd) {
|
||||
const response = await this.sdk.session.prompt({
|
||||
sessionID,
|
||||
model: {
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
},
|
||||
variant: this.sessionManager.getVariant(sessionID),
|
||||
parts,
|
||||
agent,
|
||||
directory,
|
||||
})
|
||||
const msg = response.data?.info
|
||||
|
||||
await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
|
||||
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
usage: msg ? buildUsage(msg) : undefined,
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
const command = await this.config.sdk.command
|
||||
.list({ directory }, { throwOnError: true })
|
||||
.then((x) => x.data!.find((c) => c.name === cmd.name))
|
||||
if (command) {
|
||||
const response = await this.sdk.session.command({
|
||||
sessionID,
|
||||
command: command.name,
|
||||
arguments: cmd.args,
|
||||
model: model.providerID + "/" + model.modelID,
|
||||
agent,
|
||||
directory,
|
||||
})
|
||||
const msg = response.data?.info
|
||||
|
||||
await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
|
||||
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
usage: msg ? buildUsage(msg) : undefined,
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "compact":
|
||||
await this.config.sdk.session.summarize(
|
||||
{
|
||||
sessionID,
|
||||
directory,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
await sendUsageUpdate(this.connection, this.sdk, sessionID, directory)
|
||||
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
_meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification) {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
await this.config.sdk.session.abort(
|
||||
{
|
||||
sessionID: params.sessionId,
|
||||
directory: session.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
}
|
||||
|
||||
private async loadSessionMessages(directory: string, sessionId: string, limit?: number) {
|
||||
return this.sdk.session
|
||||
.messages(
|
||||
{
|
||||
sessionID: sessionId,
|
||||
directory,
|
||||
limit,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
log.error("unexpected error when fetching message", { error })
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
private restoreSessionStateFromMessages(sessionId: string, messages: SessionMessageResponse[] | undefined) {
|
||||
const lastUser = messages?.findLast((message) => message.info.role === "user")?.info
|
||||
if (lastUser?.role !== "user") return
|
||||
|
||||
this.sessionManager.setModel(sessionId, {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
})
|
||||
this.sessionManager.setVariant(sessionId, lastUser.model.variant)
|
||||
if (lastUser.agent) {
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
cancel(params: CancelNotification) {
|
||||
return run(this.service.cancel(params))
|
||||
}
|
||||
}
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case ShellID.ToolID:
|
||||
return "execute"
|
||||
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
|
||||
case "edit":
|
||||
case "patch":
|
||||
case "write":
|
||||
return "edit"
|
||||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "repo_clone":
|
||||
case "repo_overview":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
return "search"
|
||||
|
||||
case "read":
|
||||
return "read"
|
||||
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
|
||||
switch (tool) {
|
||||
case "read":
|
||||
case "edit":
|
||||
case "write":
|
||||
return input["filePath"] ? [{ path: input["filePath"] }] : []
|
||||
case "glob":
|
||||
case "grep":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "repo_clone":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case "repo_overview":
|
||||
return input["path"] ? [{ path: input["path"] }] : []
|
||||
case ShellID.ToolID:
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] {
|
||||
if (part.state.status !== "completed") return []
|
||||
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
content.push(...imageContents(part.state.attachments ?? []))
|
||||
return content
|
||||
}
|
||||
|
||||
function completedToolRawOutput(part: ToolPart) {
|
||||
if (part.state.status !== "completed") return {}
|
||||
return {
|
||||
output: part.state.output,
|
||||
metadata: part.state.metadata,
|
||||
...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] {
|
||||
return attachments.flatMap((attachment): ToolCallContent[] => {
|
||||
const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
|
||||
const mime = match?.[1] ?? attachment.mime
|
||||
if (!mime.startsWith("image/")) return []
|
||||
const data = match?.[2]
|
||||
if (data === undefined) return []
|
||||
return [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: {
|
||||
type: "image" as const,
|
||||
mimeType: mime,
|
||||
data,
|
||||
},
|
||||
},
|
||||
]
|
||||
function run<A>(effect: Effect.Effect<A, ACPService.Error>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.mapError(ACPError.toRequestError))).catch((defect: unknown) => {
|
||||
if (defect instanceof RequestError) throw defect
|
||||
throw ACPError.toRequestError(ACPError.fromUnknownDefect(defect))
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> {
|
||||
const sdk = config.sdk
|
||||
const configured = config.defaultModel
|
||||
if (configured) return configured
|
||||
|
||||
const directory = cwd ?? process.cwd()
|
||||
|
||||
const specified = await sdk.config
|
||||
.get({ directory }, { throwOnError: true })
|
||||
.then((resp) => {
|
||||
const cfg = resp.data
|
||||
if (!cfg || !cfg.model) return undefined
|
||||
return Provider.parseModel(cfg.model)
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("failed to load user config for default model", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const providers = await sdk.config
|
||||
.providers({ directory }, { throwOnError: true })
|
||||
.then((x) => x.data?.providers ?? [])
|
||||
.catch((error) => {
|
||||
log.error("failed to list providers for default model", { error })
|
||||
return []
|
||||
})
|
||||
|
||||
if (specified && providers.length) {
|
||||
const provider = providers.find((p) => p.id === specified.providerID)
|
||||
if (provider && provider.models[specified.modelID]) return specified
|
||||
}
|
||||
|
||||
if (specified && !providers.length) return specified
|
||||
|
||||
const lastUsed = await lastUsedModel(sdk, directory, providers)
|
||||
if (lastUsed) return lastUsed
|
||||
|
||||
const opencodeProvider = providers.find((p) => p.id === "opencode")
|
||||
if (opencodeProvider) {
|
||||
const [best] = Provider.sort(Object.values(opencodeProvider.models))
|
||||
if (best) {
|
||||
return {
|
||||
providerID: ProviderID.make(best.providerID),
|
||||
modelID: ModelID.make(best.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const models = providers.flatMap((p) => Object.values(p.models))
|
||||
const [best] = Provider.sort(models)
|
||||
if (best) {
|
||||
return {
|
||||
providerID: ProviderID.make(best.providerID),
|
||||
modelID: ModelID.make(best.id),
|
||||
}
|
||||
}
|
||||
|
||||
if (specified) return specified
|
||||
throw new Error("No models available")
|
||||
}
|
||||
|
||||
async function lastUsedModel(
|
||||
sdk: OpencodeClient,
|
||||
directory: string,
|
||||
providers: Array<{ id: string; models: Record<string, unknown> }>,
|
||||
): Promise<{ providerID: ProviderID; modelID: ModelID } | undefined> {
|
||||
const session = await sdk.session
|
||||
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
|
||||
.then((x) => x.data?.[0])
|
||||
.catch((error) => {
|
||||
log.error("failed to list sessions for default model", { error })
|
||||
return undefined
|
||||
})
|
||||
if (!session) return
|
||||
|
||||
const lastUser = await sdk.session
|
||||
.messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
|
||||
.then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
|
||||
.catch((error) => {
|
||||
log.error("failed to load session messages for default model", { error, sessionID: session.id })
|
||||
return undefined
|
||||
})
|
||||
if (lastUser?.role !== "user") return
|
||||
|
||||
const provider = providers.find((entry) => entry.id === lastUser.model.providerID)
|
||||
if (!provider?.models[lastUser.model.modelID]) return
|
||||
return {
|
||||
providerID: ProviderID.make(lastUser.model.providerID),
|
||||
modelID: ModelID.make(lastUser.model.modelID),
|
||||
}
|
||||
}
|
||||
|
||||
function parseUri(
|
||||
uri: string,
|
||||
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
const path = uri.slice(7)
|
||||
const name = path.split("/").pop() || path
|
||||
return {
|
||||
type: "file",
|
||||
url: uri,
|
||||
filename: name,
|
||||
mime: "text/plain",
|
||||
}
|
||||
}
|
||||
if (uri.startsWith("zed://")) {
|
||||
const url = new URL(uri)
|
||||
const path = url.searchParams.get("path")
|
||||
if (path) {
|
||||
const name = path.split("/").pop() || path
|
||||
return {
|
||||
type: "file",
|
||||
url: pathToFileURL(path).href,
|
||||
filename: name,
|
||||
mime: "text/plain",
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "text",
|
||||
text: uri,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
type: "text",
|
||||
text: uri,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
|
||||
const result = applyPatch(fileOriginal, unifiedDiff)
|
||||
if (result === false) {
|
||||
log.error("Failed to apply unified diff (context mismatch)")
|
||||
return undefined
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function sortProvidersByName<T extends { name: string }>(providers: T[]): T[] {
|
||||
return [...providers].sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase()
|
||||
const nameB = b.name.toLowerCase()
|
||||
if (nameA < nameB) return -1
|
||||
if (nameA > nameB) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
function modelVariantsFromProviders(
|
||||
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
): string[] {
|
||||
const provider = providers.find((entry) => entry.id === model.providerID)
|
||||
if (!provider) return []
|
||||
const modelInfo = provider.models[model.modelID]
|
||||
if (!modelInfo?.variants) return []
|
||||
return Object.keys(modelInfo.variants)
|
||||
}
|
||||
|
||||
function buildAvailableModels(
|
||||
providers: Array<{ id: string; name: string; models: Record<string, any> }>,
|
||||
options: { includeVariants?: boolean } = {},
|
||||
): ModelOption[] {
|
||||
const includeVariants = options.includeVariants ?? false
|
||||
return providers.flatMap((provider) => {
|
||||
const unsorted: Array<{ id: string; name: string; variants?: Record<string, any> }> = Object.values(provider.models)
|
||||
const models = Provider.sort(unsorted)
|
||||
return models.flatMap((model) => {
|
||||
const base: ModelOption = {
|
||||
modelId: `${provider.id}/${model.id}`,
|
||||
name: `${provider.name}/${model.name}`,
|
||||
}
|
||||
if (!includeVariants || !model.variants) return [base]
|
||||
const variants = Object.keys(model.variants).filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
|
||||
const variantOptions = variants.map((variant) => ({
|
||||
modelId: `${provider.id}/${model.id}/${variant}`,
|
||||
name: `${provider.name}/${model.name} (${variant})`,
|
||||
}))
|
||||
return [base, ...variantOptions]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatModelIdWithVariant(
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
variant: string | undefined,
|
||||
availableVariants: string[],
|
||||
includeVariant: boolean,
|
||||
) {
|
||||
const base = `${model.providerID}/${model.modelID}`
|
||||
if (!includeVariant || availableVariants.length === 0) return base
|
||||
const selectedVariant =
|
||||
variant && availableVariants.includes(variant)
|
||||
? variant
|
||||
: availableVariants.includes(DEFAULT_VARIANT_VALUE)
|
||||
? DEFAULT_VARIANT_VALUE
|
||||
: availableVariants[0]
|
||||
return `${base}/${selectedVariant}`
|
||||
}
|
||||
|
||||
function buildVariantMeta(input: {
|
||||
model: { providerID: ProviderID; modelID: ModelID }
|
||||
variant?: string
|
||||
availableVariants: string[]
|
||||
}) {
|
||||
return {
|
||||
opencode: {
|
||||
modelId: `${input.model.providerID}/${input.model.modelID}`,
|
||||
variant: input.variant ?? null,
|
||||
availableVariants: input.availableVariants,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseModelSelection(
|
||||
modelId: string,
|
||||
providers: Array<{ id: string; models: Record<string, { variants?: Record<string, any> }> }>,
|
||||
): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } {
|
||||
const parsed = Provider.parseModel(modelId)
|
||||
const provider = providers.find((p) => p.id === parsed.providerID)
|
||||
if (!provider) {
|
||||
return { model: parsed, variant: undefined }
|
||||
}
|
||||
|
||||
// Check if modelID exists directly
|
||||
if (provider.models[parsed.modelID]) {
|
||||
return { model: parsed, variant: undefined }
|
||||
}
|
||||
|
||||
// Try to extract variant from end of modelID (e.g., "claude-sonnet-4/high" -> model: "claude-sonnet-4", variant: "high")
|
||||
const segments = parsed.modelID.split("/")
|
||||
if (segments.length > 1) {
|
||||
const candidateVariant = segments[segments.length - 1]
|
||||
const baseModelId = segments.slice(0, -1).join("/")
|
||||
const baseModelInfo = provider.models[baseModelId]
|
||||
if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) {
|
||||
return {
|
||||
model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) },
|
||||
variant: candidateVariant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { model: parsed, variant: undefined }
|
||||
}
|
||||
|
||||
function buildConfigOptions(input: {
|
||||
currentModelId: string
|
||||
availableModels: ModelOption[]
|
||||
currentVariant?: string
|
||||
availableVariants?: string[]
|
||||
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||
}): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: input.currentModelId,
|
||||
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
|
||||
},
|
||||
]
|
||||
if (input.availableVariants?.length) {
|
||||
options.push({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue:
|
||||
input.currentVariant && input.availableVariants.includes(input.currentVariant)
|
||||
? input.currentVariant
|
||||
: input.availableVariants.includes(DEFAULT_VARIANT_VALUE)
|
||||
? DEFAULT_VARIANT_VALUE
|
||||
: input.availableVariants[0],
|
||||
options: input.availableVariants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
|
||||
})
|
||||
}
|
||||
if (input.modes) {
|
||||
options.push({
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.modes.currentModeId,
|
||||
options: input.modes.availableModes.map((m) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
...(m.description ? { description: m.description } : {}),
|
||||
})),
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export * as ACP from "./agent"
|
||||
|
||||
+10
-10
@@ -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"
|
||||
+4
-4
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user