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