Skip to content

Commit eb7e63a

Browse files
fix(server): thread reads preview linked PRs and list_thread_pull_requests pages (#35858)
1 parent 082a3e3 commit eb7e63a

9 files changed

Lines changed: 136 additions & 32 deletions

File tree

‎apps/server/src/mcp/OrchestratorMcpService.activity.test.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov
131131
number,
132132
url: `https://github.com/acme/web/pull/${number}`,
133133
source: "agent",
134-
linkedAt: "2026-08-04T11:00:00.000Z",
134+
linkedAt: `2026-08-04T11:0${number}:00.000Z`,
135135
snapshot: { state },
136136
stack: null,
137137
...(watched
@@ -158,7 +158,11 @@ it("readThread preserves caller access errors and prefers activity-run status ov
158158
instanceId: parentInstanceId,
159159
model: "gpt-5.4",
160160
}),
161-
pullRequests: [pullRequestLink(1, "merged", false), pullRequestLink(2, "open", true)],
161+
pullRequests: [
162+
pullRequestLink(1, "merged", false),
163+
pullRequestLink(2, "open", true),
164+
...[3, 4, 5, 6, 7, 8].map((number) => pullRequestLink(number, "open", false)),
165+
],
162166
},
163167
runs: [
164168
makeRun({ id: activeRunId, ordinal: 1, status: "running" }),
@@ -244,8 +248,10 @@ it("readThread preserves caller access errors and prefers activity-run status ov
244248
{ taskId: "task-subagent", kind: "subagent", childThreadId },
245249
]);
246250
// Every page read must not repeat each linked PR, and an open PR shows whether it is watched.
251+
// A thread with many open PRs previews a few, watched first, then newest.
247252
const listed = result.thread.pullRequests ?? [];
248-
expect(listed.map((link) => link.number)).not.toContain(1);
253+
expect(listed.map((link) => link.number)).toEqual([2, 8, 7, 6, 5]);
254+
expect(result.thread.openPullRequestCount).toBe(7);
249255
expect(listed.find((link) => link.number === 2)).toMatchObject({
250256
watching: true,
251257
watchStartedAt: "2026-08-04T11:30:00.000Z",
@@ -255,7 +261,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov
255261
afterPosition: 0,
256262
});
257263
expect(laterPage.thread.pullRequests).toBeUndefined();
258-
expect(laterPage.thread.pullRequestCount).toBe(2);
264+
expect(laterPage.thread.pullRequestCount).toBe(8);
259265

260266
callerUnavailable = true;
261267
const denied = yield* service

