diff --git a/docs/agents/system-prompt.mdx b/docs/agents/system-prompt.mdx index 3d256ae69e..5a5b14035d 100644 --- a/docs/agents/system-prompt.mdx +++ b/docs/agents/system-prompt.mdx @@ -71,8 +71,16 @@ Messages wrapped in are internal sub-agent outputs from Mu /** * Build environment context XML block describing the workspace. - * @param workspacePath - Workspace directory path + * + * Sub-project workspaces are framed identically to regular projects: the cwd + * (already the sub-project directory thanks to resolveWorkspaceExecutionPath) + * is presented as "the project" with no parent-repo callout. The agent does + * not need to know about the parent's existence to do work — it just sees a + * project rooted at this directory. + * + * @param workspacePath - Workspace directory path (cwd; for sub-projects this is already the sub-project path) * @param runtimeType - Runtime type (local, worktree, ssh, docker) + * @param bestOf - Best-of grouping metadata for sibling sub-agent batches */ function buildEnvironmentContext( workspacePath: string, diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 187e714652..071b8a56a7 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -1585,8 +1585,16 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "/**", " * Build environment context XML block describing the workspace.", - " * @param workspacePath - Workspace directory path", + " *", + " * Sub-project workspaces are framed identically to regular projects: the cwd", + " * (already the sub-project directory thanks to resolveWorkspaceExecutionPath)", + ' * is presented as "the project" with no parent-repo callout. The agent does', + " * not need to know about the parent's existence to do work — it just sees a", + " * project rooted at this directory.", + " *", + " * @param workspacePath - Workspace directory path (cwd; for sub-projects this is already the sub-project path)", " * @param runtimeType - Runtime type (local, worktree, ssh, docker)", + " * @param bestOf - Best-of grouping metadata for sibling sub-agent batches", " */", "function buildEnvironmentContext(", " workspacePath: string,", diff --git a/src/node/services/systemMessage.test.ts b/src/node/services/systemMessage.test.ts index f93efbe734..81d19bcba9 100644 --- a/src/node/services/systemMessage.test.ts +++ b/src/node/services/systemMessage.test.ts @@ -540,4 +540,557 @@ OpenAI-only instructions. }); } }); + + describe("sub-project workspaces look like regular projects", () => { + // Sub-project workspaces share the parent project's checkout but cwd into + // a descendant directory. From the agent's perspective they should be + // indistinguishable from a single-project workspace rooted at that cwd: + // no parent-repo callout in the prompt, no inherited parent AGENTS.md, + // no special "sub-project" framing in tool descriptions. + async function setupSubProjectFixture(): Promise<{ + subProjectMetadata: WorkspaceMetadata; + regularMetadata: WorkspaceMetadata; + subProjectCwd: string; + parentRoot: string; + }> { + const subProjectAbs = path.join(workspaceDir, "packages", "api"); + await fs.mkdir(subProjectAbs, { recursive: true }); + + const subProjectMetadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "test-project", + projectPath: workspaceDir, + subProjectPath: subProjectAbs, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + // Regular single-project workspace whose project path IS the sub-project + // directory. Used as the reference oracle: the sub-project workspace's + // prompt at the same cwd must match this byte-for-byte in . + const regularMetadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "test-project", + projectPath: subProjectAbs, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + return { + subProjectMetadata, + regularMetadata, + subProjectCwd: subProjectAbs, + parentRoot: workspaceDir, + }; + } + + test("environment block is identical to a regular single-project workspace at the same cwd", async () => { + // Core invariant: presence of `subProjectPath` in metadata must not + // change the block. The agent sees the same description + // and lines whether the workspace is configured as a sub-project or as + // a regular project rooted at that directory. + const { subProjectMetadata, regularMetadata, subProjectCwd } = await setupSubProjectFixture(); + + const subProjectMessage = await buildSystemMessage( + subProjectMetadata, + runtime, + subProjectCwd + ); + const regularMessage = await buildSystemMessage(regularMetadata, runtime, subProjectCwd); + + const subEnvironment = extractTagContent(subProjectMessage, "environment"); + const regularEnvironment = extractTagContent(regularMessage, "environment"); + expect(subEnvironment).toBe(regularEnvironment); + }); + + test("environment block does not mention sub-project framing or relative-path nudges", async () => { + // Regression guard against the rejected direction (PR #3244 v1) where + // the prompt called out "the `packages/api` sub-project of the X at Y" + // and added a relative-paths preamble. The agent should see the cwd as + // a regular project root with no parent-repo context. (The parentRoot + // is a path prefix of subProjectCwd, so checking for it directly is + // ambiguous — the byte-equality test above already proves the env + // block contains no parent-specific framing.) + const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture(); + + const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd); + const environment = extractTagContent(systemMessage, "environment") ?? ""; + + expect(environment).not.toContain("sub-project"); + expect(environment).not.toMatch(/Prefer paths relative to/); + }); + + test("AGENTS.md is concatenated parent → sub-project with H1 path-source headings", async () => { + // Sub-projects inherit parent conventions: parent AGENTS.md is glued + // before the sub-project's own AGENTS.md so the agent sees a single + // combined block. Each segment opens with an H1 heading whose body + // is the source path relative to the cwd (e.g. `` # `../../AGENTS.md` `` + // or `` # `./AGENTS.md` ``) so the agent can disambiguate which root + // any relative path references in each segment should resolve + // against. The H1 also bounds scoped `## Tool:` / `## Model:` + // sections inside the segment, preventing them from spanning across + // the segment join into the next segment's narrative. + // + // We deliberately avoid the rejected v1 framing of `# Project context + // (root: ...)` / `# Sub-project context (root: ...)` — the H1 here is + // just a path note, not a structural callout dressing the sub-project + // up as a special feature. + const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture(); + + await fs.writeFile( + path.join(parentRoot, "AGENTS.md"), + "PARENT_MARKER: parent project conventions.\n" + ); + await fs.writeFile( + path.join(subProjectCwd, "AGENTS.md"), + "SUB_MARKER: sub-project specific conventions.\n" + ); + + const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).toContain("PARENT_MARKER"); + expect(customInstructions).toContain("SUB_MARKER"); + // `packages/api` is two levels deep, so the parent AGENTS.md is + // `../../AGENTS.md` from the cwd. + expect(customInstructions).toContain("# `../../AGENTS.md`"); + expect(customInstructions).toContain("# `./AGENTS.md`"); + // Each H1 must precede its corresponding content. + expect(customInstructions.indexOf("# `../../AGENTS.md`")).toBeLessThan( + customInstructions.indexOf("PARENT_MARKER") + ); + expect(customInstructions.indexOf("# `./AGENTS.md`")).toBeLessThan( + customInstructions.indexOf("SUB_MARKER") + ); + // Parent first so general rules anchor before the more specific + // sub-project overrides. + expect(customInstructions.indexOf("PARENT_MARKER")).toBeLessThan( + customInstructions.indexOf("SUB_MARKER") + ); + // Regression guard against the rejected v1 verbose framing. + expect(customInstructions).not.toContain("# Project context (root:"); + expect(customInstructions).not.toContain("# Sub-project context (root:"); + }); + + test("relative-path heading depth tracks the sub-project nesting depth", async () => { + // A single-segment sub-project (`api` instead of `packages/api`) only + // needs one `../` level. Verifies the depth computation rather than + // hard-coding the fixture's two-level structure. + const subProjectAbs = path.join(workspaceDir, "api"); + await fs.mkdir(subProjectAbs, { recursive: true }); + const metadata: WorkspaceMetadata = { + id: "test-workspace", + name: "test-workspace", + projectName: "test-project", + projectPath: workspaceDir, + subProjectPath: subProjectAbs, + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }; + + await fs.writeFile( + path.join(workspaceDir, "AGENTS.md"), + "DEPTH_TEST_PARENT_MARKER: depth=1 parent.\n" + ); + + const systemMessage = await buildSystemMessage(metadata, runtime, subProjectAbs); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).toContain("# `../AGENTS.md`"); + expect(customInstructions).not.toContain("# `../../AGENTS.md`"); + expect(customInstructions).toContain("DEPTH_TEST_PARENT_MARKER"); + }); + + test("only-parent AGENTS.md is loaded when sub-project has none", async () => { + // If only the parent has AGENTS.md, sub-project workspaces should + // still inherit it — and the H1 path heading lets the agent know + // it's reading the parent's, not its own. + const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture(); + await fs.writeFile( + path.join(parentRoot, "AGENTS.md"), + "PARENT_ONLY_MARKER: only parent conventions.\n" + ); + + const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).toContain("PARENT_ONLY_MARKER"); + expect(customInstructions).toContain("# `../../AGENTS.md`"); + expect(customInstructions).not.toContain("# `./AGENTS.md`"); + }); + + test("only-sub-project AGENTS.md is loaded when parent has none", async () => { + // Symmetric: a sub-project with its own AGENTS.md but no parent + // AGENTS.md should still load the sub-project's own with the matching + // `./AGENTS.md` heading. + const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture(); + await fs.writeFile( + path.join(subProjectCwd, "AGENTS.md"), + "SUB_ONLY_MARKER: only sub-project conventions.\n" + ); + + const systemMessage = await buildSystemMessage(subProjectMetadata, runtime, subProjectCwd); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).toContain("SUB_ONLY_MARKER"); + expect(customInstructions).toContain("# `./AGENTS.md`"); + expect(customInstructions).not.toContain("# `../../AGENTS.md`"); + }); + + test("regular non-sub-project workspaces emit no path-source heading", async () => { + // Non-sub-project workspaces preserve historical behavior: the cwd's + // AGENTS.md is loaded verbatim with no path-source heading or + // comment. Otherwise we'd be subtly changing the prompt for every + // regular project workspace. + const { regularMetadata, subProjectCwd } = await setupSubProjectFixture(); + await fs.writeFile( + path.join(subProjectCwd, "AGENTS.md"), + "REGULAR_MARKER: regular project conventions.\n" + ); + + const systemMessage = await buildSystemMessage(regularMetadata, runtime, subProjectCwd); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).toContain("REGULAR_MARKER"); + expect(customInstructions).not.toContain("# `"); + expect(customInstructions).not.toContain("` comment lives BEFORE the section and + // isn't part of the extracted body. Without injecting the comment + // inside each scoped section, scoped tool/model instructions lose + // the parent-vs-subproject provenance — defeating the purpose of + // the comment for any path-sensitive guidance authored under a + // scoped heading. + // + // The injection must also play nicely with + // `stripScopedInstructionSections`, which deletes the entire + // scoped section from : the inner comment + // disappears with it (no leftover comment alone), while the + // top-level comment for the surviving narrative still anchors + // the segment. + const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture(); + + await fs.writeFile( + path.join(parentRoot, "AGENTS.md"), + `Parent narrative. + +## Tool: bash +PARENT_BASH_RULE: parent's bash convention. + +## Model: anthropic:claude-sonnet-4-20250514 +PARENT_MODEL_RULE: parent model preference. +` + ); + await fs.writeFile( + path.join(subProjectCwd, "AGENTS.md"), + `Sub-project narrative. + +## Tool: bash +SUB_BASH_RULE: sub-project's bash convention. + +## Model: anthropic:claude-sonnet-4-20250514 +SUB_MODEL_RULE: sub-project model override. +` + ); + + const modelString = "anthropic:claude-sonnet-4-20250514"; + const toolInstructions = await readToolInstructions( + subProjectMetadata, + runtime, + subProjectCwd, + modelString + ); + + const bash = toolInstructions.bash ?? ""; + // Both bash bodies must appear in the per-tool extraction with their + // respective path-source comments. + expect(bash).toContain("PARENT_BASH_RULE"); + expect(bash).toContain(""); + expect(bash).toContain("SUB_BASH_RULE"); + expect(bash).toContain(""); + // Each comment precedes its corresponding rule so the agent reads + // "this rule comes from this path" linearly. + expect(bash.indexOf("")).toBeLessThan( + bash.indexOf("PARENT_BASH_RULE") + ); + expect(bash.indexOf("")).toBeLessThan(bash.indexOf("SUB_BASH_RULE")); + + const systemMessage = await buildSystemMessage( + subProjectMetadata, + runtime, + subProjectCwd, + undefined, + modelString + ); + + // Codex-flagged regression (PR #3244): when both parent and sub-project + // define matching `## Model: ...` sections, both bodies must appear in + // the per-model section (the previous singular `extractModelSection` + // returned only the parent's, silently dropping the sub-project's + // override). Sub-project body comes second so its rule overrides the + // parent's via order of presentation. + const sonnetSection = + extractTagContent(systemMessage, "model-anthropic-claude-sonnet-4-20250514") ?? ""; + expect(sonnetSection).toContain("PARENT_MODEL_RULE"); + expect(sonnetSection).toContain("SUB_MODEL_RULE"); + expect(sonnetSection).toContain(""); + expect(sonnetSection).toContain(""); + expect(sonnetSection.indexOf("PARENT_MODEL_RULE")).toBeLessThan( + sonnetSection.indexOf("SUB_MODEL_RULE") + ); + + // Sanity check: still strips scoped sections + // (including the inner comment) so the bash/model rules don't leak + // into the unscoped instructions block. + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + expect(customInstructions).not.toContain("PARENT_BASH_RULE"); + expect(customInstructions).not.toContain("SUB_BASH_RULE"); + expect(customInstructions).not.toContain("PARENT_MODEL_RULE"); + expect(customInstructions).not.toContain("SUB_MODEL_RULE"); + // Top-level H1 path headings survive (they're outside scoped sections, + // and they bound the scoped sections to their own segment so the + // parent's `## Model: ...` can't sweep the sub-project's narrative + // into its strip range). + expect(customInstructions).toContain("# `../../AGENTS.md`"); + expect(customInstructions).toContain("# `./AGENTS.md`"); + // Inner HTML comments inside scoped sections must NOT leak into the + // block: they're stripped along with their + // section. + expect(customInstructions).not.toContain(""); + expect(customInstructions).not.toContain(""); + }); + + test("does not inject path-source comments inside fenced code examples", async () => { + // Codex-flagged regression (PR #3244): a scoped-looking line inside a + // fenced code block (e.g. an AGENTS.md authored with a `markdown` + // documentation example showing how to structure scoped sections) + // must NOT get a path-source comment injected. The downstream + // markdown parser used by stripScopedInstructionSections / + // extractToolSection correctly skips fenced content, so injecting + // there would only corrupt the documented example without serving + // any provenance purpose. + const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture(); + + // Use a string template with explicit newlines so the inner triple- + // backticks survive being embedded in this JS source file. + const parentAgents = [ + "Parent narrative.", + "", + "Here's an example of how to author scoped sections:", + "", + "```markdown", + "## Tool: bash", + "EXAMPLE_FENCE_CONTENT: example body inside the fence.", + "```", + "", + "## Tool: bash", + "PARENT_BASH_RULE: parent's real bash convention.", + "", + ].join("\n"); + + await fs.writeFile(path.join(parentRoot, "AGENTS.md"), parentAgents); + + const modelString = "anthropic:claude-sonnet-4-20250514"; + const systemMessage = await buildSystemMessage( + subProjectMetadata, + runtime, + subProjectCwd, + undefined, + modelString + ); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + // The fenced example is preserved verbatim in + // (stripScopedInstructionSections doesn't strip fenced content). + expect(customInstructions).toContain("EXAMPLE_FENCE_CONTENT"); + // Critically, no `` comment must appear + // between the fenced `## Tool: bash` line and the example body. + const fenceStart = customInstructions.indexOf("```markdown"); + expect(fenceStart).toBeGreaterThanOrEqual(0); + const fenceEnd = customInstructions.indexOf("```", fenceStart + 3); + expect(fenceEnd).toBeGreaterThan(fenceStart); + const fencedRegion = customInstructions.slice(fenceStart, fenceEnd); + expect(fencedRegion).not.toContain(""); + // The fenced example body must NOT be in the tool extraction either — + // it lived inside a fenced code block that the markdown parser ignores. + expect(bash).not.toContain("EXAMPLE_FENCE_CONTENT"); + }); + + test("treats lines with info strings as still-inside-the-fence per CommonMark §4.5", async () => { + // Codex-flagged regression (PR #3244): a closing fence in CommonMark + // must carry NO info string — only optional trailing whitespace. An + // inner line like `` ```ts `` (info string "ts") inside an outer + // `` ```markdown `` fence is NOT a valid closer; markdown-it keeps + // the outer fence open. A naive scanner that closes on any matching + // marker would prematurely "close" the outer fence at `` ```ts ``, + // then the next real `` ``` `` would open a fresh fence, and any + // `## Tool: …` lines in between would erroneously receive injected + // provenance comments — corrupting the documented example. + const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture(); + + const parentAgents = [ + "Parent narrative.", + "", + "Here's how to nest fences in a markdown example:", + "", + "````markdown", + "## Tool: bash", + "EXAMPLE_OUTER_BODY: outer body before nested fence.", + "", + "```ts", + "console.log('inner ts content');", + "```", + "", + "## Tool: foo", + "EXAMPLE_INNER_BODY: still inside the outer markdown fence.", + "````", + "", + "## Tool: bash", + "PARENT_BASH_RULE: real, non-fenced bash convention.", + "", + ].join("\n"); + + await fs.writeFile(path.join(parentRoot, "AGENTS.md"), parentAgents); + + const modelString = "anthropic:claude-sonnet-4-20250514"; + const systemMessage = await buildSystemMessage( + subProjectMetadata, + runtime, + subProjectCwd, + undefined, + modelString + ); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + // The outer fenced example must be present verbatim — both the + // outer body before the inner ```ts opener AND the inner body + // after the inner ``` closer (which is itself still inside the + // outer fence per CommonMark). + expect(customInstructions).toContain("EXAMPLE_OUTER_BODY"); + expect(customInstructions).toContain("EXAMPLE_INNER_BODY"); + + // Critically, no provenance comment must appear anywhere inside + // the outer fenced region. We anchor on the outer ````markdown + // opener and the closing ```` — everything between them must be + // free of `"); + expect(bash).not.toContain("EXAMPLE_OUTER_BODY"); + expect(bash).not.toContain("EXAMPLE_INNER_BODY"); + }); + + test("injects provenance comment for ATX headings with up to 3 leading spaces (CommonMark §4.2)", async () => { + // Codex-flagged regression (PR #3244): CommonMark §4.2 lets ATX + // headings start with 1–3 spaces of indentation and still be parsed + // as headings. markdown-it (and therefore extractToolSection / + // stripScopedInstructionSections) recognizes these as scoped + // sections, so the scanner here must too — otherwise the indented + // heading's body would survive into the per-tool prompt without the + // path-source comment, defeating the provenance hint for any + // AGENTS.md authored with that style. + const { subProjectMetadata, parentRoot, subProjectCwd } = await setupSubProjectFixture(); + + const parentAgents = [ + "Parent narrative.", + "", + " ## Tool: bash", + "INDENTED_BASH_RULE: parent's indented bash convention.", + "", + " ## Model: anthropic:claude-sonnet-4-20250514", + "INDENTED_MODEL_RULE: parent's indented model preference.", + "", + ].join("\n"); + + await fs.writeFile(path.join(parentRoot, "AGENTS.md"), parentAgents); + + const modelString = "anthropic:claude-sonnet-4-20250514"; + const toolInstructions = await readToolInstructions( + subProjectMetadata, + runtime, + subProjectCwd, + modelString + ); + const bash = toolInstructions.bash ?? ""; + expect(bash).toContain("INDENTED_BASH_RULE"); + expect(bash).toContain(""); + expect(bash.indexOf("")).toBeLessThan( + bash.indexOf("INDENTED_BASH_RULE") + ); + + const systemMessage = await buildSystemMessage( + subProjectMetadata, + runtime, + subProjectCwd, + undefined, + modelString + ); + const sonnetSection = + extractTagContent(systemMessage, "model-anthropic-claude-sonnet-4-20250514") ?? ""; + expect(sonnetSection).toContain("INDENTED_MODEL_RULE"); + expect(sonnetSection).toContain(""); + expect(sonnetSection.indexOf("")).toBeLessThan( + sonnetSection.indexOf("INDENTED_MODEL_RULE") + ); + }); + + test("falls back to cwd-only AGENTS.md when sub-project metadata is stale", async () => { + // If subProjectPath doesn't sit under projectPath (corrupted persisted + // state, or a cwd that doesn't end with the expected suffix), we + // can't safely derive the parent root. Degrade to reading just the + // cwd's AGENTS.md — historical behavior — with no path-source comment + // since there's only one source. + const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture(); + const stale = { ...subProjectMetadata, subProjectPath: "/elsewhere/api" }; + + await fs.writeFile( + path.join(parentRoot, "AGENTS.md"), + "PARENT_MARKER: should not be inherited from stale metadata.\n" + ); + await fs.writeFile( + path.join(subProjectCwd, "AGENTS.md"), + "SUB_MARKER: should still appear.\n" + ); + + const systemMessage = await buildSystemMessage(stale, runtime, subProjectCwd); + const customInstructions = extractTagContent(systemMessage, "custom-instructions") ?? ""; + + expect(customInstructions).not.toContain("PARENT_MARKER"); + expect(customInstructions).toContain("SUB_MARKER"); + expect(customInstructions).not.toContain("# `"); + expect(customInstructions).not.toContain("`; + const lines = content.split("\n"); + const out: string[] = []; + // Tracks the marker that opened the current fence (e.g. "```" or "~~~~"). + // Closing fences must use the same character as the opener and at least + // as many delimiters; null means we are not currently inside a fence. + let openFence: string | null = null; + + for (const line of lines) { + out.push(line); + + const fenceMatch = FENCE_LINE_REGEX.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1]; + const trailing = fenceMatch[2]; + if (openFence === null) { + // Opening fence — info string is allowed and ignored here. + openFence = marker; + } else if ( + marker.startsWith(openFence[0]) && + marker.length >= openFence.length && + trailing.trim().length === 0 + ) { + // Closing fence — must use the same character as the opener, be + // at least as long, and carry NO info string (only optional + // trailing whitespace). Per CommonMark §4.5, an inner line like + // `` ```ts `` inside an outer `` ```markdown `` fence is NOT a + // closer and the outer fence stays open. + openFence = null; + } + continue; + } + + if (openFence === null && SCOPED_HEADING_LINE_REGEX.test(line)) { + out.push(comment); + } + } + + return `# \`${sourcePath}\`\n\n${out.join("\n")}`; +} + +interface SubProjectLayout { + /** Runtime path of the parent project root (cwd with sub-project segment stripped). */ + parentRoot: string; + /** + * Path of the parent's AGENTS.md relative to the sub-project cwd, in + * forward-slash form (e.g. `../../AGENTS.md` for a `packages/api` + * sub-project). Used as a lightweight HTML-comment hint so the agent + * can resolve path references in the parent segment to the parent root. + * + * Always uses `AGENTS.md` as the canonical filename label even when the + * actual file on disk is `AGENT.md` or `CLAUDE.md`; the hint conveys the + * source *directory*, not the specific filename. + */ + parentAgentsMdRelativePath: string; } /** - * Compute the path of `subProjectPath` relative to `projectPath` for use under - * the workspace's own checkout. Returns `null` if the recorded sub-project - * path is not actually a descendant of the parent project (stale persisted - * state) — callers should treat that as "no sub-project segment" and fall - * back to parent-only instructions rather than failing. + * Derive the parent project root and a relative-path hint for a sub-project + * workspace by stripping the recorded sub-project relative segment off the + * cwd. Returns null when: + * + * - The workspace is not configured as a sub-project (no `subProjectPath`). + * - The recorded sub-project path is not a descendant of the parent project + * (stale metadata) — `path.relative` would emit `..` or an absolute path. + * - The cwd doesn't end with the expected sub-project suffix — some other + * resolution path produced the cwd, and we don't want to guess. + * + * Separator handling: local Windows runtimes produce backslash-separated + * cwds (`C:\repo\packages\api`) while SSH/Docker/devcontainer runtimes use + * forward slashes. We compare the suffix against a forward-slash + * normalization of both sides so Windows cwds still match, then slice the + * original `workspacePath` by length (segment counts and byte length match + * between the two separator styles) so the returned parent root retains + * the runtime-native separator style used everywhere else. */ -function deriveSubProjectRelativePath(projectPath: string, subProjectPath: string): string | null { - const relative = path.relative(projectPath, subProjectPath); +function deriveSubProjectLayout( + metadata: WorkspaceMetadata, + workspacePath: string +): SubProjectLayout | null { + const subProjectPath = metadata.subProjectPath?.trim(); + if (!subProjectPath) return null; + const relative = path.relative(metadata.projectPath, subProjectPath); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { return null; } - return relative; + const posixRelative = relative.replace(/\\/g, "/"); + const suffix = `/${posixRelative}`; + const normalizedWorkspace = workspacePath.replace(/\\/g, "/"); + if (!normalizedWorkspace.endsWith(suffix)) return null; + const parentRoot = workspacePath.slice(0, workspacePath.length - suffix.length) || "/"; + // Depth = number of non-empty segments in the sub-project relative path, + // which equals the number of "../" levels needed to climb from the cwd + // to the parent root. + const depth = posixRelative.split("/").filter((segment) => segment.length > 0).length; + return { + parentRoot, + parentAgentsMdRelativePath: `${"../".repeat(depth)}AGENTS.md`, + }; } /** diff --git a/src/node/utils/main/markdown.test.ts b/src/node/utils/main/markdown.test.ts index f2b3cd921a..ff1412a926 100644 --- a/src/node/utils/main/markdown.test.ts +++ b/src/node/utils/main/markdown.test.ts @@ -1,4 +1,8 @@ -import { extractToolSection, stripScopedInstructionSections } from "./markdown"; +import { + extractModelSection, + extractToolSection, + stripScopedInstructionSections, +} from "./markdown"; describe("extractToolSection", () => { describe("basic extraction", () => { @@ -222,3 +226,66 @@ Tool content expect(stripScopedInstructionSections("")).toBe(""); }); }); + +describe("extractModelSection", () => { + it("returns all matching Model: sections in source order so later sources override earlier ones", () => { + // Mirrors extractToolSection's multi-match semantics: parent + sub-project + // (or multi-project) AGENTS.md flatten into one blob, so collecting every + // matching scoped section in order lets later sources naturally override + // earlier ones via concatenation. Returning only the first match would + // silently drop sub-project model overrides. + const markdown = ` +# Model: claude-sonnet +Parent model rule + +# Other Section +Other content + +# Model: claude-sonnet +Sub-project model rule +`.trim(); + + const result = extractModelSection(markdown, "anthropic:claude-sonnet-4-20250514"); + expect(result).toBe("Parent model rule\n\nSub-project model rule"); + }); + + it("supports regex-style heading patterns and still collects all matches", () => { + const markdown = ` +# Model: /openai:.*codex/i +First codex rule + +# Model: /openai:.*codex/i +Second codex rule +`.trim(); + + const result = extractModelSection(markdown, "openai:gpt-5-1-codex"); + expect(result).toBe("First codex rule\n\nSecond codex rule"); + }); + + it("returns null when no Model: heading matches the active model", () => { + const markdown = ` +# Model: claude-opus +Opus rule + +# General +General content +`.trim(); + + expect(extractModelSection(markdown, "anthropic:claude-sonnet-4-20250514")).toBeNull(); + }); + + it("skips empty matching sections while keeping later content", () => { + const markdown = ` +# Model: claude-sonnet + +# Other +Other content + +# Model: claude-sonnet +Real sonnet rule +`.trim(); + + const result = extractModelSection(markdown, "anthropic:claude-sonnet-4-20250514"); + expect(result).toBe("Real sonnet rule"); + }); +}); diff --git a/src/node/utils/main/markdown.ts b/src/node/utils/main/markdown.ts index 63c32041b9..d0ca50b5f1 100644 --- a/src/node/utils/main/markdown.ts +++ b/src/node/utils/main/markdown.ts @@ -63,11 +63,6 @@ function extractSectionsByHeading(markdown: string, headingMatcher: HeadingMatch .filter((slice) => slice.length > 0); } -function extractSectionByHeading(markdown: string, headingMatcher: HeadingMatcher): string | null { - const [firstMatch] = extractSectionsByHeading(markdown, headingMatcher); - return firstMatch ?? null; -} - function removeSectionsByHeading(markdown: string, headingMatcher: HeadingMatcher): string { if (!markdown) return markdown; @@ -116,12 +111,20 @@ export function extractModelSection(markdown: string, modelId: string): string | } }; - return extractSectionByHeading(markdown, (headingText) => { + // Collect every matching `## Model: …` section in source order. Mirrors + // extractToolSection's multi-match semantics so that flattened multi- + // source instruction blobs (e.g. parent + sub-project AGENTS.md, or + // multi-project workspaces) layer naturally — later sections override + // earlier ones by appearing later in the system prompt rather than + // silently dropping out. A single AGENTS.md with multiple matching + // sections (rare but legal) gets the same treatment. + const matches = extractSectionsByHeading(markdown, (headingText) => { const match = headingPattern.exec(headingText); if (!match) return false; const regex = compileRegex(match[1] ?? ""); return Boolean(regex?.test(modelId)); }); + return matches.length > 0 ? matches.join("\n\n") : null; } /**