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
113 changes: 113 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,119 @@ describe("ProviderCommandReactor", () => {
}),
);

// A real `.code-workspace` on disk: one repo, a plain folder under the
// workspace folder, one outside it, and one that no longer exists.
const startWorkspaceFileThread = (input: {
readonly key: string;
readonly writeWorkspaceFile: boolean;
}) =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() => createHarness());
const now = "2026-01-01T00:00:00.000Z";
const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-reactor-ws-"));
createdBaseDirs.add(root);
const workspaceRoot = NodePath.join(root, "work");
const backend = NodePath.join(workspaceRoot, "backend");
const docs = NodePath.join(workspaceRoot, "docs");
const notes = NodePath.join(root, "notes");
for (const dir of [NodePath.join(backend, ".git"), docs, notes]) {
NodeFS.mkdirSync(dir, { recursive: true });
}
const workspaceFile = NodePath.join(workspaceRoot, "work.code-workspace");
if (input.writeWorkspaceFile) {
NodeFS.writeFileSync(
workspaceFile,
'{ "folders": [{ "path": "backend" }, { "path": "docs" }, { "path": "../notes" }, { "path": "gone" }] }',
);
}
const modelSelection = {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
};
const projectId = asProjectId(`project-${input.key}`);
const threadId = ThreadId.make(`thread-${input.key}`);

yield* harness.engine.dispatch({
type: "project.create",
commandId: CommandId.make(`cmd-project-create-${input.key}`),
projectId,
title: "Workspace File Project",
workspaceRoot,
workspaceFile,
repoRoots: [backend],
defaultModelSelection: modelSelection,
createdAt: now,
});
yield* harness.engine.dispatch({
type: "thread.create",
commandId: CommandId.make(`cmd-thread-create-${input.key}`),
threadId,
projectId,
title: "Workspace File Thread",
modelSelection,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt: now,
});
yield* harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make(`cmd-turn-start-${input.key}`),
threadId,
message: {
messageId: asMessageId(`user-message-${input.key}`),
role: "user",
text: "update the notes",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt: now,
});

yield* Effect.promise(() =>
waitFor(() => harness.startSession.mock.calls.some((call) => call[0] === threadId)),
);
const call = harness.startSession.mock.calls.find((entry) => entry[0] === threadId);
return { startInput: call?.[1], workspaceRoot, backend, docs, notes };
});

effectIt.effect("starts a workspace-file session with the plain folders the file lists", () =>
Effect.gen(function* () {
const { startInput, workspaceRoot, backend, docs, notes } = yield* startWorkspaceFileThread({
key: "plain-folders",
writeWorkspaceFile: true,
});

// `docs` sits inside the cwd, so only `notes` needs a grant. Neither is a
// repo root, and the missing `gone` folder is skipped.
expect(startInput).toMatchObject({
cwd: workspaceRoot,
additionalRoots: [backend, notes],
repoRoots: [backend],
plainFolders: [docs, notes],
});
expect(startInput).not.toHaveProperty("isolatedRun");
}),
);

effectIt.effect("starts with the repos only when the workspace file cannot be read", () =>
Effect.gen(function* () {
const { startInput, workspaceRoot, backend } = yield* startWorkspaceFileThread({
key: "unreadable-workspace-file",
writeWorkspaceFile: false,
});

expect(startInput).toMatchObject({
cwd: workspaceRoot,
additionalRoots: [backend],
repoRoots: [backend],
});
expect(startInput).not.toHaveProperty("plainFolders");
}),
);

it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
Expand Down
59 changes: 50 additions & 9 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ import * as Stream from "effect/Stream";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";

