Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/provider/CodexDeveloperInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ ${browserToolInstructions(browserToolsAvailable)}
export interface CodexRuntimeInfo {
readonly model: string;
readonly reasoningEffort: string;
readonly multiRepo?: boolean | undefined;
}

export function buildCodexDeveloperInstructions(
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/provider/Layers/AntigravityAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}),
},
],
},
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), /<multi_repo_workspace>/);
NodeAssert.doesNotMatch(yield* instructions(false), /<multi_repo_workspace>/);
}),
);

it("reports the same fallback model and effort in settings and instructions", () => {
const params = Effect.runSync(
buildTurnStartParams({
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
),
},
Expand All @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/provider/Layers/CursorAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, never> | undefined;
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
Expand Down Expand Up @@ -793,6 +795,7 @@ export function makeCursorAdapter(
session,
scope: sessionScope,
acp,
multiRepo: (input.additionalRoots?.length ?? 0) > 0,
notificationFiber: undefined,
pendingApprovals,
pendingUserInputs,
Expand Down Expand Up @@ -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,
}),
},
],
})
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Layers/GrokAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, never> | undefined;
readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
readonly resolvedRequestIds: Set<string>;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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],
},
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/RuntimeInstructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<multi_repo_workspace>",
);
expect(buildRuntimeInstructions({ harness: "Codex" })).not.toContain("<multi_repo_workspace>");
});
});
20 changes: 19 additions & 1 deletion apps/server/src/provider/RuntimeInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,36 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking>
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.
</pull_request_linking>`;

const MULTI_REPO_FILE_PATH_INSTRUCTIONS = `<multi_repo_workspace>
This workspace contains more than one git repository, and the same relative path, such as src/index.ts, can exist in several of them. The user's client turns a path written in inline code, such as \`/abs/path/file.ts\`, into a link that opens the file. It opens a relative path against the workspace root, so that link opens the wrong file or none. Write every file and directory path in inline code as a full absolute path that starts with "/" (or a drive letter on Windows), each time you mention it, in prose, tables, lists, and summaries alike. Code blocks and text meant to be pasted elsewhere keep their usual paths.

Tools print relative paths: git status and grep print them relative to the directory they ran in, and git diff, git show, git log, and git stash show print them relative to the repository root. Put that directory's absolute path in front of each one.

<example>
git -C <repository root> stash show lists dir/file.py. Write \`<repository root>/dir/file.py\`, with the real absolute path of that repository in place of <repository root>.
</example>

Replies in this workspace have slipped in these ways. Before you send a response, check it for each one:
- a path written in full once, then shortened on a later mention or in the closing summary
- a list of files copied from git output, such as the files in a stash, commit, or diff
- a submodule path, or a repository written as \`~/name\`
- a bare file name, such as \`README.md\`, that stands for a specific file
</multi_repo_workspace>`;

/** 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 `<runtime_info>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.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`;
return `<runtime_info>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.</runtime_info>\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}${runtime.multiRepo ? `\n\n${MULTI_REPO_FILE_PATH_INSTRUCTIONS}` : ""}`;
}

function toSingleLine(value: string): string {
Expand Down
32 changes: 21 additions & 11 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2365,8 +2365,11 @@ function useChatMarkdownState({
const diffThemeName = resolveDiffThemeName(resolvedTheme);
// Stable dep for the roots array: NUL-joined (paths never contain NUL).
const repoRootsKey = repoRoots ? repoRoots.join("\0") : "";
const roots = useMemo(
() => (repoRootsKey ? repoRootsKey.split("\0") : undefined),
[repoRootsKey],
);
const markdownFileLinkMetaByHref = useMemo(() => {
const roots = repoRootsKey ? repoRootsKey.split("\0") : undefined;
const metaByHref = new Map<
string,
NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
Expand All @@ -2381,18 +2384,18 @@ function useChatMarkdownState({
}
}
return metaByHref;
}, [cwd, imageBaseDir, repoRootsKey, text]);
}, [cwd, imageBaseDir, roots, text]);
const inlineCodeFileLinkMetaByText = useMemo(() => {
const metaByText = new Map<string, MarkdownFileLinkMeta>();
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, roots, text]);
const fileLinkParentSuffixByPath = useMemo(() => {
const filePaths = [
...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath),
Expand Down Expand Up @@ -2544,7 +2547,8 @@ function useChatMarkdownState({
const isLatestLookup = claimWorkspaceBasenameLookup();
const openAt = (path: string) =>
useRightPanelStore.getState().openFile(threadRef, path, line, root);
if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) {
// A path with an owning repo root is already exact within that repo.
if (!cwd || root || !needsWorkspaceBasenameLookup(panelPath)) {
openAt(panelPath);
return;
}
Expand All @@ -2559,9 +2563,11 @@ function useChatMarkdownState({
const revealMarkdownFileInFileManager = useCallback(
async (fileLinkMeta: MarkdownFileLinkMeta) => {
const workspaceRelativePath = fileLinkMeta.workspaceRelativePath;
const match = workspaceRelativePath
? await findWorkspaceBasenameMatch(workspaceRelativePath)
: null;
// A path with an owning repo root is already exact within that repo.
const match =
workspaceRelativePath && !fileLinkMeta.fileRoot
? await findWorkspaceBasenameMatch(workspaceRelativePath)
: null;
const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath;
return revealFileInFileManager(filePath);
},
Expand Down Expand Up @@ -2600,6 +2606,7 @@ function useChatMarkdownState({
displayPath={fileLinkMeta.displayPath}
panelPath={panelPath}
line={fileLinkMeta.line}
fileRoot={fileLinkMeta.fileRoot}
label={labelParts.join(" · ")}
copyMarkdown={copyMarkdown}
theme={resolvedTheme}
Expand Down Expand Up @@ -2668,6 +2675,7 @@ function useChatMarkdownState({
linkedThreadPullRequestFor,
resolveThreadPullRequest,
resolvedTheme,
roots,
serverConfig,
skills,
text,
Expand Down Expand Up @@ -2698,6 +2706,7 @@ function useChatMarkdownState({
linkedThreadPullRequestFor,
resolveThreadPullRequest,
resolvedTheme,
roots,
serverConfig,
skills,
text,
Expand Down Expand Up @@ -2845,6 +2854,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
updateThreadPullRequestLink,
fileLinkChip,
renderContextReference,
roots,
} = use(ChatMarkdownRendererContext);
const citation = href ? parseAssistantCitationHref(href) : null;
if (citation) return <AssistantCitationChip citation={citation} />;
Expand All @@ -2860,7 +2870,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref
? (markdownFileLinkMetaByHref.get(normalizedHref) ??
resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd))
resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd, roots))
: null;
if (!fileLinkMeta) {
const faviconHost = resolveExternalWebLinkHost(href);
Expand Down Expand Up @@ -3063,14 +3073,14 @@ const CHAT_MARKDOWN_COMPONENTS = {
);
},
code: function MarkdownCode({ node, children, className, ...props }) {
const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use(
const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip, roots } = use(
ChatMarkdownRendererContext,
);
if (node?.properties?.dataInlineCode != null) {
const codeText = nodeToPlainText(children);
const fileLinkMeta =
inlineCodeFileLinkMetaByText.get(codeText.trim()) ??
resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd);
resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd, roots);
if (fileLinkMeta) {
return fileLinkChip(
fileLinkMeta,
Expand Down
Loading
Loading