Skip to content
Closed
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
49 changes: 26 additions & 23 deletions src/services/systemMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import type { Mock } from "bun:test";

describe("buildSystemMessage", () => {
let tempDir: string;
let projectDir: string;
let workspaceDir: string;
let globalDir: string;
let mockHomedir: Mock<typeof os.homedir>;

beforeEach(async () => {
// Create temp directory for test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "systemMessage-test-"));
projectDir = path.join(tempDir, "project");
workspaceDir = path.join(tempDir, "workspace");
globalDir = path.join(tempDir, ".cmux");
await fs.mkdir(projectDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(globalDir, { recursive: true });

Expand All @@ -33,9 +36,9 @@ describe("buildSystemMessage", () => {
});

test("includes mode-specific section when mode is provided", async () => {
// Write instruction file with mode section
// Write instruction file with mode section to projectDir
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
path.join(projectDir, "AGENTS.md"),
`# General Instructions
Always be helpful.

Expand All @@ -49,7 +52,7 @@ Use diagrams where appropriate.
id: "test-workspace",
name: "test-workspace",
projectName: "test-project",
projectPath: tempDir,
projectPath: projectDir,
};

const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan");
Expand All @@ -65,9 +68,9 @@ Use diagrams where appropriate.
});

test("excludes mode-specific section when mode is not provided", async () => {
// Write instruction file with mode section
// Write instruction file with mode section to projectDir
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
path.join(projectDir, "AGENTS.md"),
`# General Instructions
Always be helpful.

Expand All @@ -80,7 +83,7 @@ Focus on planning and design.
id: "test-workspace",
name: "test-workspace",
projectName: "test-project",
projectPath: tempDir,
projectPath: projectDir,
};

const systemMessage = await buildSystemMessage(metadata, workspaceDir);
Expand All @@ -94,7 +97,7 @@ Focus on planning and design.
expect(systemMessage).toContain("Focus on planning and design");
});

test("prefers workspace mode section over global mode section", async () => {
test("prefers project mode section over global mode section", async () => {
// Write global instruction file with mode section
await fs.writeFile(
path.join(globalDir, "AGENTS.md"),
Expand All @@ -105,33 +108,33 @@ Global plan instructions.
`
);

// Write workspace instruction file with mode section
// Write project instruction file with mode section
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
`# Workspace Instructions
path.join(projectDir, "AGENTS.md"),
`# Project Instructions

## Mode: Plan
Workspace plan instructions (should win).
Project plan instructions (should win).
`
);

const metadata: WorkspaceMetadata = {
id: "test-workspace",
name: "test-workspace",
projectName: "test-project",
projectPath: tempDir,
projectPath: projectDir,
};

const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan");

// Should include workspace mode section in the <plan> tag (workspace wins)
expect(systemMessage).toMatch(/<plan>\s*Workspace plan instructions \(should win\)\./s);
// Should include project mode section in the <plan> tag (project wins)
expect(systemMessage).toMatch(/<plan>\s*Project plan instructions \(should win\)\./s);
// Global instructions are still present in <custom-instructions> section (that's correct)
// But the mode-specific <plan> section should only have workspace content
// But the mode-specific <plan> section should only have project content
expect(systemMessage).not.toMatch(/<plan>[^<]*Global plan instructions/s);
});

test("falls back to global mode section when workspace has none", async () => {
test("falls back to global mode section when project has none", async () => {
// Write global instruction file with mode section
await fs.writeFile(
path.join(globalDir, "AGENTS.md"),
Expand All @@ -142,19 +145,19 @@ Global plan instructions.
`
);

// Write workspace instruction file WITHOUT mode section
// Write project instruction file WITHOUT mode section
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
`# Workspace Instructions
Just general workspace stuff.
path.join(projectDir, "AGENTS.md"),
`# Project Instructions
Just general project stuff.
`
);

const metadata: WorkspaceMetadata = {
id: "test-workspace",
name: "test-workspace",
projectName: "test-project",
projectPath: tempDir,
projectPath: projectDir,
};

const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan");
Expand All @@ -165,7 +168,7 @@ Just general workspace stuff.

test("handles mode with special characters by sanitizing tag name", async () => {
await fs.writeFile(
path.join(workspaceDir, "AGENTS.md"),
path.join(projectDir, "AGENTS.md"),
`## Mode: My-Special_Mode!
Special mode instructions.
`
Expand All @@ -175,7 +178,7 @@ Special mode instructions.
id: "test-workspace",
name: "test-workspace",
projectName: "test-project",
projectPath: tempDir,
projectPath: projectDir,
};

const systemMessage = await buildSystemMessage(metadata, workspaceDir, "My-Special_Mode!");
Expand Down
28 changes: 15 additions & 13 deletions src/services/systemMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ function getSystemDirectory(): string {
*
* Instruction sources are layered in this order:
* 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md)
* 2. Workspace instructions: <workspace>/AGENTS.md (+ AGENTS.local.md)
* 2. Project instructions: <projectPath>/AGENTS.md (+ AGENTS.local.md)
* 3. Mode-specific context (if mode provided): Extract a section titled "Mode: <mode>"
* (case-insensitive) from the instruction file. We search at most one section in
* precedence order: workspace instructions first, then global instructions.
* precedence order: project instructions first, then global instructions.
*
* Each instruction file location is searched for in priority order:
* - AGENTS.md
Expand All @@ -68,8 +68,8 @@ function getSystemDirectory(): string {
* If a base instruction file is found, its corresponding .local.md variant is also
* checked and appended when building the instruction set (useful for personal preferences not committed to git).
*
* @param metadata - Workspace metadata
* @param workspacePath - Absolute path to the workspace worktree directory
* @param metadata - Workspace metadata (contains projectPath for reading AGENTS.md)
* @param workspacePath - Absolute path to the workspace directory (for environment context)
* @param mode - Optional mode name (e.g., "plan", "exec") - looks for {MODE}.md files if provided
* @param additionalSystemInstructions - Optional additional system instructions to append at the end
* @returns System message string with all instruction sources combined
Expand All @@ -90,21 +90,22 @@ export async function buildSystemMessage(
}

const systemDir = getSystemDirectory();
const workspaceDir = workspacePath;
const projectDir = metadata.projectPath;

// Gather instruction sets from both global and workspace directories
// Global instructions apply first, then workspace-specific ones
const instructionDirectories = [systemDir, workspaceDir];
// Gather instruction sets from both global and project directories
// Global instructions apply first, then project-specific ones
// Note: We read from projectPath (the main repo) not workspacePath (the worktree)
const instructionDirectories = [systemDir, projectDir];
const instructionSegments = await gatherInstructionSets(instructionDirectories);
const customInstructions = instructionSegments.join("\n\n");

// Look for a "Mode: <mode>" section inside instruction sets, preferring workspace over global
// Look for a "Mode: <mode>" section inside instruction sets, preferring project over global
// This behavior is documented in docs/instruction-files.md - keep both in sync when changing.
let modeContent: string | null = null;
if (mode) {
const workspaceInstructions = await readInstructionSet(workspaceDir);
if (workspaceInstructions) {
modeContent = extractModeSection(workspaceInstructions, mode);
const projectInstructions = await readInstructionSet(projectDir);
if (projectInstructions) {
modeContent = extractModeSection(projectInstructions, mode);
Comment on lines 92 to +108

Choose a reason for hiding this comment

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

P1 Badge Reading AGENTS from project root drops branch-specific instructions

The new logic loads instructions from metadata.projectPath instead of the workspace directory. For local workspaces we create git worktrees under src/<project>/<branch>, and the branch’s files (including AGENTS.md) live under that worktree path. Using the project root now always reads whatever AGENTS.md is checked out in the main repository, so any instructions modified or added in the current workspace branch are ignored. This also diverges from the documented behavior in docs/instruction-files.md, which layers instructions from <workspace>/AGENTS.md. Please continue reading from the workspace path (or fall back to it) so each workspace can provide its own instructions.

Useful? React with 👍 / 👎.

}
if (!modeContent) {
const globalInstructions = await readInstructionSet(systemDir);
Expand All @@ -115,7 +116,8 @@ export async function buildSystemMessage(
}

// Build the final system message
const environmentContext = buildEnvironmentContext(workspaceDir);
// Use workspacePath for environment context (where code actually executes)
const environmentContext = buildEnvironmentContext(workspacePath);
const trimmedPrelude = PRELUDE.trim();
let systemMessage = `${trimmedPrelude}\n\n${environmentContext}`;

Expand Down
Loading