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
165 changes: 162 additions & 3 deletions apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import {
type Project as ProjectRecord,
type ModelSelection,
type RuntimeMode,
type OrchestrationV2ThreadShell,
} from "@t3tools/contracts";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -44,6 +47,10 @@ const clientLaunchHarness = (input: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-opus",
};
// The project's own default model, which a test can clear between launches.
const projectDefaults: { model: ModelSelection | null } = {
model: input.overrideModel === undefined ? modelSelection : null,
};
const layerDependencies = Layer.mergeAll(
ServerSettings.layerTest({
...(input.defaultRuntimeMode === undefined
Expand Down Expand Up @@ -71,7 +78,22 @@ const clientLaunchHarness = (input: {
issuedAt: 0,
capabilities: new Set(["orchestration" as const]),
}),
Layer.mock(ThreadManagement.ThreadManagementService)({}),
Layer.mock(ThreadManagement.ThreadManagementService)({
// A thread exists once a launch started it.
getThreadShell: (threadId) => {
const launch = input.launched.find((candidate) => candidate.threadId === threadId);
return Effect.succeed(
launch === undefined
? null
: ({
id: launch.threadId,
projectId: launch.projectId,
modelSelection: launch.modelSelection,
deletedAt: null,
} as unknown as OrchestrationV2ThreadShell),
);
},
}),
Layer.mock(ThreadLaunch.ThreadLaunchService)({
checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void),
launch: (launch) => {
Expand All @@ -98,7 +120,7 @@ const clientLaunchHarness = (input: {
? Option.some({
id,
workspaceRoot: input.workspaceRoot ?? "/projects/client-target",
defaultModelSelection: input.overrideModel === undefined ? modelSelection : null,
defaultModelSelection: projectDefaults.model,
} as unknown as ProjectRecord)
: Option.none(),
),
Expand All @@ -115,7 +137,7 @@ const clientLaunchHarness = (input: {
),
Layer.provideMerge(NodeServices.layer),
);
return { projectId, modelSelection, dependencies: layerDependencies };
return { projectId, modelSelection, projectDefaults, dependencies: layerDependencies };
};

it.effect(
Expand Down Expand Up @@ -200,6 +222,100 @@ it.effect(
}),
);

it.effect("a launch retried with the same clientRequestId replays the first one", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const { projectId, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const launch = (clientRequestId: string | undefined) =>
toolkit
.handle("t3_thread_launch", {
title: "Fix",
projectId,
message: "Fix the bug",
...(clientRequestId === undefined ? {} : { clientRequestId }),
})
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));

yield* launch("fix-1");
yield* launch("fix-1");
yield* launch("fix-2");
yield* launch(undefined);
yield* launch(undefined);
const ids = launched.map((input) => [input.commandId, input.threadId]);
// The same key sends the same command and thread ids, which the launch
// service replays instead of starting a second thread.
expect(ids[0]).toEqual(ids[1]);
expect(ids[0]?.[0]).toMatch(/^mcp:launch:[0-9a-f]{64}$/);
expect(ids[2]).not.toEqual(ids[0]);
// Without a key every launch is new.
expect(ids[3]).not.toEqual(ids[4]);
}),
);

it.effect("a launch key of any length or content gives a short id and never a defect", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const { projectId, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const launch = (clientRequestId: string) =>
toolkit
.handle("t3_thread_launch", { title: "Fix", projectId, clientRequestId })
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));

// Percent-encoding would grow this six times; scratch folders are named from the id.
yield* launch("\u00e9".repeat(256));
expect(launched.map((input) => input.commandId)).toEqual([
expect.stringMatching(/^mcp:launch:[0-9a-f]{64}$/),
]);

const loneSurrogate = yield* launch("key-\ud800").pipe(Effect.exit);
expect(Exit.isFailure(loneSurrogate) && Cause.hasDies(loneSurrogate.cause)).toBe(false);
expect(launched).toHaveLength(1);
}),
);

it.effect("a retried launch replays after the project loses its default model", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const { projectId, modelSelection, projectDefaults, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const launch = toolkit
.handle("t3_thread_launch", { title: "Fix", projectId, clientRequestId: "fix-1" })
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));

yield* launch;
projectDefaults.model = null;
const retried = (yield* launch).at(-1)?.result;
expect(retried).toMatchObject({ projectId, modelSelection });
expect(launched.map((input) => input.threadId)).toEqual([
launched[0]?.threadId,
launched[0]?.threadId,
]);
}),
);

it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -302,3 +418,46 @@ it.effect("a launch into a new worktree creates nothing when its base ref has no
expect(launched).toHaveLength(1);
}),
);

it.effect("a retried worktree launch replays after its base ref is gone", () =>
Effect.gen(function* () {
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
const base = { exists: true };
const { projectId, dependencies } = clientLaunchHarness({
runtimeModeCeiling: "auto",
launched,
checkWorktreeBase: ({ projectId, workspaceStrategy }) =>
base.exists || workspaceStrategy.type !== "worktree"
? Effect.void
: Effect.fail(
new ThreadLaunch.ThreadLaunchBaseRefError({
projectId,
projectTitle: "Client target",
workspaceRoot: "/projects/client-target",
baseRef: workspaceStrategy.baseRef,
}),
),
});
const toolkit = yield* ProjectToolkit.pipe(
Effect.provide(
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
),
);
const launch = toolkit
.handle("t3_thread_launch", {
title: "Fix",
projectId,
clientRequestId: "fix-worktree",
workspaceStrategy: { type: "worktree", baseRef: "feature", startFromOrigin: false },
})
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));

yield* launch;
base.exists = false;
expect((yield* launch).at(-1)?.result).toMatchObject({ projectId });
expect(launched.map((input) => input.threadId)).toEqual([
launched[0]?.threadId,
launched[0]?.threadId,
]);
}),
);
49 changes: 43 additions & 6 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
import {
CommandId,
MessageId,
ThreadId,
OrchestratorMcpFailure,
ProjectId,
} from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as Hex from "effect/encoding/Hex";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
Expand Down Expand Up @@ -48,6 +56,18 @@ const assertProjectWorktree = Effect.fn("mcp.assertProjectWorktree")(function* (
});
});

