Ver Fonte

fix(acp-next): add config switch fast paths (#29255)

Shoubhit Dash há 2 meses atrás
pai
commit
a5ea910eac

+ 15 - 0
packages/opencode/src/acp-next/agent.ts

@@ -8,6 +8,9 @@ import {
   type LoadSessionRequest,
   type NewSessionRequest,
   type PromptRequest,
+  type SetSessionConfigOptionRequest,
+  type SetSessionModelRequest,
+  type SetSessionModeRequest,
 } from "@agentclientprotocol/sdk"
 import { Effect } from "effect"
 import type { OpencodeClient } from "@opencode-ai/sdk/v2"
@@ -41,6 +44,18 @@ export class Agent implements ACPAgent {
     return run(this.service.loadSession(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))
   }

+ 121 - 1
packages/opencode/src/acp-next/service.ts

@@ -13,12 +13,18 @@ import {
   type NewSessionResponse,
   type PromptRequest,
   type PromptResponse,
+  type SetSessionConfigOptionRequest,
+  type SetSessionConfigOptionResponse,
+  type SetSessionModelRequest,
+  type SetSessionModelResponse,
+  type SetSessionModeRequest,
+  type SetSessionModeResponse,
 } from "@agentclientprotocol/sdk"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import type { OpencodeClient } from "@opencode-ai/sdk/v2"
 import { Context, Effect, Layer, ManagedRuntime } from "effect"
 import * as ACPNextError from "./error"
-import { buildConfigOptions } from "./config-option"
+import { buildConfigOptions, parseModelSelection } from "./config-option"
 import { Directory } from "./directory"
 import { ACPNextSession } from "./session"
 import { ModelID, ProviderID } from "@/provider/schema"
@@ -34,6 +40,11 @@ export type Interface = {
   readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
   readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
   readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
+  readonly setSessionConfigOption: (
+    input: SetSessionConfigOptionRequest,
+  ) => Effect.Effect<SetSessionConfigOptionResponse, Error>
+  readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Error>
+  readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect<SetSessionModelResponse, Error>
   readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
   readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
 }
@@ -180,11 +191,96 @@ export function make(input: {
     }
   })
 
+  const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* (
+    params: SetSessionConfigOptionRequest,
+  ) {
+    const current = yield* session.get(params.sessionId)
+    const snapshot = yield* directorySnapshot(current.cwd)
+    if (typeof params.value !== "string") {
+      return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
+    }
+
+    if (params.configId === "model") {
+      const selected = yield* parseSelectedModel(snapshot, params.value)
+      const variant = selected.variant ?? selectVariant(snapshot, selected.model)
+      const state = yield* session
+        .setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined)
+        .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
+      return {
+        configOptions: configOptions(snapshot, {
+          model: state.model ?? selected.model,
+          variant: state.variant,
+          modeId: state.modeId,
+        }),
+      }
+    }
+
+    if (params.configId === "effort") {
+      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 })
+      }
+      const state = yield* session.setVariant(params.sessionId, params.value)
+      return {
+        configOptions: configOptions(snapshot, {
+          model: state.model ?? model,
+          variant: state.variant,
+          modeId: state.modeId,
+        }),
+      }
+    }
+
+    if (params.configId === "mode") {
+      if (!snapshot.availableModes.some((mode) => mode.id === params.value)) {
+        return yield* new ACPNextError.InvalidModeError({ mode: params.value })
+      }
+      const state = yield* session.setMode(params.sessionId, params.value)
+      return {
+        configOptions: configOptions(snapshot, {
+          model: state.model ?? selectDefaultModel(snapshot),
+          variant: state.variant,
+          modeId: state.modeId,
+        }),
+      }
+    }
+
+    return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
+  })
+
+  const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
+    const current = yield* session.get(params.sessionId)
+    const snapshot = yield* directorySnapshot(current.cwd)
+    if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
+      return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
+    }
+    yield* session.setMode(params.sessionId, params.modeId)
+    return {}
+  })
+
+  const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
+    const current = yield* session.get(params.sessionId)
+    const snapshot = yield* directorySnapshot(current.cwd)
+    const selected = yield* parseSelectedModel(snapshot, params.modelId)
+    yield* session
+      .setVariant(
+        params.sessionId,
+        Directory.variants(snapshot, selected.model)
+          ? (selected.variant ?? selectVariant(snapshot, selected.model))
+          : undefined,
+      )
+      .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
+    return {}
+  })
+
   return {
     initialize,
     authenticate,
     newSession,
     loadSession,
+    setSessionConfigOption,
+    setSessionMode,
+    setSessionModel,
     prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
       return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
     }),
@@ -371,6 +467,30 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
   })
 }
 
