import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, type OrchestrationCommand, type OrchestrationProjectShell, type OrchestrationThreadShell, type ThreadPullRequestLink, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import type { Tool } from "effect/unstable/ai"; import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, } from "../../../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts"; import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); const testCrypto = Crypto.make({ randomBytes: (size) => new Uint8Array(size).fill(7), digest: (_algorithm, data) => Effect.succeed(data), }); const invocation = ( capabilities: ReadonlyArray, ): McpInvocationContext.McpInvocationScope => ({ environmentId: EnvironmentId.make("environment-1"), threadId: THREAD_ID, providerSessionId: "provider-session-1", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(capabilities), issuedAt: 1, }); function makeProject( repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = { canonicalKey: "github.com/t3tools/t3code", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@github.com:T3Tools/T3Code.git", }, provider: "github", displayName: "T3Tools/T3Code", owner: "T3Tools", name: "T3Code", }, ): OrchestrationProjectShell { return { id: PROJECT_ID, title: "Project", workspaceRoot: "/workspace/project", defaultModelSelection: null, scripts: [], repositoryIdentity, createdAt: "2026-08-01T00:00:00.000Z", updatedAt: "2026-08-01T00:00:00.000Z", }; } function makeThread(pullRequests: ReadonlyArray): OrchestrationThreadShell { return { id: THREAD_ID, projectId: PROJECT_ID, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", branch: null, worktreePath: null, pullRequests, latestTurn: null, createdAt: "2026-08-01T00:00:00.000Z", updatedAt: "2026-08-20T00:00:00.000Z", archivedAt: null, settledOverride: null, settledAt: null, session: null, latestUserMessageAt: "2026-08-20T00:00:00.000Z", hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, }; } function makeLink( number: number, overrides: Partial & { readonly headBranch?: string; readonly baseBranch?: string; } = {}, ): ThreadPullRequestLink { const { headBranch, baseBranch, ...rest } = overrides; return { host: "github.com", repository: "t3tools/t3code", number, url: `https://github.com/t3tools/t3code/pull/${number}`, source: "manual", linkedAt: "2026-08-10T00:00:00.000Z", snapshot: headBranch === undefined ? null : { state: "open", title: `PR ${number}`, headBranch, baseBranch: baseBranch ?? "main", isDraft: false, updatedAt: null, syncedAt: "2026-08-27T00:00:00.000Z", }, stack: null, ...rest, }; } interface HarnessOptions { readonly thread?: OrchestrationThreadShell | null; readonly project?: OrchestrationProjectShell | null; readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null; } const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( options: HarnessOptions = {}, ) { const commands = yield* Ref.make>([]); const thread = options.thread === undefined ? makeThread([]) : options.thread; const project = options.project === undefined ? makeProject() : options.project; const dispatch: OrchestrationEngineShape["dispatch"] = (command) => Effect.gen(function* () { const rejection = options.reject?.(command) ?? null; if (rejection !== null) return yield* rejection; yield* Ref.update(commands, (recorded) => [...recorded, command]); return { sequence: 1 }; }); const dependencies = Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ getThreadShellById: (threadId) => Effect.succeed(threadId === THREAD_ID ? Option.fromNullishOr(thread) : Option.none()), getProjectShellById: () => Effect.succeed(Option.fromNullishOr(project)), }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, dispatch, streamDomainEvents: Stream.empty, latestSequence: Effect.succeed(0), }), Layer.succeed(Crypto.Crypto, testCrypto), ); const toolkit = yield* PullRequestsToolkit.pipe( Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))), ); const call = ( name: Name, params: Parameters>[1], capabilities: ReadonlyArray = ["pull-requests"], ) => toolkit.handle(name, params).pipe( Stream.unwrap, Stream.runCollect, // Failure mode is "error", so a delivered result is always the success shape. Effect.map( (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>, ), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)), Effect.provide(dependencies), ); return { commands, call }; }); describe("pull request toolkit handlers", () => { it.effect("refuses a credential without the pull-requests capability", () => Effect.gen(function* () { const harness = yield* makeHarness(); const error = yield* harness .call("list_thread_pull_requests", {}, ["preview"]) .pipe(Effect.flip); expect(error).toMatchObject({ _tag: "McpCapabilityUnavailableError", capability: "pull-requests", threadId: THREAD_ID, }); expect(yield* Ref.get(harness.commands)).toEqual([]); }), ); it.effect("links by URL with source agent on the token's thread", () => Effect.gen(function* () { const harness = yield* makeHarness(); const result = yield* harness.call("link_pull_request", { url: "https://github.com/T3Tools/T3Code/pull/123/files", }); expect(result).toEqual({ host: "github.com", repository: "t3tools/t3code", number: 123, url: "https://github.com/T3Tools/T3Code/pull/123/files", alreadyLinked: false, }); expect(yield* Ref.get(harness.commands)).toMatchObject([ { type: "thread.pull-request.link", threadId: THREAD_ID, host: "github.com", repository: "t3tools/t3code", number: 123, source: "agent", }, ]); }), ); it.effect("links by repository and number, defaulting the host to the project's", () => Effect.gen(function* () { const harness = yield* makeHarness(); const result = yield* harness.call("link_pull_request", { repository: "T3Tools/Other", number: 7, }); expect(result).toEqual({ host: "github.com", repository: "t3tools/other", number: 7, url: "https://github.com/t3tools/other/pull/7", alreadyLinked: false, }); }), ); it.effect("builds the URL in the project host's own shape", () => Effect.gen(function* () { const harness = yield* makeHarness({ project: makeProject({ canonicalKey: "gitlab.com/group/sub/project", locator: { source: "git-remote", remoteName: "origin", remoteUrl: "git@gitlab.com:group/sub/project.git", }, provider: "gitlab", displayName: "group/sub/project", }), }); const result = yield* harness.call("link_pull_request", { repository: "group/sub/project", number: 42, }); expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); expect(result.host).toBe("gitlab.com"); }), ); it.effect("rejects a target that names neither a URL nor repository and number", () => Effect.gen(function* () { const harness = yield* makeHarness(); const error = yield* harness .call("link_pull_request", { repository: "x/y" }) .pipe(Effect.flip); expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" }); const unknown = yield* harness .call("link_pull_request", { url: "https://github.com/t3tools/t3code/issues/1?token=private-value", }) .pipe(Effect.flip); expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" }); expect(unknown.message).not.toContain("private-value"); expect(yield* Ref.get(harness.commands)).toEqual([]); }), ); it.effect("treats a duplicate link as alreadyLinked rather than an error", () => Effect.gen(function* () { const harness = yield* makeHarness({ thread: makeThread([makeLink(123)]), reject: (command) => command.type === "thread.pull-request.link" ? new OrchestrationCommandInvariantError({ commandType: command.type, detail: "already linked", }) : null, }); const result = yield* harness.call("link_pull_request", { url: "https://github.com/t3tools/t3code/pull/123", }); expect(result.alreadyLinked).toBe(true); }), ); it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () => Effect.gen(function* () { const harness = yield* makeHarness({ thread: makeThread([makeLink(5)]), reject: (command) => command.type === "thread.pull-request.unlink" && command.number !== 5 ? new OrchestrationCommandInvariantError({ commandType: command.type, detail: "not linked", }) : null, }); const linked = yield* harness.call("unlink_pull_request", { repository: "t3tools/t3code", number: 5, }); expect(linked).toEqual({ host: "github.com", repository: "t3tools/t3code", number: 5, wasLinked: true, }); const missing = yield* harness.call("unlink_pull_request", { url: "https://github.com/t3tools/t3code/pull/9", }); expect(missing.wasLinked).toBe(false); expect(yield* Ref.get(harness.commands)).toMatchObject([ { type: "thread.pull-request.unlink", number: 5 }, ]); }), ); it.effect("fails cleanly when the token's thread no longer exists", () => Effect.gen(function* () { const harness = yield* makeHarness({ thread: null }); const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID }); }), ); it.effect("lists visible links with host state and derived chain order", () => Effect.gen(function* () { const harness = yield* makeHarness({ thread: makeThread([ makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }), makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }), makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }), makeLink(9, { source: "stack-dismissed" }), makeLink(10), ]), }); const result = yield* harness.call("list_thread_pull_requests", {}); expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]); expect(result.pullRequests[0]).toEqual({ host: "github.com", repository: "t3tools/t3code", number: 3, url: "https://github.com/t3tools/t3code/pull/3", source: "agent", state: "open", title: "PR 3", headBranch: "feat-c", baseBranch: "feat-b", isDraft: false, stack: { kind: "derived", position: 3, size: 3 }, }); expect(result.pullRequests[3]).toMatchObject({ number: 10, state: null, title: null, headBranch: null, stack: null, }); expect(result.chains).toEqual([ { kind: "derived", numbers: [1, 2, 3] }, { kind: "derived", numbers: [10] }, ]); }), ); }); describe("listThreadPullRequests", () => { it("reports a native stack position for each member", () => { const stack = { kind: "native" as const, id: "stack-1", number: 1, url: "https://github.com/t3tools/t3code/stack/1", base: "main", layers: [ { number: 1, headBranch: "a", state: "open" as const }, { number: 2, headBranch: "b", state: "open" as const }, ], }; const result = listThreadPullRequests({ pullRequests: [ makeLink(2, { stack, source: "stack" }), makeLink(1, { stack, source: "created" }), ], }); expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([ [2, { kind: "native", position: 2, size: 2 }], [1, { kind: "native", position: 1, size: 2 }], ]); expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); }); }); it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => { const cause = new Error("database internals"); const failure = new PullRequestLinkFailedError({ cause }); expect(failure.message).toBe("Could not link the pull request."); expect(failure.cause).toBe(cause); });