Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http";

import * as ProjectService from "../project/ProjectService.ts";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as PullRequestWatchReactor from "../orchestration-v2/PullRequestWatchReactor.ts";
import * as ServerConfig from "../config.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
Expand Down Expand Up @@ -52,12 +54,14 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
Layer.provideMerge(NodeServices.layer),
);
const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.pipe(
Layer.provide(PullRequestWatchReactor.layer),
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provide(
Layer.mergeAll(
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(PullRequestService.PullRequestService)({}),
NodeServices.layer,
),
),
Expand Down
108 changes: 106 additions & 2 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
PullRequestOperationError,
ThreadId,
type OrchestrationV2ServerCommand as OrchestrationCommand,
type OrchestrationProjectShell,
Expand All @@ -22,6 +23,9 @@ import {
v2PullRequestThread,
} from "../../../orchestration-v2/testkit/pullRequestFixtures.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as PullRequestService from "../../../pullRequest/PullRequestService.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";
import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts";
import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";
Expand Down Expand Up @@ -126,6 +130,7 @@ function makeLink(
interface HarnessOptions {
readonly thread?: PullRequestTestThread | null;
readonly project?: OrchestrationProjectShell | null;
readonly summary?: PullRequestService.PullRequestService["Service"]["summary"];
/** A rejection the orchestrator reports as the dispatch error's cause. */
readonly reject?: (command: OrchestrationCommand) => string | null;
}
Expand All @@ -134,8 +139,24 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
options: HarnessOptions = {},
) {
const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
const reads = yield* Ref.make<ReadonlyArray<string>>([]);
const thread = options.thread === undefined ? makeThread([]) : options.thread;
const project = options.project === undefined ? makeProject() : options.project;
const summary: PullRequestService.PullRequestService["Service"]["summary"] =
options.summary ??
((input) =>
Effect.succeed({
provider: "github",
projectId: input.projectId,
repository: input.repository,
number: input.number,
title: "Reopened PR",
url: `https://github.com/${input.repository}/pull/${input.number}`,
state: "open",
headBranch: "feature",
baseBranch: "main",
updatedAt: "2026-08-28T00:00:00.000Z",
}));
const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) =>
Effect.gen(function* () {
const rejection = options.reject?.(command) ?? null;
Expand All @@ -148,7 +169,8 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
yield* Ref.update(commands, (recorded) => [...recorded, command]);
return { sequence: 1, storedEvents: [] };
});
const dependencies = Layer.mergeAll(
const baseDependencies = Layer.mergeAll(
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(ProjectService.ProjectService)({
getShell: () => Effect.succeed(Option.fromNullishOr(project)),
}),
Expand All @@ -157,8 +179,20 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
Effect.succeed(id === THREAD_ID && thread ? v2PullRequestThread(thread) : null),
dispatch,
}),
Layer.mock(PullRequestService.PullRequestService)({
invalidate: () => Ref.update(reads, (calls) => [...calls, "invalidate"]),
summary: (input, options) =>
Ref.update(reads, (calls) => [...calls, "summary"]).pipe(
Effect.andThen(
options?.recoverTransientFailure === false && input.allowStale === false
? summary(input, options)
: Effect.die("Watch refresh must not return stale PR state"),
),
),
}),
Layer.succeed(Crypto.Crypto, testCrypto),
);
const dependencies = PullRequestWatchReactor.layer.pipe(Layer.provideMerge(baseDependencies));
const toolkit = yield* PullRequestsToolkit.pipe(
Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))),
);
Expand All @@ -177,7 +211,7 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)),
Effect.provide(dependencies),
);
return { commands, call };
return { commands, reads, call };
});

describe("pull request toolkit handlers", () => {
Expand Down Expand Up @@ -271,6 +305,76 @@ describe("pull request toolkit handlers", () => {
expect(yield* Ref.get(harness.commands)).toMatchObject([
{ type: "thread.pull-request.watch", number: 3, watching: false },
]);
expect(yield* Ref.get(harness.reads)).toEqual([]);
}),
);

it.effect("starts watching a reopened PR after refreshing its closed snapshot", () =>
Effect.gen(function* () {
const link = makeLink(7, { headBranch: "feature" });
const harness = yield* makeHarness({
thread: makeThread([
{ ...link, snapshot: link.snapshot && { ...link.snapshot, state: "closed" } },
]),
});
yield* harness.call("watch_pull_request", { repository: "t3tools/t3code", number: 7 });
expect(yield* Ref.get(harness.reads)).toEqual(["invalidate", "summary"]);
expect(yield* Ref.get(harness.commands)).toMatchObject([
{ type: "thread.pull-request-link.sync", number: 7, snapshot: { state: "open" } },
{ type: "thread.pull-request.watch", number: 7, watching: true },
]);
}),
);

it.effect("refuses a PR that remains closed on the host", () =>
Effect.gen(function* () {
const link = makeLink(7, { headBranch: "feature" });
const harness = yield* makeHarness({
thread: makeThread([
{ ...link, snapshot: link.snapshot && { ...link.snapshot, state: "closed" } },
]),
summary: (input) =>
Effect.succeed({
provider: "github",
projectId: input.projectId,
repository: input.repository,
number: input.number,
title: "Closed PR",
url: link.url,
state: "closed",
headBranch: "feature",
baseBranch: "main",
updatedAt: "2026-08-28T00:00:00.000Z",
}),
});
const error = yield* harness
.call("watch_pull_request", { repository: "t3tools/t3code", number: 7 })
.pipe(Effect.flip);
expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "closed" });
expect(yield* Ref.get(harness.commands)).toMatchObject([
{ type: "thread.pull-request-link.sync", number: 7, snapshot: { state: "closed" } },
]);
}),
);

