فهرست منبع

feat: Update ACP support, modernize and fix misc issues (#25663)

Aiden Cline 3 ماه پیش
والد
کامیت
b2e3dc87ea

+ 2 - 2
bun.lock

@@ -335,7 +335,7 @@
       "dependencies": {
         "@actions/core": "1.11.1",
         "@actions/github": "6.0.1",
-        "@agentclientprotocol/sdk": "0.16.1",
+        "@agentclientprotocol/sdk": "0.21.0",
         "@ai-sdk/alibaba": "1.0.17",
         "@ai-sdk/amazon-bedrock": "4.0.96",
         "@ai-sdk/anthropic": "3.0.71",
@@ -728,7 +728,7 @@
 
     "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
 
-    "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
+    "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
 
     "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
 

+ 1 - 1
packages/opencode/package.json

@@ -80,7 +80,7 @@
   "dependencies": {
     "@actions/core": "1.11.1",
     "@actions/github": "6.0.1",
-    "@agentclientprotocol/sdk": "0.16.1",
+    "@agentclientprotocol/sdk": "0.21.0",
     "@ai-sdk/alibaba": "1.0.17",
     "@ai-sdk/amazon-bedrock": "4.0.96",
     "@ai-sdk/anthropic": "3.0.71",

+ 185 - 70
packages/opencode/src/acp/agent.ts

@@ -5,6 +5,8 @@ import {
   type AuthenticateRequest,
   type AuthMethod,
   type CancelNotification,
+  type CloseSessionRequest,
+  type CloseSessionResponse,
   type ForkSessionRequest,
   type ForkSessionResponse,
   type InitializeRequest,
@@ -565,6 +567,7 @@ export class Agent implements ACPAgent {
           image: true,
         },
         sessionCapabilities: {
+          close: {},
           fork: {},
           list: {},
           resume: {},
@@ -627,6 +630,9 @@ export class Agent implements ACPAgent {
       // 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({
@@ -635,39 +641,6 @@ export class Agent implements ACPAgent {
         sessionId,
       })
 
-      // Replay session history
-      const messages = await this.sdk.session
-        .messages(
-          {
-            sessionID: sessionId,
-            directory,
-          },
-          { throwOnError: true },
-        )
-        .then((x) => x.data)
-        .catch((err) => {
-          log.error("unexpected error when fetching message", { error: err })
-          return undefined
-        })
-
-      const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
-      if (lastUser?.role === "user") {
-        result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
-        this.sessionManager.setModel(sessionId, {
-          providerID: ProviderID.make(lastUser.model.providerID),
-          modelID: ModelID.make(lastUser.model.modelID),
-        })
-        if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) {
-          result.modes.currentModeId = lastUser.agent
-          this.sessionManager.setMode(sessionId, lastUser.agent)
-        }
-        result.configOptions = buildConfigOptions({
-          currentModelId: result.models.currentModelId,
-          availableModels: result.models.availableModels,
-          modes: result.modes,
-        })
-      }
-
       for (const msg of messages ?? []) {
         log.debug("replay message", msg)
         await this.processMessage(msg)
@@ -756,6 +729,9 @@ export class Agent implements ACPAgent {
       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({
@@ -764,20 +740,6 @@ export class Agent implements ACPAgent {
         sessionId,
       })
 
-      const messages = await this.sdk.session
-        .messages(
-          {
-            sessionID: sessionId,
-            directory,
-          },
-          { throwOnError: true },
-        )
-        .then((x) => x.data)
-        .catch((err) => {
-          log.error("unexpected error when fetching message", { error: err })
-          return undefined
-        })
-
       for (const msg of messages ?? []) {
         log.debug("replay message", msg)
         await this.processMessage(msg)
@@ -797,7 +759,7 @@ export class Agent implements ACPAgent {
     }
   }
 
-  async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
+  async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
     const directory = params.cwd
     const sessionId = params.sessionId
     const mcpServers = params.mcpServers ?? []
@@ -806,6 +768,9 @@ export class Agent implements ACPAgent {
       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({
@@ -828,6 +793,27 @@ export class Agent implements ACPAgent {
     }
   }
 
+  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
@@ -1159,23 +1145,26 @@ export class Agent implements ACPAgent {
     sessionId: string,
   ): Promise<{ availableModes: ModeOption[]; currentModeId?: string }> {
     const availableModes = await this.loadAvailableModes(directory)
-    const currentModeId =
-      this.sessionManager.get(sessionId).modeId ||
-      (await (async () => {
-        if (!availableModes.length) return undefined
-        const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
-        const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
-        this.sessionManager.setMode(sessionId, resolvedModeId)
-        return resolvedModeId
-      })())
+    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 defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
+      const resolvedModeId = availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
+      this.sessionManager.setMode(sessionId, resolvedModeId)
+      return resolvedModeId
+    })()
 
     return { availableModes, currentModeId }
   }
 
   private async loadSessionMode(params: LoadSessionRequest) {
     const directory = params.cwd
-    const model = await defaultModel(this.config, directory)
     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)
@@ -1184,7 +1173,7 @@ export class Agent implements ACPAgent {
     if (currentVariant && !availableVariants.includes(currentVariant)) {
       this.sessionManager.setVariant(sessionId, undefined)
     }
-    const availableModels = buildAvailableModels(entries, { includeVariants: true })
+    const availableModels = buildAvailableModels(entries)
     const modeState = await this.resolveModeState(directory, sessionId)
     const currentModeId = modeState.currentModeId
     const modes = currentModeId
@@ -1267,13 +1256,15 @@ export class Agent implements ACPAgent {
     return {
       sessionId,
       models: {
-        currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
+        currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
         availableModels,
       },
       modes,
       configOptions: buildConfigOptions({
-        currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
+        currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, false),
         availableModels,
+        currentVariant,
+        availableVariants,
         modes,
       }),
       _meta: buildVariantMeta({
@@ -1296,6 +1287,24 @@ export class Agent implements ACPAgent {
 
     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({
@@ -1327,6 +1336,14 @@ export class Agent implements ACPAgent {
       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)
@@ -1341,15 +1358,21 @@ export class Agent implements ACPAgent {
     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, true)
-    const availableModels = buildAvailableModels(entries, { includeVariants: true })
+    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, modes }),
+      configOptions: buildConfigOptions({
+        currentModelId,
+        availableModels,
+        currentVariant: updatedSession.variant,
+        availableVariants,
+        modes,
+      }),
     }
   }
 
@@ -1546,6 +1569,37 @@ export class Agent implements ACPAgent {
       { 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 {
@@ -1629,11 +1683,11 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
 
   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) {
-    if (opencodeProvider.models["big-pickle"]) {
-      return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
-    }
     const [best] = Provider.sort(Object.values(opencodeProvider.models))
     if (best) {
       return {
@@ -1653,8 +1707,38 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
   }
 
   if (specified) return specified
+  throw new Error("No models available")
+}
 
-  return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") }
+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(
@@ -1757,8 +1841,14 @@ function formatModelIdWithVariant(
   includeVariant: boolean,
 ) {
   const base = `${model.providerID}/${model.modelID}`
-  if (!includeVariant || !variant || !availableVariants.includes(variant)) return base
-  return `${base}/${variant}`
+  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: {
@@ -1810,6 +1900,8 @@ function parseModelSelection(
 function buildConfigOptions(input: {
   currentModelId: string
   availableModels: ModelOption[]
+  currentVariant?: string
+  availableVariants?: string[]
   modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
 }): SessionConfigOption[] {
   const options: SessionConfigOption[] = [
@@ -1822,6 +1914,22 @@ function buildConfigOptions(input: {
       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",
@@ -1839,4 +1947,11 @@ function buildConfigOptions(input: {
   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"

+ 6 - 0
packages/opencode/src/acp/session.ts

@@ -113,4 +113,10 @@ export class ACPSessionManager {
     this.sessions.set(sessionId, session)
     return session
   }
+
+  remove(sessionId: string): ACPSessionState | undefined {
+    const session = this.sessions.get(sessionId)
+    this.sessions.delete(sessionId)
+    return session
+  }
 }

+ 3 - 2
packages/opencode/test/acp/agent-interface.test.ts

@@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => {
     "loadSession",
     "setSessionMode",
     "authenticate",
-    // Unstable - SDK checks these with unstable_ prefix
+    // Capability-gated methods checked by the SDK router
     "listSessions",
+    "resumeSession",
+    "closeSession",
     "unstable_forkSession",
-    "unstable_resumeSession",
     "unstable_setSessionModel",
   ]