Centralize stale probe and pending request guards

This commit is contained in:
murashit 2026-06-21 15:34:57 +09:00
parent 3b48ab0b9b
commit 91667051ff
9 changed files with 133 additions and 85 deletions

View file

@ -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<boolean> {
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) {

View file

@ -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,
};
}

View file

@ -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<SharedServerMetadata | null> {
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<void> {
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) {

View file

@ -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<StartedThreadSummary | null> {
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 =

View file

@ -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<string, string>): void;
cancelUserInput(input: PendingUserInput): void;
resolveMcpElicitation(elicitation: PendingMcpElicitation, action: McpElicitationAction): void;
resolveApproval(requestId: PendingRequestId, action: ApprovalAction): void;
resolveUserInput(requestId: PendingRequestId, answers: Record<string, string>): 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<string, string>): void {
if (!state(context).requests.pendingUserInputs.includes(input)) return;
function resolveUserInput(context: ChatInboundHandlerContext, requestId: PendingRequestId, answers: Record<string, string>): 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) });
}

View file

@ -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<string, string>) => void;
cancelUserInput: (input: PendingUserInput) => void;
resolveMcpElicitation: (elicitation: PendingMcpElicitation, action: McpElicitationAction) => void;
resolveApproval: (requestId: PendingRequestId, action: ApprovalAction) => void;
resolveUserInput: (requestId: PendingRequestId, answers: Record<string, string>) => 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);
};

View file

@ -39,6 +39,12 @@ export interface ThreadManagementActions {
rollbackThread: (threadId: string) => Promise<void>;
}
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<v
}
async function compactThread(host: ThreadManagementActionsHost, threadId: string): Promise<void> {
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<ThreadManagementActionScope | null> {
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;
}

View file

@ -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();
});
});

View file

@ -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([