Skip to content

Commit f713a79

Browse files
committed
Address current checkout thread reviews
1 parent 094c88c commit f713a79

2 files changed

Lines changed: 114 additions & 27 deletions

File tree

apps/server/src/mcp/toolkits/thread/handlers.test.ts

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, it } from "@effect/vitest";
22
import {
33
EnvironmentId,
4+
GitCommandError,
45
ProjectId,
56
ProviderInstanceId,
67
ThreadId,
@@ -93,7 +94,11 @@ const TestCryptoLive = Layer.sync(Crypto.Crypto, () => {
9394

9495
const makeTestLayer = (
9596
commands: OrchestrationCommand[],
96-
options: { readonly enqueueCalls?: number[] } = {},
97+
options: {
98+
readonly enqueueCalls?: number[];
99+
readonly gitStatus?: GitWorkflowService["Service"]["status"];
100+
readonly sourceThread?: OrchestrationThreadShell;
101+
} = {},
97102
) => {
98103
const bootstrapTurnStartDispatcherLayer = Layer.mock(
99104
BootstrapTurnStartDispatcher.BootstrapTurnStartDispatcher,
@@ -126,7 +131,7 @@ const makeTestLayer = (
126131
Layer.provide(
127132
Layer.mock(ProjectionSnapshotQuery)({
128133
getProjectShellById: () => Effect.succeed(Option.some(project)),
129-
getThreadShellById: () => Effect.succeed(Option.some(sourceThread)),
134+
getThreadShellById: () => Effect.succeed(Option.some(options.sourceThread ?? sourceThread)),
130135
}),
131136
),
132137
Layer.provide(
@@ -147,24 +152,26 @@ const makeTestLayer = (
147152
nextCursor: null,
148153
totalCount: 1,
149154
}),
150-
status: () =>
151-
Effect.succeed({
152-
isRepo: true,
153-
hasPrimaryRemote: true,
154-
isDefaultRef: false,
155-
refName: "feature/source",
156-
hasWorkingTreeChanges: false,
157-
workingTree: {
158-
files: [],
159-
insertions: 0,
160-
deletions: 0,
161-
},
162-
hasUpstream: true,
163-
aheadCount: 0,
164-
behindCount: 0,
165-
aheadOfDefaultCount: 0,
166-
pr: null,
167-
}),
155+
status:
156+
options.gitStatus ??
157+
(() =>
158+
Effect.succeed({
159+
isRepo: true,
160+
hasPrimaryRemote: true,
161+
isDefaultRef: false,
162+
refName: "feature/source",
163+
hasWorkingTreeChanges: false,
164+
workingTree: {
165+
files: [],
166+
insertions: 0,
167+
deletions: 0,
168+
},
169+
hasUpstream: true,
170+
aheadCount: 0,
171+
behindCount: 0,
172+
aheadOfDefaultCount: 0,
173+
pr: null,
174+
})),
168175
}),
169176
),
170177
Layer.provide(
@@ -190,7 +197,11 @@ const makeTestLayer = (
190197
const callStartTool = (
191198
arguments_: Record<string, unknown>,
192199
commands: OrchestrationCommand[],
193-
options: { readonly enqueueCalls?: number[] } = {},
200+
options: {
201+
readonly enqueueCalls?: number[];
202+
readonly gitStatus?: GitWorkflowService["Service"]["status"];
203+
readonly sourceThread?: OrchestrationThreadShell;
204+
} = {},
194205
) =>
195206
Effect.gen(function* () {
196207
const server = yield* McpServer.McpServer;
@@ -272,23 +283,75 @@ it.effect("passes explicit setup script requests for existing worktrees", () =>
272283
it.effect("starts current-checkout threads with warning metadata", () =>
273284
Effect.gen(function* () {
274285
const commands: OrchestrationCommand[] = [];
286+
const sourceWorktreePath = "/repo/.worktrees/source";
275287
const result = yield* callStartTool(
276-
{ prompt: "Read current diff", mode: "current_checkout" },
288+
{ prompt: "Read current diff", mode: "current_checkout", runSetupScript: true },
277289
commands,
290+
{ sourceThread: { ...sourceThread, worktreePath: sourceWorktreePath } },
278291
);
279292

280293
expect(result.isError).toBe(false);
281294
expect(result.structuredContent).toMatchObject({
282295
projectId,
283296
mode: "current_checkout",
284297
branch: "feature/source",
285-
worktreePath: null,
298+
worktreePath: sourceWorktreePath,
286299
});
287300
expect(result.structuredContent).toHaveProperty("warning");
288301
const command = commands[0];
289302
expect(command?.type).toBe("thread.turn.start");
290303
if (command?.type !== "thread.turn.start") return;
291304
expect(command.bootstrap?.prepareWorktree).toBeUndefined();
292-
expect(command.bootstrap?.createThread?.worktreePath).toBeNull();
305+
expect(command.bootstrap?.createThread?.worktreePath).toBe(sourceWorktreePath);
306+
expect(command.bootstrap?.runSetupScript).toBe(true);
307+
}),
308+
);
309+
310+
it.effect("fails existing worktree mode when git status cannot resolve the branch", () =>
311+
Effect.gen(function* () {
312+
const commands: OrchestrationCommand[] = [];
313+
const result = yield* callStartTool(
314+
{ prompt: "Use bad checkout", mode: "existing_worktree", worktreePath: "/missing" },
315+
commands,
316+
{
317+
gitStatus: () =>
318+
Effect.fail(
319+
new GitCommandError({
320+
operation: "status",
321+
command: "git status",
322+
cwd: "/missing",
323+
detail: "not a git repository",
324+
}),
325+
),
326+
},
327+
);
328+
329+
expect(result.isError).toBe(true);
330+
expect(commands).toHaveLength(0);
331+
}),
332+
);
333+
334+
it.effect("fails current checkout mode when fallback branch lookup fails", () =>
335+
Effect.gen(function* () {
336+
const commands: OrchestrationCommand[] = [];
337+
const result = yield* callStartTool(
338+
{ prompt: "Use current checkout", mode: "current_checkout" },
339+
commands,
340+
{
341+
sourceThread: { ...sourceThread, branch: null, worktreePath: "/missing" },
342+
gitStatus: () =>
343+
Effect.fail(
344+
new GitCommandError({
345+
operation: "status",
346+
command: "git status",
347+
cwd: "/missing",
348+
detail: "not a git repository",
349+
}),
350+
),
351+
},
352+
);
353+
354+
expect(result.isError).toBe(true);
355+
expect(commands).toHaveLength(0);
293356
}),
294357
);

apps/server/src/mcp/toolkits/thread/handlers.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime"
8989
);
9090
});
9191

92+
const resolveRequiredCurrentBranch = Effect.fn("ThreadToolkit.resolveRequiredCurrentBranch")(
93+
function* (cwd: string, failureMessage: string) {
94+
const branch = yield* gitWorkflow.status({ cwd }).pipe(
95+
Effect.map((status) => status.refName),
96+
Effect.mapError((error) => fail(error instanceof Error ? error.message : failureMessage)),
97+
);
98+
if (branch) return branch;
99+
return yield* fail(failureMessage);
100+
},
101+
);
102+
92103
const resolveDefaultBranch = Effect.fn("ThreadToolkit.resolveDefaultBranch")(function* (
93104
cwd: string,
94105
) {
@@ -132,9 +143,18 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime"
132143
if (!input.worktreePath) {
133144
return yield* fail("existing_worktree mode requires worktreePath.");
134145
}
135-
return yield* resolveCurrentBranch(input.worktreePath);
146+
return yield* resolveRequiredCurrentBranch(
147+
input.worktreePath,
148+
`Could not resolve current branch for existing worktree ${input.worktreePath}.`,
149+
);
136150
}
137-
return sourceThread.branch ?? (yield* resolveCurrentBranch(project.workspaceRoot));
151+
return (
152+
sourceThread.branch ??
153+
(yield* resolveRequiredCurrentBranch(
154+
sourceThread.worktreePath ?? project.workspaceRoot,
155+
"Could not resolve current branch for the current checkout.",
156+
))
157+
);
138158
});
139159

140160
const loadSourceContext = Effect.fn("ThreadToolkit.loadSourceContext")(function* (
@@ -176,7 +196,11 @@ const makeActiveThreadStartRuntime = Effect.fn("ThreadToolkit.makeActiveRuntime"
176196
const createdAt = yield* nowIso;
177197
const branch = (yield* resolveInitialBranch(mode, input, project, sourceThread)) ?? null;
178198
const worktreePath: string | null =
179-
mode === "existing_worktree" ? (input.worktreePath ?? null) : null;
199+
mode === "existing_worktree"
200+
? (input.worktreePath ?? null)
201+
: mode === "current_checkout"
202+
? (sourceThread.worktreePath ?? project.workspaceRoot)
203+
: null;
180204
const title = input.title ?? truncateTitle(input.prompt);
181205
const modelSelection = resolveModelSelection(input, sourceThread);
182206
const runtimeMode = input.runtimeMode ?? sourceThread.runtimeMode;

0 commit comments

Comments
 (0)