From ae3c645a41bbf377b9c64e9a3f9d47f9a44985a4 Mon Sep 17 00:00:00 2001 From: Rahat Hameed Date: Thu, 24 Sep 2026 23:49:34 +0500 Subject: [PATCH] fix: multi-repo agents name files by absolute path so links open In a multi-repo project, agents quote paths the way git prints them, relative to the repository they ran in. The client resolves a relative path against the workspace root, so a link like `.graft/config.json` failed with "Failed to read workspace file". The path is also ambiguous when several repos hold a file at the same relative path. Runtime instructions now tell a session that spans more than one repo root to write absolute paths. Every provider adapter passes the flag. Inline-code file links also get the repo roots, so an absolute path in a repo outside the workspace root opens in that repo instead of as a read-only host file. Fixes #302 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UJCWNTsJkzwPag1ZX8GtRq --- .../provider/CodexDeveloperInstructions.ts | 1 + .../src/provider/Layers/AntigravityAdapter.ts | 9 +++++++- .../src/provider/Layers/ClaudeAdapter.test.ts | 23 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 5 +++- .../Layers/CodexSessionRuntime.test.ts | 18 +++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 7 +++++- .../src/provider/Layers/CursorAdapter.ts | 9 +++++++- .../server/src/provider/Layers/GrokAdapter.ts | 4 ++++ .../src/provider/Layers/OpenCodeAdapter.ts | 4 ++++ .../src/provider/RuntimeInstructions.test.ts | 7 ++++++ .../src/provider/RuntimeInstructions.ts | 8 ++++++- apps/web/src/components/ChatMarkdown.tsx | 5 ++-- 12 files changed, 93 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 6a7fee351bce..01d336a3193c 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -195,6 +195,7 @@ ${browserToolInstructions(browserToolsAvailable)} export interface CodexRuntimeInfo { readonly model: string; readonly reasoningEffort: string; + readonly multiRepo?: boolean | undefined; } export function buildCodexDeveloperInstructions( diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 61a9b3c3a645..138077f353f4 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -192,6 +192,8 @@ interface TurnIntent { interface SessionContext { readonly threadId: ThreadId; readonly cwd: string; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; readonly nativeSessionId: string; readonly scope: Scope.Closeable; readonly runtime: Runtime; @@ -865,6 +867,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi context = { threadId: input.threadId, cwd, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, nativeSessionId: started.sessionId, scope: sessionScope, runtime, @@ -1085,7 +1088,11 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi ...prompt, { type: "text", - text: buildRuntimeInstructions({ harness: "Antigravity", model }), + text: buildRuntimeInstructions({ + harness: "Antigravity", + model, + multiRepo: context.multiRepo, + }), }, ], }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ca596e6501cf..1f167fe6dd04 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -461,6 +461,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("asks a multi-repo session for absolute file paths", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + cwd: "/workspace", + additionalRoots: ["/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 }), + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("derives auto permission mode from auto runtime policy without skip flag", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 382a340e36d3..83e785a149ce 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4925,7 +4925,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( type: "preset", preset: "claude_code", // Model and effort can change after this session-level prompt is set. - append: buildRuntimeInstructions({ harness: "Claude Code" }), + append: buildRuntimeInstructions({ + harness: "Claude Code", + multiRepo: (input.additionalRoots?.length ?? 0) > 0, + }), }, settingSources: [...CLAUDE_SETTING_SOURCES], // `ultracode` is a Claude Code setting, not an API effort level. It is diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ec113ab7c521..a9c59a45103a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -287,6 +287,24 @@ describe("buildTurnStartParams", () => { }); }); + it.effect("asks a multi-repo session for absolute file paths", () => + Effect.gen(function* () { + const instructions = function* (multiRepo: boolean) { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + multiRepo, + }); + return params.collaborationMode?.settings.developer_instructions ?? ""; + }; + + NodeAssert.match(yield* instructions(true), //); + NodeAssert.doesNotMatch(yield* instructions(false), //); + }), + ); + it("reports the same fallback model and effort in settings and instructions", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 64ac8efda9ea..4188124c7808 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -590,6 +590,7 @@ function buildCodexCollaborationMode(input: { readonly model?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; + readonly multiRepo?: boolean; }): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { if (input.interactionMode === undefined) { return undefined; @@ -603,7 +604,7 @@ function buildCodexCollaborationMode(input: { reasoning_effort: reasoningEffort, developer_instructions: buildCodexDeveloperInstructions( input.interactionMode, - { model, reasoningEffort }, + { model, reasoningEffort, multiRepo: input.multiRepo }, input.browserToolsAvailable ?? true, ), }, @@ -628,6 +629,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; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -649,6 +652,7 @@ export function buildTurnStartParams(input: { ...(input.model ? { model: input.model } : {}), ...(input.effort ? { effort: input.effort } : {}), browserToolsAvailable: input.browserToolsAvailable ?? true, + ...(input.multiRepo ? { multiRepo: true } : {}), }); return decodeCodexTurnStartParamsWithCollaborationMode({ @@ -2552,6 +2556,7 @@ export const makeCodexSessionRuntime = ( options.appServerArgs, options.mcpCapabilities, ), + multiRepo: (options.additionalRoots?.length ?? 0) > 0, }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index e0fe5016326d..0dfc6470a72c 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -138,6 +138,8 @@ interface CursorSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -793,6 +795,7 @@ export function makeCursorAdapter( session, scope: sessionScope, acp, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, notificationFiber: undefined, pendingApprovals, pendingUserInputs, @@ -1097,7 +1100,11 @@ export function makeCursorAdapter( ...promptParts, { type: "text", - text: buildRuntimeInstructions({ harness: "Cursor", model: resolvedModel }), + text: buildRuntimeInstructions({ + harness: "Cursor", + model: resolvedModel, + multiRepo: ctx.multiRepo, + }), }, ], }) diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index bec0fc39faa0..d8bd22c5ef0e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -138,6 +138,8 @@ interface GrokSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -1291,6 +1293,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte session, scope: sessionScope, acp, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, notificationFiber: undefined, pendingApprovals, pendingUserInputs, @@ -1647,6 +1650,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte harness: "Grok", model: displayModel, reasoningEffort: normalizeGrokReasoningEffort(requestedTurnReasoningEffort), + multiRepo: ctx.multiRepo, }); for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { yield* Effect.yieldNow; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..d9462b8f62d9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -341,6 +341,8 @@ interface OpenCodeSessionContext { readonly client: OpencodeClient; readonly server: OpenCodeServerConnection; readonly directory: string; + /** The session spans several repository roots. */ + readonly multiRepo: boolean; openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; @@ -3007,6 +3009,7 @@ export function makeOpenCodeAdapter( client: started.client, server: started.server, directory, + multiRepo: (input.additionalRoots?.length ?? 0) > 0, openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), @@ -3283,6 +3286,7 @@ export function makeOpenCodeAdapter( system: buildRuntimeInstructions({ harness: "OpenCode", model: `${parsedModel.providerID}/${parsedModel.modelID}`, + multiRepo: context.multiRepo, }), parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], }, diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index e73c50adfd6d..91d812e13d6c 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -25,4 +25,11 @@ describe("buildRuntimeInstructions", () => { expect(instructions).toContain("through the Cursor harness."); expect(instructions).not.toContain("reasoning effort"); }); + + it("asks for absolute file paths only when the session spans several repos", () => { + expect(buildRuntimeInstructions({ harness: "Codex", multiRepo: true })).toContain( + "", + ); + expect(buildRuntimeInstructions({ harness: "Codex" })).not.toContain(""); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5e72586062e5..19b732c0906b 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -2,18 +2,24 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. `; +const MULTI_REPO_FILE_PATH_INSTRUCTIONS = ` +This workspace contains more than one git repository. Tools such as git print paths relative to the repository they ran in, so the same relative path can refer to files in different repositories. When you mention a file or directory in your response, write its absolute path. +`; + /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ export function buildRuntimeInstructions(runtime: { readonly harness: string; readonly model?: string | undefined; readonly reasoningEffort?: string | undefined; + /** True when the session spans more than one repository root. */ + readonly multiRepo?: boolean | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); const effort = toSingleLine(runtime.reasoningEffort ?? ""); const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; 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}`; + 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}` : ""}`; } function toSingleLine(value: string): string { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1265f7a473ff..b66da840682e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2383,16 +2383,17 @@ function useChatMarkdownState({ return metaByHref; }, [cwd, imageBaseDir, repoRootsKey, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { + const roots = repoRootsKey ? repoRootsKey.split("\0") : undefined; const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd, roots); if (meta) { metaByText.set(span, meta); } } return metaByText; - }, [cwd, imageBaseDir, text]); + }, [cwd, imageBaseDir, repoRootsKey, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),