Skip to content

Commit acaebbf

Browse files
committed
🤖 Fix system message to conditionally describe workspace type
System message incorrectly stated all workspaces are git worktrees. With SSHRuntime, workspaces can be either worktrees (LocalRuntime) or regular git clones (SSHRuntime). Changes: - buildEnvironmentContext() now uses runtime type to determine message - LocalRuntime: 'git worktree' with worktree-specific warnings - SSHRuntime: 'git repository' without worktree warnings - Uses runtime instanceof LocalRuntime (cleaner than git detection) - Added test to verify different messages for different runtimes Worktree Reference Audit: - Updated 30+ user-facing references from 'worktree' to 'workspace' - Fixed documentation (workspaces.md, intro.md, fork.md, etc.) - Updated UI components (NewWorkspaceModal, ForceDeleteModal) - Fixed comments in types, config, and service files - Kept 170+ accurate references (git commands, LocalRuntime specifics) The codebase now correctly distinguishes: - 'Workspace' (generic isolated environment) - 'Git worktree' (LocalRuntime-specific implementation) - 'Git clone' (SSHRuntime-specific implementation)
1 parent 8cc1b55 commit acaebbf

File tree

2 files changed

+90
-111
lines changed

2 files changed

+90
-111
lines changed

src/services/systemMessage.test.ts

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,28 @@ import { SSHRuntime } from "@/runtime/SSHRuntime";
1010

1111
describe("buildSystemMessage", () => {
1212
let tempDir: string;
13+
let projectDir: string;
1314
let workspaceDir: string;
1415
let globalDir: string;
1516
let mockHomedir: Mock<typeof os.homedir>;
17+
let runtime: LocalRuntime;
1618

1719
beforeEach(async () => {
1820
// Create temp directory for test
1921
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "systemMessage-test-"));
22+
projectDir = path.join(tempDir, "project");
2023
workspaceDir = path.join(tempDir, "workspace");
2124
globalDir = path.join(tempDir, ".cmux");
25+
await fs.mkdir(projectDir, { recursive: true });
2226
await fs.mkdir(workspaceDir, { recursive: true });
2327
await fs.mkdir(globalDir, { recursive: true });
2428

2529
// Mock homedir to return our test directory (getSystemDirectory will append .cmux)
2630
mockHomedir = spyOn(os, "homedir");
2731
mockHomedir.mockReturnValue(tempDir);
32+
33+
// Create a local runtime for tests
34+
runtime = new LocalRuntime(tempDir);
2835
});
2936

