@@ -540,4 +540,355 @@ OpenAI-only instructions.
540540 } ) ;
541541 }
542542 } ) ;
543+
544+ describe ( "sub-project workspaces look like regular projects" , ( ) => {
545+ // Sub-project workspaces share the parent project's checkout but cwd into
546+ // a descendant directory. From the agent's perspective they should be
547+ // indistinguishable from a single-project workspace rooted at that cwd:
548+ // no parent-repo callout in the prompt, no inherited parent AGENTS.md,
549+ // no special "sub-project" framing in tool descriptions.
550+ async function setupSubProjectFixture ( ) : Promise < {
551+ subProjectMetadata : WorkspaceMetadata ;
552+ regularMetadata : WorkspaceMetadata ;
553+ subProjectCwd : string ;
554+ parentRoot : string ;
555+ } > {
556+ const subProjectAbs = path . join ( workspaceDir , "packages" , "api" ) ;
557+ await fs . mkdir ( subProjectAbs , { recursive : true } ) ;
558+
559+ const subProjectMetadata : WorkspaceMetadata = {
560+ id : "test-workspace" ,
561+ name : "test-workspace" ,
562+ projectName : "test-project" ,
563+ projectPath : workspaceDir ,
564+ subProjectPath : subProjectAbs ,
565+ runtimeConfig : DEFAULT_RUNTIME_CONFIG ,
566+ } ;
567+
568+ // Regular single-project workspace whose project path IS the sub-project
569+ // directory. Used as the reference oracle: the sub-project workspace's
570+ // prompt at the same cwd must match this byte-for-byte in <environment>.
571+ const regularMetadata : WorkspaceMetadata = {
572+ id : "test-workspace" ,
573+ name : "test-workspace" ,
574+ projectName : "test-project" ,
575+ projectPath : subProjectAbs ,
576+ runtimeConfig : DEFAULT_RUNTIME_CONFIG ,
577+ } ;
578+
579+ return {
580+ subProjectMetadata,
581+ regularMetadata,
582+ subProjectCwd : subProjectAbs ,
583+ parentRoot : workspaceDir ,
584+ } ;
585+ }
586+
587+ test ( "environment block is identical to a regular single-project workspace at the same cwd" , async ( ) => {
588+ // Core invariant: presence of `subProjectPath` in metadata must not
589+ // change the <environment> block. The agent sees the same description
590+ // and lines whether the workspace is configured as a sub-project or as
591+ // a regular project rooted at that directory.
592+ const { subProjectMetadata, regularMetadata, subProjectCwd } = await setupSubProjectFixture ( ) ;
593+
594+ const subProjectMessage = await buildSystemMessage (
595+ subProjectMetadata ,
596+ runtime ,
597+ subProjectCwd
598+ ) ;
599+ const regularMessage = await buildSystemMessage ( regularMetadata , runtime , subProjectCwd ) ;
600+
601+ const subEnvironment = extractTagContent ( subProjectMessage , "environment" ) ;
602+ const regularEnvironment = extractTagContent ( regularMessage , "environment" ) ;
603+ expect ( subEnvironment ) . toBe ( regularEnvironment ) ;
604+ } ) ;
605+
606+ test ( "environment block does not mention sub-project framing or relative-path nudges" , async ( ) => {
607+ // Regression guard against the rejected direction (PR #3244 v1) where
608+ // the prompt called out "the `packages/api` sub-project of the X at Y"
609+ // and added a relative-paths preamble. The agent should see the cwd as
610+ // a regular project root with no parent-repo context. (The parentRoot
611+ // is a path prefix of subProjectCwd, so checking for it directly is
612+ // ambiguous — the byte-equality test above already proves the env
613+ // block contains no parent-specific framing.)
614+ const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture ( ) ;
615+
616+ const systemMessage = await buildSystemMessage ( subProjectMetadata , runtime , subProjectCwd ) ;
617+ const environment = extractTagContent ( systemMessage , "environment" ) ?? "" ;
618+
619+ expect ( environment ) . not . toContain ( "sub-project" ) ;
620+ expect ( environment ) . not . toMatch ( / P r e f e r p a t h s r e l a t i v e t o / ) ;
621+ } ) ;
622+
623+ test ( "AGENTS.md is concatenated parent → sub-project with H1 path-source headings" , async ( ) => {
624+ // Sub-projects inherit parent conventions: parent AGENTS.md is glued
625+ // before the sub-project's own AGENTS.md so the agent sees a single
626+ // combined block. Each segment opens with an H1 heading whose body
627+ // is the source path relative to the cwd (e.g. `` # `../../AGENTS.md` ``
628+ // or `` # `./AGENTS.md` ``) so the agent can disambiguate which root
629+ // any relative path references in each segment should resolve
630+ // against. The H1 also bounds scoped `## Tool:` / `## Model:`
631+ // sections inside the segment, preventing them from spanning across
632+ // the segment join into the next segment's narrative.
633+ //
634+ // We deliberately avoid the rejected v1 framing of `# Project context
635+ // (root: ...)` / `# Sub-project context (root: ...)` — the H1 here is
636+ // just a path note, not a structural callout dressing the sub-project
637+ // up as a special feature.
638+ const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture ( ) ;
639+
640+ await fs . writeFile (
641+ path . join ( parentRoot , "AGENTS.md" ) ,
642+ "PARENT_MARKER: parent project conventions.\n"
643+ ) ;
644+ await fs . writeFile (
645+ path . join ( subProjectCwd , "AGENTS.md" ) ,
646+ "SUB_MARKER: sub-project specific conventions.\n"
647+ ) ;
648+
649+ const systemMessage = await buildSystemMessage ( subProjectMetadata , runtime , subProjectCwd ) ;
650+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
651+
652+ expect ( customInstructions ) . toContain ( "PARENT_MARKER" ) ;
653+ expect ( customInstructions ) . toContain ( "SUB_MARKER" ) ;
654+ // `packages/api` is two levels deep, so the parent AGENTS.md is
655+ // `../../AGENTS.md` from the cwd.
656+ expect ( customInstructions ) . toContain ( "# `../../AGENTS.md`" ) ;
657+ expect ( customInstructions ) . toContain ( "# `./AGENTS.md`" ) ;
658+ // Each H1 must precede its corresponding content.
659+ expect ( customInstructions . indexOf ( "# `../../AGENTS.md`" ) ) . toBeLessThan (
660+ customInstructions . indexOf ( "PARENT_MARKER" )
661+ ) ;
662+ expect ( customInstructions . indexOf ( "# `./AGENTS.md`" ) ) . toBeLessThan (
663+ customInstructions . indexOf ( "SUB_MARKER" )
664+ ) ;
665+ // Parent first so general rules anchor before the more specific
666+ // sub-project overrides.
667+ expect ( customInstructions . indexOf ( "PARENT_MARKER" ) ) . toBeLessThan (
668+ customInstructions . indexOf ( "SUB_MARKER" )
669+ ) ;
670+ // Regression guard against the rejected v1 verbose framing.
671+ expect ( customInstructions ) . not . toContain ( "# Project context (root:" ) ;
672+ expect ( customInstructions ) . not . toContain ( "# Sub-project context (root:" ) ;
673+ } ) ;
674+
675+ test ( "relative-path heading depth tracks the sub-project nesting depth" , async ( ) => {
676+ // A single-segment sub-project (`api` instead of `packages/api`) only
677+ // needs one `../` level. Verifies the depth computation rather than
678+ // hard-coding the fixture's two-level structure.
679+ const subProjectAbs = path . join ( workspaceDir , "api" ) ;
680+ await fs . mkdir ( subProjectAbs , { recursive : true } ) ;
681+ const metadata : WorkspaceMetadata = {
682+ id : "test-workspace" ,
683+ name : "test-workspace" ,
684+ projectName : "test-project" ,
685+ projectPath : workspaceDir ,
686+ subProjectPath : subProjectAbs ,
687+ runtimeConfig : DEFAULT_RUNTIME_CONFIG ,
688+ } ;
689+
690+ await fs . writeFile (
691+ path . join ( workspaceDir , "AGENTS.md" ) ,
692+ "DEPTH_TEST_PARENT_MARKER: depth=1 parent.\n"
693+ ) ;
694+
695+ const systemMessage = await buildSystemMessage ( metadata , runtime , subProjectAbs ) ;
696+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
697+
698+ expect ( customInstructions ) . toContain ( "# `../AGENTS.md`" ) ;
699+ expect ( customInstructions ) . not . toContain ( "# `../../AGENTS.md`" ) ;
700+ expect ( customInstructions ) . toContain ( "DEPTH_TEST_PARENT_MARKER" ) ;
701+ } ) ;
702+
703+ test ( "only-parent AGENTS.md is loaded when sub-project has none" , async ( ) => {
704+ // If only the parent has AGENTS.md, sub-project workspaces should
705+ // still inherit it — and the H1 path heading lets the agent know
706+ // it's reading the parent's, not its own.
707+ const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture ( ) ;
708+ await fs . writeFile (
709+ path . join ( parentRoot , "AGENTS.md" ) ,
710+ "PARENT_ONLY_MARKER: only parent conventions.\n"
711+ ) ;
712+
713+ const systemMessage = await buildSystemMessage ( subProjectMetadata , runtime , subProjectCwd ) ;
714+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
715+
716+ expect ( customInstructions ) . toContain ( "PARENT_ONLY_MARKER" ) ;
717+ expect ( customInstructions ) . toContain ( "# `../../AGENTS.md`" ) ;
718+ expect ( customInstructions ) . not . toContain ( "# `./AGENTS.md`" ) ;
719+ } ) ;
720+
721+ test ( "only-sub-project AGENTS.md is loaded when parent has none" , async ( ) => {
722+ // Symmetric: a sub-project with its own AGENTS.md but no parent
723+ // AGENTS.md should still load the sub-project's own with the matching
724+ // `./AGENTS.md` heading.
725+ const { subProjectMetadata, subProjectCwd } = await setupSubProjectFixture ( ) ;
726+ await fs . writeFile (
727+ path . join ( subProjectCwd , "AGENTS.md" ) ,
728+ "SUB_ONLY_MARKER: only sub-project conventions.\n"
729+ ) ;
730+
731+ const systemMessage = await buildSystemMessage ( subProjectMetadata , runtime , subProjectCwd ) ;
732+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
733+
734+ expect ( customInstructions ) . toContain ( "SUB_ONLY_MARKER" ) ;
735+ expect ( customInstructions ) . toContain ( "# `./AGENTS.md`" ) ;
736+ expect ( customInstructions ) . not . toContain ( "# `../../AGENTS.md`" ) ;
737+ } ) ;
738+
739+ test ( "regular non-sub-project workspaces emit no path-source heading" , async ( ) => {
740+ // Non-sub-project workspaces preserve historical behavior: the cwd's
741+ // AGENTS.md is loaded verbatim with no path-source heading or
742+ // comment. Otherwise we'd be subtly changing the prompt for every
743+ // regular project workspace.
744+ const { regularMetadata, subProjectCwd } = await setupSubProjectFixture ( ) ;
745+ await fs . writeFile (
746+ path . join ( subProjectCwd , "AGENTS.md" ) ,
747+ "REGULAR_MARKER: regular project conventions.\n"
748+ ) ;
749+
750+ const systemMessage = await buildSystemMessage ( regularMetadata , runtime , subProjectCwd ) ;
751+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
752+
753+ expect ( customInstructions ) . toContain ( "REGULAR_MARKER" ) ;
754+ expect ( customInstructions ) . not . toContain ( "# `" ) ;
755+ expect ( customInstructions ) . not . toContain ( "<!--" ) ;
756+ } ) ;
757+
758+ test ( "path-source comment is injected inside scoped Tool: / Model: sections so they survive extraction" , async ( ) => {
759+ // Codex-flagged regression (PR #3244): `extractToolSection` /
760+ // `extractModelSection` pull the body of a `## Tool: bash` /
761+ // `## Model: …` section out of the segment, but the top-level
762+ // `<!-- ../../AGENTS.md -->` comment lives BEFORE the section and
763+ // isn't part of the extracted body. Without injecting the comment
764+ // inside each scoped section, scoped tool/model instructions lose
765+ // the parent-vs-subproject provenance — defeating the purpose of
766+ // the comment for any path-sensitive guidance authored under a
767+ // scoped heading.
768+ //
769+ // The injection must also play nicely with
770+ // `stripScopedInstructionSections`, which deletes the entire
771+ // scoped section from <custom-instructions>: the inner comment
772+ // disappears with it (no leftover comment alone), while the
773+ // top-level comment for the surviving narrative still anchors
774+ // the segment.
775+ const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture ( ) ;
776+
777+ await fs . writeFile (
778+ path . join ( parentRoot , "AGENTS.md" ) ,
779+ `Parent narrative.
780+
781+ ## Tool: bash
782+ PARENT_BASH_RULE: parent's bash convention.
783+
784+ ## Model: anthropic:claude-sonnet-4-20250514
785+ PARENT_MODEL_RULE: parent model preference.
786+ `
787+ ) ;
788+ await fs . writeFile (
789+ path . join ( subProjectCwd , "AGENTS.md" ) ,
790+ `Sub-project narrative.
791+
792+ ## Tool: bash
793+ SUB_BASH_RULE: sub-project's bash convention.
794+
795+ ## Model: anthropic:claude-sonnet-4-20250514
796+ SUB_MODEL_RULE: sub-project model override.
797+ `
798+ ) ;
799+
800+ const modelString = "anthropic:claude-sonnet-4-20250514" ;
801+ const toolInstructions = await readToolInstructions (
802+ subProjectMetadata ,
803+ runtime ,
804+ subProjectCwd ,
805+ modelString
806+ ) ;
807+
808+ const bash = toolInstructions . bash ?? "" ;
809+ // Both bash bodies must appear in the per-tool extraction with their
810+ // respective path-source comments.
811+ expect ( bash ) . toContain ( "PARENT_BASH_RULE" ) ;
812+ expect ( bash ) . toContain ( "<!-- ../../AGENTS.md -->" ) ;
813+ expect ( bash ) . toContain ( "SUB_BASH_RULE" ) ;
814+ expect ( bash ) . toContain ( "<!-- ./AGENTS.md -->" ) ;
815+ // Each comment precedes its corresponding rule so the agent reads
816+ // "this rule comes from this path" linearly.
817+ expect ( bash . indexOf ( "<!-- ../../AGENTS.md -->" ) ) . toBeLessThan (
818+ bash . indexOf ( "PARENT_BASH_RULE" )
819+ ) ;
820+ expect ( bash . indexOf ( "<!-- ./AGENTS.md -->" ) ) . toBeLessThan ( bash . indexOf ( "SUB_BASH_RULE" ) ) ;
821+
822+ const systemMessage = await buildSystemMessage (
823+ subProjectMetadata ,
824+ runtime ,
825+ subProjectCwd ,
826+ undefined ,
827+ modelString
828+ ) ;
829+
830+ // Codex-flagged regression (PR #3244): when both parent and sub-project
831+ // define matching `## Model: ...` sections, both bodies must appear in
832+ // the per-model section (the previous singular `extractModelSection`
833+ // returned only the parent's, silently dropping the sub-project's
834+ // override). Sub-project body comes second so its rule overrides the
835+ // parent's via order of presentation.
836+ const sonnetSection =
837+ extractTagContent ( systemMessage , "model-anthropic-claude-sonnet-4-20250514" ) ?? "" ;
838+ expect ( sonnetSection ) . toContain ( "PARENT_MODEL_RULE" ) ;
839+ expect ( sonnetSection ) . toContain ( "SUB_MODEL_RULE" ) ;
840+ expect ( sonnetSection ) . toContain ( "<!-- ../../AGENTS.md -->" ) ;
841+ expect ( sonnetSection ) . toContain ( "<!-- ./AGENTS.md -->" ) ;
842+ expect ( sonnetSection . indexOf ( "PARENT_MODEL_RULE" ) ) . toBeLessThan (
843+ sonnetSection . indexOf ( "SUB_MODEL_RULE" )
844+ ) ;
845+
846+ // Sanity check: <custom-instructions> still strips scoped sections
847+ // (including the inner comment) so the bash/model rules don't leak
848+ // into the unscoped instructions block.
849+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
850+ expect ( customInstructions ) . not . toContain ( "PARENT_BASH_RULE" ) ;
851+ expect ( customInstructions ) . not . toContain ( "SUB_BASH_RULE" ) ;
852+ expect ( customInstructions ) . not . toContain ( "PARENT_MODEL_RULE" ) ;
853+ expect ( customInstructions ) . not . toContain ( "SUB_MODEL_RULE" ) ;
854+ // Top-level H1 path headings survive (they're outside scoped sections,
855+ // and they bound the scoped sections to their own segment so the
856+ // parent's `## Model: ...` can't sweep the sub-project's narrative
857+ // into its strip range).
858+ expect ( customInstructions ) . toContain ( "# `../../AGENTS.md`" ) ;
859+ expect ( customInstructions ) . toContain ( "# `./AGENTS.md`" ) ;
860+ // Inner HTML comments inside scoped sections must NOT leak into the
861+ // <custom-instructions> block: they're stripped along with their
862+ // section.
863+ expect ( customInstructions ) . not . toContain ( "<!-- ../../AGENTS.md -->" ) ;
864+ expect ( customInstructions ) . not . toContain ( "<!-- ./AGENTS.md -->" ) ;
865+ } ) ;
866+
867+ test ( "falls back to cwd-only AGENTS.md when sub-project metadata is stale" , async ( ) => {
868+ // If subProjectPath doesn't sit under projectPath (corrupted persisted
869+ // state, or a cwd that doesn't end with the expected suffix), we
870+ // can't safely derive the parent root. Degrade to reading just the
871+ // cwd's AGENTS.md — historical behavior — with no path-source comment
872+ // since there's only one source.
873+ const { subProjectMetadata, subProjectCwd, parentRoot } = await setupSubProjectFixture ( ) ;
874+ const stale = { ...subProjectMetadata , subProjectPath : "/elsewhere/api" } ;
875+
876+ await fs . writeFile (
877+ path . join ( parentRoot , "AGENTS.md" ) ,
878+ "PARENT_MARKER: should not be inherited from stale metadata.\n"
879+ ) ;
880+ await fs . writeFile (
881+ path . join ( subProjectCwd , "AGENTS.md" ) ,
882+ "SUB_MARKER: should still appear.\n"
883+ ) ;
884+
885+ const systemMessage = await buildSystemMessage ( stale , runtime , subProjectCwd ) ;
886+ const customInstructions = extractTagContent ( systemMessage , "custom-instructions" ) ?? "" ;
887+
888+ expect ( customInstructions ) . not . toContain ( "PARENT_MARKER" ) ;
889+ expect ( customInstructions ) . toContain ( "SUB_MARKER" ) ;
890+ expect ( customInstructions ) . not . toContain ( "# `" ) ;
891+ expect ( customInstructions ) . not . toContain ( "<!--" ) ;
892+ } ) ;
893+ } ) ;
543894} ) ;
0 commit comments