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),