Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion src/agents/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
40 changes: 33 additions & 7 deletions src/agents/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -174,7 +177,8 @@ function applyEnvironmentContext(config: AgentConfig, directory?: string): Agent
function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
mergedCategories: Record<string, CategoryConfig>,
directory?: string
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
Expand All @@ -183,21 +187,43 @@ function applyOverrides(
}

if (override) {
result = mergeAgentConfig(result, override)
result = mergeAgentConfig(result, override, directory)
}

return result
}

function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
override: AgentOverrideConfig,
configDir?: string
): AgentConfig {
const { prompt_append, ...rest } = override
const merged = deepMerge(base, rest as Partial<AgentConfig>)

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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down