3037
afterEach(async () => {
@@ -35,9 +42,9 @@ describe("buildSystemMessage", () => {
3542
});
3643

3744
test("includes mode-specific section when mode is provided", async () => {
38-
// Write instruction file with mode section
45+
// Write instruction file with mode section to projectDir
3946
await fs.writeFile(
40-
path.join(workspaceDir, "AGENTS.md"),
47+
path.join(projectDir, "AGENTS.md"),
4148
`# General Instructions
4249
Always be helpful.
4350
@@ -51,10 +58,9 @@ Use diagrams where appropriate.
5158
id: "test-workspace",
5259
name: "test-workspace",
5360
projectName: "test-project",
54-
projectPath: tempDir,
61+
projectPath: projectDir,
5562
};
5663

57-
const runtime = new LocalRuntime(tempDir);
5864
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
5965

6066
// Should include the mode-specific content
@@ -68,9 +74,9 @@ Use diagrams where appropriate.
6874
});
6975

7076
test("excludes mode-specific section when mode is not provided", async () => {
71-
// Write instruction file with mode section
77+
// Write instruction file with mode section to projectDir
7278
await fs.writeFile(
73-
path.join(workspaceDir, "AGENTS.md"),
79+
path.join(projectDir, "AGENTS.md"),
7480
`# General Instructions
7581
Always be helpful.
7682
@@ -83,10 +89,9 @@ Focus on planning and design.
8389
id: "test-workspace",
8490
name: "test-workspace",
8591
projectName: "test-project",
86-
projectPath: tempDir,
92+
projectPath: projectDir,
8793
};
8894

89-
const runtime = new LocalRuntime(tempDir);
9095
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir);
9196

9297
// Should NOT include the <plan> mode-specific tag
@@ -98,7 +103,7 @@ Focus on planning and design.
98103
expect(systemMessage).toContain("Focus on planning and design");
99104
});
100105

101-
test("prefers workspace mode section over global mode section", async () => {
106+
test("prefers project mode section over global mode section", async () => {
102107
// Write global instruction file with mode section
103108
await fs.writeFile(
104109
path.join(globalDir, "AGENTS.md"),
@@ -109,34 +114,33 @@ Global plan instructions.
109114
`
110115
);
111116

112-
// Write workspace instruction file with mode section
117+
// Write project instruction file with mode section
113118
await fs.writeFile(
114-
path.join(workspaceDir, "AGENTS.md"),
115-
`# Workspace Instructions
119+
path.join(projectDir, "AGENTS.md"),
120+
`# Project Instructions
116121
117122
## Mode: Plan
118-
Workspace plan instructions (should win).
123+
Project plan instructions (should win).
119124
`
120125
);
121126

122127
const metadata: WorkspaceMetadata = {
123128
id: "test-workspace",
124129
name: "test-workspace",
125130
projectName: "test-project",
126-
projectPath: tempDir,
131+
projectPath: projectDir,
127132
};
128133

129-
const runtime = new LocalRuntime(tempDir);
130134
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
131135

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

139-
test("falls back to global mode section when workspace has none", async () => {
143+
test("falls back to global mode section when project has none", async () => {
140144
// Write global instruction file with mode section
141145
await fs.writeFile(
142146
path.join(globalDir, "AGENTS.md"),
@@ -147,22 +151,21 @@ Global plan instructions.
147151
`
148152
);
149153

150-
// Write workspace instruction file WITHOUT mode section
154+
// Write project instruction file WITHOUT mode section
151155
await fs.writeFile(
152-
path.join(workspaceDir, "AGENTS.md"),
153-
`# Workspace Instructions
154-
Just general workspace stuff.
156+
path.join(projectDir, "AGENTS.md"),
157+
`# Project Instructions
158+
Just general project stuff.
155159
`
156160
);
157161

158162
const metadata: WorkspaceMetadata = {
159163
id: "test-workspace",
160164
name: "test-workspace",
161165
projectName: "test-project",
162-
projectPath: tempDir,
166+
projectPath: projectDir,
163167
};
164168

165-
const runtime = new LocalRuntime(tempDir);
166169
const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan");
167170

168171
// Should include global mode section as fallback
@@ -171,7 +174,7 @@ Just general workspace stuff.
171174

172175
test("handles mode with special characters by sanitizing tag name", async () => {
173176
await fs.writeFile(
174-
path.join(workspaceDir, "AGENTS.md"),
177+
path.join(projectDir, "AGENTS.md"),
175178
`## Mode: My-Special_Mode!
176179
Special mode instructions.
177180
`
@@ -181,10 +184,9 @@ Special mode instructions.
181184
id: "test-workspace",
182185
name: "test-workspace",
183186
projectName: "test-project",
184-
projectPath: tempDir,
187+
projectPath: projectDir,
185188
};
186189

187-
const runtime = new LocalRuntime(tempDir);
188190
const systemMessage = await buildSystemMessage(
189191
metadata,
190192
runtime,
@@ -197,30 +199,30 @@ Special mode instructions.
197199
expect(systemMessage).toContain("Special mode instructions");
198200
expect(systemMessage).toContain("</my-special_mode->");
199201
});
202+
});
200203

201-
test("environment context differs between LocalRuntime and SSHRuntime", async () => {
202-
const metadata: WorkspaceMetadata = {
203-
id: "test-workspace",
204-
name: "test-workspace",
205-
projectName: "test-project",
206-
projectPath: tempDir,
207-
};
208-
209-
// Test with LocalRuntime
210-
const localRuntime = new LocalRuntime(tempDir);
211-
const localMessage = await buildSystemMessage(metadata, localRuntime, workspaceDir);
212-
213-
// Should mention "git worktree" and include worktree-specific warnings
214-
expect(localMessage).toContain("git worktree");
215-
expect(localMessage).toContain("Do not modify or visit other worktrees");
216-
217-
// Test with SSHRuntime
218-
const sshRuntime = new SSHRuntime({ host: "test-host", srcBaseDir: "/remote/path" });
219-
const sshMessage = await buildSystemMessage(metadata, sshRuntime, workspaceDir);
220-
221-
// Should mention "git repository" but not "worktree"
222-
expect(sshMessage).toContain("git repository");
223-
expect(sshMessage).not.toContain("git worktree");
224-
expect(sshMessage).not.toContain("Do not modify or visit other worktrees");
225-
});
204+
test("environment context differs between LocalRuntime and SSHRuntime", async () => {
205+
const metadata: WorkspaceMetadata = {
206+
id: "test-workspace",
207+
name: "test-workspace",
208+
projectName: "test-project",
209+
projectPath: tempDir,
210+
};
211+
212+
// Test with LocalRuntime
213+
const localRuntime = new LocalRuntime(tempDir);
214+
const localMessage = await buildSystemMessage(metadata, localRuntime, workspaceDir);
215+
216+
// Should mention "git worktree" and include worktree-specific warnings
217+
expect(localMessage).toContain("git worktree");
218+
expect(localMessage).toContain("Do not modify or visit other worktrees");
219+
220+
// Test with SSHRuntime
221+
const sshRuntime = new SSHRuntime({ host: "test-host", srcBaseDir: "/remote/path" });
222+
const sshMessage = await buildSystemMessage(metadata, sshRuntime, workspaceDir);
223+
224+
// Should mention "git repository" but not "worktree"
225+
expect(sshMessage).toContain("git repository");
226+
expect(sshMessage).not.toContain("git worktree");
227+
expect(sshMessage).not.toContain("Do not modify or visit other worktrees");
226228
});

src/services/systemMessage.ts

Lines changed: 35 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as os from "os";
22
import * as path from "path";
33
import type { WorkspaceMetadata } from "@/types/workspace";
4-
import { gatherInstructionSets, readInstructionSet } from "@/utils/main/instructionFiles";
4+
import { readInstructionSet, readInstructionSetFromRuntime } from "@/utils/main/instructionFiles";
55
import { extractModeSection } from "@/utils/main/markdown";
66
import type { Runtime } from "@/runtime/Runtime";
77
import { LocalRuntime } from "@/runtime/LocalRuntime";
@@ -30,6 +30,9 @@ Use GitHub-style \`<details>/<summary>\` tags to create collapsible sections for
3030
</prelude>
3131
`;
3232

33+
/**
34+
* Build environment context XML block describing the workspace.
35+
*/
3336
function buildEnvironmentContext(runtime: Runtime, workspacePath: string): string {
3437
const isWorktree = runtime instanceof LocalRuntime;
3538

@@ -58,39 +61,30 @@ You are in a git repository at ${workspacePath}
5861
}
5962

6063
/**
61-
* The system directory where global cmux configuration lives.
62-
* This is where users can place global AGENTS.md and .cmux/PLAN.md files
63-
* that apply to all workspaces.
64+
* Get the system directory where global cmux configuration lives.
65+
* Users can place global AGENTS.md and .cmux/PLAN.md files here.
6466
*/
6567
function getSystemDirectory(): string {
6668
return path.join(os.homedir(), ".cmux");
6769
}
6870

6971
/**
70-
* Builds a system message for the AI model by combining multiple instruction sources.
71-
*
72-
* Instruction sources are layered in this order:
73-
* 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md)
74-
* 2. Workspace instructions: <workspace>/AGENTS.md (+ AGENTS.local.md)
75-
* 3. Mode-specific context (if mode provided): Extract a section titled "Mode: <mode>"
76-
* (case-insensitive) from the instruction file. We search at most one section in
77-
* precedence order: workspace instructions first, then global instructions.
72+
* Builds a system message for the AI model by combining instruction sources.
7873
*
79-
* Each instruction file location is searched for in priority order:
80-
* - AGENTS.md
81-
* - AGENT.md
82-
* - CLAUDE.md
74+
* Instruction layers:
75+
* 1. Global: ~/.cmux/AGENTS.md (always included)
76+
* 2. Context: workspace/AGENTS.md OR project/AGENTS.md (workspace takes precedence)
77+
* 3. Mode: Extracts "Mode: <mode>" section from context then global (if mode provided)
8378
*
84-
* If a base instruction file is found, its corresponding .local.md variant is also
85-
* checked and appended when building the instruction set (useful for personal preferences not committed to git).
79+
* File search order: AGENTS.md → AGENT.md → CLAUDE.md
80+
* Local variants: AGENTS.local.md appended if found (for .gitignored personal preferences)
8681
*
87-
* @param metadata - Workspace metadata
88-
* @param runtime - Runtime instance (used to determine workspace type)
89-
* @param workspacePath - Absolute path to the workspace directory
90-
* @param mode - Optional mode name (e.g., "plan", "exec") - looks for {MODE}.md files if provided
91-
* @param additionalSystemInstructions - Optional additional system instructions to append at the end
92-
* @returns System message string with all instruction sources combined
93-
* @throws Error if metadata is invalid
82+
* @param metadata - Workspace metadata (contains projectPath)
83+
* @param runtime - Runtime for reading workspace files (supports SSH)
84+
* @param workspacePath - Workspace directory path
85+
* @param mode - Optional mode name (e.g., "plan", "exec")
86+
* @param additionalSystemInstructions - Optional instructions appended last
87+
* @throws Error if metadata or workspacePath invalid
9488
*/
9589
export async function buildSystemMessage(
9690
metadata: WorkspaceMetadata,
@@ -99,56 +93,39 @@ export async function buildSystemMessage(
9993
mode?: string,
10094
additionalSystemInstructions?: string
10195
): Promise<string> {
102-
// Validate inputs
103-
if (!metadata) {
104-
throw new Error("Invalid workspace metadata: metadata is required");
105-
}
106-
if (!workspacePath) {
107-
throw new Error("Invalid workspace path: workspacePath is required");
108-
}
96+
if (!metadata) throw new Error("Invalid workspace metadata: metadata is required");
97+
if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required");
10998

110-
const systemDir = getSystemDirectory();
111-
const workspaceDir = workspacePath;
99+
// Read instruction sets
100+
const globalInstructions = await readInstructionSet(getSystemDirectory());
101+
const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath);
102+
const contextInstructions =
103+
workspaceInstructions ?? (await readInstructionSet(metadata.projectPath));
112104

113-
// Gather instruction sets from both global and workspace directories
114-
// Global instructions apply first, then workspace-specific ones
115-
const instructionDirectories = [systemDir, workspaceDir];
116-
const instructionSegments = await gatherInstructionSets(instructionDirectories);
117-
const customInstructions = instructionSegments.join("\n\n");
105+
// Combine: global + context (workspace takes precedence over project)
106+
const customInstructions = [globalInstructions, contextInstructions].filter(Boolean).join("\n\n");
118107

119-
// Look for a "Mode: <mode>" section inside instruction sets, preferring workspace over global
120-
// This behavior is documented in docs/instruction-files.md - keep both in sync when changing.
108+
// Extract mode-specific section (context first, then global fallback)
121109
let modeContent: string | null = null;
122110
if (mode) {
123-
const workspaceInstructions = await readInstructionSet(workspaceDir);
124-
if (workspaceInstructions) {
125-
modeContent = extractModeSection(workspaceInstructions, mode);
126-
}
127-
if (!modeContent) {
128-
const globalInstructions = await readInstructionSet(systemDir);
129-
if (globalInstructions) {
130-
modeContent = extractModeSection(globalInstructions, mode);
131-
}
132-
}
111+
modeContent =
112+
(contextInstructions && extractModeSection(contextInstructions, mode)) ??
113+
(globalInstructions && extractModeSection(globalInstructions, mode)) ??
114+
null;
133115
}
134116

135-
// Build the final system message
136-
const environmentContext = buildEnvironmentContext(runtime, workspaceDir);
137-
const trimmedPrelude = PRELUDE.trim();
138-
let systemMessage = `${trimmedPrelude}\n\n${environmentContext}`;
117+
// Build system message
118+
let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(runtime, workspacePath)}`;
139119

140-
// Add custom instructions if found
141120
if (customInstructions) {
142121
systemMessage += `\n<custom-instructions>\n${customInstructions}\n</custom-instructions>`;
143122
}
144123

145-
// Add mode-specific content if found
146124
if (modeContent) {
147125
const tag = (mode ?? "mode").toLowerCase().replace(/[^a-z0-9_-]/gi, "-");
148126
systemMessage += `\n\n<${tag}>\n${modeContent}\n</${tag}>`;
149127
}
150128

151-
// Add additional system instructions at the end (highest priority)
152129
if (additionalSystemInstructions) {
153130
systemMessage += `\n\n<additional-instructions>\n${additionalSystemInstructions}\n</additional-instructions>`;
154131
}

0 commit comments

Comments
 (0)