diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index f413447cb..24ca780af 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -294,8 +294,8 @@ export async function loadProjectContextFiles( } /** - * Load system prompt customization files (SYSTEM.md). - * Returns combined content from all discovered SYSTEM.md files. + * Load the effective system prompt customization from SYSTEM.md. + * Project-level SYSTEM.md overrides user-level SYSTEM.md. */ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise { const resolvedCwd = options.cwd ?? getProjectDir(); @@ -304,16 +304,13 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = { if (result.items.length === 0) return null; - // Combine all SYSTEM.md contents (user-level first, then project-level) - const userLevel = result.items.filter(item => item.level === "user"); - const projectLevel = result.items.filter(item => item.level === "project"); - - const parts: string[] = []; - for (const item of [...userLevel, ...projectLevel]) { - parts.push(item.content); + const projectLevel = result.items.find(item => item.level === "project"); + if (projectLevel) { + return projectLevel.content; } - return parts.join("\n\n"); + const userLevel = result.items.find(item => item.level === "user"); + return userLevel?.content ?? null; } export interface SystemPromptToolMetadata { @@ -510,7 +507,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): const environment = await logger.timeAsync("getEnvironmentInfo", getEnvironmentInfo); const data = { - systemPromptCustomization: systemPromptCustomization ?? "", + // Explicit custom prompts replace discovered SYSTEM.md content rather than layering it twice. + systemPromptCustomization: resolvedCustomPrompt ? "" : (systemPromptCustomization ?? ""), customPrompt: resolvedCustomPrompt, appendPrompt: resolvedAppendPrompt ?? "", tools: toolNames, diff --git a/packages/coding-agent/test/system-prompt-dedup.test.ts b/packages/coding-agent/test/system-prompt-dedup.test.ts new file mode 100644 index 000000000..1334a074a --- /dev/null +++ b/packages/coding-agent/test/system-prompt-dedup.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { loadSystemPromptFiles } from "@oh-my-pi/pi-coding-agent/system-prompt"; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +describe("SYSTEM.md prompt assembly", () => { + let tempDir = ""; + let tempHomeDir = ""; + let originalHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-system-prompt-")); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-system-home-")); + originalHome = process.env.HOME; + process.env.HOME = tempHomeDir; + }); + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + if (tempHomeDir) { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + }); + + it("renders SYSTEM.md exactly once when it is used as the custom base prompt", async () => { + const projectDir = path.join(tempDir, "project"); + const systemDir = path.join(projectDir, ".omp"); + const systemPrompt = "You are the project SYSTEM prompt."; + fs.mkdirSync(systemDir, { recursive: true }); + fs.writeFileSync(path.join(systemDir, "SYSTEM.md"), systemPrompt); + + const { session } = await createAgentSession({ + cwd: projectDir, + agentDir: projectDir, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated(), + systemPrompt, + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + + try { + const formatted = session.formatSessionAsText(); + const matches = formatted.match(new RegExp(escapeRegExp(systemPrompt), "g")) ?? []; + expect(matches).toHaveLength(1); + } finally { + await session.dispose(); + } + }); + + it("prefers project SYSTEM.md over user SYSTEM.md", async () => { + const projectDir = path.join(tempDir, "project"); + fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true }); + fs.mkdirSync(path.join(tempHomeDir, ".omp", "agent"), { recursive: true }); + fs.writeFileSync(path.join(tempHomeDir, ".omp", "agent", "SYSTEM.md"), "User SYSTEM prompt"); + fs.writeFileSync(path.join(projectDir, ".omp", "SYSTEM.md"), "Project SYSTEM prompt"); + + await expect(loadSystemPromptFiles({ cwd: projectDir })).resolves.toBe("Project SYSTEM prompt"); + }); +});