Skip to content
Merged
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
72 changes: 72 additions & 0 deletions packages/runtime/src/__tests__/workspace-instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<workspace-instructions ');
}

function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}

async function withWorkspaceAndHome(
fn: (dirs: { workspaceRoot: string; homeDir: string }) => Promise<void>,
): Promise<void> {
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime/src/system-prompt/workspace-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] This set is recreated for each scope read, so the same physical directory is not actually deduplicated when it is both the global and project root. A production-function repro with cwd === <home>/.maka and one <home>/.maka/AGENTS.md produces blocks=2, copies=2, and scopes [global, project]: buildWorkspaceInstructionsPromptFragment calls this function twice, and each call starts with an empty set. This is inside the PR's stated ‘one directory’ contract, not an unrelated enhancement. It is non-blocking because ordinary project roots differ from ~/.maka, but editing the config directory (or a realpath alias of it) still reproduces the duplicate-budget bug. Please share dedupe state by resolved root, or special-case identical resolved roots, and add the same-root regression.

for (const file of WORKSPACE_INSTRUCTION_FILES) {
const candidate = join(root, file);
let resolved: string;
Expand All @@ -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,
Expand Down