diff --git a/src/tests/websocket/listen-client-protocol.test.ts b/src/tests/websocket/listen-client-protocol.test.ts index 462f5cd..ca9f60a 100644 --- a/src/tests/websocket/listen-client-protocol.test.ts +++ b/src/tests/websocket/listen-client-protocol.test.ts @@ -261,6 +261,51 @@ describe("listen-client controlResponseCapable latch", () => { }); }); +describe("listen-client state_response pending interrupt snapshot", () => { + test("includes queued interrupted tool returns for refresh hydration", () => { + const runtime = __listenClientTestUtils.createRuntime(); + + __listenClientTestUtils.populateInterruptQueue(runtime, { + lastExecutionResults: null, + lastExecutingToolCallIds: ["call-running-1"], + lastNeedsUserInputToolCallIds: [], + agentId: "agent-1", + conversationId: "conv-1", + }); + + const snapshot = __listenClientTestUtils.buildStateResponse(runtime, 17); + + expect(snapshot.pending_interrupt).toEqual({ + agent_id: "agent-1", + conversation_id: "conv-1", + interrupted_tool_call_ids: ["call-running-1"], + tool_returns: [ + { + tool_call_id: "call-running-1", + status: "error", + tool_return: INTERRUPTED_BY_USER, + }, + ], + }); + }); + + test("does not expose pending approval denials as interrupted tool state", () => { + const runtime = __listenClientTestUtils.createRuntime(); + + __listenClientTestUtils.populateInterruptQueue(runtime, { + lastExecutionResults: null, + lastExecutingToolCallIds: [], + lastNeedsUserInputToolCallIds: ["call-awaiting-approval"], + agentId: "agent-1", + conversationId: "conv-1", + }); + + const snapshot = __listenClientTestUtils.buildStateResponse(runtime, 18); + + expect(snapshot.pending_interrupt).toBeNull(); + }); +}); + describe("listen-client capability-gated approval flow", () => { test("control_response with allow + updatedInput rewrites tool args", async () => { const runtime = __listenClientTestUtils.createRuntime(); diff --git a/src/websocket/listen-client.ts b/src/websocket/listen-client.ts index e9ba01a..0fd8a34 100644 --- a/src/websocket/listen-client.ts +++ b/src/websocket/listen-client.ts @@ -212,6 +212,12 @@ interface StateResponseMessage { request_id: string; request: ControlRequest["request"]; }>; + pending_interrupt: { + agent_id: string; + conversation_id: string; + interrupted_tool_call_ids: string[]; + tool_returns: InterruptToolReturn[]; + } | null; queue: { queue_len: number; pending_turns: number; @@ -747,6 +753,7 @@ function buildStateResponse( started_at: runtime.activeRunStartedAt, }, pending_control_requests: pendingControlRequests, + pending_interrupt: buildPendingInterruptState(runtime), queue: { queue_len: runtime.queueRuntime.length, pending_turns: runtime.pendingTurns, @@ -944,6 +951,38 @@ function asToolReturnStatus(value: unknown): "success" | "error" | null { return null; } +function buildPendingInterruptState( + runtime: ListenerRuntime, +): StateResponseMessage["pending_interrupt"] { + const context = runtime.pendingInterruptedContext; + const approvals = runtime.pendingInterruptedResults; + const interruptedToolCallIds = runtime.pendingInterruptedToolCallIds; + if ( + !context || + !approvals || + approvals.length === 0 || + !interruptedToolCallIds || + interruptedToolCallIds.length === 0 + ) { + return null; + } + + const interruptedSet = new Set(interruptedToolCallIds); + const toolReturns = extractInterruptToolReturns(approvals).filter( + (toolReturn) => interruptedSet.has(toolReturn.tool_call_id), + ); + if (toolReturns.length === 0) { + return null; + } + + return { + agent_id: context.agentId, + conversation_id: context.conversationId, + interrupted_tool_call_ids: [...interruptedToolCallIds], + tool_returns: toolReturns, + }; +} + function normalizeToolReturnValue(value: unknown): string { if (typeof value === "string") { return value; @@ -3381,6 +3420,7 @@ export function stopListenerClient(): void { export const __listenClientTestUtils = { createRuntime, stopRuntime, + buildStateResponse, emitToWS, rememberPendingApprovalBatchIds, resolvePendingApprovalBatchId,