it.effect("reports a failed host refresh without starting a watch", () =>
Effect.gen(function* () {
const link = makeLink(7, { headBranch: "feature" });
const failure = new PullRequestOperationError({
operation: "summary",
detail: "Host offline",
});
const harness = yield* makeHarness({
thread: makeThread([
{ ...link, snapshot: link.snapshot && { ...link.snapshot, state: "closed" } },
]),
summary: () => Effect.fail(failure),
});
const error = yield* harness
.call("watch_pull_request", { repository: "t3tools/t3code", number: 7 })
.pipe(Effect.flip);
expect(error).toMatchObject({ _tag: "PullRequestWatchFailedError", cause: failure });
expect(yield* Ref.get(harness.commands)).toEqual([]);
}),
);

Expand Down
28 changes: 12 additions & 16 deletions apps/server/src/mcp/toolkits/pullRequests/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as Option from "effect/Option";

import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import {
type ListThreadPullRequestsResult,
Expand All @@ -34,7 +35,6 @@ import {
PullRequestHostRequiredError,
PullRequestUnlinkFailedError,
PullRequestListFailedError,
PullRequestNotOpenError,
type PullRequestTargetInput,
PullRequestWatchFailedError,
PullRequestThreadNotFoundError,
Expand Down Expand Up @@ -152,6 +152,7 @@ export function listThreadPullRequests(

const make = Effect.gen(function* () {
const engine = yield* Orchestrator.OrchestratorV2;
const watches = yield* PullRequestWatchReactor.PullRequestWatchReactor;

const projects = yield* ProjectService.ProjectService;
const crypto = yield* Crypto.Crypto;
Expand Down Expand Up @@ -220,17 +221,8 @@ const make = Effect.gen(function* () {
const thread = yield* requireThread(PullRequestWatchFailedError);
const project = yield* projectOf(thread, PullRequestWatchFailedError);
const target = yield* resolveTarget(input, project);
const watchedLink = (shell: OrchestrationV2ThreadShell) =>
threadPullRequestsOf(shell).find(
(link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target),
);
const before = watchedLink(thread);
const state = before?.snapshot?.state;
if (watching && state !== undefined && state !== "open") {
return yield* new PullRequestNotOpenError({ state });
}
yield* engine
.dispatch({
const result = yield* watches
.setWatching({
type: "thread.pull-request.watch",
commandId: yield* commandId("mcp-pr-watch", thread.id),
threadId: thread.id,
Expand All @@ -240,15 +232,19 @@ const make = Effect.gen(function* () {
watching,
...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
})
.pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError)));
const after = yield* requireThread(PullRequestWatchFailedError);
.pipe(
Effect.mapError((cause) =>
cause._tag === "PullRequestNotOpenError"
? cause
: new PullRequestWatchFailedError({ cause }),
),
);
return {
host: target.host,
repository: target.repository,
number: target.number,
url: target.url,
watching: watchedLink(after)?.watch !== undefined,
wasWatching: before?.watch !== undefined,
...result,
};
});

Expand Down
13 changes: 3 additions & 10 deletions apps/server/src/mcp/toolkits/pullRequests/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import * as Toolkit from "effect/unstable/ai/Toolkit";
import * as McpInvocationContext from "../../McpInvocationContext.ts";
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";

const dependencies = [
McpInvocationContext.McpInvocationContext,
Orchestrator.OrchestratorV2,
ProjectService.ProjectService,
PullRequestWatchReactor.PullRequestWatchReactor,
];

const REGISTER_EVERY_PR =
Expand Down Expand Up @@ -117,15 +119,6 @@ export class PullRequestWatchFailedError extends Schema.TaggedError<PullRequestW
}
}

export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOpenError>()(
"PullRequestNotOpenError",
{ state: Schema.String },
) {
override get message(): string {
return `The pull request is ${this.state}, so there is nothing to watch.`;
}
}

export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()(
"PullRequestListFailedError",
{ cause: Schema.Defect() },
Expand All @@ -145,7 +138,7 @@ export const PullRequestToolError = Schema.Union([
PullRequestUnlinkFailedError,
PullRequestListFailedError,
PullRequestWatchFailedError,
PullRequestNotOpenError,
PullRequestWatchReactor.PullRequestNotOpenError,
]);
export type PullRequestToolError = typeof PullRequestToolError.Type;

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/worktree/registration.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts";
import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts";
import * as DeviceService from "../../../device/DeviceService.ts";
import * as ServerConfig from "../../../config.ts";
import { expect, it } from "@effect/vitest";
Expand Down Expand Up @@ -28,6 +29,7 @@ import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
const StubServicesLive = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({}),
Layer.mock(DeviceService.DeviceService)({}),
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
Layer.mock(ProviderRegistry.ProviderRegistry)({}),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ interface LinkEntry {
readonly link: ThreadPullRequestLink;
}

function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields {
export function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields {
return {
state: summary.state,
title: summary.title,
Expand Down
Loading
Loading