import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
import { buildWorkspaceManifest, manifestExtraRoots } from "../../workspace/WorkspaceManifest.ts";
import { makeWorkspaceFile } from "../../workspace/WorkspaceFile.ts";
import {
buildWorkspaceManifest,
manifestExtraRoots,
manifestPlainFolderGrants,
} from "../../workspace/WorkspaceManifest.ts";
import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
import {
ProviderAdapterProcessError,
Expand Down Expand Up @@ -221,6 +226,7 @@ const make = Effect.gen(function* () {
const gitWorkflow = yield* GitWorkflowService;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceFile = yield* makeWorkspaceFile;
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
const textGeneration = yield* TextGeneration;
const serverSettingsService = yield* ServerSettingsService;
Expand Down Expand Up @@ -472,6 +478,24 @@ const make = Effect.gen(function* () {
.pipe(Effect.map(Option.getOrUndefined));
});

/**
* The folders a project's `.code-workspace` lists, read when a provider
* session starts so the agent can reach the ones that are not repositories.
* An unreadable file leaves the session with its repositories only.
*/
const readWorkspaceFolders = (threadId: ThreadId, workspaceFilePath: string | undefined) =>
workspaceFilePath === undefined
? Effect.succeed([])
: workspaceFile.read(workspaceFilePath).pipe(
Effect.map((resolved) => resolved.folders),
Effect.catch((cause) =>
Effect.logWarning(
"provider command reactor could not read the workspace file; starting with repositories only",
{ threadId, workspaceFilePath, cause },
).pipe(Effect.as([])),
),
);

/**
* Recreates a thread's worktree from its branch when the directory has
* disappeared. Provider sessions resume into the persisted cwd, so a missing
Expand Down Expand Up @@ -710,14 +734,15 @@ const make = Effect.gen(function* () {
// Multi-repo workspaces (D1): launch with a resolved manifest. The session
// anchors at the `.code-workspace` dir (or the single repo / worktree) and
// the agent is handed every repo root as an additional visible directory.
const manifest = project
? buildWorkspaceManifest({
const manifestInput = project
? {
worktreePath: thread.worktreePath ?? null,
worktrees: thread.worktrees,
workspaceRoot: project.workspaceRoot,
repoRoots: project.repoRoots ?? [],
})
}
: undefined;
const manifest = manifestInput ? buildWorkspaceManifest(manifestInput) : undefined;
const effectiveCwd =
manifest?.anchor ??
resolveThreadWorkspaceCwd({
Expand Down Expand Up @@ -746,20 +771,36 @@ const make = Effect.gen(function* () {
readonly resumeCursor?: unknown;
readonly provider?: ProviderDriverKind;
}) =>
providerService
.startSession(threadId, {
Effect.gen(function* () {
// Read here, not per turn: a `.code-workspace` edit applies to the next
// session start. Plain folders ride `additionalRoots` to be reachable,
// but stay out of `repoRoots`, which drives git-facing behaviour.
const sessionManifest = manifestInput
? buildWorkspaceManifest({
...manifestInput,
workspaceFolders: yield* readWorkspaceFolders(threadId, project?.workspaceFile),
})
: undefined;
const plainFolders = sessionManifest?.plainFolders.map((folder) => folder.path) ?? [];
const sessionRoots = [
...additionalRoots,
...(sessionManifest ? manifestPlainFolderGrants(sessionManifest) : []),
];
return yield* providerService.startSession(threadId, {
threadId,
...(preferredProvider ? { provider: preferredProvider } : {}),
providerInstanceId: desiredInstanceId,
...(effectiveCwd ? { cwd: effectiveCwd } : {}),
...(sessionTitle ? { title: sessionTitle } : {}),
...(additionalRoots.length > 0 ? { additionalRoots } : {}),
...(sessionRoots.length > 0 ? { additionalRoots: sessionRoots } : {}),
...(repoRoots.length > 0 ? { repoRoots } : {}),
...(plainFolders.length > 0 ? { plainFolders } : {}),
...(plainFolders.length > 0 && sessionManifest?.isolated ? { isolatedRun: true } : {}),
modelSelection: desiredModelSelection,
...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
runtimeMode: desiredRuntimeMode,
})
.pipe(Effect.tap(() => refreshWorkspaceSnapshot));
});
}).pipe(Effect.tap(() => refreshWorkspaceSnapshot));

const bindSessionToThread = (session: ProviderSession) =>
Effect.gen(function* () {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2283,6 +2283,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
? { additionalRoots: input.additionalRoots }
: {}),
...(input.repoRoots && input.repoRoots.length > 0 ? { repoRoots: input.repoRoots } : {}),
...(input.plainFolders && input.plainFolders.length > 0
? { plainFolders: input.plainFolders }
: {}),
...(input.isolatedRun ? { isolatedRun: true } : {}),
binaryPath: codexConfig.binaryPath,
...(options?.models ? { models: options.models } : {}),
launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ export interface CodexSessionRuntimeOptions {
readonly additionalRoots?: ReadonlyArray<string>;
/** Every repo root the session works in, including `cwd` when it is one. */
readonly repoRoots?: ReadonlyArray<string>;
/** Project folders that are not git repositories, named in the instructions. */
readonly plainFolders?: ReadonlyArray<string>;
readonly isolatedRun?: boolean;
readonly runtimeMode: RuntimeMode;
readonly model?: string;
readonly serviceTier?: CodexServiceTier | undefined;
Expand Down
75 changes: 75 additions & 0 deletions apps/server/src/provider/RuntimeInstructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,79 @@ describe("multiRepoWorkspace", () => {
}),
).toEqual({ cwd: "/home/me", repoRoots: ["/home/me/api"] });
});

