From bca7db64ab2cec7a19b73a116df31343a36c9dcb Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Tue, 27 Jan 2026 12:10:53 +0100 Subject: [PATCH] fix(sdk): preserve OPENCODE_CONFIG_CONTENT when spawning CLI Previously, when spawning a new CLI instance via the SDK, any existing OPENCODE_CONFIG_CONTENT environment variable would be completely overwritten. This caused issues when Kilocode injected modes via this env var - they would be lost when spawning nested CLI instances. Now the SDK merges the existing OPENCODE_CONFIG_CONTENT with the new config, preserving both: - Existing agents, commands, mcp, mode configs are merged (new wins on conflict) - Arrays (plugin, instructions) are concatenated Added comprehensive tests for the merge behavior. --- packages/sdk/js/src/server.ts | 37 +++++++++- packages/sdk/js/src/v2/server.ts | 37 +++++++++- packages/sdk/js/test/server.test.ts | 111 ++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/js/test/server.test.ts diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts index 174131ccfd5..e88b9dd9cf4 100644 --- a/packages/sdk/js/src/server.ts +++ b/packages/sdk/js/src/server.ts @@ -1,6 +1,39 @@ import { spawn } from "node:child_process" import { type Config } from "./gen/types.gen.js" +// kilocode_change start - Merge existing OPENCODE_CONFIG_CONTENT with new config +// This preserves Kilocode-injected modes when spawning nested CLI instances +function mergeConfig(existing: Config | undefined, incoming: Config | undefined): Config { + const base = existing ?? {} + const override = incoming ?? {} + return { + ...base, + ...override, + agent: { ...base.agent, ...override.agent }, + command: { ...base.command, ...override.command }, + mcp: { ...base.mcp, ...override.mcp }, + mode: { ...base.mode, ...override.mode }, + plugin: [...(base.plugin ?? []), ...(override.plugin ?? [])], + instructions: [...(base.instructions ?? []), ...(override.instructions ?? [])], + } +} + +function parseExistingConfig(): Config | undefined { + const content = process.env.OPENCODE_CONFIG_CONTENT + if (!content) return undefined + try { + return JSON.parse(content) + } catch { + return undefined + } +} + +export function buildConfigEnv(config?: Config): string { + const merged = mergeConfig(parseExistingConfig(), config) + return JSON.stringify(merged) +} +// kilocode_change end + export type ServerOptions = { hostname?: string port?: number @@ -35,7 +68,7 @@ export async function createOpencodeServer(options?: ServerOptions) { signal: options.signal, env: { ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), + OPENCODE_CONFIG_CONTENT: buildConfigEnv(options.config), // kilocode_change }, }) @@ -111,7 +144,7 @@ export function createOpencodeTui(options?: TuiOptions) { stdio: "inherit", env: { ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), + OPENCODE_CONFIG_CONTENT: buildConfigEnv(options?.config), // kilocode_change }, }) diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts index 174131ccfd5..e88b9dd9cf4 100644 --- a/packages/sdk/js/src/v2/server.ts +++ b/packages/sdk/js/src/v2/server.ts @@ -1,6 +1,39 @@ import { spawn } from "node:child_process" import { type Config } from "./gen/types.gen.js" +// kilocode_change start - Merge existing OPENCODE_CONFIG_CONTENT with new config +// This preserves Kilocode-injected modes when spawning nested CLI instances +function mergeConfig(existing: Config | undefined, incoming: Config | undefined): Config { + const base = existing ?? {} + const override = incoming ?? {} + return { + ...base, + ...override, + agent: { ...base.agent, ...override.agent }, + command: { ...base.command, ...override.command }, + mcp: { ...base.mcp, ...override.mcp }, + mode: { ...base.mode, ...override.mode }, + plugin: [...(base.plugin ?? []), ...(override.plugin ?? [])], + instructions: [...(base.instructions ?? []), ...(override.instructions ?? [])], + } +} + +function parseExistingConfig(): Config | undefined { + const content = process.env.OPENCODE_CONFIG_CONTENT + if (!content) return undefined + try { + return JSON.parse(content) + } catch { + return undefined + } +} + +export function buildConfigEnv(config?: Config): string { + const merged = mergeConfig(parseExistingConfig(), config) + return JSON.stringify(merged) +} +// kilocode_change end + export type ServerOptions = { hostname?: string port?: number @@ -35,7 +68,7 @@ export async function createOpencodeServer(options?: ServerOptions) { signal: options.signal, env: { ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}), + OPENCODE_CONFIG_CONTENT: buildConfigEnv(options.config), // kilocode_change }, }) @@ -111,7 +144,7 @@ export function createOpencodeTui(options?: TuiOptions) { stdio: "inherit", env: { ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}), + OPENCODE_CONFIG_CONTENT: buildConfigEnv(options?.config), // kilocode_change }, }) diff --git a/packages/sdk/js/test/server.test.ts b/packages/sdk/js/test/server.test.ts new file mode 100644 index 00000000000..f2920dc93ad --- /dev/null +++ b/packages/sdk/js/test/server.test.ts @@ -0,0 +1,111 @@ +// kilocode_change start - Tests for OPENCODE_CONFIG_CONTENT merging +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { buildConfigEnv } from "../src/server" + +describe("buildConfigEnv", () => { + const originalEnv = process.env.OPENCODE_CONFIG_CONTENT + + beforeEach(() => { + delete process.env.OPENCODE_CONFIG_CONTENT + }) + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.OPENCODE_CONFIG_CONTENT + } else { + process.env.OPENCODE_CONFIG_CONTENT = originalEnv + } + }) + + test("returns empty config when no existing env and no incoming config", () => { + const result = buildConfigEnv() + const parsed = JSON.parse(result) + + expect(parsed).toEqual({ + agent: {}, + command: {}, + mcp: {}, + mode: {}, + plugin: [], + instructions: [], + }) + }) + + test("returns incoming config when no existing env", () => { + const result = buildConfigEnv({ + agent: { custom: { mode: "primary", prompt: "test" } }, + }) + const parsed = JSON.parse(result) + + expect(parsed.agent).toEqual({ custom: { mode: "primary", prompt: "test" } }) + }) + + test("preserves existing OPENCODE_CONFIG_CONTENT when spawning with new config", () => { + // Simulate Kilocode having injected modes via OPENCODE_CONFIG_CONTENT + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + agent: { + translate: { mode: "primary", prompt: "You are a translator" }, + }, + instructions: [".kilocode/rules/main.md"], + }) + + // Now spawn with additional config + const result = buildConfigEnv({ + agent: { review: { mode: "primary", prompt: "You are a reviewer" } }, + instructions: ["additional-rule.md"], + }) + const parsed = JSON.parse(result) + + // Both agents should be present + expect(parsed.agent.translate).toEqual({ mode: "primary", prompt: "You are a translator" }) + expect(parsed.agent.review).toEqual({ mode: "primary", prompt: "You are a reviewer" }) + + // Both instructions should be present + expect(parsed.instructions).toContain(".kilocode/rules/main.md") + expect(parsed.instructions).toContain("additional-rule.md") + }) + + test("incoming config overrides existing config for same keys", () => { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + agent: { code: { mode: "primary", prompt: "Original prompt" } }, + model: "original-model", + }) + + const result = buildConfigEnv({ + agent: { code: { mode: "primary", prompt: "New prompt" } }, + model: "new-model", + }) + const parsed = JSON.parse(result) + + // Agent should be overridden + expect(parsed.agent.code.prompt).toBe("New prompt") + // Top-level config should be overridden + expect(parsed.model).toBe("new-model") + }) + + test("handles invalid JSON in existing OPENCODE_CONFIG_CONTENT gracefully", () => { + process.env.OPENCODE_CONFIG_CONTENT = "invalid json {" + + const result = buildConfigEnv({ + agent: { test: { mode: "primary", prompt: "test" } }, + }) + const parsed = JSON.parse(result) + + // Should still work with just the incoming config + expect(parsed.agent.test).toEqual({ mode: "primary", prompt: "test" }) + }) + + test("merges plugins from both sources", () => { + process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + plugin: ["plugin-a", "plugin-b"], + }) + + const result = buildConfigEnv({ + plugin: ["plugin-c"], + }) + const parsed = JSON.parse(result) + + expect(parsed.plugin).toEqual(["plugin-a", "plugin-b", "plugin-c"]) + }) +}) +// kilocode_change end