History

fix(mcp): bound pending request discovery

Julius Marminge committed 2 months ago

Commit 4458bf81 parent 7bdb5407 files · +548 −64Browse files

Unified · Split

7 files
  1. apps/server/src/mcp/PendingRequestMcpService.test.ts +293 −5
  2. apps/server/src/mcp/PendingRequestMcpService.ts +152 −48
  3. apps/server/src/mcp/toolkits/pendingRequest/tools.ts +3 −3
  4. docs/orchestration-v2/orchestrator-mcp-server.md +9 −1
  5. docs/user/permission-modes.md +2 −1
  6. packages/contracts/src/pendingRequestMcp.test.ts +53 −2
  7. packages/contracts/src/pendingRequestMcp.ts +36 −4
apps/server/src/mcp/PendingRequestMcpService.test.ts+293−5
@@ -3,6 +3,7 @@ import { assert, describe, it, vi } from "@effect/vitest"; · 2 unmodified lines
33 import {
44 EnvironmentId,
55 NodeId,
6+ PENDING_REQUEST_MCP_MAX_QUESTION_CHARS,
67 ProjectId,
78 ProviderInstanceId,
89 ProviderSessionId,
@@ -21,7 +22,11 @@ import * as Ref from "effect/Ref"; · 12 unmodified lines
2122
2223 import type { CommandReceiptV2 } from "../orchestration-v2/CommandReceiptStore.ts";
2324 import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";
24−import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
25+import { ProjectionStoreThreadNotFoundError } from "../orchestration-v2/ProjectionStore.ts";
26+import {
27+ ThreadManagementProjectionLoadError,
28+ ThreadManagementService,
29+} from "../orchestration-v2/ThreadManagementService.ts";
2530 import type { McpInvocationScope } from "./McpInvocationContext.ts";
2631 import { layer, PendingRequestMcpService } from "./PendingRequestMcpService.ts";
2732
@@ -150,10 +155,72 @@ function childProjection( · 122 unmodified lines
150155 } as unknown as OrchestrationV2ThreadProjection;
151156 }
152157
158+function parentProjectionWithTasks(
159+ tasks: ReadonlyArray<{
160+ readonly taskId: ReturnType<typeof NodeId.make>;
161+ readonly childThreadId: ReturnType<typeof ThreadId.make>;
162+ readonly updatedAt?: typeof now;
163+ }>,
164+) {
165+ const parent = parentProjection();
166+ const baseTask = parent.subagents[0];
167+ if (baseTask === undefined) throw new Error("Pending-request parent fixture is incomplete.");
168+ return {
169+ ...parent,
170+ subagents: tasks.map((task) => ({
171+ ...baseTask,
172+ id: task.taskId,
173+ threadId: parentThreadId,
174+ origin: "app_owned" as const,
175+ childThreadId: task.childThreadId,
176+ updatedAt: task.updatedAt ?? now,
177+ })),
178+ } as OrchestrationV2ThreadProjection;
179+}
180+
181+function childProjectionWithRequests(input: {
182+ readonly childThreadId: ReturnType<typeof ThreadId.make>;
183+ readonly requests: ReadonlyArray<{
184+ readonly requestId: ReturnType<typeof RuntimeRequestId.make>;
185+ readonly status?: OrchestrationV2RuntimeRequest["status"];
186+ readonly questions?: typeof questions;
187+ }>;
188+}) {
189+ const base = childProjection();
190+ const providerThread = base.providerThreads[0];
191+ const item = base.turnItems.find((candidate) => candidate.type === "user_input_request");
192+ if (providerThread === undefined || item?.type !== "user_input_request") {
193+ throw new Error("Pending-request test fixture is incomplete.");
194+ }
195+ return {
196+ ...base,
197+ thread: { ...base.thread, id: input.childThreadId },
198+ runtimeRequests: input.requests.map((request) => ({
199+ ...base.runtimeRequests[0],
200+ id: request.requestId,
201+ nodeId: NodeId.make(`node:pending-request:${request.requestId}`),
202+ status: request.status ?? "pending",
203+ resolvedAt: request.status === undefined || request.status === "pending" ? null : now,
204+ })),
205+ turnItems: input.requests.map((request, index) => ({
206+ ...item,
207+ id: TurnItemId.make(`turn-item:pending-request:${index}`),
208+ threadId: input.childThreadId,
209+ nodeId: NodeId.make(`node:pending-request:${request.requestId}`),
210+ requestId: request.requestId,
211+ questions: request.questions ?? questions,
212+ status:
213+ request.status === undefined || request.status === "pending" ? "waiting" : "completed",
214+ completedAt: request.status === undefined || request.status === "pending" ? null : now,
215+ })),
216+ } as OrchestrationV2ThreadProjection;
217+}
218+
153219 function serviceLayer(input: {
154220 readonly getParent?: () => OrchestrationV2ThreadProjection;
155221 readonly getChild: () => OrchestrationV2ThreadProjection;
156222 readonly getThreadProjection?: ThreadManagementService["Service"]["getThreadProjection"];
223+ readonly getProjectThread?: ThreadManagementService["Service"]["getProjectThread"];
157224 readonly getReceipt?: ThreadManagementService["Service"]["getCommandReceipt"];
158225 readonly dispatch?: ThreadManagementService["Service"]["dispatch"];
159226 }) {
@@ -165,10 +232,12 @@ function serviceLayer(input: { · 5 unmodified lines
165232 getThreadProjection:
166233 input.getThreadProjection ??
167234 (() => Effect.succeed(input.getParent?.() ?? parentProjection())),
168− getProjectThread: ({ threadId }) =>
169− threadId === childThreadId
170− ? Effect.succeed(input.getChild())
171− : Effect.die(new Error(`Unexpected child projection read: ${threadId}`)),
235+ getProjectThread:
236+ input.getProjectThread ??
237+ (({ threadId }) =>
238+ threadId === childThreadId
239+ ? Effect.succeed(input.getChild())
240+ : Effect.die(new Error(`Unexpected child projection read: ${threadId}`))),
172241 getCommandReceipt: input.getReceipt ?? (() => Effect.succeed(Option.none())),
173242 dispatch:
174243 input.dispatch ??
@@ -191,6 +260,8 @@ describe("PendingRequestMcpService", () => { · 16 unmodified lines
191260 assert.equal(listed.requests[0]?.childThreadId, childThreadId);
192261 assert.equal(listed.requests[0]?.requestId, requestId);
193262 assert.deepEqual(listed.requests[0]?.questions, questions);
263+ assert.equal(listed.requests[0]?.questionPayloadStatus, "complete");
264+ assert.isTrue(listed.requests[0]?.answerable);
194265 assert.equal(listed.nextCursor, null);
195266
196267 const read = yield* service.read(scope, { childThreadId, requestId });
@@ -201,6 +272,223 @@ describe("PendingRequestMcpService", () => { · 4 unmodified lines
201272 }).pipe(Effect.provide(serviceLayer({ getChild: () => childProjection() }))),
202273 );
203274
275+ it.effect("continues by stable task and request identities after prior results change", () =>
276+ Effect.gen(function* () {
277+ const taskA = NodeId.make("node:pending-request-task-a");
278+ const taskB = NodeId.make("node:pending-request-task-b");
279+ const childA = ThreadId.make("thread:pending-request-child-a");
280+ const childB = ThreadId.make("thread:pending-request-child-b");
281+ const requestA = RuntimeRequestId.make("request:pending-request-a");
282+ const requestB = RuntimeRequestId.make("request:pending-request-b");
283+ const requestC = RuntimeRequestId.make("request:pending-request-c");
284+ let parent = parentProjectionWithTasks([
285+ { taskId: taskB, childThreadId: childB },
286+ { taskId: taskA, childThreadId: childA },
287+ ]);
288+ let projectionA = childProjectionWithRequests({
289+ childThreadId: childA,
290+ requests: [{ requestId: requestA }, { requestId: requestB }],
291+ });
292+ const projectionB = childProjectionWithRequests({
293+ childThreadId: childB,
294+ requests: [{ requestId: requestC }],
295+ });
296+
297+ yield* Effect.gen(function* () {
298+ const service = yield* PendingRequestMcpService;
299+ const first = yield* service.list(scope, { limit: 1 });
300+ assert.deepEqual(
301+ first.requests.map((request) => request.requestId),
302+ [requestA],
303+ );
304+ assert.isNotNull(first.nextCursor);
305+
306+ parent = parentProjectionWithTasks([
307+ {
308+ taskId: taskA,
309+ childThreadId: childA,
310+ updatedAt: DateTime.makeUnsafe("2026-08-29T12:02:00.000Z"),
311+ },
312+ {
313+ taskId: taskB,
314+ childThreadId: childB,
315+ updatedAt: DateTime.makeUnsafe("2026-08-29T12:01:00.000Z"),
316+ },
317+ ]);
318+ projectionA = childProjectionWithRequests({
319+ childThreadId: childA,
320+ requests: [{ requestId: requestA, status: "resolved" }, { requestId: requestB }],
321+ });
322+
323+ const second = yield* service.list(scope, {
324+ cursor: first.nextCursor ?? undefined,
325+ limit: 2,
326+ });
327+ assert.deepEqual(
328+ second.requests.map((request) => request.requestId),
329+ [requestB, requestC],
330+ );
331+ assert.isNull(second.nextCursor);
332+ }).pipe(
333+ Effect.provide(
334+ serviceLayer({
335+ getParent: () => parent,
336+ getChild: () => projectionA,
337+ getProjectThread: ({ threadId }) =>
338+ Effect.succeed(threadId === childA ? projectionA : projectionB),
339+ }),
340+ ),
341+ );
342+ }),
343+ );
344+
345+ it.effect("bounds empty child scans and returns a continuation for unscanned children", () =>
346+ Effect.gen(function* () {
347+ const tasks = Array.from({ length: 22 }, (_, index) => ({
348+ taskId: NodeId.make(`node:pending-request-empty-${String(index).padStart(2, "0")}`),
349+ childThreadId: ThreadId.make(
350+ `thread:pending-request-empty-${String(index).padStart(2, "0")}`,
351+ ),
352+ }));
353+ const getProjectThread = vi.fn(({ threadId }) =>
354+ Effect.succeed(childProjectionWithRequests({ childThreadId: threadId, requests: [] })),
355+ );
356+
357+ yield* Effect.gen(function* () {
358+ const service = yield* PendingRequestMcpService;
359+ const first = yield* service.list(scope, { limit: 50 });
360+ assert.deepEqual(first.requests, []);
361+ assert.isNotNull(first.nextCursor);
362+ assert.equal(getProjectThread.mock.calls.length, 20);
363+
364+ const second = yield* service.list(scope, { cursor: first.nextCursor ?? undefined });
365+ assert.deepEqual(second.requests, []);
366+ assert.isNull(second.nextCursor);
367+ assert.equal(getProjectThread.mock.calls.length, 22);
368+ }).pipe(
369+ Effect.provide(
370+ serviceLayer({
371+ getParent: () => parentProjectionWithTasks(tasks),
372+ getChild: () => childProjection(),
373+ getProjectThread,
374+ }),
375+ ),
376+ );
377+ }),
378+ );
379+
380+ it.effect("skips only typed missing children while surfacing projection storage failures", () =>
381+ Effect.gen(function* () {
382+ const missingTask = NodeId.make("node:pending-request-missing-a");
383+ const validTask = NodeId.make("node:pending-request-valid-b");
384+ const missingChild = ThreadId.make("thread:pending-request-missing-a");
385+ const validChild = ThreadId.make("thread:pending-request-valid-b");
386+ const validRequest = RuntimeRequestId.make("request:pending-request-valid");
387+ const parent = parentProjectionWithTasks([
388+ { taskId: missingTask, childThreadId: missingChild },
389+ { taskId: validTask, childThreadId: validChild },
390+ ]);
391+ const missingCause = new ThreadManagementProjectionLoadError({
392+ projectId,
393+ threadId: missingChild,
394+ cause: new OrchestratorProjectionError({
395+ threadId: missingChild,
396+ cause: new ProjectionStoreThreadNotFoundError({ threadId: missingChild }),
397+ }),
398+ });
399+ const storageCause = new ThreadManagementProjectionLoadError({
400+ projectId,
401+ threadId: missingChild,
402+ cause: new OrchestratorProjectionError({
403+ threadId: missingChild,
404+ cause: new Error("sqlite credentials leaked"),
405+ }),
406+ });
407+
408+ yield* Effect.gen(function* () {
409+ const service = yield* PendingRequestMcpService;
410+ const listed = yield* service.list(scope, {});
411+ assert.deepEqual(
412+ listed.requests.map((request) => request.requestId),
413+ [validRequest],
414+ );
415+ }).pipe(
416+ Effect.provide(
417+ serviceLayer({
418+ getParent: () => parent,
419+ getChild: () => childProjection(),
420+ getProjectThread: ({ threadId }) =>
421+ threadId === missingChild
422+ ? Effect.fail(missingCause)
423+ : Effect.succeed(
424+ childProjectionWithRequests({
425+ childThreadId: validChild,
426+ requests: [{ requestId: validRequest }],
427+ }),
428+ ),
429+ }),
430+ ),
431+ );
432+
433+ yield* Effect.gen(function* () {
434+ const service = yield* PendingRequestMcpService;
435+ const failed = yield* service.list(scope, {}).pipe(Effect.flip);
436+ assert.equal(failed.code, "orchestration_error");
437+ assert.notInclude(failed.message, "sqlite credentials leaked");
438+ }).pipe(
439+ Effect.provide(
440+ serviceLayer({
441+ getParent: () =>
442+ parentProjectionWithTasks([{ taskId: missingTask, childThreadId: missingChild }]),
443+ getChild: () => childProjection(),
444+ getProjectThread: () => Effect.fail(storageCause),
445+ }),
446+ ),
447+ );
448+ }),
449+ );
450+
451+ it.effect("marks oversized provider questions as unavailable and refuses partial answers", () => {
452+ const dispatch = vi.fn(() => Effect.die("oversized requests must not dispatch"));
453+ const oversizedQuestions = [
454+ {
455+ ...questions[0]!,
456+ question: "q".repeat(PENDING_REQUEST_MCP_MAX_QUESTION_CHARS + 1),
457+ },
458+ ];
459+ const oversized = childProjectionWithRequests({
460+ childThreadId,
461+ requests: [{ requestId, questions: oversizedQuestions }],
462+ });
463+
464+ return Effect.gen(function* () {
465+ const service = yield* PendingRequestMcpService;
466+ const read = yield* service.read(scope, { childThreadId, requestId });
467+ assert.equal(read.questionPayloadStatus, "too_large");
468+ assert.equal(read.questionCount, 1);
469+ assert.deepEqual(read.questions, []);
470+ assert.isFalse(read.answerable);
471+
472+ const rejected = yield* service
473+ .respond(scope, {
474+ childThreadId,
475+ requestId,
476+ answers: { editor: "Vim" },
477+ clientRequestId: "oversized-question",
478+ })
479+ .pipe(Effect.flip);
480+ assert.equal(rejected.code, "request_payload_too_large");
481+ assert.equal(dispatch.mock.calls.length, 0);
482+ }).pipe(
483+ Effect.provide(
484+ serviceLayer({
485+ getChild: () => oversized,
486+ dispatch,
487+ }),
488+ ),
489+ );
490+ });
491+
204492 it.effect("keeps projection defects out of MCP failure messages", () =>
205493 Effect.gen(function* () {
206494 const service = yield* PendingRequestMcpService;
apps/server/src/mcp/PendingRequestMcpService.ts+152−48
@@ -1,5 +1,13 @@
11 import {
22 CommandId,
3+ PENDING_REQUEST_MCP_MAX_HEADER_CHARS,
4+ PENDING_REQUEST_MCP_MAX_OPTION_DESCRIPTION_CHARS,
5+ PENDING_REQUEST_MCP_MAX_OPTION_LABEL_CHARS,
6+ PENDING_REQUEST_MCP_MAX_OPTIONS_PER_QUESTION,
7+ PENDING_REQUEST_MCP_MAX_QUESTION_CHARS,
8+ PENDING_REQUEST_MCP_MAX_QUESTION_ID_CHARS,
9+ PENDING_REQUEST_MCP_MAX_QUESTIONS,
10+ PENDING_REQUEST_MCP_MAX_TOTAL_QUESTION_CHARS,
311 type OrchestrationV2RuntimeRequest,
412 type OrchestrationV2Subagent,
513 type OrchestrationV2ThreadProjection,
@@ -24,6 +32,12 @@ import * as Option from "effect/Option"; · 18 unmodified lines
2432 import * as Schema from "effect/Schema";
2533
2634 import {
35+ OrchestratorProjectionError,
36+ type OrchestratorV2Error,
37+} from "../orchestration-v2/Orchestrator.ts";
38+import { ProjectionStoreThreadNotFoundError } from "../orchestration-v2/ProjectionStore.ts";
39+import {
40+ ThreadManagementProjectionLoadError,
2741 ThreadManagementService,
2842 ThreadManagementThreadNotFoundError,
2943 } from "../orchestration-v2/ThreadManagementService.ts";
@@ -31,23 +45,34 @@ import { interactionModeWithinMcpCeiling, runtimeModeWithinMcpCeiling } from "./ · 1 unmodified line
3145 import type { McpInvocationScope } from "./McpInvocationContext.ts";
3246
3347 const DEFAULT_LIST_LIMIT = 20;
48+const MAX_LIST_CHILD_PROJECTIONS = 20;
49+
50+interface ListCursor {
51+ readonly taskId: string;
52+ readonly requestId: string | null;
53+}
3454
3555 function parseListCursor(
3656 cursor: string | undefined,
37−): Effect.Effect<
38− { readonly childIndex: number; readonly requestIndex: number },
39− PendingRequestMcpFailure
40−> {
41− if (cursor === undefined) return Effect.succeed({ childIndex: 0, requestIndex: 0 });
42− const match = /^(0|[1-9]\d*):(0|[1-9]\d*)$/.exec(cursor);
57+): Effect.Effect<ListCursor | null, PendingRequestMcpFailure> {
58+ if (cursor === undefined) return Effect.succeed(null);
59+ const match = /^v1:([^:]+):(.*)$/.exec(cursor);
4360 if (match === null) {
4461 return Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."));
4562 }
46− const childIndex = Number(match[1]);
47− const requestIndex = Number(match[2]);
48− return Number.isSafeInteger(childIndex) && Number.isSafeInteger(requestIndex)
49− ? Effect.succeed({ childIndex, requestIndex })
50− : Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."));
63+ try {
64+ const taskId = decodeURIComponent(match[1] ?? "");
65+ const requestId = decodeURIComponent(match[2] ?? "");
66+ return taskId.length === 0
67+ ? Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."))
68+ : Effect.succeed({ taskId, requestId: requestId.length === 0 ? null : requestId });
69+ } catch {
70+ return Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."));
71+ }
72+}
73+
74+function listCursor(taskId: string, requestId: string | null): string {
75+ return `v1:${encodeURIComponent(taskId)}:${requestId === null ? "" : encodeURIComponent(requestId)}`;
5176 }
5277
5378 export class PendingRequestMcpService extends Context.Service<
@@ -76,20 +101,22 @@ function failure( · 22 unmodified lines
76101 }
77102
78103 function errorMessage(error: unknown): string {
79− if (typeof error === "object" && error !== null && "detail" in error) {
80− return String((error as { readonly detail: unknown }).detail);
81− }
82− if (typeof error === "object" && error !== null && "cause" in error) {
83− const cause = (error as { readonly cause: unknown }).cause;
84− if (typeof cause === "string") return cause;
85− }
86104 return error instanceof Error ? error.message : String(error);
87105 }
88106
89107 const isThreadNotFound = Schema.is(ThreadManagementThreadNotFoundError);
108+const isProjectionLoadError = Schema.is(ThreadManagementProjectionLoadError);
109+const isOrchestratorProjectionError = Schema.is(OrchestratorProjectionError);
110+const isProjectionStoreThreadNotFound = Schema.is(ProjectionStoreThreadNotFoundError);
111+
112+function isMissingChild(error: unknown): boolean {
113+ if (isThreadNotFound(error)) return true;
114+ if (!isProjectionLoadError(error) || !isOrchestratorProjectionError(error.cause)) return false;
115+ return isProjectionStoreThreadNotFound(error.cause.cause);
116+}
90117
91118 function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMcpFailure {
92− return isThreadNotFound(error)
119+ return isMissingChild(error)
93120 ? failure("child_not_found", `Delegated child thread '${threadId}' was not found.`)
94121 : failure(
95122 "orchestration_error",
@@ -97,16 +124,15 @@ function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMc · 1 unmodified line
97124 );
98125 }
99126
100−function dispatchFailure(error: unknown): PendingRequestMcpFailure {
101− const tag =
102− typeof error === "object" && error !== null && "_tag" in error
103− ? String((error as { readonly _tag: unknown })._tag)
104− : "";
105− return tag === "OrchestratorDispatchError" ||
106− tag === "OrchestratorCommandPreviouslyRejectedError" ||
107− tag === "OrchestratorCommandIdConflictError"
108− ? failure("operation_rejected", errorMessage(error))
109− : failure("orchestration_error", errorMessage(error));
127+function dispatchFailure(error: OrchestratorV2Error): PendingRequestMcpFailure {
128+ switch (error._tag) {
129+ case "OrchestratorDispatchError":
130+ case "OrchestratorCommandPreviouslyRejectedError":
131+ case "OrchestratorCommandIdConflictError":
132+ return failure("operation_rejected", error.message);
133+ default:
134+ return failure("orchestration_error", error.message);
135+ }
110136 }
111137
112138 function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) {
@@ -115,11 +141,7 @@ function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) { · 2 unmodified lines
115141 (task): task is OrchestrationV2Subagent & { readonly childThreadId: ThreadId } =>
116142 task.origin === "app_owned" && task.childThreadId !== null,
117143 )
118− .toSorted(
119− (left, right) =>
120− DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) ||
121− right.id.localeCompare(left.id),
122− );
144+ .toSorted((left, right) => left.id.localeCompare(right.id));
123145 }
124146
125147 function findUserInputItem(
@@ -132,6 +154,35 @@ function findUserInputItem( · 6 unmodified lines
132154 );
133155 }
134156
157+function questionPayloadFits(
158+ questions: Extract<OrchestrationV2TurnItem, { readonly type: "user_input_request" }>["questions"],
159+): boolean {
160+ if (questions.length > PENDING_REQUEST_MCP_MAX_QUESTIONS) return false;
161+ let totalChars = 0;
162+ for (const question of questions) {
163+ if (
164+ question.id.length > PENDING_REQUEST_MCP_MAX_QUESTION_ID_CHARS ||
165+ question.header.length > PENDING_REQUEST_MCP_MAX_HEADER_CHARS ||
166+ question.question.length > PENDING_REQUEST_MCP_MAX_QUESTION_CHARS ||
167+ question.options.length > PENDING_REQUEST_MCP_MAX_OPTIONS_PER_QUESTION
168+ ) {
169+ return false;
170+ }
171+ totalChars += question.id.length + question.header.length + question.question.length;
172+ for (const option of question.options) {
173+ if (
174+ option.label.length > PENDING_REQUEST_MCP_MAX_OPTION_LABEL_CHARS ||
175+ option.description.length > PENDING_REQUEST_MCP_MAX_OPTION_DESCRIPTION_CHARS
176+ ) {
177+ return false;
178+ }
179+ totalChars += option.label.length + option.description.length;
180+ }
181+ if (totalChars > PENDING_REQUEST_MCP_MAX_TOTAL_QUESTION_CHARS) return false;
182+ }
183+ return true;
184+}
185+
135186 function requestSummary(input: {
136187 readonly task: OrchestrationV2Subagent & { readonly childThreadId: ThreadId };
137188 readonly projection: OrchestrationV2ThreadProjection;
@@ -152,6 +203,7 @@ function requestSummary(input: { · 14 unmodified lines
152203 ),
153204 );
154205 }
206+ const payloadFits = questionPayloadFits(input.item.questions);
155207 return Effect.succeed({
156208 taskId: input.task.id,
157209 childThreadId: input.task.childThreadId,
@@ -162,7 +214,13 @@ function requestSummary(input: { · 4 unmodified lines
162214 driverKind: providerThread.driver,
163215 status: input.request.status,
164216 resumable: input.request.responseCapability.type === "live",
165− questions: input.item.questions,
217+ answerable:
218+ input.request.status === "pending" &&
219+ input.request.responseCapability.type === "live" &&
220+ payloadFits,
221+ questionCount: input.item.questions.length,
222+ questionPayloadStatus: payloadFits ? "complete" : "too_large",
223+ questions: payloadFits ? input.item.questions : [],
166224 createdAt: DateTime.formatIso(input.request.createdAt),
167225 resolvedAt:
168226 input.request.resolvedAt === null ? null : DateTime.formatIso(input.request.resolvedAt),
@@ -323,33 +381,73 @@ export const make = Effect.gen(function* () { · 154 unmodified lines
323381 const limit = input.limit ?? DEFAULT_LIST_LIMIT;
324382 const cursor = yield* parseListCursor(input.cursor);
325383 const requests: Array<PendingRequestMcpRequest> = [];
326− let childIndex = cursor.childIndex;
327− let requestIndex = cursor.requestIndex;
328− while (childIndex < children.length && requests.length < limit) {
384+ let projectionCount = 0;
385+ let continuation: string | null = null;
386+ let lastScannedChildIndex = -1;
387+
388+ for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
329389 const task = children[childIndex];
330390 if (task === undefined) break;
391+ const taskComparison = cursor === null ? 1 : task.id.localeCompare(cursor.taskId);
392+ if (taskComparison < 0 || (taskComparison === 0 && cursor?.requestId === null)) continue;
393+ if (projectionCount >= MAX_LIST_CHILD_PROJECTIONS) {
394+ break;
395+ }
396+ projectionCount += 1;
397+ lastScannedChildIndex = childIndex;
331398 const projection = yield* threadManagement
332399 .getProjectThread({
333400 projectId: parent.thread.projectId,
334401 threadId: task.childThreadId,
335402 })
336− .pipe(Effect.mapError((error) => projectionFailure(error, task.childThreadId)));
337− const pending = projection.runtimeRequests.filter(
338− (request) => request.kind === "user_input" && request.status === "pending",
339− );
340− while (requestIndex < pending.length && requests.length < limit) {
403+ .pipe(
404+ Effect.matchEffect({
405+ onFailure: (error) =>
406+ isMissingChild(error)
407+ ? Effect.void
408+ : Effect.fail(projectionFailure(error, task.childThreadId)),
409+ onSuccess: Effect.succeed,
410+ }),
411+ );
412+ continuation = listCursor(task.id, null);
413+ if (projection === undefined) continue;
414+
415+ const pending = projection.runtimeRequests
416+ .filter((request) => request.kind === "user_input" && request.status === "pending")
417+ .toSorted((left, right) => left.id.localeCompare(right.id));
418+ for (let requestIndex = 0; requestIndex < pending.length; requestIndex += 1) {
341419 const request = pending[requestIndex];
342420 if (request === undefined) break;
421+ if (
422+ cursor !== null &&
423+ taskComparison === 0 &&
424+ cursor.requestId !== null &&
425+ request.id.localeCompare(cursor.requestId) <= 0
426+ ) {
427+ continue;
428+ }
343429 requests.push(yield* resultFor({ task, projection, requestId: request.id }));
344− requestIndex += 1;
430+ continuation = listCursor(task.id, request.id);
431+ if (requests.length === limit) {
432+ const hasMoreRequests = pending
433+ .slice(requestIndex + 1)
434+ .some((candidate) => candidate.id.localeCompare(request.id) > 0);
435+ const hasMoreChildren = childIndex + 1 < children.length;
436+ return {
437+ requests,
438+ nextCursor: hasMoreRequests || hasMoreChildren ? continuation : null,
439+ } satisfies PendingRequestMcpListResult;
440+ }
345441 }
346− if (requestIndex < pending.length) break;
347− childIndex += 1;
348− requestIndex = 0;
349442 }
350443 return {
351444 requests,
352− nextCursor: childIndex < children.length ? `${childIndex}:${requestIndex}` : null,
445+ nextCursor:
446+ projectionCount >= MAX_LIST_CHILD_PROJECTIONS &&
447+ continuation !== null &&
448+ lastScannedChildIndex + 1 < children.length
449+ ? continuation
450+ : null,
353451 } satisfies PendingRequestMcpListResult;
354452 }),
355453 read: (scope, input) =>
@@ -416,6 +514,12 @@ export const make = Effect.gen(function* () { · 60 unmodified lines
416514 if (request.responseCapability.type !== "live") {
417515 return yield* failure("request_not_resumable", request.responseCapability.reason);
418516 }
517+ if (!questionPayloadFits(item.questions)) {
518+ return yield* failure(
519+ "request_payload_too_large",
520+ `User-input request '${request.id}' exceeds the bounded MCP question payload and cannot be answered through this tool.`,
521+ );
522+ }
419523 const expectedIds = new Set(item.questions.map((question) => question.id));
420524 const suppliedIds = Object.keys(input.answers);
421525 const missing = [...expectedIds].filter((id) => !(id in input.answers));
apps/server/src/mcp/toolkits/pendingRequest/tools.ts+3−3
@@ -16,7 +16,7 @@ const dependencies = [McpInvocationContext.McpInvocationContext, PendingRequestM · 15 unmodified lines
1616
1717 export const PendingRequestListTool = Tool.make("t3_pending_request_list", {
1818 description:
19− "List pending user-input questions from direct app-owned delegated children of the calling thread. Results are bounded and never include approval requests or unrelated threads.",
19+ "List pending user-input questions from direct app-owned delegated children of the calling thread. Results and child scans are bounded; an empty page can have a continuation cursor. Oversized question payloads are explicitly unavailable and never partial. Approval requests and unrelated threads are excluded.",
2020 parameters: PendingRequestMcpListInput,
2121 success: PendingRequestMcpListResult,
2222 failure: PendingRequestMcpFailure,
@@ -30,7 +30,7 @@ export const PendingRequestListTool = Tool.make("t3_pending_request_list", { · 7 unmodified lines
3030
3131 export const PendingRequestReadTool = Tool.make("t3_pending_request_read", {
3232 description:
33− "Read one structured user-input question from a direct app-owned delegated child. Resolved and stale requests remain readable; this tool never responds or approves anything.",
33+ "Read one structured user-input question from a direct app-owned delegated child. Resolved and stale requests remain readable. Oversized question payloads are explicitly unavailable and never partial. This tool never responds or approves anything.",
3434 parameters: PendingRequestMcpReadInput,
3535 success: PendingRequestMcpReadResult,
3636 failure: PendingRequestMcpFailure,
@@ -44,7 +44,7 @@ export const PendingRequestReadTool = Tool.make("t3_pending_request_read", { · 7 unmodified lines
4444
4545 export const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {
4646 description:
47− "Answer a pending user-input question from a direct app-owned delegated child. Supply every exact question ID. This cannot answer approval requests, grant permissions, or target the calling agent or an unrelated thread. Acceptance is reported by a durable V2 command receipt; provider delivery may complete asynchronously.",
47+ "Answer a pending user-input question from a direct app-owned delegated child. Supply every exact question ID from a complete bounded payload; oversized payloads cannot be answered through MCP. This cannot answer approval requests, grant permissions, or target the calling agent or an unrelated thread. Acceptance is reported by a durable V2 command receipt; provider delivery may complete asynchronously.",
4848 parameters: PendingRequestMcpRespondInput,
4949 success: PendingRequestMcpRespondResult,
5050 failure: PendingRequestMcpFailure,
docs/orchestration-v2/orchestrator-mcp-server.md+9−1
@@ -234,7 +234,10 @@ reason. Use `t3_thread_interrupt` to interrupt a later follow-up run. · 233 unmodified lines
234234 Lists pending structured user-input questions from direct app-owned delegated
235235 children of the calling thread. The bounded page follows the parent's durable
236236 sub-agent records and never scans unrelated or provider-native child threads.
237−Approval and permission requests are excluded.
237+Approval and permission requests are excluded. Cursors continue from stable
238+task/request identities, so resolving an earlier page does not shift later
239+results. A page also caps child projections inspected and can therefore return
240+an empty result with a continuation cursor.
238241
239242 ### `t3_pending_request_read`
240243
@@ -243,6 +246,11 @@ Unlike the list path, read can return resolved, expired, or cancelled requests · 2 unmodified lines
243246 so callers can distinguish a stale response attempt from a missing request.
244247 The read path is non-mutating.
245248
249+Question payloads are bounded by count, field length, and total text. A request
250+that exceeds those bounds remains discoverable with its stable IDs,
251+`questionPayloadStatus: "too_large"`, `answerable: false`, and no partial
252+question set. Such a request cannot be answered through MCP.
253+
246254 ### `t3_pending_request_respond`
247255
248256 Answers a pending, live `user_input` request on a direct app-owned delegated
docs/user/permission-modes.md+2−1
@@ -25,7 +25,8 @@ including OpenCode and Antigravity, fall back to asking. · 24 unmodified lines
2525 When an agent delegates work to a T3-owned child conversation, it can relay that child's
2626 structured questions and send your answers back. This applies only to user-input questions from
2727 that direct child. It does not let either agent approve permission requests or expand the child's
28−permission mode.
28+permission mode. Exceptionally large question sets remain visible as unavailable instead of being
29+partially shown or answered.
2930
3031 For Grok, **Always allow this session** remembers the matching command or tool input. Other
3132 actions still require approval.
packages/contracts/src/pendingRequestMcp.test.ts+53−2
@@ -2,13 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
22 import * as Schema from "effect/Schema";
33
44 import {
5+ PENDING_REQUEST_MCP_MAX_QUESTION_CHARS,
56 PendingRequestMcpListInput,
7+ PendingRequestMcpReadResult,
68 PendingRequestMcpReadInput,
79 PendingRequestMcpRespondInput,
810 } from "./pendingRequestMcp.ts";
911
1012 const decodeList = Schema.decodeUnknownSync(PendingRequestMcpListInput);
1113 const decodeRead = Schema.decodeUnknownSync(PendingRequestMcpReadInput);
14+const decodeReadResult = Schema.decodeUnknownSync(PendingRequestMcpReadResult);
1215 const decodeRespond = Schema.decodeUnknownSync(PendingRequestMcpRespondInput);
1316
1417 describe("pending-request MCP contracts", () => {
@@ -22,7 +25,7 @@ describe("pending-request MCP contracts", () => { · 7 unmodified lines
2225 }
2326 });
2427
25− it("accepts structured single and multiple-choice answers", () => {
28+ it("accepts structured text, numeric, boolean, and multiple-choice answers", () => {
2629 expect(
2730 decodeRespond({
2831 childThreadId: "thread:child",
@@ -30,21 +33,69 @@ describe("pending-request MCP contracts", () => { · 1 unmodified line
3033 answers: {
3134 editor: "vim",
3235 features: ["queue controls", "attachments"],
36+ retries: 3,
37+ confirmed: true,
3338 },
3439 clientRequestId: "answer-questions-1",
3540 }).answers,
3641 ).toEqual({
3742 editor: "vim",
3843 features: ["queue controls", "attachments"],
44+ retries: 3,
45+ confirmed: true,
3946 });
4047 });
4148
4249 it("bounds list pages and requires stable target identifiers", () => {
43− expect(decodeList({ limit: 50, cursor: "2:1" })).toEqual({ limit: 50, cursor: "2:1" });
50+ expect(decodeList({ limit: 50, cursor: "v1:node%3Atask:request%3Aquestion" })).toEqual({
51+ limit: 50,
52+ cursor: "v1:node%3Atask:request%3Aquestion",
53+ });
4454 expect(() => decodeList({ limit: 51 })).toThrow();
4555 expect(decodeRead({ childThreadId: "thread:child", requestId: "request:questions" })).toEqual({
4656 childThreadId: "thread:child",
4757 requestId: "request:questions",
4858 });
4959 });
60+
61+ it("rejects oversized complete question payloads but represents them explicitly", () => {
62+ const result = {
63+ taskId: "node:task",
64+ childThreadId: "thread:child",
65+ runId: null,
66+ nodeId: "node:request",
67+ requestId: "request:questions",
68+ providerInstanceId: "codex",
69+ driverKind: "codex",
70+ status: "pending",
71+ resumable: true,
72+ answerable: true,
73+ questionCount: 1,
74+ questionPayloadStatus: "complete",
75+ questions: [
76+ {
77+ id: "editor",
78+ header: "Editor",
79+ question: "q".repeat(PENDING_REQUEST_MCP_MAX_QUESTION_CHARS + 1),
80+ options: [],
81+ },
82+ ],
83+ createdAt: "2026-08-29T12:00:00.000Z",
84+ resolvedAt: null,
85+ };
86+ expect(() => decodeReadResult(result)).toThrow();
87+ expect(
88+ decodeReadResult({
89+ ...result,
90+ answerable: false,
91+ questionPayloadStatus: "too_large",
92+ questions: [],
93+ }),
94+ ).toMatchObject({
95+ requestId: "request:questions",
96+ questionCount: 1,
97+ questionPayloadStatus: "too_large",
98+ questions: [],
99+ });
100+ });
50101 });
packages/contracts/src/pendingRequestMcp.ts+36−4
@@ -11,20 +11,30 @@ import { · 10 unmodified lines
1111 ThreadId,
1212 TrimmedNonEmptyString,
1313 } from "./baseSchemas.ts";
14−import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts";
1514 import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
1615
16+export const PENDING_REQUEST_MCP_MAX_QUESTIONS = 20;
17+export const PENDING_REQUEST_MCP_MAX_OPTIONS_PER_QUESTION = 20;
18+export const PENDING_REQUEST_MCP_MAX_QUESTION_ID_CHARS = 256;
19+export const PENDING_REQUEST_MCP_MAX_HEADER_CHARS = 256;
20+export const PENDING_REQUEST_MCP_MAX_QUESTION_CHARS = 4_000;
21+export const PENDING_REQUEST_MCP_MAX_OPTION_LABEL_CHARS = 256;
22+export const PENDING_REQUEST_MCP_MAX_OPTION_DESCRIPTION_CHARS = 2_000;
23+export const PENDING_REQUEST_MCP_MAX_TOTAL_QUESTION_CHARS = 32_000;
24+
1725 const PendingRequestMcpLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(50));
1826 const PendingRequestMcpClientRequestId = TrimmedNonEmptyString.check(
1927 Schema.isMaxLength(256),
2028 ).annotate({ description: "Stable idempotency key to reuse when retrying this response." });
2129 const PendingRequestMcpAnswer = Schema.Union([
2230 TrimmedNonEmptyString,
31+ Schema.Number,
32+ Schema.Boolean,
2333 Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(20)),
2434 ]);
2535
2636 export const PendingRequestMcpListInput = Schema.Struct({
27− cursor: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(64))).annotate({
37+ cursor: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(4_096))).annotate({
2838 description: "Opaque cursor returned by the previous list page.",
2939 }),
3040 limit: Schema.optional(PendingRequestMcpLimit),
@@ -42,12 +52,28 @@ export const PendingRequestMcpRespondInput = Schema.Struct({ · 11 unmodified lines
4252 requestId: RuntimeRequestId,
4353 answers: Schema.Record(TrimmedNonEmptyString, PendingRequestMcpAnswer).annotate({
4454 description:
45− "Answers keyed by the exact question IDs returned by list/read. Every question must be answered; values may be one string or a non-empty string array.",
55+ "Answers keyed by the exact question IDs returned by list/read. Every question must be answered; values may be a string, number, boolean, or non-empty string array.",
4656 }),
4757 clientRequestId: Schema.optional(PendingRequestMcpClientRequestId),
4858 });
4959 export type PendingRequestMcpRespondInput = typeof PendingRequestMcpRespondInput.Type;
5060
61+const PendingRequestMcpQuestion = Schema.Struct({
62+ id: TrimmedNonEmptyString.check(Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_QUESTION_ID_CHARS)),
63+ header: TrimmedNonEmptyString.check(Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_HEADER_CHARS)),
64+ question: TrimmedNonEmptyString.check(Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_QUESTION_CHARS)),
65+ options: Schema.Array(
66+ Schema.Struct({
67+ label: TrimmedNonEmptyString.check(
68+ Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_OPTION_LABEL_CHARS),
69+ ),
70+ description: TrimmedNonEmptyString.check(
71+ Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_OPTION_DESCRIPTION_CHARS),
72+ ),
73+ }),
74+ ).check(Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_OPTIONS_PER_QUESTION)),
75+});
76+
5177 export const PendingRequestMcpRequest = Schema.Struct({
5278 taskId: NodeId,
5379 childThreadId: ThreadId,
@@ -58,7 +84,12 @@ export const PendingRequestMcpRequest = Schema.Struct({ · 4 unmodified lines
5884 driverKind: ProviderDriverKind,
5985 status: Schema.Literals(["pending", "resolved", "expired", "cancelled"]),
6086 resumable: Schema.Boolean,
61− questions: Schema.Array(OrchestrationV2UserInputQuestion),
87+ answerable: Schema.Boolean,
88+ questionCount: NonNegativeInt,
89+ questionPayloadStatus: Schema.Literals(["complete", "too_large"]),
90+ questions: Schema.Array(PendingRequestMcpQuestion).check(
91+ Schema.isMaxLength(PENDING_REQUEST_MCP_MAX_QUESTIONS),
92+ ),
6293 createdAt: IsoDateTime,
6394 resolvedAt: Schema.NullOr(IsoDateTime),
6495 });
@@ -95,6 +126,7 @@ export class PendingRequestMcpFailure extends Schema.TaggedErrorClass<PendingReq · 30 unmodified lines
95126 "interaction_mode_escalation_denied",
96127 "invalid_request",
97128 "invalid_answers",
129+ "request_payload_too_large",
98130 "operation_rejected",
99131 "orchestration_error",
100132 ]),