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
3import {3import {
4 EnvironmentId,4 EnvironmentId,
5 NodeId,5 NodeId,
6 PENDING_REQUEST_MCP_MAX_QUESTION_CHARS,
6 ProjectId,7 ProjectId,
7 ProviderInstanceId,8 ProviderInstanceId,
8 ProviderSessionId,9 ProviderSessionId,
@@ -21,7 +22,11 @@ import * as Ref from "effect/Ref"; · 12 unmodified lines
21 22
22import type { CommandReceiptV2 } from "../orchestration-v2/CommandReceiptStore.ts";23import type { CommandReceiptV2 } from "../orchestration-v2/CommandReceiptStore.ts";
23import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";24import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";
24import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";25import { ProjectionStoreThreadNotFoundError } from "../orchestration-v2/ProjectionStore.ts";
26import {
27 ThreadManagementProjectionLoadError,
28 ThreadManagementService,
29} from "../orchestration-v2/ThreadManagementService.ts";
25import type { McpInvocationScope } from "./McpInvocationContext.ts";30import type { McpInvocationScope } from "./McpInvocationContext.ts";
26import { layer, PendingRequestMcpService } from "./PendingRequestMcpService.ts";31import { layer, PendingRequestMcpService } from "./PendingRequestMcpService.ts";
27 32
@@ -150,10 +155,72 @@ function childProjection( · 122 unmodified lines
150 } as unknown as OrchestrationV2ThreadProjection;155 } as unknown as OrchestrationV2ThreadProjection;
151}156}
152 157
158function 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
181function 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
153function serviceLayer(input: {219function serviceLayer(input: {
154 readonly getParent?: () => OrchestrationV2ThreadProjection;220 readonly getParent?: () => OrchestrationV2ThreadProjection;
155 readonly getChild: () => OrchestrationV2ThreadProjection;221 readonly getChild: () => OrchestrationV2ThreadProjection;
156 readonly getThreadProjection?: ThreadManagementService["Service"]["getThreadProjection"];222 readonly getThreadProjection?: ThreadManagementService["Service"]["getThreadProjection"];
223 readonly getProjectThread?: ThreadManagementService["Service"]["getProjectThread"];
157 readonly getReceipt?: ThreadManagementService["Service"]["getCommandReceipt"];224 readonly getReceipt?: ThreadManagementService["Service"]["getCommandReceipt"];
158 readonly dispatch?: ThreadManagementService["Service"]["dispatch"];225 readonly dispatch?: ThreadManagementService["Service"]["dispatch"];
159}) {226}) {
@@ -165,10 +232,12 @@ function serviceLayer(input: { · 5 unmodified lines
165 getThreadProjection:232 getThreadProjection:
166 input.getThreadProjection ??233 input.getThreadProjection ??
167 (() => Effect.succeed(input.getParent?.() ?? parentProjection())),234 (() => Effect.succeed(input.getParent?.() ?? parentProjection())),
168 getProjectThread: ({ threadId }) =>235 getProjectThread:
169 threadId === childThreadId236 input.getProjectThread ??
170 ? Effect.succeed(input.getChild())237 (({ threadId }) =>
171 : Effect.die(new Error(`Unexpected child projection read: ${threadId}`)),238 threadId === childThreadId
239 ? Effect.succeed(input.getChild())
240 : Effect.die(new Error(`Unexpected child projection read: ${threadId}`))),
172 getCommandReceipt: input.getReceipt ?? (() => Effect.succeed(Option.none())),241 getCommandReceipt: input.getReceipt ?? (() => Effect.succeed(Option.none())),
173 dispatch:242 dispatch:
174 input.dispatch ??243 input.dispatch ??
@@ -191,6 +260,8 @@ describe("PendingRequestMcpService", () => { · 16 unmodified lines
191 assert.equal(listed.requests[0]?.childThreadId, childThreadId);260 assert.equal(listed.requests[0]?.childThreadId, childThreadId);
192 assert.equal(listed.requests[0]?.requestId, requestId);261 assert.equal(listed.requests[0]?.requestId, requestId);
193 assert.deepEqual(listed.requests[0]?.questions, questions);262 assert.deepEqual(listed.requests[0]?.questions, questions);
263 assert.equal(listed.requests[0]?.questionPayloadStatus, "complete");
264 assert.isTrue(listed.requests[0]?.answerable);
194 assert.equal(listed.nextCursor, null);265 assert.equal(listed.nextCursor, null);
195 266
196 const read = yield* service.read(scope, { childThreadId, requestId });267 const read = yield* service.read(scope, { childThreadId, requestId });
@@ -201,6 +272,223 @@ describe("PendingRequestMcpService", () => { · 4 unmodified lines
201 }).pipe(Effect.provide(serviceLayer({ getChild: () => childProjection() }))),272 }).pipe(Effect.provide(serviceLayer({ getChild: () => childProjection() }))),
202 );273 );
203 274
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
204 it.effect("keeps projection defects out of MCP failure messages", () =>492 it.effect("keeps projection defects out of MCP failure messages", () =>
205 Effect.gen(function* () {493 Effect.gen(function* () {
206 const service = yield* PendingRequestMcpService;494 const service = yield* PendingRequestMcpService;
apps/server/src/mcp/PendingRequestMcpService.ts+152−48
@@ -1,5 +1,13 @@
1import {1import {
2 CommandId,2 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,
3 type OrchestrationV2RuntimeRequest,11 type OrchestrationV2RuntimeRequest,
4 type OrchestrationV2Subagent,12 type OrchestrationV2Subagent,
5 type OrchestrationV2ThreadProjection,13 type OrchestrationV2ThreadProjection,
@@ -24,6 +32,12 @@ import * as Option from "effect/Option"; · 18 unmodified lines
24import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
25 33
26import {34import {
35 OrchestratorProjectionError,
36 type OrchestratorV2Error,
37} from "../orchestration-v2/Orchestrator.ts";
38import { ProjectionStoreThreadNotFoundError } from "../orchestration-v2/ProjectionStore.ts";
39import {
40 ThreadManagementProjectionLoadError,
27 ThreadManagementService,41 ThreadManagementService,
28 ThreadManagementThreadNotFoundError,42 ThreadManagementThreadNotFoundError,
29} from "../orchestration-v2/ThreadManagementService.ts";43} from "../orchestration-v2/ThreadManagementService.ts";
@@ -31,23 +45,34 @@ import { interactionModeWithinMcpCeiling, runtimeModeWithinMcpCeiling } from "./ · 1 unmodified line
31import type { McpInvocationScope } from "./McpInvocationContext.ts";45import type { McpInvocationScope } from "./McpInvocationContext.ts";
32 46
33const DEFAULT_LIST_LIMIT = 20;47const DEFAULT_LIST_LIMIT = 20;
48const MAX_LIST_CHILD_PROJECTIONS = 20;
49
50interface ListCursor {
51 readonly taskId: string;
52 readonly requestId: string | null;
53}
34 54
35function parseListCursor(55function parseListCursor(
36 cursor: string | undefined,56 cursor: string | undefined,
37): Effect.Effect<57): Effect.Effect<ListCursor | null, PendingRequestMcpFailure> {
38 { readonly childIndex: number; readonly requestIndex: number },58 if (cursor === undefined) return Effect.succeed(null);
39 PendingRequestMcpFailure59 const match = /^v1:([^:]+):(.*)$/.exec(cursor);
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);
43 if (match === null) {60 if (match === null) {
44 return Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."));61 return Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."));
45 }62 }
46 const childIndex = Number(match[1]);63 try {
47 const requestIndex = Number(match[2]);64 const taskId = decodeURIComponent(match[1] ?? "");
48 return Number.isSafeInteger(childIndex) && Number.isSafeInteger(requestIndex)65 const requestId = decodeURIComponent(match[2] ?? "");
49 ? Effect.succeed({ childIndex, requestIndex })66 return taskId.length === 0
50 : Effect.fail(failure("invalid_request", "The pending-request cursor is invalid."));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
74function listCursor(taskId: string, requestId: string | null): string {
75 return `v1:${encodeURIComponent(taskId)}:${requestId === null ? "" : encodeURIComponent(requestId)}`;
51}76}
52 77
53export class PendingRequestMcpService extends Context.Service<78export class PendingRequestMcpService extends Context.Service<
@@ -76,20 +101,22 @@ function failure( · 22 unmodified lines
76}101}
77 102
78function errorMessage(error: unknown): string {103function 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 }
86 return error instanceof Error ? error.message : String(error);104 return error instanceof Error ? error.message : String(error);
87}105}
88 106
89const isThreadNotFound = Schema.is(ThreadManagementThreadNotFoundError);107const isThreadNotFound = Schema.is(ThreadManagementThreadNotFoundError);
108const isProjectionLoadError = Schema.is(ThreadManagementProjectionLoadError);
109const isOrchestratorProjectionError = Schema.is(OrchestratorProjectionError);
110const isProjectionStoreThreadNotFound = Schema.is(ProjectionStoreThreadNotFoundError);
111
112function 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}
90 117
91function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMcpFailure {118function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMcpFailure {
92 return isThreadNotFound(error)119 return isMissingChild(error)
93 ? failure("child_not_found", `Delegated child thread '${threadId}' was not found.`)120 ? failure("child_not_found", `Delegated child thread '${threadId}' was not found.`)
94 : failure(121 : failure(
95 "orchestration_error",122 "orchestration_error",
@@ -97,16 +124,15 @@ function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMc · 1 unmodified line
97 );124 );
98}125}
99 126
100function dispatchFailure(error: unknown): PendingRequestMcpFailure {127function dispatchFailure(error: OrchestratorV2Error): PendingRequestMcpFailure {
101 const tag =128 switch (error._tag) {
102 typeof error === "object" && error !== null && "_tag" in error129 case "OrchestratorDispatchError":
103 ? String((error as { readonly _tag: unknown })._tag)130 case "OrchestratorCommandPreviouslyRejectedError":
104 : "";131 case "OrchestratorCommandIdConflictError":
105 return tag === "OrchestratorDispatchError" ||132 return failure("operation_rejected", error.message);
106 tag === "OrchestratorCommandPreviouslyRejectedError" ||133 default:
107 tag === "OrchestratorCommandIdConflictError"134 return failure("orchestration_error", error.message);
108 ? failure("operation_rejected", errorMessage(error))135 }
109 : failure("orchestration_error", errorMessage(error));
110}136}
111 137
112function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) {138function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) {
@@ -115,11 +141,7 @@ function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) { · 2 unmodified lines
115 (task): task is OrchestrationV2Subagent & { readonly childThreadId: ThreadId } =>141 (task): task is OrchestrationV2Subagent & { readonly childThreadId: ThreadId } =>
116 task.origin === "app_owned" && task.childThreadId !== null,142 task.origin === "app_owned" && task.childThreadId !== null,
117 )143 )
118 .toSorted(144 .toSorted((left, right) => left.id.localeCompare(right.id));
119 (left, right) =>
120 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt) ||
121 right.id.localeCompare(left.id),
122 );
123}145}
124 146
125function findUserInputItem(147function findUserInputItem(
@@ -132,6 +154,35 @@ function findUserInputItem( · 6 unmodified lines
132 );154 );
133}155}
134 156
157function 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
135function requestSummary(input: {186function requestSummary(input: {
136 readonly task: OrchestrationV2Subagent & { readonly childThreadId: ThreadId };187 readonly task: OrchestrationV2Subagent & { readonly childThreadId: ThreadId };
137 readonly projection: OrchestrationV2ThreadProjection;188 readonly projection: OrchestrationV2ThreadProjection;
@@ -152,6 +203,7 @@ function requestSummary(input: { · 14 unmodified lines
152 ),203 ),
153 );204 );
154 }205 }
206 const payloadFits = questionPayloadFits(input.item.questions);
155 return Effect.succeed({207 return Effect.succeed({
156 taskId: input.task.id,208 taskId: input.task.id,
157 childThreadId: input.task.childThreadId,209 childThreadId: input.task.childThreadId,
@@ -162,7 +214,13 @@ function requestSummary(input: { · 4 unmodified lines
162 driverKind: providerThread.driver,214 driverKind: providerThread.driver,
163 status: input.request.status,215 status: input.request.status,
164 resumable: input.request.responseCapability.type === "live",216 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 : [],
166 createdAt: DateTime.formatIso(input.request.createdAt),224 createdAt: DateTime.formatIso(input.request.createdAt),
167 resolvedAt:225 resolvedAt:
168 input.request.resolvedAt === null ? null : DateTime.formatIso(input.request.resolvedAt),226 input.request.resolvedAt === null ? null : DateTime.formatIso(input.request.resolvedAt),
@@ -323,33 +381,73 @@ export const make = Effect.gen(function* () { · 154 unmodified lines
323 const limit = input.limit ?? DEFAULT_LIST_LIMIT;381 const limit = input.limit ?? DEFAULT_LIST_LIMIT;
324 const cursor = yield* parseListCursor(input.cursor);382 const cursor = yield* parseListCursor(input.cursor);
325 const requests: Array<PendingRequestMcpRequest> = [];383 const requests: Array<PendingRequestMcpRequest> = [];
326 let childIndex = cursor.childIndex;384 let projectionCount = 0;
327 let requestIndex = cursor.requestIndex;385 let continuation: string | null = null;
328 while (childIndex < children.length && requests.length < limit) {386 let lastScannedChildIndex = -1;
387
388 for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
329 const task = children[childIndex];389 const task = children[childIndex];
330 if (task === undefined) break;390 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;
331 const projection = yield* threadManagement398 const projection = yield* threadManagement
332 .getProjectThread({399 .getProjectThread({
333 projectId: parent.thread.projectId,400 projectId: parent.thread.projectId,
334 threadId: task.childThreadId,401 threadId: task.childThreadId,
335 })402 })
336 .pipe(Effect.mapError((error) => projectionFailure(error, task.childThreadId)));403 .pipe(
337 const pending = projection.runtimeRequests.filter(404 Effect.matchEffect({
338 (request) => request.kind === "user_input" && request.status === "pending",405 onFailure: (error) =>
339 );406 isMissingChild(error)
340 while (requestIndex < pending.length && requests.length < limit) {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) {
341 const request = pending[requestIndex];419 const request = pending[requestIndex];
342 if (request === undefined) break;420 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 }
343 requests.push(yield* resultFor({ task, projection, requestId: request.id }));429 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 }
345 }441 }
346 if (requestIndex < pending.length) break;
347 childIndex += 1;
348 requestIndex = 0;
349 }442 }
350 return {443 return {
351 requests,444 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,
353 } satisfies PendingRequestMcpListResult;451 } satisfies PendingRequestMcpListResult;
354 }),452 }),
355 read: (scope, input) =>453 read: (scope, input) =>
@@ -416,6 +514,12 @@ export const make = Effect.gen(function* () { · 60 unmodified lines
416 if (request.responseCapability.type !== "live") {514 if (request.responseCapability.type !== "live") {
417 return yield* failure("request_not_resumable", request.responseCapability.reason);515 return yield* failure("request_not_resumable", request.responseCapability.reason);
418 }516 }
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 }
419 const expectedIds = new Set(item.questions.map((question) => question.id));523 const expectedIds = new Set(item.questions.map((question) => question.id));
420 const suppliedIds = Object.keys(input.answers);524 const suppliedIds = Object.keys(input.answers);
421 const missing = [...expectedIds].filter((id) => !(id in input.answers));525 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
16 16
17export const PendingRequestListTool = Tool.make("t3_pending_request_list", {17export const PendingRequestListTool = Tool.make("t3_pending_request_list", {
18 description:18 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.",
20 parameters: PendingRequestMcpListInput,20 parameters: PendingRequestMcpListInput,
21 success: PendingRequestMcpListResult,21 success: PendingRequestMcpListResult,
22 failure: PendingRequestMcpFailure,22 failure: PendingRequestMcpFailure,
@@ -30,7 +30,7 @@ export const PendingRequestListTool = Tool.make("t3_pending_request_list", { · 7 unmodified lines
30 30
31export const PendingRequestReadTool = Tool.make("t3_pending_request_read", {31export const PendingRequestReadTool = Tool.make("t3_pending_request_read", {
32 description:32 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.",
34 parameters: PendingRequestMcpReadInput,34 parameters: PendingRequestMcpReadInput,
35 success: PendingRequestMcpReadResult,35 success: PendingRequestMcpReadResult,
36 failure: PendingRequestMcpFailure,36 failure: PendingRequestMcpFailure,
@@ -44,7 +44,7 @@ export const PendingRequestReadTool = Tool.make("t3_pending_request_read", { · 7 unmodified lines
44 44
45export const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {45export const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", {
46 description:46 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.",
48 parameters: PendingRequestMcpRespondInput,48 parameters: PendingRequestMcpRespondInput,
49 success: PendingRequestMcpRespondResult,49 success: PendingRequestMcpRespondResult,
50 failure: PendingRequestMcpFailure,50 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
234Lists pending structured user-input questions from direct app-owned delegated234Lists pending structured user-input questions from direct app-owned delegated
235children of the calling thread. The bounded page follows the parent's durable235children of the calling thread. The bounded page follows the parent's durable
236sub-agent records and never scans unrelated or provider-native child threads.236sub-agent records and never scans unrelated or provider-native child threads.
237Approval and permission requests are excluded.237Approval and permission requests are excluded. Cursors continue from stable
238task/request identities, so resolving an earlier page does not shift later
239results. A page also caps child projections inspected and can therefore return
240an empty result with a continuation cursor.
238 241
239### `t3_pending_request_read`242### `t3_pending_request_read`
240 243
@@ -243,6 +246,11 @@ Unlike the list path, read can return resolved, expired, or cancelled requests · 2 unmodified lines
243so callers can distinguish a stale response attempt from a missing request.246so callers can distinguish a stale response attempt from a missing request.
244The read path is non-mutating.247The read path is non-mutating.
245 248
249Question payloads are bounded by count, field length, and total text. A request
250that exceeds those bounds remains discoverable with its stable IDs,
251`questionPayloadStatus: "too_large"`, `answerable: false`, and no partial
252question set. Such a request cannot be answered through MCP.
253
246### `t3_pending_request_respond`254### `t3_pending_request_respond`
247 255
248Answers a pending, live `user_input` request on a direct app-owned delegated256Answers 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
25When an agent delegates work to a T3-owned child conversation, it can relay that child's25When an agent delegates work to a T3-owned child conversation, it can relay that child's
26structured questions and send your answers back. This applies only to user-input questions from26structured questions and send your answers back. This applies only to user-input questions from
27that direct child. It does not let either agent approve permission requests or expand the child's27that direct child. It does not let either agent approve permission requests or expand the child's
28permission mode.28permission mode. Exceptionally large question sets remain visible as unavailable instead of being
29partially shown or answered.
29 30
30For Grok, **Always allow this session** remembers the matching command or tool input. Other31For Grok, **Always allow this session** remembers the matching command or tool input. Other
31actions still require approval.32actions 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
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3 3
4import {4import {
5 PENDING_REQUEST_MCP_MAX_QUESTION_CHARS,
5 PendingRequestMcpListInput,6 PendingRequestMcpListInput,
7 PendingRequestMcpReadResult,
6 PendingRequestMcpReadInput,8 PendingRequestMcpReadInput,
7 PendingRequestMcpRespondInput,9 PendingRequestMcpRespondInput,
8} from "./pendingRequestMcp.ts";10} from "./pendingRequestMcp.ts";
9 11
10const decodeList = Schema.decodeUnknownSync(PendingRequestMcpListInput);12const decodeList = Schema.decodeUnknownSync(PendingRequestMcpListInput);
11const decodeRead = Schema.decodeUnknownSync(PendingRequestMcpReadInput);13const decodeRead = Schema.decodeUnknownSync(PendingRequestMcpReadInput);
14const decodeReadResult = Schema.decodeUnknownSync(PendingRequestMcpReadResult);
12const decodeRespond = Schema.decodeUnknownSync(PendingRequestMcpRespondInput);15const decodeRespond = Schema.decodeUnknownSync(PendingRequestMcpRespondInput);
13 16
14describe("pending-request MCP contracts", () => {17describe("pending-request MCP contracts", () => {
@@ -22,7 +25,7 @@ describe("pending-request MCP contracts", () => { · 7 unmodified lines
22 }25 }
23 });26 });
24 27
25 it("accepts structured single and multiple-choice answers", () => {28 it("accepts structured text, numeric, boolean, and multiple-choice answers", () => {
26 expect(29 expect(
27 decodeRespond({30 decodeRespond({
28 childThreadId: "thread:child",31 childThreadId: "thread:child",
@@ -30,21 +33,69 @@ describe("pending-request MCP contracts", () => { · 1 unmodified line
30 answers: {33 answers: {
31 editor: "vim",34 editor: "vim",
32 features: ["queue controls", "attachments"],35 features: ["queue controls", "attachments"],
36 retries: 3,
37 confirmed: true,
33 },38 },
34 clientRequestId: "answer-questions-1",39 clientRequestId: "answer-questions-1",
35 }).answers,40 }).answers,
36 ).toEqual({41 ).toEqual({
37 editor: "vim",42 editor: "vim",
38 features: ["queue controls", "attachments"],43 features: ["queue controls", "attachments"],
44 retries: 3,
45 confirmed: true,
39 });46 });
40 });47 });
41 48
42 it("bounds list pages and requires stable target identifiers", () => {49 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 });
44 expect(() => decodeList({ limit: 51 })).toThrow();54 expect(() => decodeList({ limit: 51 })).toThrow();
45 expect(decodeRead({ childThreadId: "thread:child", requestId: "request:questions" })).toEqual({55 expect(decodeRead({ childThreadId: "thread:child", requestId: "request:questions" })).toEqual({
46 childThreadId: "thread:child",56 childThreadId: "thread:child",
47 requestId: "request:questions",57 requestId: "request:questions",
48 });58 });
49 });59 });
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 });
50});101});
packages/contracts/src/pendingRequestMcp.ts+36−4
@@ -11,20 +11,30 @@ import { · 10 unmodified lines
11 ThreadId,11 ThreadId,
12 TrimmedNonEmptyString,12 TrimmedNonEmptyString,
13} from "./baseSchemas.ts";13} from "./baseSchemas.ts";
14import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts";
15import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";14import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
16 15
16export const PENDING_REQUEST_MCP_MAX_QUESTIONS = 20;
17export const PENDING_REQUEST_MCP_MAX_OPTIONS_PER_QUESTION = 20;
18export const PENDING_REQUEST_MCP_MAX_QUESTION_ID_CHARS = 256;
19export const PENDING_REQUEST_MCP_MAX_HEADER_CHARS = 256;
20export const PENDING_REQUEST_MCP_MAX_QUESTION_CHARS = 4_000;
21export const PENDING_REQUEST_MCP_MAX_OPTION_LABEL_CHARS = 256;
22export const PENDING_REQUEST_MCP_MAX_OPTION_DESCRIPTION_CHARS = 2_000;
23export const PENDING_REQUEST_MCP_MAX_TOTAL_QUESTION_CHARS = 32_000;
24
17const PendingRequestMcpLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(50));25const PendingRequestMcpLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(50));
18const PendingRequestMcpClientRequestId = TrimmedNonEmptyString.check(26const PendingRequestMcpClientRequestId = TrimmedNonEmptyString.check(
19 Schema.isMaxLength(256),27 Schema.isMaxLength(256),
20).annotate({ description: "Stable idempotency key to reuse when retrying this response." });28).annotate({ description: "Stable idempotency key to reuse when retrying this response." });
21const PendingRequestMcpAnswer = Schema.Union([29const PendingRequestMcpAnswer = Schema.Union([
22 TrimmedNonEmptyString,30 TrimmedNonEmptyString,
31 Schema.Number,
32 Schema.Boolean,
23 Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(20)),33 Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(20)),
24]);34]);
25 35
26export const PendingRequestMcpListInput = Schema.Struct({36export 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({
28 description: "Opaque cursor returned by the previous list page.",38 description: "Opaque cursor returned by the previous list page.",
29 }),39 }),
30 limit: Schema.optional(PendingRequestMcpLimit),40 limit: Schema.optional(PendingRequestMcpLimit),
@@ -42,12 +52,28 @@ export const PendingRequestMcpRespondInput = Schema.Struct({ · 11 unmodified lines
42 requestId: RuntimeRequestId,52 requestId: RuntimeRequestId,
43 answers: Schema.Record(TrimmedNonEmptyString, PendingRequestMcpAnswer).annotate({53 answers: Schema.Record(TrimmedNonEmptyString, PendingRequestMcpAnswer).annotate({
44 description:54 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.",
46 }),56 }),
47 clientRequestId: Schema.optional(PendingRequestMcpClientRequestId),57 clientRequestId: Schema.optional(PendingRequestMcpClientRequestId),
48});58});
49export type PendingRequestMcpRespondInput = typeof PendingRequestMcpRespondInput.Type;59export type PendingRequestMcpRespondInput = typeof PendingRequestMcpRespondInput.Type;
50 60
61const 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
51export const PendingRequestMcpRequest = Schema.Struct({77export const PendingRequestMcpRequest = Schema.Struct({
52 taskId: NodeId,78 taskId: NodeId,
53 childThreadId: ThreadId,79 childThreadId: ThreadId,
@@ -58,7 +84,12 @@ export const PendingRequestMcpRequest = Schema.Struct({ · 4 unmodified lines
58 driverKind: ProviderDriverKind,84 driverKind: ProviderDriverKind,
59 status: Schema.Literals(["pending", "resolved", "expired", "cancelled"]),85 status: Schema.Literals(["pending", "resolved", "expired", "cancelled"]),
60 resumable: Schema.Boolean,86 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 ),
62 createdAt: IsoDateTime,93 createdAt: IsoDateTime,
63 resolvedAt: Schema.NullOr(IsoDateTime),94 resolvedAt: Schema.NullOr(IsoDateTime),
64});95});
@@ -95,6 +126,7 @@ export class PendingRequestMcpFailure extends Schema.TaggedErrorClass<PendingReq · 30 unmodified lines
95 "interaction_mode_escalation_denied",126 "interaction_mode_escalation_denied",
96 "invalid_request",127 "invalid_request",
97 "invalid_answers",128 "invalid_answers",
129 "request_payload_too_large",
98 "operation_rejected",130 "operation_rejected",
99 "orchestration_error",131 "orchestration_error",
100 ]),132 ]),