1import {
2 EnvironmentId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationV2ServerCommand as OrchestrationCommand,
7 PullRequestOperationError,
8 type OrchestrationProjectShell,
9 type PullRequestRef,
10 type PullRequestReviewGuideResult,
11 type ThreadPullRequestLink,
12} from "@t3tools/contracts";
13import { describe, expect, it } from "@effect/vitest";
14import * as Crypto from "effect/Crypto";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Ref from "effect/Ref";
19import * as Stream from "effect/Stream";
20import type { Tool } from "effect/ai";
21
22import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
23import {
24 type PullRequestTestThread,
25 v2PullRequestThread,
26} from "../../../orchestration-v2/testkit/pullRequestFixtures.ts";
27import * as ProjectService from "../../../project/ProjectService.ts";
28import * as PullRequestReviewGuideService from "../../../pullRequest/PullRequestReviewGuideService.ts";
29import * as McpInvocationContext from "../../McpInvocationContext.ts";
30import * as McpToolAccess from "../../McpToolAccess.ts";
31import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts";
32import { listThreadPullRequests } from "./handlers.ts";
33import * as PullRequestsHandlers from "./handlers.ts";
34import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts";
35
36const PROJECT_ID = ProjectId.make("project-1");
37const THREAD_ID = ThreadId.make("thread-1");
38
39const testCrypto = Crypto.make({
40 randomBytes: (size) => new Uint8Array(size).fill(7),
41 digest: (_algorithm, data) => Effect.succeed(data),
42});
43
44const invocation = (
45 capabilities: ReadonlyArray<McpInvocationContext.McpCapability>,
46): McpInvocationContext.McpInvocationScope => ({
47 environmentId: EnvironmentId.make("environment-1"),
48 requestNamespace: "provider-session-1",
49 thread: {
50 threadId: THREAD_ID,
51 providerSessionId: "provider-session-1",
52 providerInstanceId: ProviderInstanceId.make("codex"),
53 },
54 client: undefined,
55 capabilities: new Set(capabilities),
56 issuedAt: 1,
57});
58
59function makeProject(
60 repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = {
61 canonicalKey: "github.com/t3tools/t3code",
62 locator: {
63 source: "git-remote",
64 remoteName: "origin",
65 remoteUrl: "git@github.com:T3Tools/T3Code.git",
66 },
67 provider: "github",
68 displayName: "T3Tools/T3Code",
69 owner: "T3Tools",
70 name: "T3Code",
71 },
72): OrchestrationProjectShell {
73 return {
74 id: PROJECT_ID,
75 title: "Project",
76 workspaceRoot: "/workspace/project",
77 defaultModelSelection: null,
78 scripts: [],
79 repositoryIdentity,
80 createdAt: "2026-08-01T00:00:00.000Z",
81 updatedAt: "2026-08-01T00:00:00.000Z",
82 };
83}
84
85function makeThread(pullRequests: ReadonlyArray<ThreadPullRequestLink>): PullRequestTestThread {
86 return {
87 id: THREAD_ID,
88 projectId: PROJECT_ID,
89 title: "Thread",
90 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
91 runtimeMode: "full-access",
92 interactionMode: "default",
93 branch: null,
94 worktreePath: null,
95 pullRequests,
96 createdAt: "2026-08-01T00:00:00.000Z",
97 updatedAt: "2026-08-20T00:00:00.000Z",
98 archivedAt: null,
99 settledOverride: null,
100 settledAt: null,
101 latestUserMessageAt: "2026-08-20T00:00:00.000Z",
102 };
103}
104
105function makeLink(
106 number: number,
107 overrides: Partial<ThreadPullRequestLink> & {
108 readonly headBranch?: string;
109 readonly baseBranch?: string;
110 } = {},
111): ThreadPullRequestLink {
112 const { headBranch, baseBranch, ...rest } = overrides;
113 return {
114 host: "github.com",
115 repository: "t3tools/t3code",
116 number,
117 url: `https://github.com/t3tools/t3code/pull/${number}`,
118 source: "manual",
119 linkedAt: "2026-08-10T00:00:00.000Z",
120 snapshot:
121 headBranch === undefined
122 ? null
123 : {
124 state: "open",
125 title: `PR ${number}`,
126 headBranch,
127 baseBranch: baseBranch ?? "main",
128 isDraft: false,
129 updatedAt: null,
130 syncedAt: "2026-08-27T00:00:00.000Z",
131 },
132 stack: null,
133 ...rest,
134 };
135}
136
137interface HarnessOptions {
138 readonly thread?: PullRequestTestThread | null;
139 readonly project?: OrchestrationProjectShell | null;
140 /** A rejection the orchestrator reports as the dispatch error's cause. */
141 readonly reject?: (command: OrchestrationCommand) => string | null;
142 /** Every project in the environment; defaults to the thread's own. */
143 readonly projects?: ReadonlyArray<OrchestrationProjectShell>;
144 /** What the guide service answers for a pull request it is asked to write. */
145 readonly generate?: (
146 input: PullRequestRef,
147 ) => Effect.Effect<
148 PullRequestReviewGuideResult & { started: boolean },
149 PullRequestOperationError
150 >;
151}
152
153const NO_GUIDE: PullRequestReviewGuideResult = { guide: null, run: null, currentHeadSha: "head-1" };
154
155const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* (
156 options: HarnessOptions = {},
157) {
158 const commands = yield* Ref.make<ReadonlyArray<OrchestrationCommand>>([]);
159 const guideRequests = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]);
160 const thread = options.thread === undefined ? makeThread([]) : options.thread;
161 const project = options.project === undefined ? makeProject() : options.project;
162 const dispatch: Orchestrator.OrchestratorV2Shape["dispatch"] = (command) =>
163 Effect.gen(function* () {
164 const rejection = options.reject?.(command) ?? null;
165 if (rejection !== null)
166 return yield* new Orchestrator.OrchestratorDispatchError({
167 commandId: command.commandId,
168 commandType: command.type,
169 cause: rejection,
170 });
171 yield* Ref.update(commands, (recorded) => [...recorded, command]);
172 return { sequence: 1, storedEvents: [] };
173 });
174 const layerDependencies = Layer.mergeAll(
175 Layer.mock(ProjectService.ProjectService)({
176 getShell: () => Effect.succeed(Option.fromNullishOr(project)),
177 listShells: () => Effect.succeed(options.projects ?? (project ? [project] : [])),
178 }),
179 Layer.mock(PullRequestReviewGuideService.PullRequestReviewGuideService)({
180 get: () => Effect.succeed(NO_GUIDE),
181 generate: (input) =>
182 Ref.update(guideRequests, (recorded) => [...recorded, input]).pipe(
183 Effect.andThen(
184 options.generate?.(input) ??
185 Effect.succeed({
186 ...NO_GUIDE,
187 run: {
188 _tag: "Running" as const,
189 headSha: "head-1",
190 startedAt: "2026-10-11T00:00:00.000Z",
191 },
192 started: true,
193 }),
194 ),
195 ),
196 }),
197 Layer.mock(Orchestrator.OrchestratorV2)({
198 getThreadShell: (id) =>
199 Effect.succeed(id === THREAD_ID && thread ? v2PullRequestThread(thread) : null),
200 dispatch,
201 }),
202 Layer.succeed(Crypto.Crypto, testCrypto),
203 McpToolAccessTestkit.liveThreadsLayer,
204 );
205 const toolkit = yield* PullRequestsToolkit.pipe(
206 Effect.provide(
207 McpToolAccess.HandlersLayer.layer(PullRequestsHandlers.layer).pipe(
208 Layer.provide(layerDependencies),
209 ),
210 ),
211 );
212 const call = <Name extends keyof typeof PullRequestsToolkit.tools>(
213 name: Name,
214 params: Parameters<typeof toolkit.handle<Name>>[1],
215 capabilities: ReadonlyArray<McpInvocationContext.McpCapability> = ["pull-requests"],
216 ) =>
217 toolkit.handle(name, params).pipe(
218 Stream.unwrap,
219 Stream.runCollect,
220 // Failure mode is "error", so a delivered result is always the success shape.
221 Effect.map(
222 (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>,
223 ),
224 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)),
225 Effect.provide(layerDependencies),
226 );
227 return { commands, guideRequests, call };
228});
229
230describe("pull request toolkit handlers", () => {
231 it.effect("refuses a credential without the pull-requests capability", () =>
232 Effect.gen(function* () {
233 const harness = yield* makeHarness();
234 const error = yield* harness
235 .call("list_thread_pull_requests", {}, ["preview"])
236 .pipe(Effect.flip);
237 expect(error).toMatchObject({
238 _tag: "McpCapabilityUnavailableError",
239 capability: "pull-requests",
240 threadId: THREAD_ID,
241 });
242 expect(yield* Ref.get(harness.commands)).toEqual([]);
243 }),
244 );
245
246 it.effect("links by URL with source agent on the token's thread", () =>
247 Effect.gen(function* () {
248 const harness = yield* makeHarness();
249 const result = yield* harness.call("link_pull_request", {
250 url: "https://github.com/T3Tools/T3Code/pull/123/files",
251 });
252 expect(result).toEqual({
253 host: "github.com",
254 repository: "t3tools/t3code",
255 number: 123,
256 url: "https://github.com/T3Tools/T3Code/pull/123/files",
257 alreadyLinked: false,
258 });
259 expect(yield* Ref.get(harness.commands)).toMatchObject([
260 {
261 type: "thread.pull-request.link",
262 threadId: THREAD_ID,
263 host: "github.com",
264 repository: "t3tools/t3code",
265 number: 123,
266 source: "agent",
267 },
268 ]);
269 }),
270 );
271
272 it.effect("watching an unlinked pull request links it first", () =>
273 Effect.gen(function* () {
274 const harness = yield* makeHarness();
275 const result = yield* harness.call("watch_pull_request", {
276 url: "https://github.com/t3tools/t3code/pull/9",
277 });
278 // The harness thread never changes, so the result reports what it still holds.
279 expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false });
280 expect(yield* Ref.get(harness.commands)).toMatchObject([
281 {
282 type: "thread.pull-request.watch",
283 number: 9,
284 watching: true,
285 link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" },
286 },
287 ]);
288 }),
289 );
290
291 it.effect("refuses to watch a merged pull request and stops an existing watch", () =>
292 Effect.gen(function* () {
293 const watch = {
294 startedAt: "2026-08-20T00:00:00.000Z",
295 headSha: null,
296 failedChecks: [],
297 passed: false,
298 passedChecks: [],
299 remarksThrough: "2026-08-20T00:00:00.000Z",
300 remarkIds: [],
301 conflicting: false,
302 wakes: 0,
303 };
304 const merged = makeLink(1, { headBranch: "done" });
305 const harness = yield* makeHarness({
306 thread: makeThread([
307 { ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } },
308 makeLink(2, { headBranch: "idle" }),
309 makeLink(3, { headBranch: "watched", watch }),
310 ]),
311 });
312 const error = yield* harness
313 .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 })
314 .pipe(Effect.flip);
315 expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" });
316 expect(
317 yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }),
318 ).toMatchObject({ wasWatching: true });
319 expect(yield* Ref.get(harness.commands)).toMatchObject([
320 { type: "thread.pull-request.watch", number: 3, watching: false },
321 ]);
322 }),
323 );
324
325 it.effect("watches a pull request saved as closed, since it may have reopened", () =>
326 Effect.gen(function* () {
327 const closed = makeLink(1, { headBranch: "closed" });
328 const harness = yield* makeHarness({
329 thread: makeThread([
330 { ...closed, snapshot: closed.snapshot && { ...closed.snapshot, state: "closed" } },
331 ]),
332 });
333 yield* harness.call("watch_pull_request", { repository: "t3tools/t3code", number: 1 });
334 expect(yield* Ref.get(harness.commands)).toMatchObject([
335 { type: "thread.pull-request.watch", number: 1, watching: true },
336 ]);
337 }),
338 );
339
340 it.effect("refuses a watch from a subagent thread, whose parent owns the pull request", () =>
341 Effect.gen(function* () {
342 const harness = yield* makeHarness({
343 thread: {
344 ...makeThread([makeLink(1, { headBranch: "feature" })]),
345 lineage: {
346 rootThreadId: ThreadId.make("parent"),
347 parentThreadId: ThreadId.make("parent"),
348 relationshipToParent: "subagent",
349 },
350 },
351 });
352 const error = yield* harness
353 .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 })
354 .pipe(Effect.flip);
355 expect(error).toMatchObject({ _tag: "PullRequestWatchFromSubagentError" });
356 expect(yield* Ref.get(harness.commands)).toEqual([]);
357 }),
358 );
359
360 it.effect("links by repository and number, defaulting the host to the project's", () =>
361 Effect.gen(function* () {
362 const harness = yield* makeHarness();
363 const result = yield* harness.call("link_pull_request", {
364 repository: "T3Tools/Other",
365 number: 7,
366 });
367 expect(result).toEqual({
368 host: "github.com",
369 repository: "t3tools/other",
370 number: 7,
371 url: "https://github.com/t3tools/other/pull/7",
372 alreadyLinked: false,
373 });
374 }),
375 );
376
377 it.effect("builds the URL in the project host's own shape", () =>
378 Effect.gen(function* () {
379 const harness = yield* makeHarness({
380 project: makeProject({
381 canonicalKey: "gitlab.com/group/sub/project",
382 locator: {
383 source: "git-remote",
384 remoteName: "origin",
385 remoteUrl: "git@gitlab.com:group/sub/project.git",
386 },
387 provider: "gitlab",
388 displayName: "group/sub/project",
389 }),
390 });
391 const result = yield* harness.call("link_pull_request", {
392 repository: "group/sub/project",
393 number: 42,
394 });
395 expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42");
396 expect(result.host).toBe("gitlab.com");
397 }),
398 );
399
400 it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () =>
401 Effect.gen(function* () {
402 const harness = yield* makeHarness({
403 project: makeProject({
404 canonicalKey: "forge.example/git/owner/repo",
405 locator: {
406 source: "git-remote",
407 remoteName: "origin",
408 remoteUrl: "http://forge.example:3000/git/owner/repo.git",
409 },
410 provider: "forgejo",
411 displayName: "git/owner/repo",
412 }),
413 });
414 const result = yield* harness.call("link_pull_request", {
415 repository: "git/owner/repo",
416 number: 42,
417 });
418 expect(result).toEqual({
419 host: "forge.example:3000",
420 repository: "git/owner/repo",
421 number: 42,
422 url: "http://forge.example:3000/git/owner/repo/pulls/42",
423 alreadyLinked: false,
424 });
425 const other = yield* harness.call("link_pull_request", {
426 host: "other.example",
427 repository: "owner/repo",
428 number: 42,
429 });
430 expect(other.url).toBe("https://other.example/owner/repo/pull/42");
431 }),
432 );
433
434 it.effect("rejects a target that names neither a URL nor repository and number", () =>
435 Effect.gen(function* () {
436 const harness = yield* makeHarness();
437 const error = yield* harness
438 .call("link_pull_request", { repository: "x/y" })
439 .pipe(Effect.flip);
440 expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" });
441 const unknown = yield* harness
442 .call("link_pull_request", {
443 url: "https://github.com/t3tools/t3code/issues/1?token=private-value",
444 })
445 .pipe(Effect.flip);
446 expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" });
447 expect(unknown.message).not.toContain("private-value");
448 expect(yield* Ref.get(harness.commands)).toEqual([]);
449 }),
450 );
451
452 it.effect("treats a duplicate link as alreadyLinked rather than an error", () =>
453 Effect.gen(function* () {
454 const harness = yield* makeHarness({
455 thread: makeThread([makeLink(123)]),
456 reject: (command) =>
457 command.type === "thread.pull-request.link" ? "already linked" : null,
458 });
459 const result = yield* harness.call("link_pull_request", {
460 url: "https://github.com/t3tools/t3code/pull/123",
461 });
462 expect(result.alreadyLinked).toBe(true);
463 }),
464 );
465
466 it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () =>
467 Effect.gen(function* () {
468 const harness = yield* makeHarness({
469 thread: makeThread([makeLink(5)]),
470 reject: (command) =>
471 command.type === "thread.pull-request.unlink" && command.number !== 5
472 ? "not linked"
473 : null,
474 });
475 const linked = yield* harness.call("unlink_pull_request", {
476 repository: "t3tools/t3code",
477 number: 5,
478 });
479 expect(linked).toEqual({
480 host: "github.com",
481 repository: "t3tools/t3code",
482 number: 5,
483 wasLinked: true,
484 });
485 const missing = yield* harness.call("unlink_pull_request", {
486 url: "https://github.com/t3tools/t3code/pull/9",
487 });
488 expect(missing.wasLinked).toBe(false);
489 expect(yield* Ref.get(harness.commands)).toMatchObject([
490 { type: "thread.pull-request.unlink", number: 5 },
491 ]);
492 }),
493 );
494
495 it("reports an older Forgejo link's HTTP port when listing thread links", () => {
496 const result = listThreadPullRequests(
497 makeThread([
498 makeLink(42, {
499 host: "forge.example",
500 url: "http://forge.example:3000/t3tools/t3code/pulls/42",
501 }),
502 ]),
503 );
504 expect(result.pullRequests[0]?.host).toBe("forge.example:3000");
505 });
506
507 it.effect("fails cleanly when the token's thread no longer exists", () =>
508 Effect.gen(function* () {
509 const harness = yield* makeHarness({ thread: null });
510 const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip);
511 expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID });
512 }),
513 );
514
515 it.effect("lists visible links with host state and derived chain order", () =>
516 Effect.gen(function* () {
517 const harness = yield* makeHarness({
518 thread: makeThread([
519 makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }),
520 makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }),
521 makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }),
522 makeLink(9, { source: "stack-dismissed" }),
523 makeLink(10),
524 ]),
525 });
526 const result = yield* harness.call("list_thread_pull_requests", {});
527 expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]);
528 expect(result.pullRequests[0]).toEqual({
529 host: "github.com",
530 repository: "t3tools/t3code",
531 number: 3,
532 url: "https://github.com/t3tools/t3code/pull/3",
533 source: "agent",
534 watching: false,
535 state: "open",
536 title: "PR 3",
537 headBranch: "feat-c",
538 baseBranch: "feat-b",
539 isDraft: false,
540 stack: { kind: "derived", position: 3, size: 3 },
541 });
542 expect(result.pullRequests[3]).toMatchObject({
543 number: 10,
544 state: null,
545 title: null,
546 headBranch: null,
547 stack: null,
548 });
549 expect(result.chains).toEqual([
550 { kind: "derived", numbers: [1, 2, 3] },
551 { kind: "derived", numbers: [10] },
552 ]);
553 }),
554 );
555});
556
557describe("listThreadPullRequests", () => {
558 it("reports a native stack position for each member", () => {
559 const stack = {
560 kind: "native" as const,
561 id: "stack-1",
562 number: 1,
563 url: "https://github.com/t3tools/t3code/stack/1",
564 base: "main",
565 layers: [
566 { number: 1, headBranch: "a", state: "open" as const },
567 { number: 2, headBranch: "b", state: "open" as const },
568 ],
569 };
570 const result = listThreadPullRequests({
571 pullRequests: [
572 makeLink(2, { stack, source: "stack" }),
573 makeLink(1, { stack, source: "created" }),
574 ],
575 });
576 expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([
577 [2, { kind: "native", position: 2, size: 2 }],
578 [1, { kind: "native", position: 1, size: 2 }],
579 ]);
580 expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]);
581 });
582});
583
584describe("pull request guide tools", () => {
585 it.effect(
586 "writes guides through the project whose checkout is each pull request's repository",
587 () =>
588 Effect.gen(function* () {
589 const other = {
590 ...makeProject({
591 canonicalKey: "github.com/acme/web",
592 locator: {
593 source: "git-remote",
594 remoteName: "origin",
595 remoteUrl: "git@github.com:acme/web.git",
596 },
597 provider: "github",
598 displayName: "acme/web",
599 owner: "acme",
600 name: "web",
601 }),
602 id: ProjectId.make("project-web"),
603 };
604 const harness = yield* makeHarness({ projects: [makeProject(), other] });
605 const result = yield* harness.call("write_pull_request_guides", {
606 pullRequests: [
607 { url: "https://github.com/acme/web/pull/7" },
608 { repository: "t3tools/t3code", number: 12 },
609 { url: "https://github.com/someone/else/pull/1" },
610 ],
611 });
612 expect(
613 result.guides.map((guide) => [guide.repository, guide.number, guide.outcome]),
614 ).toEqual([
615 ["acme/web", 7, "started"],
616 ["t3tools/t3code", 12, "started"],
617 ["someone/else", 1, "failed"],
618 ]);
619 expect(result.guides[2]?.detail).toContain("No project in this environment");
620 expect(
621 (yield* Ref.get(harness.guideRequests)).map((ref) => [ref.projectId, ref.number]),
622 ).toEqual([
623 ["project-web", 7],
624 [PROJECT_ID, 12],
625 ]);
626 }),
627 );
628
629 it.effect("reports a refused run on its own pull request without failing the rest", () =>
630 Effect.gen(function* () {
631 const harness = yield* makeHarness({
632 generate: (input) =>
633 input.number === 1
634 ? Effect.fail(
635 new PullRequestOperationError({
636 operation: "generateReviewGuide",
637 detail: "Review guides are off. Turn them on in Settings → Source Control.",
638 }),
639 )
640 : Effect.succeed({
641 ...NO_GUIDE,
642 run: {
643 _tag: "Running" as const,
644 headSha: "head-1",
645 startedAt: "2026-10-11T00:00:00.000Z",
646 },
647 started: true,
648 }),
649 });
650 const result = yield* harness.call("write_pull_request_guides", {
651 pullRequests: [
652 { repository: "t3tools/t3code", number: 1 },
653 { repository: "t3tools/t3code", number: 2 },
654 ],
655 });
656 expect(result.guides.map((guide) => [guide.number, guide.outcome, guide.detail])).toEqual([
657 [1, "failed", "Review guides are off. Turn them on in Settings → Source Control."],
658 [2, "started", null],
659 ]);
660 }),
661 );
662
663 it.effect("reports an unreadable entry on its own and still writes the rest of the batch", () =>
664 Effect.gen(function* () {
665 const harness = yield* makeHarness();
666 const result = yield* harness.call("write_pull_request_guides", {
667 pullRequests: [
668 { url: "https://example.com/not-a-pull-request" },
669 { repository: "t3tools/t3code" },
670 { repository: "t3tools/t3code", number: 5 },
671 ],
672 });
673 expect(result.guides.map((guide) => guide.outcome)).toEqual(["failed", "failed", "started"]);
674 expect(result.guides[0]?.detail).toContain("not a recognised pull request URL");
675 expect(result.guides[1]?.detail).toContain("Pass either url");
676 expect((yield* Ref.get(harness.guideRequests)).map((ref) => ref.number)).toEqual([5]);
677 }),
678 );
679
680 it.effect("reports a run the service joined as running, not as a new one", () =>
681 Effect.gen(function* () {
682 const harness = yield* makeHarness({
683 generate: () =>
684 Effect.succeed({
685 ...NO_GUIDE,
686 run: { _tag: "Running", headSha: "head-1", startedAt: "2026-10-11T00:00:00.000Z" },
687 started: false,
688 }),
689 });
690 const result = yield* harness.call("write_pull_request_guides", {
691 pullRequests: [{ repository: "t3tools/t3code", number: 4 }],
692 });
693 expect(result.guides.map((guide) => guide.outcome)).toEqual(["running"]);
694 }),
695 );
696
697 it.effect("finds an Azure DevOps checkout for a pull request named by its full path", () =>
698 Effect.gen(function* () {
699 const azure = {
700 ...makeProject({
701 canonicalKey: "dev.azure.com/org/project/_git/web",
702 locator: {
703 source: "git-remote",
704 remoteName: "origin",
705 remoteUrl: "https://dev.azure.com/org/project/_git/web",
706 },
707 provider: "azure-devops",
708 displayName: "org/project/_git/web",
709 owner: "org",
710 name: "web",
711 }),
712 id: ProjectId.make("project-azure"),
713 };
714 const harness = yield* makeHarness({ projects: [makeProject(), azure] });
715 const result = yield* harness.call("write_pull_request_guides", {
716 pullRequests: [{ host: "dev.azure.com", repository: "org/project/_git/web", number: 9 }],
717 });
718 expect(result.guides.map((guide) => guide.outcome)).toEqual(["started"]);
719 expect((yield* Ref.get(harness.guideRequests)).map((ref) => ref.projectId)).toEqual([
720 "project-azure",
721 ]);
722 }),
723 );
724
725 it.effect("reads a stored guide and says when a push left it behind", () =>
726 Effect.gen(function* () {
727 const harness = yield* makeHarness();
728 const result = yield* harness.call("read_pull_request_guide", {
729 repository: "t3tools/t3code",
730 number: 3,
731 });
732 expect(result).toMatchObject({ number: 3, guide: null, run: null, stale: false });
733 }),
734 );
735});
736
737it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => {
738 const cause = new Error("database internals");
739 const failure = new PullRequestLinkFailedError({ cause });
740 expect(failure.message).toBe("Could not link the pull request.");
741 expect(failure.cause).toBe(cause);
742});