‎apps/server/src/mcp/OrchestratorMcpService.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { getProviderOptionCurrentValue, withLegacyUltracodeEffort } from "@t3too
7070
import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
7171
import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
7272
import { formatThreadLink } from "@t3tools/shared/threadLinks";
73+
import { threadPullRequestUpdatedAtMs } from "@t3tools/shared/threadPullRequests";
7374
import {
7475
normalizeThreadPullRequestKey,
7576
visibleThreadPullRequests,
@@ -146,6 +147,7 @@ const DEFAULT_THREAD_LIST_LIMIT = 10;
146147
const DEFAULT_THREAD_READ_LIMIT = 10;
147148
const DEFAULT_THREAD_RUN_LIMIT = 10;
148149
const DEFAULT_THREAD_ITEM_MAX_CHARS = 500;
150+
const THREAD_PULL_REQUEST_PREVIEW_LIMIT = 5;
149151
// Claude Code saves MCP results over ~25k tokens to a file instead of showing them. Bytes, not
150152
// chars: CJK text runs near one token per char, which a char budget lets through at 30k tokens.
151153
const THREAD_READ_RESULT_MAX_BYTES = 30_000;
@@ -823,12 +825,23 @@ function threadBackgroundWork(
823825
function threadPullRequests(
824826
links: OrchestrationV2ThreadShell["pullRequests"],
825827
includeLinks = true,
826-
): Pick<OrchestratorMcpThreadListItem, "pullRequests" | "pullRequestCount"> {
828+
): Pick<
829+
OrchestratorMcpThreadListItem,
830+
"pullRequests" | "pullRequestCount" | "openPullRequestCount"
831+
> {
827832
const visible = visibleThreadPullRequests(links ?? []);
828833
if (!includeLinks) return { pullRequestCount: visible.length };
834+
const open = visible.filter(
835+
(link) => link.snapshot?.state !== "merged" && link.snapshot?.state !== "closed",
836+
);
829837
return {
830-
pullRequests: visible
831-
.filter((link) => link.snapshot?.state !== "merged" && link.snapshot?.state !== "closed")
838+
pullRequests: open
839+
.toSorted(
840+
(left, right) =>
841+
Number(right.watch !== undefined) - Number(left.watch !== undefined) ||
842+
threadPullRequestUpdatedAtMs(right) - threadPullRequestUpdatedAtMs(left),
843+
)
844+
.slice(0, THREAD_PULL_REQUEST_PREVIEW_LIMIT)
832845
.map((link) => ({
833846
host: normalizeThreadPullRequestKey(link).host,
834847
repository: link.repository,
@@ -840,6 +853,7 @@ function threadPullRequests(
840853
watchStartedAt: link.watch?.startedAt ?? null,
841854
})),
842855
pullRequestCount: visible.length,
856+
openPullRequestCount: open.length,
843857
};
844858
}
845859

‎apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,8 @@ describe("pull request toolkit handlers", () => {
537537
]),
538538
});
539539
const result = yield* harness.call("list_thread_pull_requests", {});
540-
expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]);
541-
expect(result.pullRequests[0]).toEqual({
540+
expect(result.pullRequests.map((entry) => entry.number)).toEqual([1, 2, 3, 10]);
541+
expect(result.pullRequests[2]).toEqual({
542542
host: "github.com",
543543
repository: "t3tools/t3code",
544544
number: 3,
@@ -587,8 +587,8 @@ describe("listThreadPullRequests", () => {
587587
],
588588
});
589589
expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([
590-
[2, { kind: "native", position: 2, size: 2 }],
591590
[1, { kind: "native", position: 1, size: 2 }],
591+
[2, { kind: "native", position: 2, size: 2 }],
592592
]);
593593
expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]);
594594
});
@@ -608,6 +608,58 @@ describe("listThreadPullRequests", () => {
608608
]);
609609
expect(result.chains).toEqual([{ kind: "derived", numbers: [1, 2, 3] }]);
610610
});
611+
612+
it("pages a large list once through, with each page's stacks whole", () => {
613+
const thread = {
614+
pullRequests: [
615+
makeLink(1, { headBranch: "feat-a" }),
616+
...Array.from({ length: 118 }, (_, index) => makeLink(index + 2)),
617+
makeLink(120, { headBranch: "feat-z", baseBranch: "feat-a" }),
618+
],
619+
};
620+
const pages = [listThreadPullRequests(thread)];
621+
for (let next = pages[0]!.nextCursor; next !== null; next = pages.at(-1)!.nextCursor) {
622+
pages.push(listThreadPullRequests(thread, next));
623+
}
624+
expect(pages.map((page) => page.pullRequests.length)).toEqual([50, 50, 20]);
625+
expect(pages.flatMap((page) => page.pullRequests.map((entry) => entry.number))).toEqual(
626+
Array.from({ length: 120 }, (_, index) => index + 1),
627+
);
628+
const stack = { kind: "derived", numbers: [1, 120] };
629+
expect(pages.map((page) => page.chains.length)).toEqual([50, 50, 20]);
630+
expect(pages.map((page) => page.chains.filter((chain) => chain.numbers.length > 1))).toEqual([
631+
[stack],
632+
[],
633+
[stack],
634+
]);
635+
636+
// Unlinking the first page must not shift the cursor past PRs not yet seen.
637+
const remaining = { pullRequests: thread.pullRequests.slice(50) };
638+
expect(
639+
listThreadPullRequests(remaining, pages[0]!.nextCursor!).pullRequests.map(
640+
(entry) => entry.number,
641+
),
642+
).toEqual(Array.from({ length: 50 }, (_, index) => index + 51));
643+
});
644+
645+
it("filters by state before paging, counting a PR with no known state as open", () => {
646+
const merged = (number: number) => {
647+
const link = makeLink(number, { headBranch: `feat-${number}` });
648+
return { ...link, snapshot: { ...link.snapshot!, state: "merged" as const } };
649+
};
650+
const thread = {
651+
pullRequests: [
652+
...Array.from({ length: 50 }, (_, index) => merged(index + 1)),
653+
...Array.from({ length: 10 }, (_, index) => makeLink(index + 51)),
654+
],
655+
};
656+
const open = listThreadPullRequests(thread, undefined, ["open"]);
657+
expect(open.pullRequests.map((entry) => entry.number)).toEqual(
658+
Array.from({ length: 10 }, (_, index) => index + 51),
659+
);
660+
expect(open.nextCursor).toBeNull();
661+
expect(listThreadPullRequests(thread, undefined, ["merged"]).nextCursor).toBeNull();
662+
});
611663
});
612664

