From ba234a636a370166c3b68a326571f9d4c1f3dc9a Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Sat, 26 Sep 2026 01:12:24 +0500 Subject: [PATCH] fix(server): list the repo roots in multi-repo runtime instructions When a project's repos sit inside its workspace folder, Claude Code leaves them out of the environment block it shows the model, and Cursor, Grok, OpenCode and Antigravity get no repo list at all. The model then treats the workspace folder as the project and searches it for repos. The multi-repo instructions now list every repo root, and say that the working directory is the workspace folder, not a repo, when it is not one of the roots. The session start input carries the full root list so adapters can tell whether the working directory is a repo root. Co-Authored-By: Claude Opus 5.5 --- .../Layers/ProviderCommandReactor.test.ts | 84 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 3 + .../provider/CodexDeveloperInstructions.ts | 4 +- .../src/provider/Layers/AntigravityAdapter.ts | 12 ++- .../src/provider/Layers/ClaudeAdapter.test.ts | 6 +- .../src/provider/Layers/ClaudeAdapter.ts | 4 +- .../src/provider/Layers/CodexAdapter.ts | 1 + .../Layers/CodexSessionRuntime.test.ts | 12 ++- .../provider/Layers/CodexSessionRuntime.ts | 13 +-- .../src/provider/Layers/CursorAdapter.ts | 12 ++- .../server/src/provider/Layers/GrokAdapter.ts | 12 ++- .../src/provider/Layers/OpenCodeAdapter.ts | 12 ++- .../src/provider/RuntimeInstructions.test.ts | 43 +++++++++- .../src/provider/RuntimeInstructions.ts | 40 ++++++++- packages/contracts/src/provider.ts | 3 + 15 files changed, 225 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d970dccaf0c5..04629325b41f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -916,6 +916,90 @@ describe("ProviderCommandReactor", () => { expect(multiCall?.[1]).toMatchObject({ cwd: "/tmp/multi-workspace", additionalRoots: ["/tmp/multi-workspace/backend", "/tmp/oss/frontend"], + repoRoots: ["/tmp/multi-workspace/backend", "/tmp/oss/frontend"], + }); + }), + ); + + effectIt.effect( + "launches a multi-repo worktree run with every worktree, including the anchor", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + const multiModelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }; + + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-create-multi-wt"), + projectId: asProjectId("project-multi-wt"), + title: "Multi Repo Project", + workspaceRoot: "/tmp/multi-workspace", + repoRoots: ["/tmp/multi-workspace/backend", "/tmp/oss/frontend"], + defaultModelSelection: multiModelSelection, + createdAt: now, + }); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-create-multi-wt"), + threadId: ThreadId.make("thread-multi-wt"), + projectId: asProjectId("project-multi-wt"), + title: "Multi Worktree Thread", + modelSelection: multiModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "t3code/multi", + worktreePath: "/tmp/worktrees/thread-multi-wt/backend", + worktrees: [ + { + repoRoot: "/tmp/multi-workspace/backend", + worktreePath: "/tmp/worktrees/thread-multi-wt/backend", + }, + { + repoRoot: "/tmp/oss/frontend", + worktreePath: "/tmp/worktrees/thread-multi-wt/frontend", + }, + ], + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-multi-wt"), + threadId: ThreadId.make("thread-multi-wt"), + message: { + messageId: asMessageId("user-message-multi-wt"), + role: "user", + text: "edit both repos", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(() => + harness.startSession.mock.calls.some( + (call) => call[0] === ThreadId.make("thread-multi-wt"), + ), + ), + ); + const call = harness.startSession.mock.calls.find( + (entry) => entry[0] === ThreadId.make("thread-multi-wt"), + ); + // The session anchors in the first worktree, so `additionalRoots` + // leaves it out while `repoRoots` keeps it. + expect(call?.[1]).toMatchObject({ + cwd: "/tmp/worktrees/thread-multi-wt/backend", + additionalRoots: ["/tmp/worktrees/thread-multi-wt/frontend"], + repoRoots: [ + "/tmp/worktrees/thread-multi-wt/backend", + "/tmp/worktrees/thread-multi-wt/frontend", + ], }); }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 245f1c686dae..883a57219ab5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -725,6 +725,8 @@ const make = Effect.gen(function* () { projects: project ? [project] : [], }); const additionalRoots = manifest ? manifestExtraRoots(manifest) : []; + const repoRoots = + manifest && additionalRoots.length > 0 ? manifest.roots.map((root) => root.path) : []; const refreshWorkspaceSnapshot = effectiveCwd ? providerRegistry .refreshWorkspaceSnapshot({ instanceId: desiredInstanceId, cwd: effectiveCwd }) @@ -752,6 +754,7 @@ const make = Effect.gen(function* () { ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(sessionTitle ? { title: sessionTitle } : {}), ...(additionalRoots.length > 0 ? { additionalRoots } : {}), + ...(repoRoots.length > 0 ? { repoRoots } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 16a94c8f028a..50e2a3b41dff 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,6 +1,6 @@ import type { ProviderInteractionMode } from "@t3tools/contracts"; import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; -import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; +import { buildRuntimeInstructions, type MultiRepoWorkspace } from "./RuntimeInstructions.ts"; const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `## T3 Code collaborative browser @@ -188,7 +188,7 @@ export interface CodexRuntimeInfo { readonly model: string; readonly modelName?: string | undefined; readonly reasoningEffort: string; - readonly multiRepo?: boolean | undefined; + readonly multiRepo?: MultiRepoWorkspace | undefined; } /** Mode prompt for `turn/start.collaborationMode.settings.developer_instructions`. */ diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 56b8c360f585..979e09265f2b 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -36,7 +36,11 @@ import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { ServerConfig } from "../../config.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { + buildRuntimeInstructions, + multiRepoWorkspace, + type MultiRepoWorkspace, +} from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import type { AntigravityAuth } from "../AntigravityAuth.ts"; import { @@ -192,8 +196,8 @@ interface TurnIntent { interface SessionContext { readonly threadId: ThreadId; readonly cwd: string; - /** The session spans several repository roots. */ - readonly multiRepo: boolean; + /** Set when the session has roots beyond its working directory. */ + readonly multiRepo: MultiRepoWorkspace | undefined; readonly nativeSessionId: string; readonly scope: Scope.Closeable; readonly runtime: Runtime; @@ -867,7 +871,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi context = { threadId: input.threadId, cwd, - multiRepo: (input.additionalRoots?.length ?? 0) > 0, + multiRepo: multiRepoWorkspace(input), nativeSessionId: started.sessionId, scope: sessionScope, runtime, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2c07c30af6c1..48313eb6937b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -471,13 +471,17 @@ describe("ClaudeAdapterLive", () => { provider: ProviderDriverKind.make("claudeAgent"), cwd: "/workspace", additionalRoots: ["/workspace/api", "/workspace/web"], + repoRoots: ["/workspace/api", "/workspace/web"], runtimeMode: "full-access", }); assert.deepEqual(harness.getLastCreateQueryInput()?.options.systemPrompt, { type: "preset", preset: "claude_code", - append: buildRuntimeInstructions({ harness: "Claude Code", multiRepo: true }), + append: buildRuntimeInstructions({ + harness: "Claude Code", + multiRepo: { cwd: "/workspace", repoRoots: ["/workspace/api", "/workspace/web"] }, + }), }); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 044324c73344..6e1eb83cc7eb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -93,7 +93,7 @@ import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { buildRuntimeInstructions, multiRepoWorkspace } from "../RuntimeInstructions.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -4915,7 +4915,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Model and effort can change after this session-level prompt is set. append: buildRuntimeInstructions({ harness: "Claude Code", - multiRepo: (input.additionalRoots?.length ?? 0) > 0, + multiRepo: multiRepoWorkspace(input), }), }, settingSources: [...CLAUDE_SETTING_SOURCES], diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 33360622ad98..8e2954ac2e42 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2282,6 +2282,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.additionalRoots && input.additionalRoots.length > 0 ? { additionalRoots: input.additionalRoots } : {}), + ...(input.repoRoots && input.repoRoots.length > 0 ? { repoRoots: input.repoRoots } : {}), binaryPath: codexConfig.binaryPath, ...(options?.models ? { models: options.models } : {}), launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 9d53a2049817..29026a379ccc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -13,6 +13,7 @@ import { buildCodexAdditionalContext, buildCodexDeveloperInstructions, } from "../CodexDeveloperInstructions.ts"; +import type { MultiRepoWorkspace } from "../RuntimeInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -297,7 +298,7 @@ describe("buildTurnStartParams", () => { it.effect("asks a multi-repo session for absolute file paths", () => Effect.gen(function* () { - const instructions = function* (multiRepo: boolean) { + const instructions = function* (multiRepo: MultiRepoWorkspace | undefined) { const params = yield* buildTurnStartParams({ threadId: "provider-thread-1", runtimeMode: "full-access", @@ -308,8 +309,13 @@ describe("buildTurnStartParams", () => { return params.additionalContext?.t3_code_runtime?.value ?? ""; }; - NodeAssert.match(yield* instructions(true), //); - NodeAssert.doesNotMatch(yield* instructions(false), //); + const multiRepo = yield* instructions({ + cwd: "/work", + repoRoots: ["/work/api", "/work/web"], + }); + NodeAssert.match(multiRepo, //); + NodeAssert.match(multiRepo, /- \/work\/api\n- \/work\/web/); + NodeAssert.doesNotMatch(yield* instructions(undefined), //); }), ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 01a76b80cfe1..a32efb3d627c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -45,6 +45,7 @@ import { buildCodexDeveloperInstructions, type T3CodeToolAvailability, } from "../CodexDeveloperInstructions.ts"; +import { multiRepoWorkspace, type MultiRepoWorkspace } from "../RuntimeInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -183,6 +184,8 @@ export interface CodexSessionRuntimeOptions { * registered after thread open via `skills/extraRoots/set`. */ readonly additionalRoots?: ReadonlyArray; + /** Every repo root the session works in, including `cwd` when it is one. */ + readonly repoRoots?: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; @@ -597,7 +600,7 @@ function buildCodexTurnInstructions(input: { readonly modelName?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; - readonly multiRepo?: boolean; + readonly multiRepo?: MultiRepoWorkspace | undefined; }): Pick { if (input.interactionMode === undefined) { return {}; @@ -640,8 +643,8 @@ export function buildTurnStartParams(input: { readonly interactionMode?: ProviderInteractionMode; /** Defaults to true so callers that predate the agent-access gate are unchanged. */ readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; - /** The session spans several repository roots. */ - readonly multiRepo?: boolean; + /** Set when the session has roots beyond its working directory. */ + readonly multiRepo?: MultiRepoWorkspace | undefined; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -664,7 +667,7 @@ export function buildTurnStartParams(input: { ...(input.modelName ? { modelName: input.modelName } : {}), ...(input.effort ? { effort: input.effort } : {}), browserToolsAvailable: input.browserToolsAvailable ?? true, - ...(input.multiRepo ? { multiRepo: true } : {}), + ...(input.multiRepo ? { multiRepo: input.multiRepo } : {}), }); return decodeCodexTurnStartParamsWithCollaborationMode({ @@ -2608,7 +2611,7 @@ export const makeCodexSessionRuntime = ( options.appServerArgs, options.mcpCapabilities, ), - multiRepo: (options.additionalRoots?.length ?? 0) > 0, + multiRepo: multiRepoWorkspace(options), }); yield* Ref.set(lastAdditionalContextRef, params.additionalContext); const rawResponse = yield* client.raw.request("turn/start", params); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 0dfc6470a72c..ad30718aa115 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -42,7 +42,11 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { + buildRuntimeInstructions, + multiRepoWorkspace, + type MultiRepoWorkspace, +} from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -138,8 +142,8 @@ interface CursorSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; - /** The session spans several repository roots. */ - readonly multiRepo: boolean; + /** Set when the session has roots beyond its working directory. */ + readonly multiRepo: MultiRepoWorkspace | undefined; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -795,7 +799,7 @@ export function makeCursorAdapter( session, scope: sessionScope, acp, - multiRepo: (input.additionalRoots?.length ?? 0) > 0, + multiRepo: multiRepoWorkspace(input), notificationFiber: undefined, pendingApprovals, pendingUserInputs, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index e095d93cfbda..cee314959cd1 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -40,7 +40,11 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { + buildRuntimeInstructions, + multiRepoWorkspace, + type MultiRepoWorkspace, +} from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -138,8 +142,8 @@ interface GrokSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; - /** The session spans several repository roots. */ - readonly multiRepo: boolean; + /** Set when the session has roots beyond its working directory. */ + readonly multiRepo: MultiRepoWorkspace | undefined; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -1292,7 +1296,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte session, scope: sessionScope, acp, - multiRepo: (input.additionalRoots?.length ?? 0) > 0, + multiRepo: multiRepoWorkspace(input), notificationFiber: undefined, pendingApprovals, pendingUserInputs, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 42e605b89459..da2d3aa86806 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -43,7 +43,11 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; +import { + buildRuntimeInstructions, + multiRepoWorkspace, + type MultiRepoWorkspace, +} from "../RuntimeInstructions.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { buildOpenCodePermissionRules, @@ -341,8 +345,8 @@ interface OpenCodeSessionContext { readonly client: OpencodeClient; readonly server: OpenCodeServerConnection; readonly directory: string; - /** The session spans several repository roots. */ - readonly multiRepo: boolean; + /** Set when the session has roots beyond its working directory. */ + readonly multiRepo: MultiRepoWorkspace | undefined; openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; @@ -3010,7 +3014,7 @@ export function makeOpenCodeAdapter( client: started.client, server: started.server, directory, - multiRepo: (input.additionalRoots?.length ?? 0) > 0, + multiRepo: multiRepoWorkspace(input), openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 685868b089e6..90987946b156 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; +import { buildRuntimeInstructions, multiRepoWorkspace } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { it("requires explicit registration of every PR and stack layer", () => { @@ -36,9 +36,44 @@ describe("buildRuntimeInstructions", () => { }); it("asks for absolute file paths only when the session spans several repos", () => { - expect(buildRuntimeInstructions({ harness: "Codex", multiRepo: true })).toContain( - "", - ); + expect( + buildRuntimeInstructions({ harness: "Codex", multiRepo: { repoRoots: ["/a", "/b"] } }), + ).toContain(""); expect(buildRuntimeInstructions({ harness: "Codex" })).not.toContain(""); }); + + it("names the repos and says a workspace folder anchor is not a repo", () => { + const instructions = buildRuntimeInstructions({ + harness: "Claude Code", + multiRepo: { cwd: "/home/me", repoRoots: ["/home/me/api", "/elsewhere/web"] }, + }); + expect(instructions).toContain( + "This project's repositories are:\n- /home/me/api\n- /elsewhere/web\n", + ); + expect(instructions).toContain( + "The working directory, /home/me, is the project's workspace folder, not a repository. Other repositories under it are not part of this project", + ); + }); + + it("does not call the working directory a workspace folder when it is a repo root", () => { + const instructions = buildRuntimeInstructions({ + harness: "Claude Code", + multiRepo: { cwd: "/wt/api", repoRoots: ["/wt/api", "/wt/web"] }, + }); + expect(instructions).toContain("- /wt/api\n- /wt/web"); + expect(instructions).not.toContain("workspace folder"); + }); +}); + +describe("multiRepoWorkspace", () => { + it("is set only when the session has roots beyond its working directory", () => { + expect(multiRepoWorkspace({ cwd: "/wt/api", repoRoots: ["/wt/api"] })).toBeUndefined(); + expect( + multiRepoWorkspace({ + cwd: "/home/me", + additionalRoots: ["/home/me/api"], + repoRoots: ["/home/me/api"], + }), + ).toEqual({ cwd: "/home/me", repoRoots: ["/home/me/api"] }); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index cdef3ddd87f0..354e1b737f1c 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -18,6 +18,37 @@ Replies in this workspace have slipped in these ways. Before you send a response - a bare file name, such as \`README.md\`, that stands for a specific file `; +/** A session with roots beyond its working directory: a multi-repo workspace. */ +export interface MultiRepoWorkspace { + /** The session's working directory: the workspace folder, or one of the repo roots. */ + readonly cwd?: string | undefined; + /** Every repository root the session works in (the worktrees, in an isolated run). */ + readonly repoRoots: ReadonlyArray; +} + +/** The multi-repo context for a session start, or undefined when it has no roots beyond its working directory. */ +export function multiRepoWorkspace(input: { + readonly cwd?: string | undefined; + readonly additionalRoots?: ReadonlyArray | undefined; + readonly repoRoots?: ReadonlyArray | undefined; +}): MultiRepoWorkspace | undefined { + if ((input.additionalRoots?.length ?? 0) === 0) return undefined; + return { cwd: input.cwd, repoRoots: input.repoRoots ?? [] }; +} + +// Claude Code leaves repo roots inside its working directory out of the +// environment block it shows the model, and the other providers show the model +// no root list at all, so name the repos here. +function projectRepositoriesInstructions(workspace: MultiRepoWorkspace): string { + if (workspace.repoRoots.length === 0) return ""; + const repos = workspace.repoRoots.map((root) => `- ${root}`).join("\n"); + const anchor = + workspace.cwd && !workspace.repoRoots.includes(workspace.cwd) + ? `\nThe working directory, ${workspace.cwd}, is the project's workspace folder, not a repository. Other repositories under it are not part of this project, so there is no need to search it for more.` + : ""; + return `\n\n\nThis project's repositories are:\n${repos}${anchor}\n`; +} + /** * Shared runtime context; omit model and effort when the harness manages them dynamically. * `modelName` is the display name users see in the model picker; `model` is the slug. @@ -27,8 +58,8 @@ export function buildRuntimeInstructions(runtime: { readonly model?: string | undefined; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; - /** True when the session spans more than one repository root. */ - readonly multiRepo?: boolean | undefined; + /** Set when the session has roots beyond its working directory. */ + readonly multiRepo?: MultiRepoWorkspace | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); @@ -38,7 +69,10 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}${runtime.multiRepo ? `\n\n${MULTI_REPO_FILE_PATH_INSTRUCTIONS}` : ""}`; + const multiRepo = runtime.multiRepo + ? `\n\n${MULTI_REPO_FILE_PATH_INSTRUCTIONS}${projectRepositoriesInstructions(runtime.multiRepo)}` + : ""; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}${multiRepo}`; } function toSingleLine(value: string): string { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 5d94d34d0039..0402ad206c96 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -62,6 +62,9 @@ export const ProviderSessionStartInput = Schema.Struct({ // `additionalDirectories`, Codex `skills/extraRoots/set`) and degrade to the // anchor alone where unsupported. additionalRoots: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + // Every repo root the session works in (the worktrees, in an isolated run). + // Unlike `additionalRoots`, it keeps `cwd` when `cwd` is itself a repo root. + repoRoots: Schema.optional(Schema.Array(TrimmedNonEmptyString)), title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown),