Просмотр исходного кода

feat(acp): implement acp-next session slice (#29250)

Shoubhit Dash 2 месяцев назад
Родитель
Сommit
0373ea9128

+ 7 - 2
packages/opencode/src/acp-next/agent.ts

@@ -5,6 +5,7 @@ import {
   type AuthenticateRequest,
   type CancelNotification,
   type InitializeRequest,
+  type LoadSessionRequest,
   type NewSessionRequest,
   type PromptRequest,
 } from "@agentclientprotocol/sdk"
@@ -15,8 +16,8 @@ import * as ACPNextService from "./service"
 
 export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
   return {
-    create: (_connection: AgentSideConnection) => {
-      return new Agent(ACPNextService.make())
+    create: (connection: AgentSideConnection) => {
+      return new Agent(ACPNextService.make({ sdk: _sdk, connection }))
     },
   }
 }
@@ -36,6 +37,10 @@ export class Agent implements ACPAgent {
     return run(this.service.newSession(params))
   }
 
+  loadSession(params: LoadSessionRequest) {
+    return run(this.service.loadSession(params))
+  }
+
   prompt(params: PromptRequest) {
     return run(this.service.prompt(params))
   }

+ 408 - 4
packages/opencode/src/acp-next/service.ts

@@ -1,18 +1,28 @@
 import {
+  type AgentSideConnection,
   type AuthenticateRequest,
   type AuthenticateResponse,
   type AuthMethod,
   type CancelNotification,
   type InitializeRequest,
   type InitializeResponse,
+  type LoadSessionRequest,
+  type LoadSessionResponse,
+  type McpServer,
   type NewSessionRequest,
   type NewSessionResponse,
   type PromptRequest,
   type PromptResponse,
 } from "@agentclientprotocol/sdk"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2"
 import { Context, Effect } from "effect"
 import * as ACPNextError from "./error"
+import { buildConfigOptions } from "./config-option"
+import { Directory } from "./directory"
+import { ModelID, ProviderID } from "@/provider/schema"
+import { Provider } from "@/provider/provider"
+import type { Command } from "@/command"
 
 export const AuthMethodID = "opencode-login"
 
@@ -22,13 +32,18 @@ export type Interface = {
   readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
   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 prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
   readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
 
-export function make(): Interface {
+export function make(input: { sdk: OpencodeClient; connection?: Pick<AgentSideConnection, "sessionUpdate"> }): Interface {
+  const sessions = new Map<string, SessionState>()
+  const directories = new Map<string, Promise<Directory.Snapshot>>()
+  const registeredMcp = new Map<string, Set<string>>()
+
   const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
     const authMethod: AuthMethod = {
       description: "Run `opencode auth login` in the terminal",
@@ -49,6 +64,7 @@ export function make(): Interface {
     return {
       protocolVersion: 1,
       agentCapabilities: {
+        loadSession: true,
         mcpCapabilities: {
           http: true,
           sse: true,
@@ -73,12 +89,96 @@ export function make(): Interface {
     return {}
   })
 
+  const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (directory: string) {
+    const cached = directories.get(directory)
+    if (cached) return yield* request(() => cached, "directory")
+
+    const promise = loadDirectorySnapshot(input.sdk, directory).catch((error: unknown) => {
+      directories.delete(directory)
+      throw fromUnknownError(error, "directory")
+    })
+    directories.set(directory, promise)
+    return yield* request(() => promise, "directory")
+  })
+
+  const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
+    const snapshot = yield* directorySnapshot(params.cwd)
+    const selected = selectDefaultModel(snapshot)
+    const variant = selectVariant(snapshot, selected)
+    const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
+    const created = yield* request(
+      () =>
+        input.sdk.session.create(
+          {
+            directory: params.cwd,
+            ...(modeId ? { agent: modeId } : {}),
+            model: {
+              providerID: selected.providerID,
+              id: selected.modelID,
+              ...(variant ? { variant } : {}),
+            },
+          },
+          { throwOnError: true },
+        ),
+      "session",
+    )
+    const state = storeSession(sessions, {
+      id: created.id,
+      cwd: params.cwd,
+      mcpServers: params.mcpServers,
+      model: selected,
+      variant,
+      modeId,
+    })
+
+    yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, params.mcpServers)
+    yield* sendAvailableCommands(input.connection, state.id, snapshot)
+
+    return {
+      sessionId: state.id,
+      configOptions: configOptions(snapshot, state),
+    }
+  })
+
+  const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
+    const snapshot = yield* directorySnapshot(params.cwd)
+    yield* request(
+      () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
+      "session",
+    )
+    const messages = yield* request(
+      () =>
+        input.sdk.session.messages(
+          { directory: params.cwd, sessionID: params.sessionId, limit: 100 },
+          { throwOnError: true },
+        ),
+      "session",
+    )
+    const restored = restoreFromMessages(messages.map((item) => item.info))
+    const model = restored.model ?? selectDefaultModel(snapshot)
+    const state = storeSession(sessions, {
+      id: params.sessionId,
+      cwd: params.cwd,
+      mcpServers: params.mcpServers,
+      model,
+      variant: restored.variant ?? selectVariant(snapshot, model),
+      modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
+    })
+
+    yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, params.mcpServers)
+    yield* sendAvailableCommands(input.connection, state.id, snapshot)
+
+    return {
+      sessionId: state.id,
+      configOptions: configOptions(snapshot, state),
+    }
+  })
+
   return {
     initialize,
     authenticate,
-    newSession: Effect.fn("ACPNext.newSession")(function* (_input: NewSessionRequest) {
-      return yield* new ACPNextError.UnsupportedOperationError({ method: "session/new" })
-    }),
+    newSession,
+    loadSession,
     prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
       return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
     }),