/** A fixed-length id, so a long key fits in the scratch folder name derived from it. */
const launchCommandId = Effect.fn("mcp.launchCommandId")(function* (
namespace: string,
key: string,
) {
const crypto = yield* Crypto.Crypto;
// @effect-diagnostics-next-line preferSchemaOverJson:off - a JSON pair keeps the caller apart from the key.
const bytes = new TextEncoder().encode(JSON.stringify([namespace, key]));
const digest = yield* crypto.digest("SHA-256", bytes).pipe(Effect.mapError(unavailable));
return CommandId.make(`mcp:launch:${Hex.encode(digest)}`);
});

const access = Effect.gen(function* () {
yield* readCaller();
return yield* Project.ProjectService;
Expand All @@ -58,8 +78,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
(input, { runtimeMode, interactionMode }) =>
Effect.gen(function* () {
const context = yield* readCaller();
const { caller } = context;
const commandId = yield* newCommandId();
const { caller, scope } = context;
// A retry with the same key replays the first launch: the ids derive
// from the caller and the key, and the launch service replays a
// command it already accepted.
const commandId =
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
input.clientRequestId === undefined
? yield* newCommandId()
: yield* launchCommandId(scope.requestNamespace, input.clientRequestId);
const threadId = ThreadId.make(commandId);
const messageId = MessageId.make(commandId);
const attachments = input.attachments ?? [];
Expand Down Expand Up @@ -95,7 +121,17 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
Effect.mapError(unavailable),
Effect.map(Option.getOrUndefined),
);
if (input.workspaceStrategy?.type === "existing_worktree") {
// A retry of an accepted launch replays it: it keeps the model it started
// with and skips the checks only a new launch needs, which the project
// may no longer pass, such as a base branch deleted since.
const launched =
input.clientRequestId === undefined
? null
: yield* context.threads.getThreadShell(threadId).pipe(Effect.mapError(unavailable));
const launchedModel =
launched === null || launched.deletedAt !== null ? undefined : launched.modelSelection;
const replaying = launchedModel !== undefined;
if (!replaying && input.workspaceStrategy?.type === "existing_worktree") {
const project = yield* readProject;
if (project === undefined)
return yield* new OrchestratorMcpFailure({
Expand All @@ -106,7 +142,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
}
// Reject unresolved base refs before accepting a launch; worktree provisioning happens later.
const strategy = input.workspaceStrategy;
if (strategy?.type === "worktree")
if (!replaying && strategy?.type === "worktree")
yield* ThreadLaunch.ThreadLaunchService.pipe(
Effect.flatMap((launches) =>
launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }),
Expand All @@ -127,7 +163,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
// project's effective defaults, as the composer does. Its mode stays
// within the ceiling it was approved with.
const projectDefaults =
caller === undefined
caller === undefined && !replaying
? yield* ServerSettings.ServerSettingsService.pipe(
Effect.flatMap((settings) => settings.getSettings),
Effect.mapError(unavailable),
Expand All @@ -141,6 +177,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
: undefined;
const modelSelection =
input.modelSelection ??
launchedModel ??
caller?.modelSelection ??
projectDefaults?.defaultModelSelection ??
undefined;
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ProjectCreatePayload,
ProjectUpdatePayload,
ProjectId,
OrchestratorMcpClientRequestId,
OrchestratorMcpFailure,
SourceControlCloneRepositoryInput,
SourceControlCloneRepositoryResult,
Expand Down Expand Up @@ -101,7 +102,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
const ThreadLaunchTool = Tool.make("t3_thread_launch", {
...shared,
description:
"Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. No retry key: after an error or lost reply, check `t3_thread_list` before retrying. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.",
"Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.",
parameters: Schema.Struct({
projectId: Schema.optional(ProjectId),
scratch: Schema.optional(
Expand All @@ -126,6 +127,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
}),
),
attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))),
clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
}),
success: Schema.Struct({
threadId: ThreadId,
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,6 +1423,40 @@ it.effect("replays an accepted launch after the project disables the instance",
}).pipe(Effect.provide(harness.layer));
});

it.effect("an agent's launch never starts work above the modes it asks for", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const launches = yield* ThreadLaunch.ThreadLaunchService;
const threads = yield* ThreadManagement.ThreadManagementService;
// The first launch, at full access, creates the thread without a message.
const created = yield* launches.launch(
launchInput({ command: "command:launch:narrowed-retry", thread: "thread:narrowed" }),
);
// The same key comes back from a narrower agent, now carrying a message,
// as a retry or a concurrent launch that lost the create would.
const failed = yield* launches
.launch({
...launchInput({
command: "command:launch:narrowed-retry",
thread: "thread:narrowed",
message: "Run this",
}),
runtimeMode: "approval-required",
interactionMode: "plan",
createdBy: "agent",
creationSource: "mcp",
})
.pipe(Effect.flip);
if (failed._tag !== "ThreadLaunchError") {
assert.fail(`expected ThreadLaunchError, got ${failed._tag}`);
}
assert.equal(failed.operation, "dispatch-message");
const projection = yield* threads.getThreadProjection(created.threadId);
assert.equal(projection.runs.length, 0);
assert.equal(projection.messages.length, 0);
}).pipe(Effect.provide(harness.layer));
});

it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
Loading
Loading