613665
describe("pull request guide tools", () => {

‎apps/server/src/mcp/toolkits/pullRequests/handlers.ts‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
CommandId,
33
type OrchestrationProjectShell,
4+
type PullRequestState,
45
type OrchestrationV2ThreadShell,
56
type ThreadId,
67
type ThreadPullRequestLink,
@@ -72,18 +73,39 @@ function entryOf(
7273
};
7374
}
7475

76+
const PULL_REQUEST_LIST_PAGE_SIZE = 50;
77+
78+
// A total order that survives unlinking: the cursor names a position, not an index, so removing
79+
// PRs already returned cannot skip later ones. PRs linked in one call share `linkedAt`.
80+
function pullRequestListPosition(link: ThreadPullRequestLink): string {
81+
return [link.linkedAt, link.host, link.repository, String(link.number).padStart(10, "0")].join(
82+
" ",
83+
);
84+
}
85+
7586
export function listThreadPullRequests(
7687
thread: Pick<OrchestrationV2ThreadShell, "pullRequests">,
88+
cursor?: string,
89+
states?: ReadonlyArray<PullRequestState>,
7790
): ListThreadPullRequestsResult {
7891
const chains = resolveThreadPullRequestChains(thread.pullRequests ?? []);
92+
const after = visibleThreadPullRequests(thread.pullRequests ?? [])
93+
.filter((link) => states === undefined || states.includes(link.snapshot?.state ?? "open"))
94+
.map((link) => ({ link, position: pullRequestListPosition(link) }))
95+
.filter(({ position }) => cursor === undefined || position > cursor)
96+
.toSorted((left, right) => (left.position < right.position ? -1 : 1));
97+
const page = after.slice(0, PULL_REQUEST_LIST_PAGE_SIZE);
98+
const pageKeys = new Set(page.map(({ link }) => threadPullRequestKeyOf(link)));
7999
return {
80-
pullRequests: visibleThreadPullRequests(thread.pullRequests ?? []).map((link) =>
81-
entryOf(link, chains),
82-
),
83-
chains: chains.map((chain) => ({
84-
kind: chain.kind,
85-
numbers: chain.layers.map((layer) => layer.number),
86-
})),
100+
pullRequests: page.map(({ link }) => entryOf(link, chains)),
101+
// Stacks with a pull request on this page, whole.
102+
chains: chains
103+
.filter((chain) => chain.layers.some((layer) => pageKeys.has(threadPullRequestKeyOf(layer))))
104+
.map((chain) => ({
105+
kind: chain.kind,
106+
numbers: chain.layers.map((layer) => layer.number),
107+
})),
108+
nextCursor: after.length > page.length ? page.at(-1)!.position : null,
87109
};
88110
}
89111

@@ -407,7 +429,7 @@ const make = Effect.gen(function* () {
407429
),
408430
list_thread_pull_requests: McpToolAccess.reads((input) =>
409431
requireThread(PullRequestListFailedError, input.threadId).pipe(
410-
Effect.map(listThreadPullRequests),
432+
Effect.map((thread) => listThreadPullRequests(thread, input.cursor, input.states)),
411433
),
412434
),
413435
watch_pull_request: writesThread((input) => setWatching(input, true)),

‎apps/server/src/mcp/toolkits/pullRequests/tools.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ export const ListThreadPullRequestsResult = Schema.Struct({
236236
numbers: Schema.Array(Schema.Int),
237237
}),
238238
),
239+
/** Pass as `cursor` for the next page; null on the last page. */
240+
nextCursor: Schema.NullOr(Schema.String),
239241
});
240242
export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type;
241243

