From 91667051ff111ea10bd4b5bc3fd112adaf8d2d1e Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 21 Jun 2026 15:34:57 +0900 Subject: [PATCH] Centralize stale probe and pending request guards --- .../chat/app-server/actions/diagnostics.ts | 9 +-- src/features/chat/app-server/actions/host.ts | 13 ++++ .../chat/app-server/actions/metadata.ts | 14 ++-- .../chat/app-server/actions/threads.ts | 10 +-- .../chat/app-server/inbound/handler.ts | 57 ++++++++++------ .../pending-request-actions.ts | 16 ++--- .../threads/thread-management-actions.ts | 68 ++++++++++++------- .../pending-request-actions.test.ts | 2 +- .../chat/protocol/inbound/handler.test.ts | 29 ++++---- 9 files changed, 133 insertions(+), 85 deletions(-) diff --git a/src/features/chat/app-server/actions/diagnostics.ts b/src/features/chat/app-server/actions/diagnostics.ts index e228c7b5..4f8c539e 100644 --- a/src/features/chat/app-server/actions/diagnostics.ts +++ b/src/features/chat/app-server/actions/diagnostics.ts @@ -14,7 +14,7 @@ import { import { readToolInventory } from "../../../../app-server/tool-inventory"; import { readRateLimitMetadataProbe } from "../../../../app-server/query/metadata-probes"; import type { SharedServerMetadata } from "../../../../domain/server/metadata"; -import type { ChatServerActionHost } from "./host"; +import { captureChatServerActionClientScope, type ChatServerActionHost } from "./host"; interface RefreshServerDiagnosticsOptions { appServerMetadataSnapshot?: boolean; @@ -50,8 +50,9 @@ async function refreshServerDiagnostics( host: ChatServerDiagnosticsActionsHost, options: RefreshServerDiagnosticsOptions = {}, ): Promise { - const client = host.currentClient(); - if (!client) return false; + const scope = captureChatServerActionClientScope(host); + if (!scope.client) return false; + const client = scope.client; const initialDiagnostics = currentMetadataDiagnostics(host); const state = host.stateStore.getState(); @@ -82,7 +83,7 @@ async function refreshServerDiagnostics( } const [results, toolInventoryResult] = await Promise.all([Promise.all(probes), toolInventory]); - if (host.currentClient() !== client) return false; + if (scope.isStale()) return false; let diagnostics = currentMetadataDiagnostics(host); for (const result of results) { diff --git a/src/features/chat/app-server/actions/host.ts b/src/features/chat/app-server/actions/host.ts index 344c84a7..4e729cbd 100644 --- a/src/features/chat/app-server/actions/host.ts +++ b/src/features/chat/app-server/actions/host.ts @@ -6,3 +6,16 @@ export interface ChatServerActionHost { vaultPath: string; currentClient: () => AppServerClient | null; } + +export interface ChatServerActionClientScope { + client: AppServerClient | null; + isStale: () => boolean; +} + +export function captureChatServerActionClientScope(host: ChatServerActionHost): ChatServerActionClientScope { + const client = host.currentClient(); + return { + client, + isStale: () => client !== null && host.currentClient() !== client, + }; +} diff --git a/src/features/chat/app-server/actions/metadata.ts b/src/features/chat/app-server/actions/metadata.ts index 3d2c9cca..2a2f85b8 100644 --- a/src/features/chat/app-server/actions/metadata.ts +++ b/src/features/chat/app-server/actions/metadata.ts @@ -6,7 +6,7 @@ import { import { isStaleAppServerSharedQueryContextError } from "../../../../app-server/query/shared-queries"; import { cloneServerDiagnostics, diagnosticsWithProbe } from "../../../../domain/server/diagnostics"; import type { SharedServerMetadata } from "../../../../domain/server/metadata"; -import type { ChatServerActionHost } from "./host"; +import { captureChatServerActionClientScope, type ChatServerActionHost } from "./host"; export interface ChatServerMetadataActionsHost extends ChatServerActionHost { updateAppServerMetadata: (updater: (metadata: SharedServerMetadata | null) => SharedServerMetadata | null) => SharedServerMetadata | null; @@ -68,9 +68,9 @@ function applyAppServerMetadataSnapshot(host: ChatServerMetadataActionsHost): vo } async function refreshSkills(host: ChatServerMetadataActionsHost, forceReload = false): Promise { - const client = host.currentClient(); - const skills = await readSkillMetadataProbe(client, host.vaultPath, forceReload); - if (client && host.currentClient() !== client) return null; + const scope = captureChatServerActionClientScope(host); + const skills = await readSkillMetadataProbe(scope.client, host.vaultPath, forceReload); + if (scope.isStale()) return null; const next = host.updateAppServerMetadata((metadata) => { if (!metadata) return null; return { @@ -96,9 +96,9 @@ async function refreshRateLimits( host: ChatServerMetadataActionsHost, options: { preserveExistingOnFailure?: boolean } = {}, ): Promise { - const client = host.currentClient(); - const rateLimit = await readRateLimitMetadataProbe(client); - if (client && host.currentClient() !== client) return; + const scope = captureChatServerActionClientScope(host); + const rateLimit = await readRateLimitMetadataProbe(scope.client); + if (scope.isStale()) return; const preserveExistingOnFailure = options.preserveExistingOnFailure === true; const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: preserveExistingOnFailure }); if (next) { diff --git a/src/features/chat/app-server/actions/threads.ts b/src/features/chat/app-server/actions/threads.ts index 79e84d6c..e7584723 100644 --- a/src/features/chat/app-server/actions/threads.ts +++ b/src/features/chat/app-server/actions/threads.ts @@ -4,7 +4,7 @@ import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import { runtimeConfigOrDefault } from "../../domain/runtime/effective"; import { serviceTierRequestForThreadStart } from "../../application/runtime/thread-settings-update"; import { resumedThreadAction } from "../../application/state/actions"; -import type { ChatServerActionHost } from "./host"; +import { captureChatServerActionClientScope, type ChatServerActionHost } from "./host"; import type { ChatState } from "../../application/state/root-reducer"; interface StartedThreadSummary { @@ -40,15 +40,15 @@ async function startThread( preview?: string, options: { syncGoal?: boolean } = {}, ): Promise { - const client = host.currentClient(); - if (!client) return null; + const scope = captureChatServerActionClientScope(host); + if (!scope.client) return null; const requestState = host.stateStore.getState(); const serviceTier = serviceTierRequestForThreadStart( host.runtimeSnapshotForState(requestState), runtimeConfigOrDefault(requestState.connection.runtimeConfig), ); - const response = await client.startThread({ cwd: host.vaultPath, serviceTier }); - if (host.currentClient() !== client) return null; + const response = await scope.client.startThread({ cwd: host.vaultPath, serviceTier }); + if (scope.isStale()) return null; const state = host.stateStore.getState(); const fallbackPreview = preview?.trim(); const activationResponse = diff --git a/src/features/chat/app-server/inbound/handler.ts b/src/features/chat/app-server/inbound/handler.ts index 4b666f49..8df19296 100644 --- a/src/features/chat/app-server/inbound/handler.ts +++ b/src/features/chat/app-server/inbound/handler.ts @@ -14,6 +14,7 @@ import { type McpElicitationAction, type PendingApproval, type PendingMcpElicitation, + type PendingRequestId, type PendingUserInput, } from "../../domain/pending-requests/model"; import { approvalResponse } from "../requests/approval"; @@ -70,10 +71,10 @@ export interface ChatInboundHandler { handleNotification(notification: ServerNotification): void; handleServerRequest(request: ServerRequest): void; handleAppServerLog(message: string): void; - resolveApproval(approval: PendingApproval, action: ApprovalAction): void; - resolveUserInput(input: PendingUserInput, answers: Record): void; - cancelUserInput(input: PendingUserInput): void; - resolveMcpElicitation(elicitation: PendingMcpElicitation, action: McpElicitationAction): void; + resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void; + resolveUserInput(requestId: PendingRequestId, answers: Record): void; + cancelUserInput(requestId: PendingRequestId): void; + resolveMcpElicitation(requestId: PendingRequestId, action: McpElicitationAction): void; addSystemMessage(text: string): void; addStructuredSystemMessage(text: string, details: MessageStreamNoticeSection[]): void; addDedupedSystemMessage(text: string): void; @@ -101,17 +102,17 @@ export function createChatInboundHandler( handleAppServerLog: (message) => { handleAppServerLog(context, message); }, - resolveApproval: (approval, action) => { - resolveApproval(context, approval, action); + resolveApproval: (requestId, action) => { + resolveApproval(context, requestId, action); }, - resolveUserInput: (input, answers) => { - resolveUserInput(context, input, answers); + resolveUserInput: (requestId, answers) => { + resolveUserInput(context, requestId, answers); }, - cancelUserInput: (input) => { - cancelUserInput(context, input); + cancelUserInput: (requestId) => { + cancelUserInput(context, requestId); }, - resolveMcpElicitation: (elicitation, action) => { - resolveMcpElicitation(context, elicitation, action); + resolveMcpElicitation: (requestId, action) => { + resolveMcpElicitation(context, requestId, action); }, addSystemMessage: (text) => { addSystemMessage(context, text); @@ -173,8 +174,9 @@ function handleAppServerLog(context: ChatInboundHandlerContext, message: string) } } -function resolveApproval(context: ChatInboundHandlerContext, approval: PendingApproval, action: ApprovalAction): void { - if (!state(context).requests.approvals.includes(approval)) return; +function resolveApproval(context: ChatInboundHandlerContext, requestId: PendingRequestId, action: ApprovalAction): void { + const approval = pendingApproval(context, requestId); + if (!approval) return; if (!context.actions.respondToServerRequest(approval.requestId, approvalResponse(approval, action))) { addSystemMessage(context, cannotSendApprovalResponseMessage()); return; @@ -182,8 +184,9 @@ function resolveApproval(context: ChatInboundHandlerContext, approval: PendingAp dispatch(context, { type: "request/resolved", requestId: approval.requestId, resultItem: createApprovalResultItem(approval, action) }); } -function resolveUserInput(context: ChatInboundHandlerContext, input: PendingUserInput, answers: Record): void { - if (!state(context).requests.pendingUserInputs.includes(input)) return; +function resolveUserInput(context: ChatInboundHandlerContext, requestId: PendingRequestId, answers: Record): void { + const input = pendingUserInput(context, requestId); + if (!input) return; if (!context.actions.respondToServerRequest(input.requestId, userInputResponse(input, answers))) { addSystemMessage(context, cannotSendUserInputMessage()); return; @@ -195,8 +198,9 @@ function resolveUserInput(context: ChatInboundHandlerContext, input: PendingUser }); } -function cancelUserInput(context: ChatInboundHandlerContext, input: PendingUserInput): void { - if (!state(context).requests.pendingUserInputs.includes(input)) return; +function cancelUserInput(context: ChatInboundHandlerContext, requestId: PendingRequestId): void { + const input = pendingUserInput(context, requestId); + if (!input) return; if (!context.actions.rejectServerRequest(input.requestId, -32000, userCancelledInputRequestMessage())) { addSystemMessage(context, cannotCancelUserInputMessage()); return; @@ -208,8 +212,9 @@ function cancelUserInput(context: ChatInboundHandlerContext, input: PendingUserI }); } -function resolveMcpElicitation(context: ChatInboundHandlerContext, elicitation: PendingMcpElicitation, action: McpElicitationAction): void { - if (!state(context).requests.pendingMcpElicitations.includes(elicitation)) return; +function resolveMcpElicitation(context: ChatInboundHandlerContext, requestId: PendingRequestId, action: McpElicitationAction): void { + const elicitation = pendingMcpElicitation(context, requestId); + if (!elicitation) return; const content = action === "accept" ? contentForPendingMcpElicitation(elicitation, state(context).requests.mcpElicitationDrafts) : null; if (!context.actions.respondToServerRequest(elicitation.requestId, mcpElicitationResponse(action, content))) { addSystemMessage(context, cannotSendMcpElicitationMessage()); @@ -222,6 +227,18 @@ function resolveMcpElicitation(context: ChatInboundHandlerContext, elicitation: }); } +function pendingApproval(context: ChatInboundHandlerContext, requestId: PendingRequestId): PendingApproval | null { + return state(context).requests.approvals.find((approval) => approval.requestId === requestId) ?? null; +} + +function pendingUserInput(context: ChatInboundHandlerContext, requestId: PendingRequestId): PendingUserInput | null { + return state(context).requests.pendingUserInputs.find((input) => input.requestId === requestId) ?? null; +} + +function pendingMcpElicitation(context: ChatInboundHandlerContext, requestId: PendingRequestId): PendingMcpElicitation | null { + return state(context).requests.pendingMcpElicitations.find((elicitation) => elicitation.requestId === requestId) ?? null; +} + function addSystemMessage(context: ChatInboundHandlerContext, text: string): void { dispatch(context, { type: "message-stream/system-item-added", item: createSystemItem(localItemId(context, "system"), text) }); } diff --git a/src/features/chat/application/pending-requests/pending-request-actions.ts b/src/features/chat/application/pending-requests/pending-request-actions.ts index 3a89fb41..4f0051eb 100644 --- a/src/features/chat/application/pending-requests/pending-request-actions.ts +++ b/src/features/chat/application/pending-requests/pending-request-actions.ts @@ -13,10 +13,10 @@ import type { PendingRequestBlockActions, PendingRequestBlockState, PendingReque import type { ChatState } from "../state/root-reducer"; interface PendingRequestResponder { - resolveApproval: (approval: PendingApproval, action: ApprovalAction) => void; - resolveUserInput: (input: PendingUserInput, answers: Record) => void; - cancelUserInput: (input: PendingUserInput) => void; - resolveMcpElicitation: (elicitation: PendingMcpElicitation, action: McpElicitationAction) => void; + resolveApproval: (requestId: PendingRequestId, action: ApprovalAction) => void; + resolveUserInput: (requestId: PendingRequestId, answers: Record) => void; + cancelUserInput: (requestId: PendingRequestId) => void; + resolveMcpElicitation: (requestId: PendingRequestId, action: McpElicitationAction) => void; } export interface PendingRequestActionsHost { @@ -42,7 +42,7 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe const resolveApproval = (requestId: PendingRequestId, approvalAction: ApprovalAction): void => { const approval = pendingApproval(host, requestId); if (!approval) return; - host.responder.resolveApproval(approval, approvalAction); + host.responder.resolveApproval(requestId, approvalAction); commitRequestAction(host); }; @@ -50,7 +50,7 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe const input = pendingUserInput(host, requestId); if (!input) return; host.responder.resolveUserInput( - input, + requestId, answersForPendingUserInput(input, pendingRequestBlockState(host.stateStore.getState()).userInputDrafts), ); commitRequestAction(host); @@ -59,14 +59,14 @@ export function createPendingRequestActions(host: PendingRequestActionsHost): Pe const cancelUserInput = (requestId: PendingRequestId): void => { const input = pendingUserInput(host, requestId); if (!input) return; - host.responder.cancelUserInput(input); + host.responder.cancelUserInput(requestId); commitRequestAction(host); }; const resolveMcpElicitation = (requestId: PendingRequestId, action: McpElicitationAction): void => { const elicitation = pendingMcpElicitation(host, requestId); if (!elicitation) return; - host.responder.resolveMcpElicitation(elicitation, action); + host.responder.resolveMcpElicitation(requestId, action); commitRequestAction(host); }; diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index c93a5704..df4af544 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -39,6 +39,12 @@ export interface ThreadManagementActions { rollbackThread: (threadId: string) => Promise; } +interface ThreadManagementActionScope { + client: AppServerClient; + targetThreadId: string; + initialActiveThreadId: string | null; +} + export function createThreadManagementActions(host: ThreadManagementActionsHost): ThreadManagementActions { return { compactActiveThread: () => compactActiveThread(host), @@ -101,13 +107,11 @@ async function compactActiveThread(host: ThreadManagementActionsHost): Promise { - const client = await host.connectedClient(); - if (!client) return; - const initialActiveThreadId = threadManagementState(host).activeThread.id; + const scope = await captureThreadManagementActionScope(host, threadId); + if (!scope) return; try { - await client.compactThread(threadId); - if (host.currentClient() !== client) return; - if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; + await scope.client.compactThread(threadId); + if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; host.addSystemMessage(STATUS_COMPACTION_REQUESTED); host.setStatus(STATUS_COMPACTION_REQUESTED); } catch (error) { @@ -147,10 +151,9 @@ async function forkThreadFromTurn( host.addSystemMessage(finishBeforeForkingThreadsMessage()); return; } - const client = await host.connectedClient(); - if (!client) return; + const scope = await captureThreadManagementActionScope(host, threadId); + if (!scope) return; - const initialActiveThreadId = threadManagementState(host).activeThread.id; const turnsToDrop = turnId ? messageStreamTurnsAfterTurnId(threadManagementState(host).messageStream, turnId) : 0; if (turnsToDrop === null) { host.addSystemMessage(selectedTurnNotFoundForForkMessage()); @@ -159,15 +162,15 @@ async function forkThreadFromTurn( try { const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads); - const forkedThread = await forkThreadOnAppServer(client, threadId, host.vaultPath); - if (host.currentClient() !== client) return; + const forkedThread = await forkThreadOnAppServer(scope.client, threadId, host.vaultPath); + if (threadManagementScopeClientStale(host, scope)) return; const forkedThreadId = forkedThread.id; if (turnsToDrop > 0) { - await rollbackThreadOnAppServer(client, forkedThreadId, turnsToDrop); - if (host.currentClient() !== client) return; + await rollbackThreadOnAppServer(scope.client, forkedThreadId, turnsToDrop); + if (threadManagementScopeClientStale(host, scope)) return; } host.recordThreadForked(forkedThread); - if (!threadManagementStillTargetsOriginalPanel(threadManagementState(host), initialActiveThreadId, threadId)) return; + if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (sourceName) { try { if (!(await host.operations.renameThread(forkedThreadId, sourceName))) return; @@ -213,8 +216,8 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin host.addSystemMessage(interruptBeforeRollbackMessage()); return; } - const client = await host.connectedClient(); - if (!client) return; + const scope = await captureThreadManagementActionScope(host, threadId); + if (!scope) return; const candidate = messageStreamRollbackCandidate(threadManagementState(host).messageStream); if (!candidate) { @@ -224,9 +227,8 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin try { host.setStatus(STATUS_ROLLBACK_STARTING); - const snapshot = await rollbackThreadOnAppServer(client, threadId); - if (host.currentClient() !== client) return; - if (!threadManagementStillTargetsPanel(threadManagementState(host), threadId)) return; + const snapshot = await rollbackThreadOnAppServer(scope.client, threadId); + if (!threadManagementScopeStillTargetsPanel(host, scope)) return; threadManagementDispatch( host, resumedThreadActionFromActiveRuntime({ @@ -261,11 +263,29 @@ function threadManagementDispatch(host: ThreadManagementActionsHost, action: Cha host.stateStore.dispatch(action); } -function threadManagementStillTargetsPanel(state: ChatState, threadId: string): boolean { - return state.activeThread.id === threadId; +async function captureThreadManagementActionScope( + host: ThreadManagementActionsHost, + targetThreadId: string, +): Promise { + const client = await host.connectedClient(); + if (!client) return null; + return { + client, + targetThreadId, + initialActiveThreadId: threadManagementState(host).activeThread.id, + }; } -function threadManagementStillTargetsOriginalPanel(state: ChatState, initialThreadId: string | null, threadId: string): boolean { - if (!initialThreadId) return true; - return initialThreadId === threadId && state.activeThread.id === threadId; +function threadManagementScopeClientStale(host: ThreadManagementActionsHost, scope: ThreadManagementActionScope): boolean { + return host.currentClient() !== scope.client; +} + +function threadManagementScopeStillTargetsPanel(host: ThreadManagementActionsHost, scope: ThreadManagementActionScope): boolean { + return !threadManagementScopeClientStale(host, scope) && threadManagementState(host).activeThread.id === scope.targetThreadId; +} + +function threadManagementScopeStillTargetsOriginalPanel(host: ThreadManagementActionsHost, scope: ThreadManagementActionScope): boolean { + if (threadManagementScopeClientStale(host, scope)) return false; + if (!scope.initialActiveThreadId) return true; + return scope.initialActiveThreadId === scope.targetThreadId && threadManagementState(host).activeThread.id === scope.targetThreadId; } diff --git a/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts b/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts index b5a3927a..1cebb442 100644 --- a/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts +++ b/tests/features/chat/conversation/pending-requests/pending-request-actions.test.ts @@ -33,7 +33,7 @@ describe("PendingRequestActions", () => { pendingRequests.resolveUserInput(input.requestId); - expect(resolveUserInput).toHaveBeenCalledWith(input, { direction: "Left" }); + expect(resolveUserInput).toHaveBeenCalledWith(input.requestId, { direction: "Left" }); expect(refreshLiveState).toHaveBeenCalledOnce(); }); }); diff --git a/tests/features/chat/protocol/inbound/handler.test.ts b/tests/features/chat/protocol/inbound/handler.test.ts index ca21d966..bd6ae8cc 100644 --- a/tests/features/chat/protocol/inbound/handler.test.ts +++ b/tests/features/chat/protocol/inbound/handler.test.ts @@ -759,7 +759,7 @@ describe("ChatInboundHandler", () => { }); expect(handler.currentState().requests.pendingUserInputs).toHaveLength(1); - handler.resolveUserInput(expectPresent(handler.currentState().requests.pendingUserInputs[0]), { scope: "Narrow" }); + handler.resolveUserInput(42, { scope: "Narrow" }); expect(respondToServerRequest).toHaveBeenCalledWith(42, { answers: { scope: { answers: ["Narrow"] } } }); expect(handler.currentState().requests.pendingUserInputs).toEqual([]); expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ @@ -788,7 +788,7 @@ describe("ChatInboundHandler", () => { }, }); - handler.cancelUserInput(expectPresent(handler.currentState().requests.pendingUserInputs[0])); + handler.cancelUserInput(43); expect(rejectServerRequest).toHaveBeenCalledWith(43, -32000, "User cancelled input request."); expect(handler.currentState().requests.pendingUserInputs).toEqual([]); expect(chatStateMessageStreamItems(handler.currentState()).at(-1)).toMatchObject({ @@ -834,7 +834,7 @@ describe("ChatInboundHandler", () => { }); expect(handler.currentState().requests.pendingMcpElicitations).toHaveLength(1); - handler.resolveMcpElicitation(expectPresent(handler.currentState().requests.pendingMcpElicitations[0]), "accept"); + handler.resolveMcpElicitation(45, "accept"); expect(respondToServerRequest).toHaveBeenCalledWith(45, { action: "accept", @@ -873,7 +873,7 @@ describe("ChatInboundHandler", () => { }, }); - handler.resolveMcpElicitation(expectPresent(handler.currentState().requests.pendingMcpElicitations[0]), "decline"); + handler.resolveMcpElicitation(46, "decline"); expect(respondToServerRequest).toHaveBeenCalledWith(46, { action: "decline", content: null, _meta: null }); expect(handler.currentState().requests.pendingMcpElicitations).toEqual([]); @@ -884,7 +884,7 @@ describe("ChatInboundHandler", () => { }); }); - it("ignores stale requestUserInput objects with a reused request id", () => { + it("ignores missing requestUserInput ids", () => { const state = chatStateFixture(); const respondToServerRequest = vi.fn(() => true); const rejectServerRequest = vi.fn(() => true); @@ -892,10 +892,9 @@ describe("ChatInboundHandler", () => { handler.handleServerRequest(userInputRequest(44)); const current = expectPresent(handler.currentState().requests.pendingUserInputs[0]); - const stale = { ...current }; - handler.resolveUserInput(stale, { note: "stale" }); - handler.cancelUserInput(stale); + handler.resolveUserInput(45, { note: "stale" }); + handler.cancelUserInput(45); expect(respondToServerRequest).not.toHaveBeenCalled(); expect(rejectServerRequest).not.toHaveBeenCalled(); @@ -903,16 +902,15 @@ describe("ChatInboundHandler", () => { expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]); }); - it("ignores stale MCP elicitation objects with a reused request id", () => { + it("ignores missing MCP elicitation ids", () => { const state = chatStateFixture(); const respondToServerRequest = vi.fn(() => true); const handler = handlerForState(state, { respondToServerRequest }); handler.handleServerRequest(mcpElicitationRequest(47)); const current = expectPresent(handler.currentState().requests.pendingMcpElicitations[0]); - const stale = { ...current }; - handler.resolveMcpElicitation(stale, "accept"); + handler.resolveMcpElicitation(48, "accept"); expect(respondToServerRequest).not.toHaveBeenCalled(); expect(handler.currentState().requests.pendingMcpElicitations).toEqual([current]); @@ -925,7 +923,7 @@ describe("ChatInboundHandler", () => { const handler = handlerForState(state, { respondToServerRequest }); handler.handleServerRequest(expectPresent(supportedApprovalRequests()[2])); - handler.resolveApproval(expectPresent(handler.currentState().requests.approvals[0]), "accept-session"); + handler.resolveApproval(12, "accept-session"); expect(respondToServerRequest).toHaveBeenCalledWith(12, { scope: "session", @@ -948,16 +946,15 @@ describe("ChatInboundHandler", () => { }); }); - it("ignores stale approval objects with a reused request id", () => { + it("ignores missing approval ids", () => { const state = chatStateFixture(); const respondToServerRequest = vi.fn(() => true); const handler = handlerForState(state, { respondToServerRequest }); handler.handleServerRequest(expectPresent(supportedApprovalRequests()[2])); const current = expectPresent(handler.currentState().requests.approvals[0]); - const stale = { ...current }; - handler.resolveApproval(stale, "accept-session"); + handler.resolveApproval(13, "accept-session"); expect(respondToServerRequest).not.toHaveBeenCalled(); expect(handler.currentState().requests.approvals).toEqual([current]); @@ -1090,7 +1087,7 @@ describe("ChatInboundHandler", () => { const handler = handlerForState(state, { respondToServerRequest }); handler.handleServerRequest(userInputRequest(55)); - handler.resolveUserInput(expectPresent(handler.currentState().requests.pendingUserInputs[0]), { note: "Later" }); + handler.resolveUserInput(55, { note: "Later" }); expect(handler.currentState().requests.pendingUserInputs).toHaveLength(1); expect(chatStateMessageStreamItems(handler.currentState())).toEqual([