Skip to content

Commit 5f7620f

Browse files
t3dotggclaude
andcommitted
fix(server): answered questions say "already answered" instead of "not found"
t3_pending_request_respond and t3_pending_request_read now tell an answered, expired, or cancelled user-input request apart from a missing one. Closes #19984 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 066c8fc commit 5f7620f

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

‎apps/server/src/mcp/toolkits/core.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ProviderDriverKind,
1212
ProviderInstanceId,
1313
RunId,
14+
RuntimeRequestId,
1415
ThreadId,
1516
TurnItemId,
1617
type OrchestrationV2ProjectedTurnItem,
@@ -125,6 +126,85 @@ it.effect("checks capability through the production registration", () =>
125126
),
126127
);
127128

129+
it.effect("an answered question reports already answered instead of not found", () => {
130+
const shell = McpToolAccessTestkit.liveThreadShell(threadId);
131+
const requestId = RuntimeRequestId.make("answered-question");
132+
const projection = McpToolAccessTestkit.idleThreadProjection(shell);
133+
return Effect.gen(function* () {
134+
const server = yield* McpServer.McpServer;
135+
const respond = (id: string) =>
136+
server
137+
.callTool({
138+
name: "t3_pending_request_respond",
139+
arguments: { requestId: id, answers: { size: "large" } },
140+
})
141+
.pipe(
142+
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
143+
Effect.provideService(McpSchema.McpServerClient, client),
144+
);
145+
expect(declaredFailure(yield* respond(requestId))).toMatchObject({
146+
code: "invalid_request",
147+
message: "The user-input request was already answered.",
148+
});
149+
expect(declaredFailure(yield* respond("missing-question"))).toMatchObject({
150+
code: "invalid_request",
151+
message: "The pending user-input request was not found.",
152+
});
153+
}).pipe(
154+
Effect.provide(
155+
layerThreadToolkit.pipe(
156+
Layer.provideMerge(McpServer.McpServer.layer),
157+
Layer.provide(NodeCrypto.layer),
158+
Layer.provide(
159+
Layer.mock(ThreadManagement.ThreadManagementService)({
160+
getThreadShell: () => Effect.succeed(shell),
161+
getProjectThreadRecords: () =>
162+
Effect.succeed({
163+
...projection,
164+
runtimeRequests: [
165+
{
166+
id: requestId,
167+
nodeId: NodeId.make("root"),
168+
providerTurnId: null,
169+
nativeRequestRef: null,
170+
kind: "user_input",
171+
status: "resolved",
172+
responseCapability: { type: "message" },
173+
createdAt: shell.createdAt,
174+
resolvedAt: shell.updatedAt,
175+
answers: { size: "large" },
176+
},
177+
],
178+
turnItems: [
179+
{
180+
id: TurnItemId.make("answered-question-item"),
181+
threadId,
182+
runId: null,
183+
nodeId: null,
184+
providerThreadId: null,
185+
providerTurnId: null,
186+
nativeItemRef: null,
187+
parentItemId: null,
188+
ordinal: 0,
189+
status: "completed",
190+
title: null,
191+
startedAt: shell.createdAt,
192+
completedAt: shell.updatedAt,
193+
updatedAt: shell.updatedAt,
194+
type: "user_input_request",
195+
requestId,
196+
questions: [],
197+
},
198+
],
199+
}),
200+
dispatch: () => Effect.die("an answered question must not dispatch again"),
201+
}),
202+
),
203+
),
204+
),
205+
);
206+
});
207+
128208
it("bounds public command rejections and redacts internal dispatch causes", () => {
129209
const command = { commandId: CommandId.make("mcp-core-command"), commandType: "thread.settle" };
130210
expect(

‎apps/server/src/mcp/toolkits/thread/handlers.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,7 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: {
5555
}) {
5656
const context = yield* readThread(input.threadId, ["runtimeRequests", "turnItems"]);
5757
const request = context.projection.runtimeRequests.find(
58-
(request) =>
59-
request.id === input.requestId &&
60-
request.kind === "user_input" &&
61-
request.status === "pending",
58+
(request) => request.id === input.requestId && request.kind === "user_input",
6259
);
6360
const item = context.projection.turnItems.find(
6461
(item) => item.type === "user_input_request" && item.requestId === input.requestId,
@@ -68,6 +65,14 @@ const readQuestion = Effect.fn("mcp.readQuestion")(function* (input: {
6865
code: "invalid_request",
6966
message: "The pending user-input request was not found.",
7067
});
68+
if (request.status !== "pending")
69+
return yield* new OrchestratorMcpFailure({
70+
code: "invalid_request",
71+
message:
72+
request.status === "resolved"
73+
? "The user-input request was already answered."
74+
: `The user-input request is no longer pending (${request.status}).`,
75+
});
7176
return { ...context, request, item };
7277
});
7378
const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(

0 commit comments

Comments
 (0)