@@ -255,7 +257,7 @@ const LinkPullRequestTool = Tool.make("link_pull_request", {
255257

256258
const LinkPullRequestsTool = Tool.make("link_pull_requests", {
257259
description:
258-
"Link several PRs to a thread in one call. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.",
260+
"Link several PRs to a thread. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.",
259261
parameters: Schema.Struct({
260262
environmentId: OrchestratorMcpEnvironmentTarget,
261263
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
@@ -287,7 +289,7 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", {
287289

288290
const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", {
289291
description:
290-
"Unlink every PR on a thread whose last known state (as `list_thread_pull_requests` shows) is in `states`, in one call. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.",
292+
"Unlink a thread's PRs whose last known state is in `states`. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.",
291293
parameters: Schema.Struct({
292294
environmentId: OrchestratorMcpEnvironmentTarget,
293295
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
@@ -305,10 +307,12 @@ const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", {
305307

306308
const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
307309
description:
308-
"List PRs linked to a thread, with last known state and stack order (bottom to top). Call only when you need that list. Never as an end-of-work check.",
310+
"List a thread's PRs, with last known state and stack order (bottom to top). Not an end-of-work check.",
309311
parameters: Schema.Struct({
310312
environmentId: OrchestratorMcpEnvironmentTarget,
311313
threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })),
314+
states: Schema.optional(Schema.Array(PullRequestState)),
315+
cursor: Schema.optional(Schema.String),
312316
}),
313317
success: ListThreadPullRequestsResult,
314318
failure: PullRequestToolError,
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"claudeSystemAppendBytes": 5597,
33
"codexAdditionalContextBytes": 7327,
4-
"mcpToolListJsonBytes": 48099
4+
"mcpToolListJsonBytes": 48094
55
}

‎evals/mcp-tool-use/run.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,15 +1302,19 @@ export const t3Default = (
13021302
return { ...pr(), watching: true, wasWatching: false };
13031303
case "unwatch_pull_request":
13041304
return { ...pr(), watching: false, wasWatching: true };
1305-
case "list_thread_pull_requests":
1305+
case "list_thread_pull_requests": {
1306+
const states = Array.isArray(input.states) ? input.states : undefined;
1307+
const matching = [...sample.linked]
1308+
.map((url) => ({ ...linkedEntry(sample, url), source: "agent", watching: false }))
1309+
.filter((entry) => states === undefined || states.includes(entry.state));
1310+
const start = Number(valueText(input.cursor)) || 0;
1311+
const end = start + 50;
13061312
return {
1307-
pullRequests: [...sample.linked].map((url) => ({
1308-
...linkedEntry(sample, url),
1309-
source: "agent",
1310-
watching: false,
1311-
})),
1313+
pullRequests: matching.slice(start, end),
13121314
chains: [],
1315+
nextCursor: end < matching.length ? String(end) : null,
13131316
};
1317+
}
13141318
case "t3_thread_read": {
13151319
const threadId = valueText(input.threadId);
13161320
const launched = sample.threads.get(threadId);

‎packages/contracts/src/orchestratorMcp.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,10 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({
387387
runtimeMode: RuntimeMode,
388388
interactionMode: ProviderInteractionMode,
389389
linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
390-
/** Linked pull requests not yet merged or closed; list_thread_pull_requests has all of them. */
390+
/** Up to 5 open linked pull requests, watched first; list_thread_pull_requests pages all of them. */
391391
pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)),
392392
pullRequestCount: Schema.optional(NonNegativeInt),
393+
openPullRequestCount: Schema.optional(NonNegativeInt),
393394
settled: Schema.Boolean,
394395
settledAt: Schema.NullOr(IsoDateTime),
395396
snoozed: Schema.Boolean,
@@ -461,9 +462,10 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({
461462
runtimeMode: RuntimeMode,
462463
interactionMode: ProviderInteractionMode,
463464
linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest),
464-
/** Open linked pull requests, on the first page only; list_thread_pull_requests has all of them. */
465+
/** Up to 5 open linked pull requests, watched first, on the first page only; list_thread_pull_requests pages all of them. */
465466
pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)),
466467
pullRequestCount: Schema.optional(NonNegativeInt),
468+
openPullRequestCount: Schema.optional(NonNegativeInt),
467469
titleRegeneration: Schema.NullOr(ThreadTitleRegeneration),
468470
lastTitleRegeneration: Schema.optional(Schema.NullOr(LastThreadTitleRegeneration)),
469471
branch: Schema.NullOr(Schema.String),

‎packages/shared/src/threadPullRequests.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function visibleThreadPullRequests(
77
return links.filter((link) => link.source !== "stack-dismissed");
88
}
99

10-
function latestUpdatedAt(link: ThreadPullRequestLink): number {
10+
export function threadPullRequestUpdatedAtMs(link: ThreadPullRequestLink): number {
1111
const value = link.snapshot?.updatedAt ?? link.linkedAt;
1212
const ms = Date.parse(value);
1313
return Number.isNaN(ms) ? 0 : ms;
@@ -41,7 +41,7 @@ export function threadSearchPullRequests(thread: {
4141
// Every pull request matches by number and link, but only the newest few
4242
// by title: a triage thread linked to dozens would match almost any word.
4343
const newestFirst = [...visibleThreadPullRequests(thread.pullRequests)].sort(
44-
(left, right) => latestUpdatedAt(right) - latestUpdatedAt(left),
44+
(left, right) => threadPullRequestUpdatedAtMs(right) - threadPullRequestUpdatedAtMs(left),
4545
);
4646
return branch.concat(
4747
newestFirst.map((link, index) =>

0 commit comments

Comments
 (0)