From 0a6a8604af18134e28cdedd8dbf39065d9ed219f Mon Sep 17 00:00:00 2001 From: Suyeol Jeon Date: Thu, 15 Jan 2026 23:57:26 +0900 Subject: [PATCH 1/4] feat(agents): add file:// URI support in prompt_append configuration --- src/agents/utils.test.ts | 57 +++++++++++++++++++++++++++++++++++++++- src/agents/utils.ts | 31 +++++++++++++++++----- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index d4249c6120..2c2f4b8d79 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -1,4 +1,7 @@ -import { describe, test, expect, beforeEach, spyOn, afterEach } from "bun:test" +import { describe, test, expect, beforeEach, beforeAll, afterAll, spyOn, afterEach } from "bun:test" +import { mkdirSync, writeFileSync, rmSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" import { createBuiltinAgents } from "./utils" import type { AgentConfig } from "@opencode-ai/sdk" import { clearSkillCache } from "../features/opencode-skill-loader/skill-content" @@ -7,6 +10,19 @@ import * as modelAvailability from "../shared/model-availability" const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5" +const TEST_DIR = join(tmpdir(), "utils-test-prompts") +const TEST_PROMPT_FILE = join(TEST_DIR, "custom-prompt.md") +const TEST_PROMPT_CONTENT = "Custom prompt from file\nWith multiple lines" + +beforeAll(() => { + mkdirSync(TEST_DIR, { recursive: true }) + writeFileSync(TEST_PROMPT_FILE, TEST_PROMPT_CONTENT) +}) + +afterAll(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) +}) + describe("createBuiltinAgents with model overrides", () => { test("Sisyphus with default model has thinking config", async () => { // #given - no overrides, using systemDefaultModel @@ -107,6 +123,45 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.oracle.textVerbosity).toBeUndefined() }) + test("resolves file:// URI in prompt_append with absolute path", async () => { + // #given + const overrides = { + oracle: { prompt_append: `file://${TEST_PROMPT_FILE}` }, + } + + // #when + const agents = await createBuiltinAgents([], overrides, TEST_DIR, TEST_DEFAULT_MODEL) + + // #then + expect(agents.oracle.prompt).toContain(TEST_PROMPT_CONTENT) + }) + + test("resolves file:// URI in prompt_append with relative path", async () => { + // #given + const overrides = { + oracle: { prompt_append: "file://custom-prompt.md" }, + } + + // #when + const agents = await createBuiltinAgents([], overrides, TEST_DIR, TEST_DEFAULT_MODEL) + + // #then + expect(agents.oracle.prompt).toContain(TEST_PROMPT_CONTENT) + }) + + test("handles non-existent file:// URI in prompt_append with warning", async () => { + // #given + const overrides = { + oracle: { prompt_append: "file://non-existent-prompt.md" }, + } + + // #when + const agents = await createBuiltinAgents([], overrides, TEST_DIR, TEST_DEFAULT_MODEL) + + // #then + expect(agents.oracle.prompt).toContain("[WARNING: Could not resolve file URI: file://non-existent-prompt.md]") + }) + test("non-model overrides are still applied after factory rebuild", async () => { // #given const overrides = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index d4a80d9448..36743b4d65 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -12,6 +12,9 @@ import { createMomusAgent, momusPromptMetadata } from "./momus" import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable } from "../shared" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" +import { existsSync, readFileSync } from "fs" +import { resolve, isAbsolute } from "path" +import { homedir } from "os" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" import { createBuiltinSkills } from "../features/builtin-skills" import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types" @@ -174,7 +177,8 @@ function applyEnvironmentContext(config: AgentConfig, directory?: string): Agent function applyOverrides( config: AgentConfig, override: AgentOverrideConfig | undefined, - mergedCategories: Record + mergedCategories: Record, + directory?: string ): AgentConfig { let result = config const overrideCategory = (override as Record | undefined)?.category as string | undefined @@ -183,7 +187,7 @@ function applyOverrides( } if (override) { - result = mergeAgentConfig(result, override) + result = mergeAgentConfig(result, override, directory) } return result @@ -191,13 +195,26 @@ function applyOverrides( function mergeAgentConfig( base: AgentConfig, - override: AgentOverrideConfig + override: AgentOverrideConfig, + configDir?: string ): AgentConfig { const { prompt_append, ...rest } = override const merged = deepMerge(base, rest as Partial) if (prompt_append && merged.prompt) { - merged.prompt = merged.prompt + "\n" + prompt_append + let resolved = prompt_append + if (prompt_append.startsWith("file://")) { + const path = prompt_append.slice(7) + const abs = path.startsWith("~/") + ? resolve(homedir(), path.slice(2)) + : isAbsolute(path) + ? path + : resolve(configDir ?? process.cwd(), path) + resolved = existsSync(abs) + ? readFileSync(abs, "utf-8") + : `[WARNING: Could not resolve file URI: ${prompt_append}]` + } + merged.prompt = merged.prompt + "\n" + resolved } return merged @@ -307,7 +324,7 @@ export async function createBuiltinAgents( config = applyEnvironmentContext(config, directory) } - config = applyOverrides(config, override, mergedCategories) + config = applyOverrides(config, override, mergedCategories, directory) result[name] = config @@ -348,7 +365,7 @@ export async function createBuiltinAgents( sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant } } - sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories) + sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories, directory) sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory) result["sisyphus"] = sisyphusConfig @@ -381,7 +398,7 @@ export async function createBuiltinAgents( orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } } - orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories) + orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories, directory) result["atlas"] = orchestratorConfig } From 9fcfdf958fc154b97cd35c35d631791146925a39 Mon Sep 17 00:00:00 2001 From: Suyeol Jeon Date: Thu, 15 Jan 2026 23:57:30 +0900 Subject: [PATCH 2/4] docs: document file:// URI support in agent prompt_append --- src/config/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/schema.ts b/src/config/schema.ts index 1598ed7105..8af8e5a0be 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -107,6 +107,7 @@ export const AgentOverrideConfigSchema = z.object({ temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), prompt: z.string().optional(), + /** Text to append to agent prompt. Supports file:// URIs (file:///abs, file://rel, file://~/home) */ prompt_append: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), disable: z.boolean().optional(), From 4ba66fa3f9e1feeae1dd2729d8aaf8f09777120d Mon Sep 17 00:00:00 2001 From: Suyeol Jeon Date: Fri, 16 Jan 2026 00:50:43 +0900 Subject: [PATCH 3/4] fix(agents): decode percent-encoded file paths in file:// URIs Properly handle encoded characters (e.g., %20 for spaces) when resolving file:// URIs in prompt_append configuration. Adds decodeURIComponent() to unwrap percent-encoded paths before filesystem access. Includes test case verifying percent-encoded paths with spaces can be loaded. --- src/agents/utils.test.ts | 16 ++++++++++++++++ src/agents/utils.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 2c2f4b8d79..55b3aaa562 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -13,10 +13,13 @@ const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5" const TEST_DIR = join(tmpdir(), "utils-test-prompts") const TEST_PROMPT_FILE = join(TEST_DIR, "custom-prompt.md") const TEST_PROMPT_CONTENT = "Custom prompt from file\nWith multiple lines" +const TEST_ENCODED_FILE = join(TEST_DIR, "my prompt file.md") +const TEST_ENCODED_CONTENT = "Prompt from percent-encoded path" beforeAll(() => { mkdirSync(TEST_DIR, { recursive: true }) writeFileSync(TEST_PROMPT_FILE, TEST_PROMPT_CONTENT) + writeFileSync(TEST_ENCODED_FILE, TEST_ENCODED_CONTENT) }) afterAll(() => { @@ -162,6 +165,19 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.oracle.prompt).toContain("[WARNING: Could not resolve file URI: file://non-existent-prompt.md]") }) + test("resolves file:// URI with percent-encoded path", async () => { + // #given - filename has space: "my prompt file.md" → "my%20prompt%20file.md" + const overrides = { + oracle: { prompt_append: "file://my%20prompt%20file.md" }, + } + + // #when + const agents = await createBuiltinAgents([], overrides, TEST_DIR, TEST_DEFAULT_MODEL) + + // #then + expect(agents.oracle.prompt).toContain(TEST_ENCODED_CONTENT) + }) + test("non-model overrides are still applied after factory rebuild", async () => { // #given const overrides = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 36743b4d65..9c72774c09 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -204,7 +204,7 @@ function mergeAgentConfig( if (prompt_append && merged.prompt) { let resolved = prompt_append if (prompt_append.startsWith("file://")) { - const path = prompt_append.slice(7) + const path = decodeURIComponent(prompt_append.slice(7)) const abs = path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : isAbsolute(path) From ac4ff234932931956901b35de2daa77cb46d9642 Mon Sep 17 00:00:00 2001 From: Suyeol Jeon Date: Fri, 16 Jan 2026 01:05:24 +0900 Subject: [PATCH 4/4] fix(agents): handle malformed percent-encoding in file:// URIs Add try-catch wrapper around decodeURIComponent() to gracefully handle invalid percent-encoding sequences (e.g., file://my%prompt.md). Shows warning message instead of crashing with URIError. Add test case verifying warning output for malformed URIs. --- src/agents/utils.test.ts | 13 +++++++++++++ src/agents/utils.ts | 27 ++++++++++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 55b3aaa562..883422519f 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -178,6 +178,19 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.oracle.prompt).toContain(TEST_ENCODED_CONTENT) }) + test("handles malformed percent-encoding in file URI with warning", async () => { + // #given - malformed percent-encoding (incomplete escape sequence) + const overrides = { + oracle: { prompt_append: "file://my%prompt.md" }, + } + + // #when + const agents = await createBuiltinAgents([], overrides, TEST_DIR, TEST_DEFAULT_MODEL) + + // #then + expect(agents.oracle.prompt).toContain("[WARNING: Malformed file URI (invalid percent-encoding): file://my%prompt.md]") + }) + test("non-model overrides are still applied after factory rebuild", async () => { // #given const overrides = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 9c72774c09..bd09b7b33f 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -204,15 +204,24 @@ function mergeAgentConfig( if (prompt_append && merged.prompt) { let resolved = prompt_append if (prompt_append.startsWith("file://")) { - const path = decodeURIComponent(prompt_append.slice(7)) - const abs = path.startsWith("~/") - ? resolve(homedir(), path.slice(2)) - : isAbsolute(path) - ? path - : resolve(configDir ?? process.cwd(), path) - resolved = existsSync(abs) - ? readFileSync(abs, "utf-8") - : `[WARNING: Could not resolve file URI: ${prompt_append}]` + let path: string + try { + path = decodeURIComponent(prompt_append.slice(7)) + } catch { + resolved = `[WARNING: Malformed file URI (invalid percent-encoding): ${prompt_append}]` + path = "" + } + + if (path) { + const abs = path.startsWith("~/") + ? resolve(homedir(), path.slice(2)) + : isAbsolute(path) + ? path + : resolve(configDir ?? process.cwd(), path) + resolved = existsSync(abs) + ? readFileSync(abs, "utf-8") + : `[WARNING: Could not resolve file URI: ${prompt_append}]` + } } merged.prompt = merged.prompt + "\n" + resolved }