diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 806f3360e9fd..02216cec576d 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -11,6 +11,7 @@ import { ProviderDriverKind, ProviderInstanceId, RunId, + RuntimeRequestId, ThreadId, TurnItemId, type OrchestrationV2ProjectedTurnItem, @@ -125,6 +126,85 @@ it.effect("checks capability through the production registration", () => ), ); +it.effect("an answered question reports already answered instead of not found", () => { + const shell = McpToolAccessTestkit.liveThreadShell(threadId); + const requestId = RuntimeRequestId.make("answered-question"); + const projection = McpToolAccessTestkit.idleThreadProjection(shell); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const respond = (id: string) => + server + .callTool({ + name: "t3_pending_request_respond", + arguments: { requestId: id, answers: { size: "large" } }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(declaredFailure(yield* respond(requestId))).toMatchObject({ + code: "invalid_request", + message: "The user-input request was already answered.", + }); + expect(declaredFailure(yield* respond("missing-question"))).toMatchObject({ + code: "invalid_request", + message: "The pending user-input request was not found.", + }); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(shell), + getProjectThreadRecords: () => + Effect.succeed({ + ...projection, + runtimeRequests: [ + { + id: requestId, + nodeId: NodeId.make("root"), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "resolved", + responseCapability: { type: "message" }, + createdAt: shell.createdAt, + resolvedAt: shell.updatedAt, + answers: { size: "large" }, + }, + ], + turnItems: [ + { + id: TurnItemId.make("answered-question-item"), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: shell.createdAt, + completedAt: shell.updatedAt, + updatedAt: shell.updatedAt, + type: "user_input_request", + requestId, + questions: [], + }, + ], + }), + dispatch: () => Effect.die("an answered question must not dispatch again"), + }), + ), + ), + ), + ); +}); + it("bounds public command rejections and redacts internal dispatch causes", () => { const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" }; expect( diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index fec17deae27c..4ab78b4fe07b 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -55,10 +55,7 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { }) { const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]); const request = context.projection.runtimeRequests.find( - (request) => - request.id === input.requestId && - request.kind === "user_input" && - request.status === "pending", + (request) => request.id === input.requestId && request.kind === "user_input", ); const item = context.projection.turnItems.find( (item) => item.type === "user_input_request" && item.requestId === input.requestId, @@ -68,6 +65,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: { code: "invalid_request", message: "The pending user-input request was not found.", }); + if (request.status !== "pending") + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + request.status === "resolved" + ? "The user-input request was already answered." + : `The user-input request is no longer pending (${request.status}).`, + }); return { ...context, request, item }; }); const writesThread =

(