+function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
+  const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
+  const provider = snapshot.providers[ProviderID.make(selected.model.providerID)]
+  const model = provider?.models[ModelID.make(selected.model.modelID)]
+  if (!model) {
+    return Effect.fail(
+      new ACPNextError.InvalidModelError({
+        providerId: selected.model.providerID,
+        modelId,
+      }),
+    )
+  }
+  if (selected.variant && !model.variants?.[selected.variant]) {
+    return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant }))
+  }
+  return Effect.succeed({
+    model: {
+      providerID: provider.id,
+      modelID: model.id,
+    },
+    variant: selected.variant,
+  })
+}
+
 function sendAvailableCommands(
   connection: Pick<AgentSideConnection, "sessionUpdate"> | undefined,
   sessionId: string,

+ 169 - 1
packages/opencode/test/acp-next/service-session.test.ts

@@ -1,5 +1,12 @@
 import { describe, expect, it } from "bun:test"
-import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
+import type {
+  AgentSideConnection,
+  LoadSessionResponse,
+  NewSessionResponse,
+  SessionConfigOption,
+  SessionConfigSelectOption,
+  SetSessionConfigOptionResponse,
+} from "@agentclientprotocol/sdk"
 import type { OpencodeClient } from "@opencode-ai/sdk/v2"
 import { Effect } from "effect"
 import * as ACPNextService from "@/acp-next/service"
@@ -10,6 +17,7 @@ import type { Provider } from "@/provider/provider"
 const providerID = ProviderID.make("test")
 const modelID = ModelID.make("test-model")
 const configuredModelID = ModelID.make("configured-model")
+const secondModelID = ModelID.make("second-model")
 
 const provider: Provider.Info = {
   id: providerID,
@@ -88,6 +96,43 @@ const provider: Provider.Info = {
       headers: {},
       release_date: "2026-01-01",
     },
+    [secondModelID]: {
+      id: secondModelID,
+      providerID,
+      api: {
+        id: secondModelID,
+        url: "https://example.com",
+        npm: "@ai-sdk/openai-compatible",
+      },
+      name: "Second Model",
+      family: "test",
+      capabilities: {
+        temperature: true,
+        reasoning: true,
+        attachment: false,
+        toolcall: true,
+        input: { text: true, audio: false, image: false, video: false, pdf: false },
+        output: { text: true, audio: false, image: false, video: false, pdf: false },
+        interleaved: false,
+      },
+      cost: {
+        input: 0,
+        output: 0,
+        cache: { read: 0, write: 0 },
+      },
+      limit: {
+        context: 128000,
+        output: 4096,
+      },
+      status: "active",
+      options: {},
+      headers: {},
+      release_date: "2026-01-01",
+      variants: {
+        low: { reasoningEffort: "low" },
+        medium: { reasoningEffort: "medium" },
+      },
+    },
   },
 }
 
@@ -359,8 +404,131 @@ describe("ACP next service sessions", () => {
     expect(result.sessionId).toBe("configured-model")
     expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
   })
+
+  it("switches model and returns updated model and effort options", async () => {
+    const { service } = makeService()
+    const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+    const updated = await Effect.runPromise(
+      service.setSessionConfigOption({
+        sessionId: session.sessionId,
+        configId: "model",
+        value: "test/second-model",
+      }),
+    )
+
+    expect(select(updated, "model")?.currentValue).toBe("test/second-model")
+    expect(select(updated, "effort")?.currentValue).toBe("low")
+    expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"])
+  })
+
+  it("switches effort and returns the updated effort current value", async () => {
+    const { service } = makeService()
+    const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+    const updated = await Effect.runPromise(
+      service.setSessionConfigOption({
+        sessionId: session.sessionId,
+        configId: "effort",
+        value: "high",
+      }),
+    )
+
+    expect(select(updated, "effort")?.currentValue).toBe("high")
+  })
+
+  it("switches mode and returns the updated mode current value", async () => {
+    const { service } = makeService()
+    const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+    const updated = await Effect.runPromise(
+      service.setSessionConfigOption({
+        sessionId: session.sessionId,
+        configId: "mode",
+        value: "plan",
+      }),
+    )
+
+    expect(select(updated, "mode")?.currentValue).toBe("plan")
+  })
+
+  it("maps invalid model effort mode and config id to invalid params", async () => {
+    const { service } = makeService()
+    const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+
+    const results = await Promise.all(
+      [
+        { configId: "model", value: "test/missing-model" },
+        { configId: "effort", value: "max" },
+        { configId: "mode", value: "missing-mode" },
+        { configId: "missing", value: "value" },
+      ].map((input) =>
+        Effect.runPromise(
+          service
+            .setSessionConfigOption({ sessionId: session.sessionId, ...input })
+            .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+        ),
+      ),
+    )
+    expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
+  })
+
+  it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
+    let providersCalls = 0
+    let commandCalls = 0
+    const sdk = {
+      config: {
+        providers: () => {
+          providersCalls++
+          return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
+        },
+        get: () => Promise.resolve({ data: {} }),
+      },
+      app: {
+        agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
+        skills: () => Promise.resolve({ data: [] }),
+      },
+      command: {
+        list: () => {
+          commandCalls++
+          return Promise.resolve({ data: [] })
+        },
+      },
+      session: {
+        create: () => Promise.resolve({ data: { id: "ses_fast" } }),
+        list: () => Promise.resolve({ data: [] }),
+      },
+      mcp: {
+        add: () => Promise.resolve({ data: {} }),
+      },
+    } as unknown as OpencodeClient
+    const service = ACPNextService.make({ sdk })
+    const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+
+    expect(providersCalls).toBe(1)
+    expect(commandCalls).toBe(1)
+
+    await Effect.runPromise(
+      service.setSessionConfigOption({
+        sessionId: session.sessionId,
+        configId: "effort",
+        value: "high",
+      }),
+    )
+
+    expect(providersCalls).toBe(1)
+    expect(commandCalls).toBe(1)
+  })
 })
 
 function categories(result: NewSessionResponse | LoadSessionResponse) {
   return result.configOptions?.map((option) => option.category) ?? []
 }