@@ -87,3 +187,307 @@ export function make(): Interface {
     }),
   }
 }
+
+type SessionState = {
+  readonly id: string
+  readonly cwd: string
+  readonly mcpServers: readonly McpServer[]
+  readonly model: Directory.DefaultModel
+  readonly variant?: string
+  readonly modeId?: string
+}
+
+type SdkResponse<T> = {
+  readonly data?: T
+  readonly error?: unknown
+}
+
+type MessageInfo = {
+  readonly role?: string
+  readonly model?: {
+    readonly providerID?: string
+    readonly modelID?: string
+    readonly variant?: string
+  }
+  readonly providerID?: string
+  readonly modelID?: string
+  readonly variant?: string
+  readonly mode?: string
+  readonly agent?: string
+}
+
+function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
+  return Effect.tryPromise({
+    try: async () => {
+      const result = await fn()
+      if (isSdkResponse<T>(result)) {
+        if (result.error) throw result.error
+        if (result.data !== undefined) return result.data
+      }
+      return result as T
+    },
+    catch: (error) => fromUnknownError(error, service),
+  })
+}
+
+async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
+  const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
+    sdk.config.providers({ directory }, { throwOnError: true }),
+    sdk.app.agents({ directory }, { throwOnError: true }),
+    sdk.command.list({ directory }, { throwOnError: true }),
+    sdk.app.skills({ directory }, { throwOnError: true }),
+  ])
+  const providersData = providersResponse.data!
+  const agents = agentsResponse.data!
+  const commandsData = commandsResponse.data!
+  const skills = skillsResponse.data!
+  const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
+    ProviderID,
+    Provider.Info
+  >
+  const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
+  const modes = agents
+    .filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
+    .map((agent) => ({
+      id: agent.name,
+      name: agent.name,
+      ...(agent.description ? { description: agent.description } : {}),
+    }))
+  const commands = [
+    ...commandsData,
+    ...skills
+      .filter((skill) => !commandsData.some((command) => command.name === skill.name))
+      .map((skill) => ({
+        name: skill.name,
+        description: skill.description,
+        source: "skill" as const,
+        template: skill.content,
+        hints: [],
+      })),
+  ] as Command.Info[]
+
+  return Directory.build({
+    directory,
+    providers,
+    modes,
+    defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
+    commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
+    ...(defaultModel ? { defaultModel } : {}),
+  })
+}
+
+async function defaultModelFromSdk(
+  sdk: OpencodeClient,
+  directory: string,
+  providers: Record<ProviderID, Provider.Info>,
+): Promise<Directory.DefaultModel | undefined> {
+  const configured = await sdk.config
+    .get({ directory }, { throwOnError: true })
+    .then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
+    .catch(() => undefined)
+  if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
+
+  const lastUsed = await lastUsedModel(sdk, directory, providers)
+  if (lastUsed) return lastUsed
+
+  const opencodeProvider = providers[ProviderID.make("opencode")]
+  const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
+  if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
+
+  const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0]
+  if (best) return { providerID: best.providerID, modelID: best.id }
+  if (configured) return configured
+}
+
+async function lastUsedModel(
+  sdk: OpencodeClient,
+  directory: string,
+  providers: Record<ProviderID, Provider.Info>,
+): Promise<Directory.DefaultModel | undefined> {
+  const session = await sdk.session
+    .list({ directory, roots: true, limit: 1 }, { throwOnError: true })
+    .then((response) => response.data?.[0])
+    .catch(() => undefined)
+  if (!session) return
+
+  const lastUser = await sdk.session
+    .messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
+    .then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
+    .catch(() => undefined)
+  if (lastUser?.role !== "user") return
+  if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
+
+  return {
+    providerID: ProviderID.make(lastUser.model.providerID),
+    modelID: ModelID.make(lastUser.model.modelID),
+  }
+}
+
+function selectDefaultModel(snapshot: Directory.Snapshot) {
+  if (snapshot.defaultModel) return snapshot.defaultModel
+  const model = snapshot.modelOptions[0]
+  if (model) return { providerID: model.providerID, modelID: model.modelID }
+  return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
+}
+
+function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
+  const variants = Directory.variants(snapshot, model)
+  if (!variants) return
+  if (variants.default) return "default"
+  return Object.keys(variants)[0]
+}
+
+function storeSession(sessions: Map<string, SessionState>, state: SessionState) {
+  sessions.set(state.id, {
+    ...state,
+    mcpServers: [...state.mcpServers],
+  })
+  return sessions.get(state.id)!
+}
+
+function configOptions(snapshot: Directory.Snapshot, session: SessionState) {
+  return buildConfigOptions({
+    providers: Object.values(snapshot.providers),
+    currentModel: session.model,
+    currentVariant: session.variant,
+    modes: snapshot.availableModes,
+    currentModeId: session.modeId,
+  })
+}
+
+function sendAvailableCommands(
+  connection: Pick<AgentSideConnection, "sessionUpdate"> | undefined,
+  sessionId: string,
+  snapshot: Directory.Snapshot,
+) {
+  if (!connection) return Effect.void
+  return Effect.sync(() => {
+    setTimeout(() => {
+      void connection.sessionUpdate({
+        sessionId,
+        update: {
+          sessionUpdate: "available_commands_update",
+          availableCommands: snapshot.availableCommands.map((command) => ({
+            name: command.name,
+            description: command.description ?? "",
+          })),
+        },
+      })
+    }, 0)
+  })
+}
+
+function registerMcpServers(
+  sdk: OpencodeClient,
+  registered: Map<string, Set<string>>,
+  directory: string,
+  servers: readonly McpServer[],
+) {
+  const current = registered.get(directory) ?? new Set<string>()
+  registered.set(directory, current)
+
+  return Effect.all(
+    Array.from(new Map(servers.map((server) => [server.name, server])).values())
+      .filter((server) => !current.has(server.name))
+      .map((server) =>
+        request(
+          () =>
+            sdk.mcp.add(
+              {
+                directory,
+                name: server.name,
+                config: mcpConfig(server),
+              },
+              { throwOnError: true },
+            ),
+          "mcp",
+        ).pipe(Effect.tap(() => Effect.sync(() => current.add(server.name))), Effect.ignore),
+      ),
+    { concurrency: "unbounded" },
+  ).pipe(Effect.asVoid)
+}
+
+function mcpConfig(server: McpServer) {
+  if ("type" in server) {
+    return {
+      type: "remote" as const,
+      url: server.url,
+      headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
+    }
+  }
+  return {
+    type: "local" as const,
+    command: [server.command, ...server.args],
+    environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
+  }
+}
+
+function restoreFromMessages(messages: readonly MessageInfo[]) {
+  const user = messages.findLast(
+    (message) => message.role === "user" && message.model?.providerID && message.model.modelID,
+  )
+  if (user?.model?.providerID && user.model.modelID) {
+    return {
+      model: { providerID: user.model.providerID as ProviderID, modelID: user.model.modelID as ModelID },
+      variant: user.model.variant,
+      modeId: user.agent,
+    }
+  }
+
+  const assistant = messages.findLast((message) => message.providerID && message.modelID)
+  if (assistant?.providerID && assistant.modelID) {
+    return {
+      model: { providerID: assistant.providerID as ProviderID, modelID: assistant.modelID as ModelID },
+      variant: assistant.variant,
+      modeId: assistant.mode ?? assistant.agent,
+    }
+  }
+
+  return {}
+}
+
+function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
+  return typeof value === "object" && value !== null && ("data" in value || "error" in value)
+}
+
+function fromUnknownError(error: unknown, service?: string): Error {
+  if (isACPNextError(error)) return error
+  if (isAuthRequired(error)) {
+    return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) })
+  }
+  return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
+}
+
+function isACPNextError(error: unknown): error is Error {
+  return (
+    typeof error === "object" &&
+    error !== null &&
+    "_tag" in error &&
+    typeof error._tag === "string" &&
+    error._tag.startsWith("ACPNext")
+  )
+}
+
+function isAuthRequired(value: unknown): boolean {
+  if (typeof value !== "object" || value === null) return false
+  if (value instanceof Error && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
+  if (
+    value instanceof Error &&
+    (value.message.includes("ProviderAuthError") || value.message.includes("LoadAPIKeyError"))
+  ) {
+    return true
+  }
+  if ("name" in value && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
+  if ("_tag" in value && (value._tag === "ProviderAuthError" || value._tag === "LoadAPIKeyError")) return true
+  if ("error" in value && isAuthRequired(value.error)) return true
+  if ("data" in value && isAuthRequired(value.data)) return true
+  return false
+}
+
+function findProviderID(value: unknown): string | undefined {
+  if (typeof value !== "object" || value === null) return
+  if ("providerID" in value && typeof value.providerID === "string") return value.providerID
+  if ("providerId" in value && typeof value.providerId === "string") return value.providerId
+  if ("data" in value) return findProviderID(value.data)
+  if ("error" in value) return findProviderID(value.error)
+}

+ 318 - 0
packages/opencode/test/acp-next/service-session.test.ts

@@ -0,0 +1,318 @@
+import { describe, expect, it } from "bun:test"
+import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
+import type { OpencodeClient } from "@opencode-ai/sdk/v2"
+import { Effect } from "effect"
+import * as ACPNextService from "@/acp-next/service"
+import * as ACPNextError from "@/acp-next/error"
+import { ModelID, ProviderID } from "@/provider/schema"
+import type { Provider } from "@/provider/provider"
+
+const providerID = ProviderID.make("test")
+const modelID = ModelID.make("test-model")
+const configuredModelID = ModelID.make("configured-model")
+
+const provider: Provider.Info = {
+  id: providerID,
+  name: "Test",
+  source: "config",
+  env: [],
+  options: {},
+  models: {
+    [modelID]: {
+      id: modelID,
+      providerID,
+      api: {
+        id: modelID,
+        url: "https://example.com",
+        npm: "@ai-sdk/openai-compatible",
+      },
+      name: "Test 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: {
+        default: {},
+        high: { reasoningEffort: "high" },
+      },
+    },
+    [configuredModelID]: {
+      id: configuredModelID,
+      providerID,
+      api: {
+        id: configuredModelID,
+        url: "https://example.com",
+        npm: "@ai-sdk/openai-compatible",
+      },
+      name: "Configured Model",
+      family: "test",
+      capabilities: {
+        temperature: true,
+        reasoning: false,
+        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",
+    },
+  },
+}
+
+describe("ACP next service sessions", () => {
+  const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
+    const updates: unknown[] = []
+    const mcpAdds: string[] = []
+    const sdk = {
+      config: {
+        providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
+        get: () => Promise.resolve({ data: {} }),
+      },
+      app: {
+        agents: () =>
+          Promise.resolve({
+            data: [
+              { name: "build", mode: "primary", permission: [], options: {} },
+              { name: "plan", mode: "primary", description: "Plan first", permission: [], options: {} },
+              { name: "hidden", mode: "primary", hidden: true, permission: [], options: {} },
+            ],
+          }),
+        skills: () =>
+          Promise.resolve({
+            data: [{ name: "review-skill", description: "Review", location: "/skills/review", content: "review" }],
+          }),
+      },
+      command: {
+        list: () =>
+          Promise.resolve({
+            data: [{ name: "init", description: "Initialize", source: "command", template: "init", hints: [] }],
+          }),
+      },
+      session: {
+        create: () => Promise.resolve({ data: { id: "ses_new" } }),
+        get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
+        list: () => Promise.resolve({ data: [] }),
+        messages: () => Promise.resolve({ data: messages }),
+      },
+      mcp: {
+        add: (input: { name?: string }) => {
+          if (input.name) mcpAdds.push(input.name)
+          return Promise.resolve({ data: {} })
+        },
+      },
+    } as unknown as OpencodeClient
+    const connection = {
+      sessionUpdate: (update: unknown) => {
+        updates.push(update)
+        return Promise.resolve()
+      },
+    } as Pick<AgentSideConnection, "sessionUpdate">
+
+    return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds }
+  }
+
+  it("creates a backed session with config options and command update", async () => {
+    const { service, updates, mcpAdds } = makeService()
+    const result = await Effect.runPromise(
+      service.newSession({
+        cwd: "/workspace",
+        mcpServers: [
+          { name: "tools", command: "node", args: ["server.js"], env: [] },
+          { name: "tools", command: "node", args: ["server.js"], env: [] },
+        ],
+      }),
+    )
+
+    await new Promise((resolve) => setTimeout(resolve, 5))
+
+    expect(result.sessionId).toBe("ses_new")
+    expect(categories(result)).toContain("model")
+    expect(categories(result)).toContain("thought_level")
+    expect(categories(result)).toContain("mode")
+    expect(updates).toHaveLength(1)
+    expect(JSON.stringify(updates[0])).toContain("available_commands_update")
+    expect(JSON.stringify(updates[0])).toContain("review-skill")
+    expect(mcpAdds).toEqual(["tools"])
+  })
+
+  it("loads a session and restores model variant and mode from messages", async () => {
+    const { service } = makeService([
+      {
+        info: {
+          role: "assistant",
+          providerID: "test",
+          modelID: "test-model",
+          variant: "high",
+          mode: "plan",
+        },
+        parts: [],
+      },
+    ])
+    const result = await Effect.runPromise(
+      service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
+    )
+
+    expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
+    expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
+  })
+
+  it("restores model variant and mode from the latest user message", async () => {
+    const { service } = makeService([
+      {
+        info: {
+          role: "user",
+          model: { providerID: "test", modelID: "test-model", variant: "default" },
+          agent: "build",
+        },
+        parts: [],
+      },
+      {
+        info: {
+          role: "user",
+          model: { providerID: "test", modelID: "test-model", variant: "high" },
+          agent: "plan",
+        },
+        parts: [],
+      },
+    ])
+    const result = await Effect.runPromise(
+      service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
+    )
+
+    expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
+    expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
+  })
+
+
+  it("maps provider auth failures to auth-required request errors", async () => {
+    const service = ACPNextService.make({
+      sdk: {
+        config: {
+          providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
+          get: () => Promise.resolve({ data: {} }),
+        },
+        app: {
+          agents: () => Promise.resolve({ data: [] }),
+          skills: () => Promise.resolve({ data: [] }),
+        },
+        command: {
+          list: () => Promise.resolve({ data: [] }),
+        },
+      } as unknown as OpencodeClient,
+    })
+    const error = await Effect.runPromise(
+      service
+        .newSession({ cwd: "/workspace", mcpServers: [] })
+        .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+    )
+
+    expect(error.code).toBe(-32000)
+  })
+
+  it("does not cache failed directory snapshots", async () => {
+    let providersCalls = 0
+    const sdk = {
+        config: {
+          providers: () => {
+          providersCalls++
+          if (providersCalls === 1) {
+            return Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } })
+          }
+          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: () => Promise.resolve({ data: [] }),
+      },
+      session: {
+        create: () => Promise.resolve({ data: { id: "ses_retry" } }),
+        list: () => Promise.resolve({ data: [] }),
+      },
+      mcp: {
+        add: () => Promise.resolve({ data: {} }),
+      },
+    } as unknown as OpencodeClient
+    const service = ACPNextService.make({ sdk })
+
+    const first = await Effect.runPromise(
+      service
+        .newSession({ cwd: "/workspace", mcpServers: [] })
+        .pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
+    )
+    const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+
+    expect(first.code).toBe(-32000)
+    expect(second.sessionId).toBe("ses_retry")
+    expect(providersCalls).toBe(2)
+  })
+
+  it("uses the configured model as the new session default", async () => {
+    const sdk = {
+      config: {
+        providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
+        get: () => Promise.resolve({ data: { model: "test/configured-model" } }),
+      },
+      app: {
+        agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
+        skills: () => Promise.resolve({ data: [] }),
+      },
+      command: {
+        list: () => Promise.resolve({ data: [] }),
+      },
+      session: {
+        create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
+        list: () => Promise.resolve({ data: [] }),
+      },
+      mcp: {
+        add: () => Promise.resolve({ data: {} }),
+      },
+    } as unknown as OpencodeClient
+    const service = ACPNextService.make({ sdk })
+
+    const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
+
+    expect(result.sessionId).toBe("configured-model")
+    expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
+  })
+})
+
+function categories(result: NewSessionResponse | LoadSessionResponse) {
+  return result.configOptions?.map((option) => option.category) ?? []
+}

