Unified · Split
| @@ -3,6 +3,7 @@ import { assert, describe, it, vi } from "@effect/vitest"; · 2 unmodified lines | |||
| 3 | import { | 3 | import { |
| 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 | ||
| 22 | import type { CommandReceiptV2 } from "../orchestration-v2/CommandReceiptStore.ts"; | 23 | import type { CommandReceiptV2 } from "../orchestration-v2/CommandReceiptStore.ts"; |
| 23 | import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts"; | 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 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 30 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 26 | import { layer, PendingRequestMcpService } from "./PendingRequestMcpService.ts"; | 31 | import { 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 | ||
| 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 | function serviceLayer(input: { | 219 | function 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 === childThreadId | 236 | 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; |
| @@ -1,5 +1,13 @@ | |||
| 1 | import { | 1 | import { |
| 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 | |||
| 24 | import * as Schema from "effect/Schema"; | 32 | import * as Schema from "effect/Schema"; |
| 25 | 33 | ||
| 26 | import { | 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 | 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 | |||
| 31 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | 45 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; |
| 32 | 46 | ||
| 33 | const DEFAULT_LIST_LIMIT = 20; | 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 | function parseListCursor( | 55 | function 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 | PendingRequestMcpFailure | 59 | 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 | |||
| 74 | function listCursor(taskId: string, requestId: string | null): string { | ||
| 75 | return `v1:${encodeURIComponent(taskId)}:${requestId === null ? "" : encodeURIComponent(requestId)}`; | ||
| 51 | } | 76 | } |
| 52 | 77 | ||
| 53 | export class PendingRequestMcpService extends Context.Service< | 78 | export class PendingRequestMcpService extends Context.Service< |
| @@ -76,20 +101,22 @@ function failure( · 22 unmodified lines | |||
| 76 | } | 101 | } |
| 77 | 102 | ||
| 78 | function errorMessage(error: unknown): string { | 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 | return error instanceof Error ? error.message : String(error); | 104 | return error instanceof Error ? error.message : String(error); |
| 87 | } | 105 | } |
| 88 | 106 | ||
| 89 | const isThreadNotFound = Schema.is(ThreadManagementThreadNotFoundError); | 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 | function projectionFailure(error: unknown, threadId: ThreadId): PendingRequestMcpFailure { | 118 | function 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 | ||
| 100 | function dispatchFailure(error: unknown): PendingRequestMcpFailure { | 127 | function dispatchFailure(error: OrchestratorV2Error): PendingRequestMcpFailure { |
| 101 | const tag = | 128 | switch (error._tag) { |
| 102 | typeof error === "object" && error !== null && "_tag" in error | 129 | 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 | ||
| 112 | function directAppOwnedChildren(parent: OrchestrationV2ThreadProjection) { | 138 | function 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 | ||
| 125 | function findUserInputItem( | 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 | function requestSummary(input: { | 186 | function 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* threadManagement | 398 | 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)); |
| @@ -16,7 +16,7 @@ const dependencies = [McpInvocationContext.McpInvocationContext, PendingRequestM · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | export const PendingRequestListTool = Tool.make("t3_pending_request_list", { | 17 | export 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 | ||
| 31 | export const PendingRequestReadTool = Tool.make("t3_pending_request_read", { | 31 | export 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 | ||
| 45 | export const PendingRequestRespondTool = Tool.make("t3_pending_request_respond", { | 45 | export 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, |
| @@ -234,7 +234,10 @@ reason. Use `t3_thread_interrupt` to interrupt a later follow-up run. · 233 unmodified lines | |||
| 234 | Lists pending structured user-input questions from direct app-owned delegated | 234 | Lists pending structured user-input questions from direct app-owned delegated |
| 235 | children of the calling thread. The bounded page follows the parent's durable | 235 | children of the calling thread. The bounded page follows the parent's durable |
| 236 | sub-agent records and never scans unrelated or provider-native child threads. | 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 | ### `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 | |||
| 243 | so callers can distinguish a stale response attempt from a missing request. | 246 | so callers can distinguish a stale response attempt from a missing request. |
| 244 | The read path is non-mutating. | 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 | ### `t3_pending_request_respond` | 254 | ### `t3_pending_request_respond` |
| 247 | 255 | ||
| 248 | Answers a pending, live `user_input` request on a direct app-owned delegated | 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 | When an agent delegates work to a T3-owned child conversation, it can relay that child's | 25 | When an agent delegates work to a T3-owned child conversation, it can relay that child's |
| 26 | structured questions and send your answers back. This applies only to user-input questions from | 26 | structured questions and send your answers back. This applies only to user-input questions from |
| 27 | that direct child. It does not let either agent approve permission requests or expand the child's | 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 | For Grok, **Always allow this session** remembers the matching command or tool input. Other | 31 | For Grok, **Always allow this session** remembers the matching command or tool input. Other |
| 31 | actions still require approval. | 32 | actions still require approval. |
| @@ -2,13 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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 | ||
| 10 | const decodeList = Schema.decodeUnknownSync(PendingRequestMcpListInput); | 12 | const decodeList = Schema.decodeUnknownSync(PendingRequestMcpListInput); |
| 11 | const decodeRead = Schema.decodeUnknownSync(PendingRequestMcpReadInput); | 13 | const decodeRead = Schema.decodeUnknownSync(PendingRequestMcpReadInput); |
| 14 | const decodeReadResult = Schema.decodeUnknownSync(PendingRequestMcpReadResult); | ||
| 12 | const decodeRespond = Schema.decodeUnknownSync(PendingRequestMcpRespondInput); | 15 | const decodeRespond = Schema.decodeUnknownSync(PendingRequestMcpRespondInput); |
| 13 | 16 | ||
| 14 | describe("pending-request MCP contracts", () => { | 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 | 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 | }); |
| @@ -11,20 +11,30 @@ import { · 10 unmodified lines | |||
| 11 | ThreadId, | 11 | ThreadId, |
| 12 | TrimmedNonEmptyString, | 12 | TrimmedNonEmptyString, |
| 13 | } from "./baseSchemas.ts"; | 13 | } from "./baseSchemas.ts"; |
| 14 | import { OrchestrationV2UserInputQuestion } from "./orchestrationV2.ts"; | ||
| 15 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | 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 | const PendingRequestMcpLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(50)); | 25 | const PendingRequestMcpLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(50)); |
| 18 | const PendingRequestMcpClientRequestId = TrimmedNonEmptyString.check( | 26 | const 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." }); |
| 21 | const PendingRequestMcpAnswer = Schema.Union([ | 29 | const 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 | ||
| 26 | export const PendingRequestMcpListInput = Schema.Struct({ | 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 | 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 | }); |
| 49 | export type PendingRequestMcpRespondInput = typeof PendingRequestMcpRespondInput.Type; | 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 | export const PendingRequestMcpRequest = Schema.Struct({ | 77 | export 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 | ]), |