diff --git a/packages/runtime/src/__tests__/workspace-instructions.test.ts b/packages/runtime/src/__tests__/workspace-instructions.test.ts index 91e23d725f..a765ec3145 100644 --- a/packages/runtime/src/__tests__/workspace-instructions.test.ts +++ b/packages/runtime/src/__tests__/workspace-instructions.test.ts @@ -124,8 +124,80 @@ describe('workspace instructions prompt fragment', () => { assert.doesNotMatch(prompt, /PROJECT_SHOULD_BE_SQUEEZED/); }); }); + + it('collapses a CLAUDE.md symlinked to AGENTS.md into one block', async () => { + // Sharing one instruction file across agent CLIs by symlinking the names + // each of them reads is the documented way to do it, so the same bytes + // arriving twice must not be injected twice. + await withWorkspaceAndHome(async ({ workspaceRoot, homeDir }) => { + await writeFile(join(workspaceRoot, 'AGENTS.md'), 'SHARED_RULE\n', 'utf8'); + await symlink(join(workspaceRoot, 'AGENTS.md'), join(workspaceRoot, 'CLAUDE.md')); + + const prompt = await buildWorkspaceInstructionsPromptFragment(workspaceRoot, { homeDir }); + + assert.ok(prompt); + assert.equal(countBlocks(prompt), 1); + assert.equal(occurrences(prompt, 'SHARED_RULE'), 1); + assert.match(prompt, /file="AGENTS\.md"/); + }); + }); + + it('collapses byte-identical instruction files that are not links', async () => { + // Copying rather than linking is the other common way to share one set of + // rules; it is the same redundancy and deserves the same treatment. + await withWorkspaceAndHome(async ({ workspaceRoot, homeDir }) => { + await writeFile(join(workspaceRoot, 'AGENTS.md'), 'COPIED_RULE\n', 'utf8'); + await writeFile(join(workspaceRoot, 'CLAUDE.md'), 'COPIED_RULE\n', 'utf8'); + + const prompt = await buildWorkspaceInstructionsPromptFragment(workspaceRoot, { homeDir }); + + assert.ok(prompt); + assert.equal(countBlocks(prompt), 1); + assert.equal(occurrences(prompt, 'COPIED_RULE'), 1); + }); + }); + + it('keeps instruction files in one directory that genuinely differ', async () => { + await withWorkspaceAndHome(async ({ workspaceRoot, homeDir }) => { + await writeFile(join(workspaceRoot, 'AGENTS.md'), 'SHARED_RULE\n', 'utf8'); + await writeFile(join(workspaceRoot, 'CLAUDE.md'), 'CLAUDE_ONLY_RULE\n', 'utf8'); + + const prompt = await buildWorkspaceInstructionsPromptFragment(workspaceRoot, { homeDir }); + + assert.ok(prompt); + assert.equal(countBlocks(prompt), 2); + assert.match(prompt, /SHARED_RULE/); + assert.match(prompt, /CLAUDE_ONLY_RULE/); + }); + }); + + it('keeps identical instructions that live in different scopes', async () => { + // Global and project files are a deliberate layering. Identical bytes in + // both is a user saying the same thing at two scopes, not a duplicate. + await withWorkspaceAndHome(async ({ workspaceRoot, homeDir }) => { + const makaDir = join(homeDir, '.maka'); + await mkdir(makaDir, { recursive: true }); + await writeFile(join(makaDir, 'AGENTS.md'), 'SAME_TEXT\n', 'utf8'); + await writeFile(join(workspaceRoot, 'AGENTS.md'), 'SAME_TEXT\n', 'utf8'); + + const prompt = await buildWorkspaceInstructionsPromptFragment(workspaceRoot, { homeDir }); + + assert.ok(prompt); + assert.equal(countBlocks(prompt), 2); + assert.match(prompt, /scope="global"/); + assert.match(prompt, /scope="project"/); + }); + }); }); +function countBlocks(prompt: string): number { + return occurrences(prompt, ' Promise, ): Promise { diff --git a/packages/runtime/src/system-prompt/workspace-instructions.ts b/packages/runtime/src/system-prompt/workspace-instructions.ts index 188a476fa6..71042cabd3 100644 --- a/packages/runtime/src/system-prompt/workspace-instructions.ts +++ b/packages/runtime/src/system-prompt/workspace-instructions.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { readFile, realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -107,6 +108,16 @@ async function readWorkspaceInstructions( } const out: WorkspaceInstruction[] = []; + // Content already accepted from this directory. Sharing one instruction file + // across the names different agent CLIs read — Claude Code reads CLAUDE.md, + // Codex reads AGENTS.md, Gemini CLI reads GEMINI.md — is the documented way + // to do it, whether by symlink or by copy, so identical bytes arriving under + // two names here are redundancy rather than two instructions. Digesting the + // cleaned text catches both forms; `realpath` alone would miss the copy. + // Scoped to one directory on purpose: the same text at global and project + // scope is a user repeating themselves deliberately, and collapsing that + // would silently drop a layer. + const seenDigests = new Set(); for (const file of WORKSPACE_INSTRUCTION_FILES) { const candidate = join(root, file); let resolved: string; @@ -120,6 +131,11 @@ async function readWorkspaceInstructions( const raw = await readFile(resolved, 'utf8'); const cleaned = cleanPromptText(raw.trim()); if (!cleaned) continue; + // Digest before truncation: two files that diverge only past the cap are + // still different instructions. + const digest = createHash('sha256').update(cleaned).digest('hex'); + if (seenDigests.has(digest)) continue; + seenDigests.add(digest); const chars = Array.from(cleaned).length; out.push({ file,