From fb6265c2bcc5d94527d076a05bef1028888a45a9 Mon Sep 17 00:00:00 2001 From: Tom Hale Date: Mon, 25 May 2026 18:23:05 +0700 Subject: [PATCH 01/65] fix(config): catch parse errors gracefully during startup Wrap ConfigParse.jsonc() and ConfigParse.effectSchema() calls in loadConfig with Effect.catchCause to prevent sync-thrown JsonError and InvalidError from becoming Effect defects. On parse/schema failure, the bad file is skipped with a log.error and {} fallback instead of crashing, matching the existing tui.jsonc error handling pattern in tui.ts. Without this, any invalid JSONC syntax or schema violation in opencode.json/opencode.jsonc causes "4 of 6 requests failed: Unexpected server error" on startup. Now the server starts with default values and logs the config error details. Fixes #29200 --- packages/opencode/src/config/config.ts | 42 +++--- packages/opencode/test/config/config.test.ts | 127 +++++++++++++++---- 2 files changed, 127 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 307b02ca4d9f..1487b8d4bcfe 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -413,24 +413,34 @@ export const layer = Layer.effect( env?: Record, ) { const source = "path" in options ? options.path : options.source - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute( - "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, + const result = yield* Effect.gen(function* () { + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute( + "path" in options + ? { text, type: "path", path: options.path, env } + : { text, type: "virtual", ...options, env }, + ), + ) + const parsed = ConfigParse.jsonc(expanded, source) + const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) + if (!("path" in options)) return data + + yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) + if (!data.$schema) { + data.$schema = "https://opencode.ai/config.json" + const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) + } + return data + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.error("invalid config", { path: source, cause }) + return {} as Info + }), ), ) - const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) - if (!("path" in options)) return data - - yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) - if (!data.$schema) { - data.$schema = "https://opencode.ai/config.json" - const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) - } - return data + return result }) const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6ce0acdb2a7b..4bdd148b433f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -580,34 +580,109 @@ accountTokenIt.instance("resolves env templates in account config with account t }), ) -it.instance("validates config schema and throws on invalid fields", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - invalid_field: "should cause error", - }) - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - }), -) +test("handles invalid schema gracefully without crashing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + invalid_field: "should cause error", + }) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.username).toBeDefined() + }, + }) +}) -it.instance("throws error for invalid JSON", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "opencode.json"), "{ invalid json }") - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - }), -) +test("handles invalid JSON gracefully without crashing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.username).toBeDefined() + }, + }) +}) -it.instance("handles agent configuration", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { - test_agent: { +test("handles invalid JSONC syntax gracefully without crashing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.jsonc"), + `{ + // comment + "model": "test/model", + "username": "testuser", + }`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.username).toBeDefined() + }, + }) +}) + +test("skips bad config file but merges others", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "global/model", + username: "globaluser", + }), + ) + await Filesystem.write( + path.join(dir, "opencode.jsonc"), + "{ invalid json }", + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("global/model") + expect(config.username).toBe("globaluser") + }, + }) +}) + +test("handles agent configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "test/model", + temperature: 0.7, + description: "test agent", + }, + }, + }) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.agent?.["test_agent"]).toEqual( + expect.objectContaining({ model: "test/model", temperature: 0.7, description: "test agent", From de8dc4a747066f12f01f11c56154cc77eb0604ce Mon Sep 17 00:00:00 2001 From: Tom Hale Date: Mon, 25 May 2026 19:21:32 +0700 Subject: [PATCH 02/65] fix(config): convert tests to it.instance pattern matching upstream --- packages/opencode/test/config/config.test.ts | 160 +++++++------------ 1 file changed, 61 insertions(+), 99 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4bdd148b433f..32cc35011892 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -580,109 +580,71 @@ accountTokenIt.instance("resolves env templates in account config with account t }), ) -test("handles invalid schema gracefully without crashing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig(dir, { - $schema: "https://opencode.ai/config.json", - invalid_field: "should cause error", - }) - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.username).toBeDefined() - }, - }) -}) - -test("handles invalid JSON gracefully without crashing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.username).toBeDefined() - }, - }) -}) +it.instance("handles invalid schema gracefully without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + $schema: "https://opencode.ai/config.json", + invalid_field: "should cause error", + }) + const config = yield* Config.use.get() + expect(config.username).toBeDefined() + }), +) -test("handles invalid JSONC syntax gracefully without crashing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "opencode.jsonc"), - `{ - // comment - "model": "test/model", - "username": "testuser", - }`, - ) - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.username).toBeDefined() - }, - }) -}) +it.instance("handles invalid JSON gracefully without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "opencode.json"), "{ invalid json }") + const config = yield* Config.use.get() + expect(config.username).toBeDefined() + }), +) -test("skips bad config file but merges others", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - model: "global/model", - username: "globaluser", - }), - ) - await Filesystem.write( - path.join(dir, "opencode.jsonc"), - "{ invalid json }", - ) - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.model).toBe("global/model") - expect(config.username).toBe("globaluser") - }, - }) -}) +it.instance("handles invalid JSONC syntax gracefully without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, "opencode.jsonc"), + `{ + // comment + "model": "test/model", + "username": "testuser", + }`, + ) + const config = yield* Config.use.get() + expect(config.username).toBeDefined() + }), +) -test("handles agent configuration", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig(dir, { +it.instance("skips bad config file but merges others", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", - agent: { - test_agent: { - model: "test/model", - temperature: 0.7, - description: "test agent", - }, - }, - }) - }, - }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.agent?.["test_agent"]).toEqual( - expect.objectContaining({ + model: "global/model", + username: "globaluser", + }), + ) + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, "opencode.jsonc"), + "{ invalid json }", + ) + const config = yield* Config.use.get() + expect(config.model).toBe("global/model") + expect(config.username).toBe("globaluser") + }), +) + +it.instance("handles agent configuration", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { model: "test/model", temperature: 0.7, description: "test agent", From 85ed2e48211c8139a3e7344c8d83b86ecd8ffcb2 Mon Sep 17 00:00:00 2001 From: Tom Hale Date: Mon, 25 May 2026 19:27:15 +0700 Subject: [PATCH 03/65] fix(config): narrow catchCause to parse/schema only, strengthen tests --- packages/opencode/src/config/config.ts | 39 ++++++++++---------- packages/opencode/test/config/config.test.ts | 4 +- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1487b8d4bcfe..53e9008bcf11 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -413,34 +413,33 @@ export const layer = Layer.effect( env?: Record, ) { const source = "path" in options ? options.path : options.source - const result = yield* Effect.gen(function* () { - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute( - "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, - ), - ) + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute( + "path" in options + ? { text, type: "path", path: options.path, env } + : { text, type: "virtual", ...options, env }, + ), + ) + const data = yield* Effect.sync(() => { const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) - if (!("path" in options)) return data - - yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) - if (!data.$schema) { - data.$schema = "https://opencode.ai/config.json" - const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) - } - return data + return ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) }).pipe( Effect.catchCause((cause) => Effect.sync(() => { log.error("invalid config", { path: source, cause }) - return {} as Info + return Schema.decodeSync(Info)({}) }), ), ) - return result + if (!("path" in options)) return data + + yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) + if (!data.$schema) { + data.$schema = "https://opencode.ai/config.json" + const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) + } + return data }) const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 32cc35011892..fdc7dc62d948 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -589,6 +589,7 @@ it.instance("handles invalid schema gracefully without crashing", () => }) const config = yield* Config.use.get() expect(config.username).toBeDefined() + expect("invalid_field" in config).toBe(false) }), ) @@ -609,8 +610,7 @@ it.instance("handles invalid JSONC syntax gracefully without crashing", () => `{ // comment "model": "test/model", - "username": "testuser", - }`, + "username": "testuser",`, ) const config = yield* Config.use.get() expect(config.username).toBeDefined() From 109a6eb483d5cc5bb02e951120820ec91e6d660b Mon Sep 17 00:00:00 2001 From: Tom Hale Date: Mon, 25 May 2026 20:14:35 +0700 Subject: [PATCH 04/65] fix(config): catch plugin resolution errors per-file to preserve multi-file merge --- packages/opencode/src/config/config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 53e9008bcf11..272e4db83e19 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -433,7 +433,13 @@ export const layer = Layer.effect( ) if (!("path" in options)) return data - yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) + yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.error("plugin resolution failed", { path: source, cause }) + }), + ), + ) if (!data.$schema) { data.$schema = "https://opencode.ai/config.json" const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') From e1406e05a33517f89016d3ba1d3a7f15f2684cc7 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 26 May 2026 01:07:50 +0800 Subject: [PATCH 05/65] fix(console): bill google non-stream zen usage (#28829) --- .../app/src/routes/zen/util/handler.ts | 5 +- .../src/routes/zen/util/provider/anthropic.ts | 1 + .../src/routes/zen/util/provider/google.ts | 1 + .../zen/util/provider/openai-compatible.ts | 1 + .../src/routes/zen/util/provider/openai.ts | 1 + .../src/routes/zen/util/provider/provider.ts | 1 + .../console/app/test/providerUsage.test.ts | 68 +++++++++++++++++++ 7 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 packages/console/app/test/providerUsage.test.ts diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index e4b42d741e9c..0434f9100baf 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -249,8 +249,9 @@ export async function handler( if (!isStream || [400, 404, 429].includes(res.status)) { const json = await res.json() await rateLimiter?.track() - if (json.usage) { - const usageInfo = providerInfo.normalizeUsage(json.usage) + const usage = providerInfo.extractUsage(json) + if (usage) { + const usageInfo = providerInfo.normalizeUsage(usage) const costInfo = calculateCost(modelInfo, usageInfo) await trialLimiter?.track(usageInfo) await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 8c394ee3e154..64053fd73457 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -175,6 +175,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => retrieve: () => usage, } }, + extractUsage: (response: any) => response.usage, normalizeUsage: (usage: Usage) => ({ inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0, diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts index 2954024e208d..eead927c82ef 100644 --- a/packages/console/app/src/routes/zen/util/provider/google.ts +++ b/packages/console/app/src/routes/zen/util/provider/google.ts @@ -58,6 +58,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({ retrieve: () => usage, } }, + extractUsage: (response: any) => response.usageMetadata, normalizeUsage: (usage: Usage) => { const inputTokens = usage.promptTokenCount ?? 0 const outputTokens = usage.candidatesTokenCount ?? 0 diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 912c89092a0c..9e5e15d94480 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -58,6 +58,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ retrieve: () => usage, } }, + extractUsage: (response: any) => response.usage, normalizeUsage: (usage: Usage) => { let inputTokens = usage.prompt_tokens ?? 0 const outputTokens = usage.completion_tokens ?? 0 diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 4b39407d442f..e55dcd878350 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -43,6 +43,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ retrieve: () => usage, } }, + extractUsage: (response: any) => response.usage ?? response.response?.usage, normalizeUsage: (usage: Usage) => { const inputTokens = usage.input_tokens ?? 0 const outputTokens = usage.output_tokens ?? 0 diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index 319f8fdca30f..d9fe55681fc8 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -49,6 +49,7 @@ export type ProviderHelper = (input: { parse: (chunk: string) => void retrieve: () => any } + extractUsage: (response: any) => any normalizeUsage: (usage: any) => UsageInfo } diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts new file mode 100644 index 000000000000..d39c9fa8617b --- /dev/null +++ b/packages/console/app/test/providerUsage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test" +import type { ZenData } from "@opencode-ai/console-core/model.js" +import type { ProviderHelper } from "../src/routes/zen/util/provider/provider" +import { anthropicHelper } from "../src/routes/zen/util/provider/anthropic" +import { googleHelper } from "../src/routes/zen/util/provider/google" +import { oaCompatHelper } from "../src/routes/zen/util/provider/openai-compatible" +import { openaiHelper } from "../src/routes/zen/util/provider/openai" + +const providers = { + anthropic: anthropicHelper({ reqModel: "claude-haiku-4-5", providerModel: "claude-haiku-4-5" }), + google: googleHelper({ reqModel: "gemini-3-flash", providerModel: "gemini-3-flash" }), + openai: openaiHelper({ reqModel: "gpt-5", providerModel: "gpt-5" }), + "oa-compat": oaCompatHelper({ reqModel: "gpt-5-nano", providerModel: "gpt-5-nano" }), +} satisfies Record> + +describe("provider usage extraction", () => { + test("extracts Google non-stream usage metadata", () => { + const usage = providers.google.extractUsage({ + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 3, + thoughtsTokenCount: 2, + cachedContentTokenCount: 4, + }, + }) + + expect(providers.google.normalizeUsage(usage)).toEqual({ + inputTokens: 6, + outputTokens: 3, + reasoningTokens: 2, + cacheReadTokens: 4, + cacheWrite5mTokens: undefined, + cacheWrite1hTokens: undefined, + }) + }) + + test("parses Google stream usage metadata", () => { + const usageParser = providers.google.createUsageParser() + usageParser.parse( + 'data: {"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":2,"cachedContentTokenCount":4}}', + ) + + expect(providers.google.normalizeUsage(usageParser.retrieve())).toEqual({ + inputTokens: 6, + outputTokens: 3, + reasoningTokens: 2, + cacheReadTokens: 4, + cacheWrite5mTokens: undefined, + cacheWrite1hTokens: undefined, + }) + }) + + test("extracts nested OpenAI Responses usage", () => { + expect( + providers.openai.extractUsage({ + response: { + usage: { + input_tokens: 5, + output_tokens: 7, + }, + }, + }), + ).toEqual({ + input_tokens: 5, + output_tokens: 7, + }) + }) +}) From 0373ea91288b373339630872a126acc40e42293a Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 25 May 2026 22:55:30 +0530 Subject: [PATCH 06/65] feat(acp): implement acp-next session slice (#29250) --- packages/opencode/src/acp-next/agent.ts | 9 +- packages/opencode/src/acp-next/service.ts | 412 +++++++++++++++++- .../test/acp-next/service-session.test.ts | 318 ++++++++++++++ .../cli/acp-next/acp-next-process.test.ts | 49 ++- 4 files changed, 775 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/acp-next/service-session.test.ts diff --git a/packages/opencode/src/acp-next/agent.ts b/packages/opencode/src/acp-next/agent.ts index 4290117f585d..f0d3a77bcd4f 100644 --- a/packages/opencode/src/acp-next/agent.ts +++ b/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)) } diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 8ee1a8bd292c..96d0c005d19d 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/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 readonly authenticate: (input: AuthenticateRequest) => Effect.Effect readonly newSession: (input: NewSessionRequest) => Effect.Effect + readonly loadSession: (input: LoadSessionRequest) => Effect.Effect readonly prompt: (input: PromptRequest) => Effect.Effect readonly cancel: (input: CancelNotification) => Effect.Effect } export class Service extends Context.Service()("@opencode/ACPNext/Service") {} -export function make(): Interface { +export function make(input: { sdk: OpencodeClient; connection?: Pick }): Interface { + const sessions = new Map() + const directories = new Map>() + const registeredMcp = new Map>() + 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 = { + 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(fn: () => Promise>, service?: string) { + return Effect.tryPromise({ + try: async () => { + const result = await fn() + if (isSdkResponse(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, +): Promise { + 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, +): Promise { + 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, 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 | 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>, + directory: string, + servers: readonly McpServer[], +) { + const current = registered.get(directory) ?? new Set() + 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(value: T | SdkResponse): value is SdkResponse { + 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) +} diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts new file mode 100644 index 000000000000..44a5acb1f358 --- /dev/null +++ b/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 + + 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) ?? [] +} diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts index 08d15b9bfc23..8f9e148118fe 100644 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ b/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("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("session/new", { cwd: home, mcpServers: [] }), + ) + expect(typeof session.sessionId).toBe("string") + expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model") + + const update = yield* acp.waitForNotification( + "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("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", From 00ea47a50203385e9dc6f90bb29086b77defdced Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 25 May 2026 17:26:51 +0000 Subject: [PATCH 07/65] chore: generate --- packages/opencode/src/acp-next/service.ts | 10 ++++++++-- .../opencode/test/acp-next/service-session.test.ts | 9 ++++----- .../test/cli/acp-next/acp-next-process.test.ts | 7 ++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index 96d0c005d19d..e1b7d7a2d61e 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -39,7 +39,10 @@ export type Interface = { export class Service extends Context.Service()("@opencode/ACPNext/Service") {} -export function make(input: { sdk: OpencodeClient; connection?: Pick }): Interface { +export function make(input: { + sdk: OpencodeClient + connection?: Pick +}): Interface { const sessions = new Map() const directories = new Map>() const registeredMcp = new Map>() @@ -401,7 +404,10 @@ function registerMcpServers( { throwOnError: true }, ), "mcp", - ).pipe(Effect.tap(() => Effect.sync(() => current.add(server.name))), Effect.ignore), + ).pipe( + Effect.tap(() => Effect.sync(() => current.add(server.name))), + Effect.ignore, + ), ), { concurrency: "unbounded" }, ).pipe(Effect.asVoid) diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 44a5acb1f358..8b13203c8bab 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -215,7 +215,6 @@ describe("ACP next service sessions", () => { 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: { @@ -244,15 +243,15 @@ describe("ACP next service sessions", () => { it("does not cache failed directory snapshots", async () => { let providersCalls = 0 const sdk = { - config: { - providers: () => { + 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: {} }), + }, + get: () => Promise.resolve({ data: {} }), }, app: { agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }), diff --git a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts index 8f9e148118fe..426f8225bf1c 100644 --- a/packages/opencode/test/cli/acp-next/acp-next-process.test.ts +++ b/packages/opencode/test/cli/acp-next/acp-next-process.test.ts @@ -69,17 +69,14 @@ describe("opencode acp-next (subprocess)", () => { ) yield* acp.request("initialize", { protocolVersion: 1 }) - const session = expectOk( - yield* acp.request("session/new", { cwd: home, mcpServers: [] }), - ) + const session = expectOk(yield* acp.request("session/new", { cwd: home, mcpServers: [] })) expect(typeof session.sessionId).toBe("string") expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model") const update = yield* acp.waitForNotification( "session/update", (params) => - params.sessionId === session.sessionId && - params.update.sessionUpdate === "available_commands_update", + params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update", ) expect(update.params?.sessionId).toBe(session.sessionId) From 56743dcf04fad9a8178aee7ed2e8eb176e00f2d4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 25 May 2026 23:09:41 +0530 Subject: [PATCH 08/65] fix(acp): share acp-next session state (#29253) --- packages/opencode/src/acp-next/directory.ts | 33 ++++- packages/opencode/src/acp-next/service.ts | 114 ++++++++++++------ .../test/acp-next/service-session.test.ts | 49 ++++++++ 3 files changed, 150 insertions(+), 46 deletions(-) diff --git a/packages/opencode/src/acp-next/directory.ts b/packages/opencode/src/acp-next/directory.ts index a991fb19996c..90ffa36358ff 100644 --- a/packages/opencode/src/acp-next/directory.ts +++ b/packages/opencode/src/acp-next/directory.ts @@ -5,6 +5,7 @@ import { InstanceStore } from "@/project/instance-store" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import { Context, Effect, Layer, SynchronizedRef } from "effect" +import type * as ACPNextError from "./error" export type ModelOption = { readonly providerID: ProviderID @@ -38,12 +39,12 @@ export type Snapshot = { } export interface LoaderInterface { - readonly load: (directory: string) => Effect.Effect + readonly load: (directory: string) => Effect.Effect } export interface Interface { - readonly get: (directory: string) => Effect.Effect - readonly refresh: (directory: string) => Effect.Effect + readonly get: (directory: string) => Effect.Effect + readonly refresh: (directory: string) => Effect.Effect readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined } @@ -141,7 +142,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const loader = yield* Loader - const snapshots = yield* SynchronizedRef.make(new Map>()) + const snapshots = yield* SynchronizedRef.make(new Map>()) const cached = Effect.fnUntraced(function* (directory: string) { return yield* SynchronizedRef.modifyEffect( @@ -149,7 +150,17 @@ export const layer = Layer.effect( Effect.fnUntraced(function* (items) { const current = items.get(directory) if (current) return [current, items] as const - const next = yield* Effect.cached(loader.load(directory)) + const next = yield* Effect.cached( + loader.load(directory).pipe( + Effect.tapError(() => + SynchronizedRef.update(snapshots, (state) => { + const next = new Map(state) + next.delete(directory) + return next + }), + ), + ), + ) return [next, new Map(items).set(directory, next)] as const }), ) @@ -163,7 +174,17 @@ export const layer = Layer.effect( return yield* SynchronizedRef.modifyEffect( snapshots, Effect.fnUntraced(function* (items) { - const next = yield* Effect.cached(loader.load(directory)) + const next = yield* Effect.cached( + loader.load(directory).pipe( + Effect.tapError(() => + SynchronizedRef.update(snapshots, (state) => { + const next = new Map(state) + next.delete(directory) + return next + }), + ), + ), + ) return [next, new Map(items).set(directory, next)] as const }), ).pipe(Effect.flatten) diff --git a/packages/opencode/src/acp-next/service.ts b/packages/opencode/src/acp-next/service.ts index e1b7d7a2d61e..66ed5aaed0ca 100644 --- a/packages/opencode/src/acp-next/service.ts +++ b/packages/opencode/src/acp-next/service.ts @@ -16,10 +16,11 @@ import { } 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 { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPNextError from "./error" import { buildConfigOptions } from "./config-option" import { Directory } from "./directory" +import { ACPNextSession } from "./session" import { ModelID, ProviderID } from "@/provider/schema" import { Provider } from "@/provider/provider" import type { Command } from "@/command" @@ -42,9 +43,11 @@ export class Service extends Context.Service()("@opencode/AC export function make(input: { sdk: OpencodeClient connection?: Pick + directory?: Directory.Interface + session?: ACPNextSession.Interface }): Interface { - const sessions = new Map() - const directories = new Map>() + const session = input.session ?? makeSessionService() + const directoryService = input.directory ?? makeDirectoryService(input.sdk) const registeredMcp = new Map>() const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) { @@ -92,16 +95,8 @@ export function make(input: { 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 directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) { + return yield* directoryService.get(cwd) }) const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) { @@ -125,7 +120,7 @@ export function make(input: { ), "session", ) - const state = storeSession(sessions, { + const state = yield* session.create({ id: created.id, cwd: params.cwd, mcpServers: params.mcpServers, @@ -134,12 +129,16 @@ export function make(input: { modeId, }) - yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, params.mcpServers) + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) return { sessionId: state.id, - configOptions: configOptions(snapshot, state), + configOptions: configOptions(snapshot, { + model: state.model ?? selected, + variant: state.variant, + modeId: state.modeId, + }), } }) @@ -159,7 +158,7 @@ export function make(input: { ) const restored = restoreFromMessages(messages.map((item) => item.info)) const model = restored.model ?? selectDefaultModel(snapshot) - const state = storeSession(sessions, { + const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers, @@ -168,12 +167,16 @@ export function make(input: { modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), }) - yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, params.mcpServers) + yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers) yield* sendAvailableCommands(input.connection, state.id, snapshot) return { sessionId: state.id, - configOptions: configOptions(snapshot, state), + configOptions: configOptions(snapshot, { + model: state.model ?? model, + variant: state.variant, + modeId: state.modeId, + }), } }) @@ -191,10 +194,28 @@ export function make(input: { } } -type SessionState = { - readonly id: string - readonly cwd: string - readonly mcpServers: readonly McpServer[] +function makeSessionService() { + return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync( + ACPNextSession.Service.use((service) => Effect.succeed(service)), + ) +} + +function makeDirectoryService(sdk: OpencodeClient) { + return ManagedRuntime.make( + Directory.layer.pipe( + Layer.provide( + Layer.succeed( + Directory.Loader, + Directory.Loader.of({ + load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), + }), + ), + ), + ), + ).runSync(Directory.Service.use((service) => Effect.succeed(service))) +} + +type ConfigState = { readonly model: Directory.DefaultModel readonly variant?: string readonly modeId?: string @@ -340,15 +361,7 @@ function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultMod return Object.keys(variants)[0] } -function storeSession(sessions: Map, state: SessionState) { - sessions.set(state.id, { - ...state, - mcpServers: [...state.mcpServers], - }) - return sessions.get(state.id)! -} - -function configOptions(snapshot: Directory.Snapshot, session: SessionState) { +function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { return buildConfigOptions({ providers: Object.values(snapshot.providers), currentModel: session.model, @@ -384,28 +397,36 @@ function registerMcpServers( sdk: OpencodeClient, registered: Map>, directory: string, + sessionId: string, servers: readonly McpServer[], ) { - const current = registered.get(directory) ?? new Set() - registered.set(directory, current) + const current = registered.get(sessionId) ?? new Set() + registered.set(sessionId, current) + const pending = new Set() return Effect.all( - Array.from(new Map(servers.map((server) => [server.name, server])).values()) - .filter((server) => !current.has(server.name)) - .map((server) => + servers + .map((server) => ({ server, config: mcpConfig(server) })) + .filter((entry) => { + const key = mcpRegistrationKey(entry.server.name, entry.config) + if (current.has(key) || pending.has(key)) return false + pending.add(key) + return true + }) + .map((entry) => request( () => sdk.mcp.add( { directory, - name: server.name, - config: mcpConfig(server), + name: entry.server.name, + config: entry.config, }, { throwOnError: true }, ), "mcp", ).pipe( - Effect.tap(() => Effect.sync(() => current.add(server.name))), + Effect.tap(() => Effect.sync(() => current.add(mcpRegistrationKey(entry.server.name, entry.config)))), Effect.ignore, ), ), @@ -413,6 +434,10 @@ function registerMcpServers( ).pipe(Effect.asVoid) } +function mcpRegistrationKey(name: string, config: ReturnType) { + return `${name}:${stableStringify(config)}` +} + function mcpConfig(server: McpServer) { if ("type" in server) { return { @@ -428,6 +453,15 @@ function mcpConfig(server: McpServer) { } } +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` + if (!value || typeof value !== "object") return JSON.stringify(value) + return `{${Object.entries(value) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}` +} + function restoreFromMessages(messages: readonly MessageInfo[]) { const user = messages.findLast( (message) => message.role === "user" && message.model?.providerID && message.model.modelID, diff --git a/packages/opencode/test/acp-next/service-session.test.ts b/packages/opencode/test/acp-next/service-session.test.ts index 8b13203c8bab..a9ecb3cf48cc 100644 --- a/packages/opencode/test/acp-next/service-session.test.ts +++ b/packages/opencode/test/acp-next/service-session.test.ts @@ -282,6 +282,55 @@ describe("ACP next service sessions", () => { expect(providersCalls).toBe(2) }) + it("registers same-name MCP servers again for different sessions or configs", async () => { + const adds: unknown[] = [] + let nextSession = 0 + 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: {} }] }), + skills: () => Promise.resolve({ data: [] }), + }, + command: { + list: () => Promise.resolve({ data: [] }), + }, + session: { + create: () => { + nextSession++ + return Promise.resolve({ data: { id: `ses_${nextSession}` } }) + }, + list: () => Promise.resolve({ data: [] }), + }, + mcp: { + add: (input: unknown) => { + adds.push(input) + return Promise.resolve({ data: {} }) + }, + }, + } as unknown as OpencodeClient + const service = ACPNextService.make({ sdk }) + + await Effect.runPromise( + service.newSession({ + cwd: "/workspace", + mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }], + }), + ) + await Effect.runPromise( + service.newSession({ + cwd: "/workspace", + mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }], + }), + ) + + expect(adds).toHaveLength(2) + expect(JSON.stringify(adds[0])).toContain("one.js") + expect(JSON.stringify(adds[1])).toContain("two.js") + }) + it("uses the configured model as the new session default", async () => { const sdk = { config: { From e426b11e46f02d895742babb3bb2c4b9ca517e35 Mon Sep 17 00:00:00 2001 From: Braxton Schafer Date: Mon, 25 May 2026 09:24:19 -0500 Subject: [PATCH 09/65] feat(tui): make prompt size responsive and configurable (#28255) --- .../src/cli/cmd/tui/component/prompt/index.tsx | 10 ++++++++-- .../opencode/src/cli/cmd/tui/config/tui-schema.ts | 10 ++++++++++ packages/opencode/src/cli/cmd/tui/routes/home.tsx | 13 +++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 0566e07b3451..e8affacda742 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1464,11 +1464,15 @@ export function Prompt(props: PromptProps) { }), } }) + const maxHeight = createMemo( + () => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)), + ) return ( <> - (anchor = r)} visible={props.visible !== false}> + (anchor = r)} visible={props.visible !== false} width="100%">