+
+function select(result: SetSessionConfigOptionResponse, id: string) {
+  return result.configOptions.find(
+    (option): option is Extract<SessionConfigOption, { type: "select" }> =>
+      option.id === id && option.type === "select",
+  )
+}
+
+function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }> | undefined) {
+  return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? []
+}

+ 93 - 1
packages/opencode/test/cli/acp-next/acp-next-process.test.ts

@@ -5,11 +5,12 @@ import type {
   LoadSessionResponse,
   NewSessionResponse,
   SessionNotification,
+  SetSessionConfigOptionResponse,
 } from "@agentclientprotocol/sdk"
 import { Effect } from "effect"
 import { cliIt } from "../../lib/cli-process"
 import { testProviderConfig } from "../../lib/test-provider"
-import { createAcpClient, expectOk, selectConfigOption } from "../acp/acp-test-client"
+import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client"
 
 describe("opencode acp-next (subprocess)", () => {
   cliIt.live(
@@ -98,6 +99,66 @@ describe("opencode acp-next (subprocess)", () => {
     60_000,
   )
 
+  cliIt.live(
+    "switches model through config options behind OPENCODE_ACP_NEXT",
+    ({ home, llm, opencode }) =>
+      Effect.gen(function* () {
+        const acp = createAcpClient(
+          yield* opencode.acp({
+            env: {
+              OPENCODE_ACP_NEXT: "1",
+              OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
+            },
+          }),
+        )
+        yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
+        const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
+
+        const updated = expectOk(
+          yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
+            sessionId: session.sessionId,
+            configId: "model",
+            value: "test/second-model",
+          }),
+        )
+
+        expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model")
+      }),
+    60_000,
+  )
+
+  cliIt.live(
+    "switches effort through config options behind OPENCODE_ACP_NEXT",
+    ({ home, llm, opencode }) =>
+      Effect.gen(function* () {
+        const acp = createAcpClient(
+          yield* opencode.acp({
+            env: {
+              OPENCODE_ACP_NEXT: "1",
+              OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
+            },
+          }),
+        )
+        yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
+        const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
+        const effort = selectConfigOption(session.configOptions, "effort")
+        expect(effort?.category).toBe("thought_level")
+        const nextEffort = effort ? firstAlternateValue(effort) : undefined
+        expect(nextEffort).toBe("high")
+
+        const updated = expectOk(
+          yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
+            sessionId: session.sessionId,
+            configId: "effort",
+            value: nextEffort,
+          }),
+        )
+
+        expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
+      }),
+    60_000,
+  )
+
   cliIt.live(
     "exits cleanly when flagged stdin is closed",
     ({ opencode }) =>
@@ -134,3 +195,34 @@ function errorCode(error: unknown) {
   if (!("code" in error)) return undefined
   return typeof error.code === "number" ? error.code : undefined
 }
+
+function verifierConfig(llmUrl: string) {
+  const config = testProviderConfig(llmUrl)
+  return {
+    ...config,
+    model: "test/test-model",
+    provider: {
+      test: {
+        ...config.provider.test,
+        models: {
+          "test-model": {
+            ...config.provider.test.models["test-model"],
+            variants: {
+              low: {},
+              high: {},
+            },
+          },
+          "second-model": {
+            ...config.provider.test.models["test-model"],
+            id: "second-model",
+            name: "Second Test Model",
+            variants: {
+              medium: {},
+              max: {},
+            },
+          },
+        },
+      },
+    },
+  }
+}