diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index d4249c6120..883422519f 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,22 @@ 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" +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(() => { + 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 +126,71 @@ 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("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("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 d4a80d9448..bd09b7b33f 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,35 @@ 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://")) { + 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 } return merged @@ -307,7 +333,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 +374,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 +407,7 @@ export async function createBuiltinAgents( orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } } - orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories) + orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories, directory) result["atlas"] = orchestratorConfig } 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(),