it("carries plain folders without counting them as repo roots", () => {
expect(
multiRepoWorkspace({
cwd: "/home/me",
additionalRoots: ["/home/me/api", "/elsewhere/notes"],
repoRoots: ["/home/me/api"],
plainFolders: ["/home/me/docs", "/elsewhere/notes"],
isolatedRun: true,
}),
).toEqual({
cwd: "/home/me",
repoRoots: ["/home/me/api"],
plainFolders: ["/home/me/docs", "/elsewhere/notes"],
isolatedRun: true,
});
});
});

describe("plain project folders", () => {
const multiRepo = { cwd: "/home/me", repoRoots: ["/home/me/api", "/elsewhere/web"] };
const plainFolders = ["/home/me/docs", "/elsewhere/notes"];

it("lists them apart from the repositories", () => {
const instructions = buildRuntimeInstructions({
harness: "Cursor",
multiRepo: { ...multiRepo, plainFolders },
});
expect(instructions).toContain(
"This project's repositories are:\n- /home/me/api\n- /elsewhere/web\n",
);
expect(instructions).toContain(
"\n</project_repositories>\n\n<project_folders>\nThese folders are also part of this project. They are not git repositories:\n- /home/me/docs\n- /elsewhere/notes\n</project_folders>",
);
});

it("says the folders are shared originals only in an isolated run", () => {
expect(
buildRuntimeInstructions({
harness: "Cursor",
multiRepo: { ...multiRepo, plainFolders, isolatedRun: true },
}),
).toContain(
"- /elsewhere/notes\nThese are the original folders, shared with other runs of this project, and T3 Code checkpoints do not cover changes made in them.\n</project_folders>",
);
expect(
buildRuntimeInstructions({ harness: "Cursor", multiRepo: { ...multiRepo, plainFolders } }),
).not.toContain("original folders");
});

it("leaves the text unchanged when there are none", () => {
const instructions = buildRuntimeInstructions({ harness: "Cursor", multiRepo });
expect(
buildRuntimeInstructions({
harness: "Cursor",
multiRepo: { ...multiRepo, plainFolders: [] },
}),
).toBe(instructions);
expect(instructions).not.toContain("<project_folders>");
expect(instructions.endsWith("</project_repositories>")).toBe(true);
});

it("names them without the multi-repo rules when the session has one repo", () => {
const instructions = buildRuntimeInstructions({
harness: "Cursor",
multiRepo: multiRepoWorkspace({
cwd: "/home/me/api",
additionalRoots: ["/elsewhere/notes"],
plainFolders: ["/home/me/api-docs", "/elsewhere/notes"],
}),
});
expect(instructions).not.toContain("<multi_repo_workspace>");
expect(instructions).not.toContain("<project_repositories>");
expect(instructions).toContain("- /home/me/api-docs\n- /elsewhere/notes\n</project_folders>");
});
});
Loading
Loading