+ 42 - 7
packages/opencode/test/cli/acp-next/acp-next-process.test.ts

@@ -1,8 +1,15 @@
 import { describe, expect } from "bun:test"
-import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
+import type {
+  AuthenticateResponse,
+  InitializeResponse,
+  LoadSessionResponse,
+  NewSessionResponse,
+  SessionNotification,
+} from "@agentclientprotocol/sdk"
 import { Effect } from "effect"
 import { cliIt } from "../../lib/cli-process"
-import { createAcpClient, expectOk } from "../acp/acp-test-client"
+import { testProviderConfig } from "../../lib/test-provider"
+import { createAcpClient, expectOk, selectConfigOption } from "../acp/acp-test-client"
 
 describe("opencode acp-next (subprocess)", () => {
   cliIt.live(
@@ -22,6 +29,7 @@ describe("opencode acp-next (subprocess)", () => {
         expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
         expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
         expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
+        expect(initialized.agentCapabilities?.loadSession).toBe(true)
         expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
         expect(initialized.agentInfo?.name).toBe("OpenCode")
         expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
@@ -48,14 +56,41 @@ describe("opencode acp-next (subprocess)", () => {
   )
 
   cliIt.live(
-    "SDK-required session stubs fail with safe unsupported errors",
-    ({ home, opencode }) =>
+    "creates and loads sessions behind OPENCODE_ACP_NEXT",
+    ({ home, llm, opencode }) =>
       Effect.gen(function* () {
-        const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
+        const acp = createAcpClient(
+          yield* opencode.acp({
+            env: {
+              OPENCODE_ACP_NEXT: "1",
+              OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
+            },
+          }),
+        )
         yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
 
-        const newSession = yield* acp.request("session/new", { cwd: home, mcpServers: [] })
-        expect(errorCode(newSession.error)).toBe(-32601)
+        const session = expectOk(
+          yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
+        )
+        expect(typeof session.sessionId).toBe("string")
+        expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
+
+        const update = yield* acp.waitForNotification<SessionNotification>(
+          "session/update",
+          (params) =>
+            params.sessionId === session.sessionId &&
+            params.update.sessionUpdate === "available_commands_update",
+        )
+        expect(update.params?.sessionId).toBe(session.sessionId)
+
+        const loaded = expectOk(
+          yield* acp.request<LoadSessionResponse>("session/load", {
+            cwd: home,
+            sessionId: session.sessionId,
+            mcpServers: [],
+          }),
+        )
+        expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
 
         const prompt = yield* acp.request("session/prompt", {
           sessionId: "ses_missing",