From fb6265c2bcc5d94527d076a05bef1028888a45a9 Mon Sep 17 00:00:00 2001 From: Tom Hale Date: Mon, 25 May 2026 18:23:05 +0700 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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 e426b11e46f02d895742babb3bb2c4b9ca517e35 Mon Sep 17 00:00:00 2001 From: Braxton Schafer Date: Mon, 25 May 2026 09:24:19 -0500 Subject: [PATCH 05/31] 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%">