From 3ca9941bbf1a3144768c422c0607e1529bdabbf6 Mon Sep 17 00:00:00 2001 From: Maksym Mospanenko Date: Sat, 4 Apr 2026 22:37:59 +0300 Subject: [PATCH] feat(config): add `escapeJson` parameter to `substitute` for raw content handling Add optional `escapeJson` parameter to `ConfigPaths.substitute` to control whether file content should be JSON-escaped. When `false`, multiline content and special characters are preserved as-is, which is required for agent markdown prompts that need raw file content rather than escaped strings. Update agent config processing to pass `escapeJson: false` when substituting file content into markdown prompts, ensuring proper handling of multiline files without unwanted escape sequences. Add comprehensive test coverage for the new parameter and existing substitution functionality. --- packages/opencode/src/config/agent.ts | 1 + packages/opencode/src/config/variable.ts | 4 +- .../test/config/agent-file-syntax.test.ts | 28 ++++++ packages/opencode/test/config/paths.test.ts | 99 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/config/paths.test.ts diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index edb3d5be983..f13ec67173d 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -169,6 +169,7 @@ export async function load(dir: string, warnings?: Warning[]) { dir: path.dirname(item), source: item, missing: "empty", + escapeJson: false, }) const config = { diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index e61e06d41bb..77ed281de9f 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -19,6 +19,7 @@ type ParseSource = type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" + escapeJson?: boolean } function source(input: ParseSource) { @@ -32,6 +33,7 @@ function dir(input: ParseSource) { /** Apply {env:VAR} and {file:path} substitutions to config text. */ export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" + const escape = input.escapeJson ?? true let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -81,7 +83,7 @@ export async function substitute(input: SubstituteInput) { }) ).trim() - out += JSON.stringify(fileContent).slice(1, -1) + out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent cursor = index + token.length } diff --git a/packages/opencode/test/config/agent-file-syntax.test.ts b/packages/opencode/test/config/agent-file-syntax.test.ts index cf0420f4ffc..d6786d324da 100644 --- a/packages/opencode/test/config/agent-file-syntax.test.ts +++ b/packages/opencode/test/config/agent-file-syntax.test.ts @@ -85,4 +85,32 @@ describe("agent {file:...} syntax in markdown body", () => { }, }) }) + + test("preserves multiline content without escaping", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const multilineContent = ["Line 1", "Line 2", "Line 3"].join("\n") + await Bun.write(path.join(dir, "multiline.md"), multilineContent) + + const agentsDir = path.join(dir, ".kilo", "agents") + await fs.mkdir(agentsDir, { recursive: true }) + await Bun.write( + path.join(agentsDir, "test.md"), + ["---", "description: Test", "---", "Base", "", "{file:../../multiline.md}", ""].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.get("test") + + expect(agent).toBeDefined() + expect(agent?.prompt).toContain("Line 1\nLine 2\nLine 3") + expect(agent?.prompt).not.toContain("Line 1\\nLine 2\\nLine 3") + }, + }) + }) }) diff --git a/packages/opencode/test/config/paths.test.ts b/packages/opencode/test/config/paths.test.ts new file mode 100644 index 00000000000..101623d4753 --- /dev/null +++ b/packages/opencode/test/config/paths.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import fs from "node:fs/promises" +import { tmpdir } from "../fixture/fixture" +import { ConfigPaths } from "../../src/config/paths" + +describe("ConfigPaths.substitute", () => { + describe("escapeJson parameter", () => { + test("escapes newlines when escapeJson is true (default)", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "Line 1\nLine 2\nLine 3") + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "config.json" }, + "error", + true, + ) + + expect(result).toBe("Line 1\\nLine 2\\nLine 3") + }) + + test("preserves newlines when escapeJson is false", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "Line 1\nLine 2\nLine 3") + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "agent.md" }, + "empty", + false, + ) + + expect(result).toBe("Line 1\nLine 2\nLine 3") + }) + + test("escapes quotes when escapeJson is true", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), 'Text with "quotes"') + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "config.json" }, + "error", + true, + ) + + expect(result).toBe('Text with \\"quotes\\"') + }) + + test("preserves quotes when escapeJson is false", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), 'Text with "quotes"') + }, + }) + + const result = await ConfigPaths.substitute( + "{file:./test.txt}", + { dir: tmp.path, source: "agent.md" }, + "empty", + false, + ) + + expect(result).toBe('Text with "quotes"') + }) + }) + + describe("existing functionality", () => { + test("substitutes {env:VAR} with environment variable", async () => { + process.env.TEST_VAR = "test_value" + + const result = await ConfigPaths.substitute("Value: {env:TEST_VAR}", { dir: "/tmp", source: "test.json" }) + + expect(result).toBe("Value: test_value") + + delete process.env.TEST_VAR + }) + + test("replaces missing {env:VAR} with empty string", async () => { + const result = await ConfigPaths.substitute("Value: {env:NONEXISTENT_VAR}", { dir: "/tmp", source: "test.json" }) + + expect(result).toBe("Value: ") + }) + }) +})