Unified · Split
| @@ -3,6 +3,7 @@ import { assert, describe, it, vi } from "@effect/vitest"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | EnvironmentId, |
| 5 | 5 | NodeId, |
| 6 | + PENDING_REQUEST_MCP_MAX_QUESTION_CHARS, | |
| 6 | 7 | ProjectId, |
| 7 | 8 | ProviderInstanceId, |
| 8 | 9 | ProviderSessionId, |
| @@ -21,7 +22,11 @@ import * as Ref from "effect/Ref"; · 12 unmodified lines | ||
| 21 | 22 | |
| 22 | 23 | import type { CommandReceiptV2 } from "../orchestration-v2/CommandReceiptStore.ts"; |
| 23 | 24 | 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"; | |
| 25 | 30 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 26 | 31 | import { layer, PendingRequestMcpService } from "./PendingRequestMcpService.ts"; |
| 27 | 32 | |
| @@ -150,10 +155,72 @@ function childProjection( · 122 unmodified lines | ||
| 150 | 155 | } as unknown as OrchestrationV2ThreadProjection; |
| 151 | 156 | } |
| 152 | 157 | |
| 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 | + | |
| 153 | 219 | function serviceLayer(input: { |
| 154 | 220 | readonly getParent?: () => OrchestrationV2ThreadProjection; |
| 155 | 221 | readonly getChild: () => OrchestrationV2ThreadProjection; |
| 156 | 222 | readonly getThreadProjection?: ThreadManagementService["Service"]["getThreadProjection"]; |
| 223 | + readonly getProjectThread?: ThreadManagementService["Service"]["getProjectThread"]; | |
| 157 | 224 | readonly getReceipt?: ThreadManagementService["Service"]["getCommandReceipt"]; |
| 158 | 225 | readonly dispatch?: ThreadManagementService["Service"]["dispatch"]; |
| 159 | 226 | }) { |
| @@ -165,10 +232,12 @@ function serviceLayer(input: { · 5 unmodified lines | ||
| 165 | 232 | getThreadProjection: |
| 166 | 233 | input.getThreadProjection ?? |
| 167 | 234 | (() => 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}`))), | |
| 172 | 241 | getCommandReceipt: input.getReceipt ?? (() => Effect.succeed(Option.none())), |
| 173 | 242 | dispatch: |
| 174 | 243 | input.dispatch ?? |
| @@ -191,6 +260,8 @@ describe("PendingRequestMcpService", () => { · 16 unmodified lines | ||
| 191 | 260 | assert.equal(listed.requests[0]?.childThreadId, childThreadId); |
| 192 | 261 | assert.equal(listed.requests[0]?.requestId, requestId); |
| 193 | 262 | assert.deepEqual(listed.requests[0]?.questions, questions); |
| 263 | + assert.equal(listed.requests[0]?.questionPayloadStatus, "complete"); | |
| 264 | + assert.isTrue(listed.requests[0]?.answerable); | |
| 194 | 265 | assert.equal(listed.nextCursor, null); |
| 195 | 266 | |
| 196 | 267 | const read = yield* service.read(scope, { childThreadId, requestId }); |
| @@ -201,6 +272,223 @@ describe("PendingRequestMcpService", () => { · 4 unmodified lines | ||
| 201 | 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 | 492 | it.effect("keeps projection defects out of MCP failure messages", () => |
| 205 | 493 | Effect.gen(function* () { |
| 206 | 494 | const service = yield* PendingRequestMcpService; |
| @@ -1,5 +1,13 @@ | ||
| 1 | 1 | import { |
| 2 | 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 | 11 | type OrchestrationV2RuntimeRequest, |
| 4 | 12 | type OrchestrationV2Subagent, |
| 5 | 13 | type OrchestrationV2ThreadProjection, |
| @@ -24,6 +32,12 @@ import * as Option from "effect/Option"; · 18 unmodified lines | ||
| 24 | 32 | import * as Schema from "effect/Schema"; |
| 25 | 33 | |
| 26 | 34 | import { |
| 35 | + OrchestratorProjectionError, | |
| 36 | + type OrchestratorV2Error, | |
| 37 | +} from "../orchestration-v2/Orchestrator.ts"; | |
| 38 | +import { ProjectionStoreThreadNotFoundError } from "../orchestration-v2/ProjectionStore.ts"; | |
| 39 | +import { | |
| 40 | + ThreadManagementProjectionLoadError, | |
| 27 | 41 | ThreadManagementService, |
| 28 | 42 | ThreadManagementThreadNotFoundError, |
| 29 | 43 | } from "../orchestration-v2/ThreadManagementService.ts"; |
| @@ -31,23 +45,34 @@ import { interactionModeWithinMcpCeiling, runtimeModeWithinMcpCeiling } from "./ · 1 unmodified line | ||
| 31 | 45 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 32 | 46 | |
| 33 | 47 | 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 | +} | |
| 34 | 54 | |
| 35 | 55 | function parseListCursor( |
| 36 | 56 | 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); | |
| 43 | 60 | if (match === null) { |
| 44 | 61 | return Effect.fail(failure("invalid_request", "The pending-request cursor is invalid.")); |
| 45 | 62 | } |
| 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)}`; | |
| 51 | 76 | } |
| 52 | 77 | |
| 53 | 78 | export class PendingRequestMcpService extends Context.Service< |
| @@ -76,20 +101,22 @@ function failure( · 22 unmodified lines | ||
| 76 | 101 | } |
| 77 | 102 | |
| 78 | 103 | 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 | − } | |
| 86 | 104 | return error instanceof Error ? error.message : String(error); |
| 87 | 105 | } |
| 88 | 106 | |
| 89 | 107 | 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 | +} | |
| 90 | 117 | |
| 91 | 118 | function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMcpFailure { |
| 92 | − return isThreadNotFound(error) | |
| 119 | + return isMissingChild(error) | |
| 93 | 120 | ? failure("child_not_found", `Delegated child thread '${threadId}' was not found.`) |
| 94 | 121 | : failure( |
| 95 | 122 | "orchestration_error", |
| @@ -97,16 +124,15 @@ function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMc · 1 unmodified line | ||
| 97 | 124 | ); |
| 98 | 125 | } |
| 99 | 126 | |
| 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 | + } | |
| 110 | 136 | } |
| 111 | 137 | |
| 112 | 138 | function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) { |
| @@ -115,11 +141,7 @@ function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) { · 2 unmodified lines | ||
| 115 | 141 | (task): task is OrchestrationV2Subagent & { readonly childThreadId: ThreadId } => |
| 116 | 142 | task.origin === "app_owned" && task.childThreadId !== null, |
| 117 | 143 | ) |
| 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)); | |
| 123 | 145 | } |
| 124 | 146 | |
| 125 | 147 | function findUserInputItem( |
| @@ -132,6 +154,35 @@ function findUserInputItem( · 6 unmodified lines | ||
| 132 | 154 | ); |
| 133 | 155 | } |
| 134 | 156 | |
| 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 | + | |
| 135 | 186 | function requestSummary(input: { |
| 136 | 187 | readonly task: OrchestrationV2Subagent & { readonly childThreadId: ThreadId }; |
| 137 | 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 | 207 | return Effect.succeed({ |
| 156 | 208 | taskId: input.task.id, |
| 157 | 209 | childThreadId: input.task.childThreadId, |
| @@ -162,7 +214,13 @@ function requestSummary(input: { · 4 unmodified lines | ||
| 162 | 214 | driverKind: providerThread.driver, |
| 163 | 215 | status: input.request.status, |
| 164 | 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 | 224 | createdAt: DateTime.formatIso(input.request.createdAt), |
| 167 | 225 | resolvedAt: |
| 168 | 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 | 381 | const limit = input.limit ?? DEFAULT_LIST_LIMIT; |
| 324 | 382 | const cursor = yield* parseListCursor(input.cursor); |
| 325 | 383 | 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) { | |
| 329 | 389 | const task = children[childIndex]; |
| 330 | 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 | 398 | const projection = yield* threadManagement |
| 332 | 399 | .getProjectThread({ |
| 333 | 400 | projectId: parent.thread.projectId, |
| 334 | 401 | threadId: task.childThreadId, |
| 335 | 402 | }) |
| 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) { | |
| 341 | 419 | const request = pending[requestIndex]; |
| 342 | 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 | 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 | 443 | return { |
| 351 | 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 | 451 | } satisfies PendingRequestMcpListResult; |
| 354 | 452 | }), |
| 355 | 453 | read: (scope, input) => |
| @@ -416,6 +514,12 @@ export const make = Effect.gen(function* () { · 60 unmodified lines | ||
| 416 | 514 | if (request.responseCapability.type !== "live") { |
| 417 | 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 | 523 | const expectedIds = new Set(item.questions.map((question) => question.id)); |
| 420 | 524 | const suppliedIds = Object.keys(input.answers); |
| 421 | 525 | const missing = [...expectedIds].filter((id) => !(id in input.answers)); |
| @@ -16,7 +16,7 @@ const dependencies = [McpInvocationContext.McpInvocationContext, PendingRequestM · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | export const PendingRequestListTool = Tool.make("t3_pending_request_list", { |
| 18 | 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 | 20 | parameters: PendingRequestMcpListInput, |
| 21 | 21 | success: PendingRequestMcpListResult, |
| 22 | 22 | failure: PendingRequestMcpFailure, |
| @@ -30,7 +30,7 @@ export const PendingRequestListTool = Tool.make("t3_pending_request_list", { · 7 unmodified lines | ||
| 30 | 30 | |
| 31 | 31 | export const PendingRequestReadTool = Tool.make("t3_pending_request_read", { |
| 32 | 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 | 34 | parameters: PendingRequestMcpReadInput, |
| 35 | 35 | success: PendingRequestMcpReadResult, |
| 36 | 36 | failure: PendingRequestMcpFailure, |
| @@ -44,7 +44,7 @@ export const PendingRequestReadTool = Tool.make("t3_pending_request_read", { · 7 unmodified lines | ||
| 44 | 44 | |
| 45 | 45 | export const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", { |
| 46 | 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 | 48 | parameters: PendingRequestMcpRespondInput, |
| 49 | 49 | success: PendingRequestMcpRespondResult, |
| 50 | 50 | failure: PendingRequestMcpFailure, |
| @@ -234,7 +234,10 @@ reason. Use `t3_thread_interrupt` to interrupt a later follow-up run. · 233 unmodified lines | ||
| 234 | 234 | Lists pending structured user-input questions from direct app-owned delegated |
| 235 | 235 | children of the calling thread. The bounded page follows the parent's durable |
| 236 | 236 | 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. | |
| 238 | 241 | |
| 239 | 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 | ||
| 243 | 246 | so callers can distinguish a stale response attempt from a missing request. |
| 244 | 247 | The read path is non-mutating. |
| 245 | 248 | |
| 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 | + | |
| 246 | 254 | ### `t3_pending_request_respond` |
| 247 | 255 | |
| 248 | 256 | Answers a pending, live `user_input` request on a direct app-owned delegated |
| @@ -25,7 +25,8 @@ including OpenCode and Antigravity, fall back to asking. · 24 unmodified lines | ||
| 25 | 25 | When an agent delegates work to a T3-owned child conversation, it can relay that child's |
| 26 | 26 | structured questions and send your answers back. This applies only to user-input questions from |
| 27 | 27 | 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. | |
| 29 | 30 | |
| 30 | 31 | For Grok, **Always allow this session** remembers the matching command or tool input. Other |
| 31 | 32 | actions still require approval. |
| @@ -2,13 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + PENDING_REQUEST_MCP_MAX_QUESTION_CHARS, | |
| 5 | 6 | PendingRequestMcpListInput, |
| 7 | + PendingRequestMcpReadResult, | |
| 6 | 8 | PendingRequestMcpReadInput, |
| 7 | 9 | PendingRequestMcpRespondInput, |
| 8 | 10 | } from "./pendingRequestMcp.ts"; |
| 9 | 11 | |
| 10 | 12 | const decodeList = Schema.decodeUnknownSync(PendingRequestMcpListInput); |
| 11 | 13 | const decodeRead = Schema.decodeUnknownSync(PendingRequestMcpReadInput); |
| 14 | +const decodeReadResult = Schema.decodeUnknownSync(PendingRequestMcpReadResult); | |
| 12 | 15 | const decodeRespond = Schema.decodeUnknownSync(PendingRequestMcpRespondInput); |
| 13 | 16 | |
| 14 | 17 | describe("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 | 29 | expect( |
| 27 | 30 | decodeRespond({ |
| 28 | 31 | childThreadId: "thread:child", |
| @@ -30,21 +33,69 @@ describe("pending-request MCP contracts", () => { · 1 unmodified line | ||
| 30 | 33 | answers: { |
| 31 | 34 | editor: "vim", |
| 32 | 35 | features: ["queue controls", "attachments"], |
| 36 | + retries: 3, | |
| 37 | + confirmed: true, | |
| 33 | 38 | }, |
| 34 | 39 | clientRequestId: "answer-questions-1", |
| 35 | 40 | }).answers, |
| 36 | 41 | ).toEqual({ |
| 37 | 42 | editor: "vim", |
| 38 | 43 | features: ["queue controls", "attachments"], |
| 44 | + retries: 3, | |
| 45 | + confirmed: true, | |
| 39 | 46 | }); |
| 40 | 47 | }); |
| 41 | 48 | |
| 42 | 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 | 54 | expect(() => decodeList({ limit: 51 })).toThrow(); |
| 45 | 55 | expect(decodeRead({ childThreadId: "thread:child", requestId: "request:questions" })).toEqual({ |
| 46 | 56 | childThreadId: "thread:child", |
| 47 | 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 | }); |
| @@ -11,20 +11,30 @@ import { · 10 unmodified lines | ||
| 11 | 11 | ThreadId, |
| 12 | 12 | TrimmedNonEmptyString, |
| 13 | 13 | } from "./baseSchemas.ts"; |
| 14 | −import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts"; | |
| 15 | 14 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; |
| 16 | 15 | |
| 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 | + | |
| 17 | 25 | const PendingRequestMcpLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(50)); |
| 18 | 26 | const PendingRequestMcpClientRequestId = TrimmedNonEmptyString.check( |
| 19 | 27 | Schema.isMaxLength(256), |
| 20 | 28 | ).annotate({ description: "Stable idempotency key to reuse when retrying this response." }); |
| 21 | 29 | const PendingRequestMcpAnswer = Schema.Union([ |
| 22 | 30 | TrimmedNonEmptyString, |
| 31 | + Schema.Number, | |
| 32 | + Schema.Boolean, | |
| 23 | 33 | Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(20)), |
| 24 | 34 | ]); |
| 25 | 35 | |
| 26 | 36 | 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({ | |
| 28 | 38 | description: "Opaque cursor returned by the previous list page.", |
| 29 | 39 | }), |
| 30 | 40 | limit: Schema.optional(PendingRequestMcpLimit), |
| @@ -42,12 +52,28 @@ export const PendingRequestMcpRespondInput = Schema.Struct({ · 11 unmodified lines | ||
| 42 | 52 | requestId: RuntimeRequestId, |
| 43 | 53 | answers: Schema.Record(TrimmedNonEmptyString, PendingRequestMcpAnswer).annotate({ |
| 44 | 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 | 57 | clientRequestId: Schema.optional(PendingRequestMcpClientRequestId), |
| 48 | 58 | }); |
| 49 | 59 | export type PendingRequestMcpRespondInput = typeof PendingRequestMcpRespondInput.Type; |
| 50 | 60 | |
| 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 | + | |
| 51 | 77 | export const PendingRequestMcpRequest = Schema.Struct({ |
| 52 | 78 | taskId: NodeId, |
| 53 | 79 | childThreadId: ThreadId, |
| @@ -58,7 +84,12 @@ export const PendingRequestMcpRequest = Schema.Struct({ · 4 unmodified lines | ||
| 58 | 84 | driverKind: ProviderDriverKind, |
| 59 | 85 | status: Schema.Literals(["pending", "resolved", "expired", "cancelled"]), |
| 60 | 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 | 93 | createdAt: IsoDateTime, |
| 63 | 94 | resolvedAt: Schema.NullOr(IsoDateTime), |
| 64 | 95 | }); |
| @@ -95,6 +126,7 @@ export class PendingRequestMcpFailure extends Schema.TaggedErrorClass<PendingReq · 30 unmodified lines | ||
| 95 | 126 | "interaction_mode_escalation_denied", |
| 96 | 127 | "invalid_request", |
| 97 | 128 | "invalid_answers", |
| 129 | + "request_payload_too_large", | |
| 98 | 130 | "operation_rejected", |
| 99 | 131 | "orchestration_error", |
| 100 | 132 | ]), |