From 3edaf91ee45bf31832d8f6bbd2d296ca3ea7a734 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Mon, 16 Mar 2026 14:46:56 -0700 Subject: [PATCH] feat(listen): add protocol_v2, move ws server to v2 (#1387) Co-authored-by: Shubham Naik Co-authored-by: Letta Code --- src/agent/check-approval.ts | 139 +- src/helpers/diffPreview.ts | 2 +- src/runtime-context.ts | 71 + src/tests/agent/getResumeData.test.ts | 77 +- src/tests/protocol/static-sync-types.test.ts | 42 +- .../websocket/listen-client-protocol.test.ts | 1033 ++-- .../websocket/listen-interrupt-queue.test.ts | 56 +- .../websocket/listen-queue-events.test.ts | 22 +- src/types/protocol.ts | 44 +- src/types/protocol_v2.ts | 372 ++ src/websocket/helpers/listenerQueueAdapter.ts | 2 +- src/websocket/listen-client.ts | 4461 +++++++++++------ 12 files changed, 4215 insertions(+), 2106 deletions(-) create mode 100644 src/runtime-context.ts create mode 100644 src/types/protocol_v2.ts diff --git a/src/agent/check-approval.ts b/src/agent/check-approval.ts index 7b2801d..f6e2587 100644 --- a/src/agent/check-approval.ts +++ b/src/agent/check-approval.ts @@ -339,6 +339,9 @@ export async function getResumeData( ): Promise { try { const includeMessageHistory = options.includeMessageHistory ?? true; + const agentWithInContext = agent as AgentState & { + in_context_message_ids?: string[] | null; + }; let inContextMessageIds: string[] | null | undefined; let messages: Message[] = []; @@ -446,50 +449,36 @@ export async function getResumeData( messageHistory: prepareMessageHistory(messages), }; } else { - // Use agent messages API for "default" conversation or when no conversation ID - // (agent's primary message history without explicit conversation isolation) - inContextMessageIds = agent.message_ids; - - if (!inContextMessageIds || inContextMessageIds.length === 0) { - debugWarn( - "check-approval", - "No in-context messages (default/agent API) - no pending approvals", - ); - // No in-context messages = empty default conversation, don't show random history - return { - pendingApproval: null, - pendingApprovals: [], - messageHistory: [], - }; - } - - // Fetch the last in-context message directly by ID - // (We already checked inContextMessageIds.length > 0 above) - const lastInContextId = inContextMessageIds.at(-1); - if (!lastInContextId) { - throw new Error("Expected at least one in-context message"); - } - const retrievedMessages = await client.messages.retrieve(lastInContextId); - - // Fetch message history for backfill through the default conversation route. - // Default conversation uses the "default" sentinel plus agent_id as a query param. - // Wrapped in try/catch so backfill failures don't crash the CLI (e.g., older servers - // may not support this pattern) - if (includeMessageHistory && isBackfillEnabled()) { + // For the default conversation, use the agent's in-context message IDs as + // the primary anchor, mirroring the explicit-conversation path. Fall back + // to the default-conversation message stream only when that anchor is not + // available, and keep using the stream for backfill/history. + inContextMessageIds = agentWithInContext.in_context_message_ids; + const lastInContextId = inContextMessageIds?.at(-1); + let defaultConversationMessages: Message[] = []; + if ((includeMessageHistory && isBackfillEnabled()) || !lastInContextId) { + const listLimit = + includeMessageHistory && isBackfillEnabled() + ? BACKFILL_PAGE_LIMIT + : 1; try { const messagesPage = await client.agents.messages.list(agent.id, { conversation_id: "default", - limit: BACKFILL_PAGE_LIMIT, + limit: listLimit, order: "desc", }); - messages = sortChronological(messagesPage.getPaginatedItems()); - + defaultConversationMessages = sortChronological( + messagesPage.getPaginatedItems(), + ); + if (includeMessageHistory && isBackfillEnabled()) { + messages = defaultConversationMessages; + } if (isDebugEnabled()) { debugLog( "check-approval", "conversations.messages.list(default, agent_id=%s) returned %d messages", agent.id, - messages.length, + defaultConversationMessages.length, ); } } catch (backfillError) { @@ -500,18 +489,88 @@ export async function getResumeData( } } - // Find the approval_request_message variant if it exists + if (lastInContextId) { + const retrievedMessages = + await client.messages.retrieve(lastInContextId); + const messageToCheck = + retrievedMessages.find( + (msg) => msg.message_type === "approval_request_message", + ) ?? retrievedMessages[0]; + + if (messageToCheck) { + debugWarn( + "check-approval", + `Found last in-context message: ${messageToCheck.id} (type: ${messageToCheck.message_type})` + + (retrievedMessages.length > 1 + ? ` - had ${retrievedMessages.length} variants` + : ""), + ); + + if (messageToCheck.message_type === "approval_request_message") { + const { pendingApproval, pendingApprovals } = + extractApprovals(messageToCheck); + return { + pendingApproval, + pendingApprovals, + messageHistory: prepareMessageHistory(messages), + }; + } + } else { + debugWarn( + "check-approval", + `Last in-context message ${lastInContextId} not found via retrieve`, + ); + } + + return { + pendingApproval: null, + pendingApprovals: [], + messageHistory: prepareMessageHistory(messages), + }; + } + + if (isDebugEnabled()) { + debugLog( + "check-approval", + "default conversation message stream returned %d messages for agent_id=%s", + defaultConversationMessages.length, + agent.id, + ); + } + + if (defaultConversationMessages.length === 0) { + debugWarn( + "check-approval", + "No messages in default conversation stream - no pending approvals", + ); + return { + pendingApproval: null, + pendingApprovals: [], + messageHistory: [], + }; + } + + const lastDefaultMessage = + defaultConversationMessages[defaultConversationMessages.length - 1]; + const latestMessageId = lastDefaultMessage?.id; + const latestMessageVariants = latestMessageId + ? defaultConversationMessages.filter( + (msg) => msg.id === latestMessageId, + ) + : []; const messageToCheck = - retrievedMessages.find( + latestMessageVariants.find( (msg) => msg.message_type === "approval_request_message", - ) ?? retrievedMessages[0]; + ) ?? + latestMessageVariants[latestMessageVariants.length - 1] ?? + lastDefaultMessage; if (messageToCheck) { debugWarn( "check-approval", `Found last in-context message: ${messageToCheck.id} (type: ${messageToCheck.message_type})` + - (retrievedMessages.length > 1 - ? ` - had ${retrievedMessages.length} variants` + (latestMessageVariants.length > 1 + ? ` - had ${latestMessageVariants.length} variants` : ""), ); @@ -527,7 +586,7 @@ export async function getResumeData( } else { debugWarn( "check-approval", - `Last in-context message ${lastInContextId} not found via retrieve (default/agent API)`, + "Last default conversation message not found after list()", ); } diff --git a/src/helpers/diffPreview.ts b/src/helpers/diffPreview.ts index a0cb436..8fc5e61 100644 --- a/src/helpers/diffPreview.ts +++ b/src/helpers/diffPreview.ts @@ -6,7 +6,7 @@ import path, { basename } from "node:path"; import type { AdvancedDiffResult, AdvancedHunk } from "../cli/helpers/diff"; -import type { DiffHunk, DiffHunkLine, DiffPreview } from "../types/protocol"; +import type { DiffHunk, DiffHunkLine, DiffPreview } from "../types/protocol_v2"; function parseHunkLinePrefix(raw: string): DiffHunkLine | null { if (raw.length === 0) { diff --git a/src/runtime-context.ts b/src/runtime-context.ts new file mode 100644 index 0000000..e315f7d --- /dev/null +++ b/src/runtime-context.ts @@ -0,0 +1,71 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { SkillSource } from "./agent/skills"; + +export type RuntimePermissionMode = + | "default" + | "acceptEdits" + | "plan" + | "bypassPermissions"; + +export interface RuntimeContextSnapshot { + agentId?: string | null; + conversationId?: string | null; + skillsDirectory?: string | null; + skillSources?: SkillSource[]; + workingDirectory?: string | null; + permissionMode?: RuntimePermissionMode; + planFilePath?: string | null; + modeBeforePlan?: RuntimePermissionMode | null; +} + +const runtimeContextStorage = new AsyncLocalStorage(); + +export function getRuntimeContext(): RuntimeContextSnapshot | undefined { + return runtimeContextStorage.getStore(); +} + +export function runWithRuntimeContext( + snapshot: RuntimeContextSnapshot, + fn: () => T, +): T { + const parent = runtimeContextStorage.getStore(); + return runtimeContextStorage.run( + { + ...parent, + ...snapshot, + ...(snapshot.skillSources + ? { skillSources: [...snapshot.skillSources] } + : {}), + }, + fn, + ); +} + +export function runOutsideRuntimeContext(fn: () => T): T { + return runtimeContextStorage.exit(fn); +} + +export function updateRuntimeContext( + update: Partial, +): void { + const current = runtimeContextStorage.getStore(); + if (!current) { + return; + } + + Object.assign( + current, + update, + update.skillSources && { + skillSources: [...update.skillSources], + }, + ); +} + +export function getCurrentWorkingDirectory(): string { + const workingDirectory = runtimeContextStorage.getStore()?.workingDirectory; + if (typeof workingDirectory === "string" && workingDirectory.length > 0) { + return workingDirectory; + } + return process.env.USER_CWD || process.cwd(); +} diff --git a/src/tests/agent/getResumeData.test.ts b/src/tests/agent/getResumeData.test.ts index 80e3af5..bc281a2 100644 --- a/src/tests/agent/getResumeData.test.ts +++ b/src/tests/agent/getResumeData.test.ts @@ -4,12 +4,16 @@ import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents" import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; import { getResumeData } from "../../agent/check-approval"; -function makeAgent(overrides: Partial = {}): AgentState { +type ResumeAgentState = AgentState & { + in_context_message_ids?: string[] | null; +}; + +function makeAgent(overrides: Partial = {}): AgentState { return { id: "agent-test", message_ids: ["msg-last"], ...overrides, - } as AgentState; + } as ResumeAgentState; } function makeApprovalMessage(id = "msg-last"): Message { @@ -74,7 +78,9 @@ describe("getResumeData", () => { const conversationsList = mock(async () => ({ getPaginatedItems: () => [], })); - const agentsList = mock(async () => ({ items: [] })); + const agentsList = mock(async () => ({ + getPaginatedItems: () => [makeApprovalMessage()], + })); const messagesRetrieve = mock(async () => [makeApprovalMessage()]); const client = { @@ -88,7 +94,10 @@ describe("getResumeData", () => { const resume = await getResumeData( client, - makeAgent({ message_ids: ["msg-last"] }), + makeAgent({ + message_ids: ["msg-last"], + in_context_message_ids: ["msg-last"], + }), "default", { includeMessageHistory: false }, ); @@ -99,6 +108,60 @@ describe("getResumeData", () => { expect(resume.messageHistory).toEqual([]); }); + test("default conversation resume uses in-context ids instead of stale agent.message_ids", async () => { + const agentsList = mock(async () => ({ + getPaginatedItems: () => [makeApprovalMessage("msg-default-latest")], + })); + const messagesRetrieve = mock(async () => [ + makeApprovalMessage("msg-live"), + ]); + + const client = { + agents: { messages: { list: agentsList } }, + messages: { retrieve: messagesRetrieve }, + } as unknown as Letta; + + const resume = await getResumeData( + client, + makeAgent({ + message_ids: ["msg-stale"], + in_context_message_ids: ["msg-live"], + }), + "default", + { includeMessageHistory: false }, + ); + + expect(messagesRetrieve).toHaveBeenCalledWith("msg-live"); + expect(messagesRetrieve).toHaveBeenCalledTimes(1); + expect(agentsList).toHaveBeenCalledTimes(0); + expect(resume.pendingApprovals).toHaveLength(1); + expect(resume.pendingApprovals[0]?.toolCallId).toBe("tool-1"); + }); + + test("default conversation falls back to default conversation stream when in-context ids are unavailable", async () => { + const agentsList = mock(async () => ({ + getPaginatedItems: () => [makeApprovalMessage("msg-default-latest")], + })); + const messagesRetrieve = mock(async () => [makeUserMessage("msg-stale")]); + + const client = { + agents: { messages: { list: agentsList } }, + messages: { retrieve: messagesRetrieve }, + } as unknown as Letta; + + const resume = await getResumeData( + client, + makeAgent({ in_context_message_ids: [] }), + "default", + { includeMessageHistory: false }, + ); + + expect(messagesRetrieve).toHaveBeenCalledTimes(0); + expect(agentsList).toHaveBeenCalledTimes(1); + expect(resume.pendingApprovals).toHaveLength(1); + expect(resume.pendingApprovals[0]?.toolCallId).toBe("tool-1"); + }); + test("default behavior keeps backfill enabled when options are omitted", async () => { const conversationsRetrieve = mock(async () => ({ in_context_message_ids: ["msg-last"], @@ -119,7 +182,11 @@ describe("getResumeData", () => { messages: { retrieve: messagesRetrieve }, } as unknown as Letta; - const resume = await getResumeData(client, makeAgent(), "default"); + const resume = await getResumeData( + client, + makeAgent({ in_context_message_ids: ["msg-last"] }), + "default", + ); expect(messagesRetrieve).toHaveBeenCalledTimes(1); expect(agentsList).toHaveBeenCalledTimes(1); diff --git a/src/tests/protocol/static-sync-types.test.ts b/src/tests/protocol/static-sync-types.test.ts index 5d90c52..fe8cc44 100644 --- a/src/tests/protocol/static-sync-types.test.ts +++ b/src/tests/protocol/static-sync-types.test.ts @@ -2,7 +2,7 @@ * Tests for the static transcript sync protocol types (LSS1). * * Verifies structural correctness, discriminant exhaustiveness, and - * membership in WireMessage / WsProtocolEvent unions. + * membership in the legacy WireMessage union. */ import { describe, expect, test } from "bun:test"; @@ -13,7 +13,6 @@ import type { TranscriptSupplementMessage, WireMessage, } from "../../types/protocol"; -import type { WsProtocolEvent } from "../../websocket/listen-client"; // ── Helpers ─────────────────────────────────────────────────────── @@ -186,45 +185,6 @@ describe("WireMessage union membership", () => { }); }); -describe("WsProtocolEvent union membership", () => { - test("TranscriptBackfillMessage is assignable to WsProtocolEvent", () => { - const msg: WsProtocolEvent = { - ...ENVELOPE, - type: "transcript_backfill", - messages: [], - is_final: true, - }; - expect(msg.type).toBe("transcript_backfill"); - }); - - test("QueueSnapshotMessage is assignable to WsProtocolEvent", () => { - const msg: WsProtocolEvent = { - ...ENVELOPE, - type: "queue_snapshot", - items: [], - }; - expect(msg.type).toBe("queue_snapshot"); - }); - - test("SyncCompleteMessage is assignable to WsProtocolEvent", () => { - const msg: WsProtocolEvent = { - ...ENVELOPE, - type: "sync_complete", - had_pending_turn: false, - }; - expect(msg.type).toBe("sync_complete"); - }); - - test("TranscriptSupplementMessage is assignable to WsProtocolEvent", () => { - const msg: WsProtocolEvent = { - ...ENVELOPE, - type: "transcript_supplement", - messages: [], - }; - expect(msg.type).toBe("transcript_supplement"); - }); -}); - // ── Discriminant exhaustiveness ─────────────────────────────────── describe("type discriminants are unique across all four types", () => { diff --git a/src/tests/websocket/listen-client-protocol.test.ts b/src/tests/websocket/listen-client-protocol.test.ts index ad8debc..10a5bf3 100644 --- a/src/tests/websocket/listen-client-protocol.test.ts +++ b/src/tests/websocket/listen-client-protocol.test.ts @@ -7,9 +7,13 @@ import WebSocket from "ws"; import { buildConversationMessagesCreateRequestBody } from "../../agent/message"; import { INTERRUPTED_BY_USER } from "../../constants"; import type { MessageQueueItem } from "../../queue/queueRuntime"; -import type { ControlRequest, ControlResponseBody } from "../../types/protocol"; +import type { + ApprovalResponseBody, + ControlRequest, +} from "../../types/protocol_v2"; import { __listenClientTestUtils, + emitInterruptedStatusDelta, parseServerMessage, rejectPendingApprovalResolvers, requestApprovalOverWS, @@ -58,58 +62,135 @@ function makeControlRequest(requestId: string): ControlRequest { }; } -function makeSuccessResponse(requestId: string): ControlResponseBody { +function makeSuccessResponse(requestId: string): ApprovalResponseBody { return { - subtype: "success", request_id: requestId, - response: { behavior: "allow" }, + decision: { behavior: "allow" }, }; } describe("listen-client parseServerMessage", () => { - test("parses valid control_response with required fields", () => { + test("parses valid input approval_response command", () => { const parsed = parseServerMessage( Buffer.from( JSON.stringify({ - type: "control_response", - response: { subtype: "success", request_id: "perm-1" }, + type: "input", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + payload: { + kind: "approval_response", + request_id: "perm-1", + decision: { behavior: "allow" }, + }, }), ), ); expect(parsed).not.toBeNull(); - expect(parsed?.type).toBe("control_response"); + expect(parsed?.type).toBe("input"); }); - test("rejects invalid control_response payloads", () => { + test("classifies invalid input approval_response payloads", () => { const missingResponse = parseServerMessage( - Buffer.from(JSON.stringify({ type: "control_response" })), + Buffer.from( + JSON.stringify({ + type: "input", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + payload: { kind: "approval_response" }, + }), + ), ); - expect(missingResponse).toBeNull(); + expect(missingResponse).not.toBeNull(); + expect(missingResponse?.type).toBe("__invalid_input"); const missingRequestId = parseServerMessage( Buffer.from( JSON.stringify({ - type: "control_response", - response: { subtype: "success" }, + type: "input", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + payload: { + kind: "approval_response", + decision: { behavior: "allow" }, + }, }), ), ); - expect(missingRequestId).toBeNull(); + expect(missingRequestId).not.toBeNull(); + expect(missingRequestId?.type).toBe("__invalid_input"); }); - test("keeps backward compatibility for message, pong, mode_change", () => { + test("classifies unknown input payload kinds for explicit protocol rejection", () => { + const unknownKind = parseServerMessage( + Buffer.from( + JSON.stringify({ + type: "input", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + payload: { kind: "slash_command", command: "/model" }, + }), + ), + ); + expect(unknownKind).not.toBeNull(); + expect(unknownKind?.type).toBe("__invalid_input"); + }); + + test("accepts input create_message and change_device_state", () => { const msg = parseServerMessage( - Buffer.from(JSON.stringify({ type: "message", messages: [] })), + Buffer.from( + JSON.stringify({ + type: "input", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + payload: { kind: "create_message", messages: [] }, + }), + ), ); - const pong = parseServerMessage( - Buffer.from(JSON.stringify({ type: "pong" })), + const changeDeviceState = parseServerMessage( + Buffer.from( + JSON.stringify({ + type: "change_device_state", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + payload: { mode: "default" }, + }), + ), ); - const modeChange = parseServerMessage( - Buffer.from(JSON.stringify({ type: "mode_change", mode: "default" })), + expect(msg?.type).toBe("input"); + expect(changeDeviceState?.type).toBe("change_device_state"); + }); + + test("parses abort_message as the canonical abort command", () => { + const abort = parseServerMessage( + Buffer.from( + JSON.stringify({ + type: "abort_message", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + request_id: "abort-1", + run_id: "run-1", + }), + ), ); - expect(msg?.type).toBe("message"); - expect(pong?.type).toBe("pong"); - expect(modeChange?.type).toBe("mode_change"); + expect(abort?.type).toBe("abort_message"); + }); + + test("parses sync as the canonical state replay command", () => { + const sync = parseServerMessage( + Buffer.from( + JSON.stringify({ + type: "sync", + runtime: { agent_id: "agent-1", conversation_id: "default" }, + }), + ), + ); + expect(sync?.type).toBe("sync"); + }); + + test("rejects legacy cancel_run in hard-cut v2 protocol", () => { + const legacyCancel = parseServerMessage( + Buffer.from( + JSON.stringify({ + type: "cancel_run", + request_id: "cancel-1", + run_id: "run-1", + }), + ), + ); + expect(legacyCancel).toBeNull(); }); }); @@ -133,8 +214,8 @@ describe("listen-client approval resolver wiring", () => { ); expect(resolved).toBe(true); await expect(pending).resolves.toMatchObject({ - subtype: "success", request_id: requestId, + decision: { behavior: "allow" }, }); expect(runtime.pendingApprovalResolvers.size).toBe(0); }); @@ -178,10 +259,10 @@ describe("listen-client approval resolver wiring", () => { test("cleanup rejects all pending resolvers", async () => { const runtime = __listenClientTestUtils.createRuntime(); - const first = new Promise((resolve, reject) => { + const first = new Promise((resolve, reject) => { runtime.pendingApprovalResolvers.set("perm-a", { resolve, reject }); }); - const second = new Promise((resolve, reject) => { + const second = new Promise((resolve, reject) => { runtime.pendingApprovalResolvers.set("perm-b", { resolve, reject }); }); @@ -193,7 +274,7 @@ describe("listen-client approval resolver wiring", () => { test("stopRuntime rejects pending resolvers even when callbacks are suppressed", async () => { const runtime = __listenClientTestUtils.createRuntime(); - const pending = new Promise((resolve, reject) => { + const pending = new Promise((resolve, reject) => { runtime.pendingApprovalResolvers.set("perm-stop", { resolve, reject }); }); const socket = new MockSocket(WebSocket.OPEN); @@ -208,6 +289,32 @@ describe("listen-client approval resolver wiring", () => { }); }); +describe("listen-client protocol emission", () => { + test("does not throw when protocol emission send fails", () => { + const runtime = __listenClientTestUtils.createRuntime(); + const socket = new MockSocket(WebSocket.OPEN); + runtime.activeAgentId = "agent-1"; + runtime.activeConversationId = "default"; + socket.sendImpl = () => { + throw new Error("socket send failed"); + }; + const originalConsoleError = console.error; + console.error = () => {}; + + try { + expect(() => + __listenClientTestUtils.emitDeviceStatusUpdate( + socket as unknown as WebSocket, + runtime, + ), + ).not.toThrow(); + expect(socket.sentPayloads).toHaveLength(0); + } finally { + console.error = originalConsoleError; + } + }); +}); + describe("listen-client requestApprovalOverWS", () => { test("rejects immediately when socket is not open", async () => { const runtime = __listenClientTestUtils.createRuntime(); @@ -246,7 +353,7 @@ describe("listen-client requestApprovalOverWS", () => { }); describe("listen-client conversation-scoped protocol events", () => { - test("queue lifecycle events carry agent_id and conversation_id from the queued item", () => { + test("queue enqueue/block updates loop status with runtime scope instead of stream_delta", async () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); runtime.socket = socket as unknown as WebSocket; @@ -264,41 +371,31 @@ describe("listen-client conversation-scoped protocol events", () => { runtime.queueRuntime.tryDequeue("runtime_busy"); - const enqueued = JSON.parse(socket.sentPayloads[0] as string); - expect(enqueued.type).toBe("queue_item_enqueued"); - expect(enqueued.agent_id).toBe("agent-default"); - expect(enqueued.conversation_id).toBe("default"); + // Flush microtask queue (update_queue is debounced via queueMicrotask) + await Promise.resolve(); - const blocked = JSON.parse(socket.sentPayloads[1] as string); - expect(blocked.type).toBe("queue_blocked"); - expect(blocked.agent_id).toBe("agent-default"); - expect(blocked.conversation_id).toBe("default"); - }); - - test("cancel_ack includes agent_id and conversation_id", () => { - const runtime = __listenClientTestUtils.createRuntime(); - const socket = new MockSocket(WebSocket.OPEN); - runtime.activeAgentId = "agent-123"; - runtime.activeConversationId = "default"; - runtime.activeRunId = "run-123"; - - __listenClientTestUtils.emitCancelAck( - socket as unknown as WebSocket, - runtime, - { - requestId: "cancel-1", - accepted: true, - }, + const outbound = socket.sentPayloads.map((payload) => + JSON.parse(payload as string), ); - - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("cancel_ack"); - expect(sent.agent_id).toBe("agent-123"); - expect(sent.conversation_id).toBe("default"); - expect(sent.run_id).toBe("run-123"); + const queueUpdate = outbound.find( + (payload) => + payload.type === "update_queue" && + payload.runtime.agent_id === "agent-default" && + payload.runtime.conversation_id === "default" && + payload.queue?.length === 1, + ); + expect(queueUpdate).toBeDefined(); + expect( + outbound.some( + (payload) => + payload.type === "stream_delta" && + typeof payload.delta?.type === "string" && + payload.delta.type.startsWith("queue_"), + ), + ).toBe(false); }); - test("queue_batch_dequeued keeps the batch scope", () => { + test("queue dequeue keeps scope through update_queue runtime envelope", async () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); runtime.socket = socket as unknown as WebSocket; @@ -313,42 +410,252 @@ describe("listen-client conversation-scoped protocol events", () => { }; runtime.queueRuntime.enqueue(input); - runtime.queueRuntime.tryDequeue(null); - const dequeued = JSON.parse(socket.sentPayloads[1] as string); - expect(dequeued.type).toBe("queue_batch_dequeued"); - expect(dequeued.agent_id).toBe("agent-xyz"); - expect(dequeued.conversation_id).toBe("conv-xyz"); + // Flush microtask queue (update_queue is debounced via queueMicrotask) + await Promise.resolve(); + + const outbound = socket.sentPayloads.map((payload) => + JSON.parse(payload as string), + ); + // With microtask coalescing, enqueue + dequeue in same tick + // produces a single update_queue with the final state (0 items) + const dequeued = outbound.find( + (payload) => + payload.type === "update_queue" && + payload.runtime.agent_id === "agent-xyz" && + payload.runtime.conversation_id === "conv-xyz" && + Array.isArray(payload.queue) && + payload.queue.length === 0, + ); + expect(dequeued).toBeDefined(); }); }); -describe("listen-client state_response control protocol", () => { - test("always advertises control_response capability", () => { +describe("listen-client v2 status builders", () => { + test("buildLoopStatus defaults to WAITING_ON_INPUT with no active run", () => { const runtime = __listenClientTestUtils.createRuntime(); - const snapshot = __listenClientTestUtils.buildStateResponse(runtime, 1); - expect(snapshot.control_response_capable).toBe(true); + const loopStatus = __listenClientTestUtils.buildLoopStatus(runtime); + expect(loopStatus.status).toBe("WAITING_ON_INPUT"); + expect(loopStatus.active_run_ids).toEqual([]); + // queue is now separate from loopStatus — verify via buildQueueSnapshot + const queueSnapshot = __listenClientTestUtils.buildQueueSnapshot(runtime); + expect(queueSnapshot).toEqual([]); }); - test("advertises tool lifecycle capability for device clients", () => { + test("buildDeviceStatus includes the effective working directory", () => { const runtime = __listenClientTestUtils.createRuntime(); - const snapshot = __listenClientTestUtils.buildStateResponse(runtime, 2); - expect(snapshot.tool_lifecycle_capable).toBe(true); + const deviceStatus = __listenClientTestUtils.buildDeviceStatus(runtime); + expect(typeof deviceStatus.current_working_directory).toBe("string"); + expect( + (deviceStatus.current_working_directory ?? "").length, + ).toBeGreaterThan(0); + expect(deviceStatus.current_toolset_preference).toBe("auto"); }); - test("includes the effective working directory", () => { + test("resolveRuntimeScope returns null until a real runtime is bound", () => { const runtime = __listenClientTestUtils.createRuntime(); - const snapshot = __listenClientTestUtils.buildStateResponse(runtime, 1); + expect(__listenClientTestUtils.resolveRuntimeScope(runtime)).toBeNull(); - expect(typeof snapshot.cwd).toBe("string"); - expect(snapshot.cwd.length).toBeGreaterThan(0); - expect(snapshot.configured_cwd).toBe(snapshot.cwd); - expect(snapshot.active_turn_cwd).toBeNull(); - expect(snapshot.cwd_agent_id).toBeNull(); - expect(snapshot.cwd_conversation_id).toBe("default"); + runtime.activeAgentId = "agent-1"; + runtime.activeConversationId = "default"; + expect(__listenClientTestUtils.resolveRuntimeScope(runtime)).toEqual({ + agent_id: "agent-1", + conversation_id: "default", + }); }); - test("scopes configured and active cwd to the requested agent and conversation", () => { + test("does not emit bootstrap status updates with __unknown_agent__ runtime", () => { + const runtime = __listenClientTestUtils.createRuntime(); + const socket = new MockSocket(WebSocket.OPEN); + + __listenClientTestUtils.emitDeviceStatusUpdate( + socket as unknown as WebSocket, + runtime, + ); + __listenClientTestUtils.emitLoopStatusUpdate( + socket as unknown as WebSocket, + runtime, + ); + + expect(socket.sentPayloads).toHaveLength(0); + + runtime.activeAgentId = "agent-1"; + runtime.activeConversationId = "default"; + + __listenClientTestUtils.emitDeviceStatusUpdate( + socket as unknown as WebSocket, + runtime, + ); + __listenClientTestUtils.emitLoopStatusUpdate( + socket as unknown as WebSocket, + runtime, + ); + + const outbound = socket.sentPayloads.map((payload) => + JSON.parse(payload as string), + ); + expect(outbound).toHaveLength(2); + expect(outbound[0].runtime).toEqual({ + agent_id: "agent-1", + conversation_id: "default", + }); + expect(outbound[1].runtime).toEqual({ + agent_id: "agent-1", + conversation_id: "default", + }); + }); + + test("sync replays device, loop, and queue state for the requested runtime", () => { + const runtime = __listenClientTestUtils.createRuntime(); + const socket = new MockSocket(WebSocket.OPEN); + const queueInput = { + clientMessageId: "cm-1", + agentId: "agent-1", + conversationId: "default", + kind: "message" as const, + source: "user" as const, + content: "hello", + } as Parameters[0]; + + runtime.queueRuntime.enqueue(queueInput); + + __listenClientTestUtils.emitStateSync( + socket as unknown as WebSocket, + runtime, + { + agent_id: "agent-1", + conversation_id: "default", + }, + ); + + const outbound = socket.sentPayloads.map((payload) => + JSON.parse(payload as string), + ); + expect(outbound.map((message) => message.type)).toEqual([ + "update_device_status", + "update_loop_status", + "update_queue", + ]); + expect( + outbound.every((message) => message.runtime.agent_id === "agent-1"), + ).toBe(true); + expect( + outbound.every( + (message) => message.runtime.conversation_id === "default", + ), + ).toBe(true); + expect(outbound[2].queue).toEqual([ + expect.objectContaining({ + id: "q-1", + client_message_id: "cm-1", + kind: "message", + }), + ]); + }); + + test("recovered approvals surface as pending control requests and WAITING_ON_APPROVAL", () => { + const runtime = __listenClientTestUtils.createRuntime(); + const socket = new MockSocket(WebSocket.OPEN); + const requestId = "perm-tool-call-1"; + + runtime.recoveredApprovalState = { + agentId: "agent-1", + conversationId: "default", + approvalsByRequestId: new Map([ + [ + requestId, + { + approval: {} as never, + controlRequest: makeControlRequest(requestId), + }, + ], + ]), + pendingRequestIds: new Set([requestId]), + responsesByRequestId: new Map(), + }; + + __listenClientTestUtils.emitStateSync( + socket as unknown as WebSocket, + runtime, + { + agent_id: "agent-1", + conversation_id: "default", + }, + ); + + const outbound = socket.sentPayloads.map((payload) => + JSON.parse(payload as string), + ); + expect(outbound[0].device_status.pending_control_requests).toEqual([ + { + request_id: requestId, + request: makeControlRequest(requestId).request, + }, + ]); + expect(outbound[1].loop_status).toEqual({ + status: "WAITING_ON_APPROVAL", + active_run_ids: [], + }); + }); + + test("sync ignores backend recovered approvals while a live turn is already processing", async () => { + const runtime = __listenClientTestUtils.createRuntime(); + runtime.isProcessing = true; + runtime.loopStatus = "PROCESSING_API_RESPONSE"; + runtime.activeAgentId = "agent-1"; + runtime.activeConversationId = "default"; + runtime.recoveredApprovalState = { + agentId: "agent-1", + conversationId: "default", + approvalsByRequestId: new Map([ + [ + "perm-stale", + { + approval: {} as never, + controlRequest: makeControlRequest("perm-stale"), + }, + ], + ]), + pendingRequestIds: new Set(["perm-stale"]), + responsesByRequestId: new Map(), + }; + + await __listenClientTestUtils.recoverApprovalStateForSync?.(runtime, { + agent_id: "agent-1", + conversation_id: "default", + }); + + expect(runtime.recoveredApprovalState).toBeNull(); + }); + + test("starting a live turn clears stale recovered approvals for the same scope", () => { + const runtime = __listenClientTestUtils.createRuntime(); + runtime.recoveredApprovalState = { + agentId: "agent-1", + conversationId: "default", + approvalsByRequestId: new Map([ + [ + "perm-stale", + { + approval: {} as never, + controlRequest: makeControlRequest("perm-stale"), + }, + ], + ]), + pendingRequestIds: new Set(["perm-stale"]), + responsesByRequestId: new Map(), + }; + + __listenClientTestUtils.clearRecoveredApprovalStateForScope(runtime, { + agent_id: "agent-1", + conversation_id: "default", + }); + + expect(runtime.recoveredApprovalState).toBeNull(); + }); + + test("scopes working directory to requested agent and conversation", () => { const runtime = __listenClientTestUtils.createRuntime(); __listenClientTestUtils.setConversationWorkingDirectory( runtime, @@ -362,36 +669,23 @@ describe("listen-client state_response control protocol", () => { "default", "/repo/b", ); - runtime.activeAgentId = "agent-a"; - runtime.activeConversationId = "conv-a"; - runtime.activeWorkingDirectory = "/repo/a"; - const activeSnapshot = __listenClientTestUtils.buildStateResponse( - runtime, - 2, - "agent-a", - "conv-a", - ); - expect(activeSnapshot.configured_cwd).toBe("/repo/a"); - expect(activeSnapshot.active_turn_cwd).toBe("/repo/a"); - expect(activeSnapshot.cwd_agent_id).toBe("agent-a"); - expect(activeSnapshot.cwd_conversation_id).toBe("conv-a"); + const activeStatus = __listenClientTestUtils.buildDeviceStatus(runtime, { + agent_id: "agent-a", + conversation_id: "conv-a", + }); + expect(activeStatus.current_working_directory).toBe("/repo/a"); - const defaultSnapshot = __listenClientTestUtils.buildStateResponse( - runtime, - 3, - "agent-b", - "default", - ); - expect(defaultSnapshot.configured_cwd).toBe("/repo/b"); - expect(defaultSnapshot.active_turn_cwd).toBeNull(); - expect(defaultSnapshot.cwd_agent_id).toBe("agent-b"); - expect(defaultSnapshot.cwd_conversation_id).toBe("default"); + const defaultStatus = __listenClientTestUtils.buildDeviceStatus(runtime, { + agent_id: "agent-b", + conversation_id: "default", + }); + expect(defaultStatus.current_working_directory).toBe("/repo/b"); }); }); describe("listen-client cwd change handling", () => { - test("resolves relative cwd changes against the conversation cwd and preserves active turn cwd", async () => { + test("resolves relative cwd changes against the conversation cwd and emits update_device_status", async () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); const tempRoot = await mkdtemp(join(os.tmpdir(), "letta-listen-cwd-")); @@ -416,7 +710,6 @@ describe("listen-client cwd change handling", () => { await __listenClientTestUtils.handleCwdChange( { - type: "change_cwd", agentId: "agent-1", conversationId: "conv-1", cwd: "../client", @@ -433,28 +726,49 @@ describe("listen-client cwd change handling", () => { ), ).toBe(normalizedClientDir); - expect(socket.sentPayloads).toHaveLength(2); - const changed = JSON.parse(socket.sentPayloads[0] as string); - expect(changed.type).toBe("cwd_changed"); - expect(changed.success).toBe(true); - expect(changed.agent_id).toBe("agent-1"); - expect(changed.cwd).toBe(normalizedClientDir); - expect(changed.conversation_id).toBe("conv-1"); - - const snapshot = JSON.parse(socket.sentPayloads[1] as string); - expect(snapshot.type).toBe("state_response"); - expect(snapshot.configured_cwd).toBe(normalizedClientDir); - expect(snapshot.active_turn_cwd).toBe(normalizedServerDir); - expect(snapshot.cwd_agent_id).toBe("agent-1"); - expect(snapshot.cwd_conversation_id).toBe("conv-1"); + expect(socket.sentPayloads).toHaveLength(1); + const updated = JSON.parse(socket.sentPayloads[0] as string); + expect(updated.type).toBe("update_device_status"); + expect(updated.runtime.agent_id).toBe("agent-1"); + expect(updated.runtime.conversation_id).toBe("conv-1"); + expect(updated.device_status.current_working_directory).toBe( + normalizedClientDir, + ); } finally { await rm(tempRoot, { recursive: true, force: true }); } }); }); -describe("listen-client state_response pending interrupt snapshot", () => { - test("includes queued interrupted tool returns for refresh hydration", () => { +describe("listen-client interrupt status delta emission", () => { + test("emits a canonical Interrupted status message", () => { + const runtime = __listenClientTestUtils.createRuntime(); + const socket = new MockSocket(WebSocket.OPEN); + + emitInterruptedStatusDelta(socket as unknown as WebSocket, runtime, { + runId: "run-1", + agentId: "agent-1", + conversationId: "default", + }); + + expect(socket.sentPayloads).toHaveLength(1); + const payload = JSON.parse(socket.sentPayloads[0] ?? "{}"); + expect(payload.type).toBe("stream_delta"); + expect(payload.delta).toMatchObject({ + message_type: "status", + message: "Interrupted", + level: "warning", + run_id: "run-1", + }); + expect(payload.runtime).toMatchObject({ + agent_id: "agent-1", + conversation_id: "default", + }); + }); +}); + +describe("listen-client interrupt queue projection", () => { + test("consumes queued interrupted tool returns with tool ids", () => { const runtime = __listenClientTestUtils.createRuntime(); __listenClientTestUtils.populateInterruptQueue(runtime, { @@ -465,23 +779,31 @@ describe("listen-client state_response pending interrupt snapshot", () => { 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, - }, - ], - }); + const consumed = __listenClientTestUtils.consumeInterruptQueue( + runtime, + "agent-1", + "conv-1", + ); + expect(consumed).not.toBeNull(); + expect(consumed?.interruptedToolCallIds).toEqual(["call-running-1"]); + expect(consumed?.approvalMessage.approvals).toEqual([ + { + type: "tool", + tool_call_id: "call-running-1", + status: "error", + tool_return: INTERRUPTED_BY_USER, + }, + ]); + expect( + __listenClientTestUtils.consumeInterruptQueue( + runtime, + "agent-1", + "conv-1", + ), + ).toBeNull(); }); - test("does not expose pending approval denials as interrupted tool state", () => { + test("approval-denial fallback does not set interrupted tool ids", () => { const runtime = __listenClientTestUtils.createRuntime(); __listenClientTestUtils.populateInterruptQueue(runtime, { @@ -492,14 +814,85 @@ describe("listen-client state_response pending interrupt snapshot", () => { conversationId: "conv-1", }); - const snapshot = __listenClientTestUtils.buildStateResponse(runtime, 18); + const consumed = __listenClientTestUtils.consumeInterruptQueue( + runtime, + "agent-1", + "conv-1", + ); + expect(consumed).not.toBeNull(); + expect(consumed?.interruptedToolCallIds).toEqual([]); + expect(consumed?.approvalMessage.approvals[0]).toMatchObject({ + type: "approval", + tool_call_id: "call-awaiting-approval", + approve: false, + }); + }); - expect(snapshot.pending_interrupt).toBeNull(); + test("recovered approvals are stashed as denials on interrupt", () => { + const runtime = __listenClientTestUtils.createRuntime(); + runtime.recoveredApprovalState = { + agentId: "agent-1", + conversationId: "conv-1", + approvalsByRequestId: new Map([ + [ + "perm-tool-1", + { + approval: { + toolCallId: "tool-1", + toolName: "Bash", + toolArgs: '{"command":"ls"}', + }, + controlRequest: makeControlRequest("perm-tool-1"), + }, + ], + [ + "perm-tool-2", + { + approval: { + toolCallId: "tool-2", + toolName: "Bash", + toolArgs: '{"command":"pwd"}', + }, + controlRequest: makeControlRequest("perm-tool-2"), + }, + ], + ]), + pendingRequestIds: new Set(["perm-tool-1", "perm-tool-2"]), + responsesByRequestId: new Map(), + }; + + const stashed = __listenClientTestUtils.stashRecoveredApprovalInterrupts( + runtime, + runtime.recoveredApprovalState, + ); + + expect(stashed).toBe(true); + expect(runtime.recoveredApprovalState).toBeNull(); + + const consumed = __listenClientTestUtils.consumeInterruptQueue( + runtime, + "agent-1", + "conv-1", + ); + expect(consumed?.approvalMessage.approvals).toEqual([ + { + type: "approval", + tool_call_id: "tool-1", + approve: false, + reason: "User interrupted the stream", + }, + { + type: "approval", + tool_call_id: "tool-2", + approve: false, + reason: "User interrupted the stream", + }, + ]); }); }); describe("listen-client capability-gated approval flow", () => { - test("control_response with allow + updatedInput rewrites tool args", async () => { + test("approval_response with allow + updated_input rewrites tool args", async () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); const requestId = "perm-update-test"; @@ -511,32 +904,34 @@ describe("listen-client capability-gated approval flow", () => { makeControlRequest(requestId), ); - // Simulate control_response with updatedInput + // Simulate approval_response with updated_input resolvePendingApprovalResolver(runtime, { - subtype: "success", request_id: requestId, - response: { + decision: { behavior: "allow", - updatedInput: { file_path: "/updated/path.ts", content: "new content" }, + updated_input: { + file_path: "/updated/path.ts", + content: "new content", + }, }, }); const response = await pending; - expect(response.subtype).toBe("success"); - if (response.subtype === "success") { - const canUseToolResponse = response.response as { + expect("decision" in response).toBe(true); + if ("decision" in response) { + const canUseToolResponse = response.decision as { behavior: string; - updatedInput?: Record; + updated_input?: Record; }; expect(canUseToolResponse.behavior).toBe("allow"); - expect(canUseToolResponse.updatedInput).toEqual({ + expect(canUseToolResponse.updated_input).toEqual({ file_path: "/updated/path.ts", content: "new content", }); } }); - test("control_response with deny includes reason", async () => { + test("approval_response with deny includes reason", async () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); const requestId = "perm-deny-test"; @@ -549,15 +944,14 @@ describe("listen-client capability-gated approval flow", () => { ); resolvePendingApprovalResolver(runtime, { - subtype: "success", request_id: requestId, - response: { behavior: "deny", message: "User declined" }, + decision: { behavior: "deny", message: "User declined" }, }); const response = await pending; - expect(response.subtype).toBe("success"); - if (response.subtype === "success") { - const canUseToolResponse = response.response as { + expect("decision" in response).toBe(true); + if ("decision" in response) { + const canUseToolResponse = response.decision as { behavior: string; message?: string; }; @@ -566,7 +960,7 @@ describe("listen-client capability-gated approval flow", () => { } }); - test("error response from WS triggers denial path", async () => { + test("approval_response error triggers denial path", async () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); const requestId = "perm-error-test"; @@ -579,26 +973,22 @@ describe("listen-client capability-gated approval flow", () => { ); resolvePendingApprovalResolver(runtime, { - subtype: "error", request_id: requestId, error: "Internal server error", }); const response = await pending; - expect(response.subtype).toBe("error"); - if (response.subtype === "error") { + expect("error" in response).toBe(true); + if ("error" in response) { expect(response.error).toBe("Internal server error"); } }); - test("outbound control_request is sent through sendControlMessageOverWebSocket (not raw socket.send)", () => { + test("requestApprovalOverWS exposes the control request through device status instead of stream_delta", () => { const runtime = __listenClientTestUtils.createRuntime(); const socket = new MockSocket(WebSocket.OPEN); const requestId = "perm-adapter-test"; - // requestApprovalOverWS uses sendControlMessageOverWebSocket internally - // which ultimately calls socket.send — but goes through the adapter stub. - // We verify the message was sent with the correct shape. void requestApprovalOverWS( runtime, socket as unknown as WebSocket, @@ -606,11 +996,19 @@ describe("listen-client capability-gated approval flow", () => { makeControlRequest(requestId), ).catch(() => {}); - expect(socket.sentPayloads).toHaveLength(1); - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("control_request"); - expect(sent.request_id).toBe(requestId); - expect(sent.request.subtype).toBe("can_use_tool"); + expect(socket.sentPayloads).toHaveLength(2); + const [loopStatus, deviceStatus] = socket.sentPayloads.map((payload) => + JSON.parse(payload as string), + ); + expect(loopStatus.type).toBe("update_loop_status"); + expect(loopStatus.loop_status.status).toBe("WAITING_ON_APPROVAL"); + expect(deviceStatus.type).toBe("update_device_status"); + expect(deviceStatus.device_status.pending_control_requests).toEqual([ + { + request_id: requestId, + request: makeControlRequest(requestId).request, + }, + ]); // Cleanup rejectPendingApprovalResolvers(runtime, "test cleanup"); @@ -690,203 +1088,83 @@ describe("listen-client approval recovery batch correlation", () => { }); }); -describe("listen-client emitToWS adapter", () => { - test("sends event when socket is OPEN", () => { - const socket = new MockSocket(WebSocket.OPEN); - const event = { - type: "error" as const, - message: "test error", - stop_reason: "error" as const, - session_id: "listen-test", - uuid: "test-uuid", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, event); - - expect(socket.sentPayloads).toHaveLength(1); - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("error"); - expect(sent.message).toBe("test error"); - expect(sent.session_id).toBe("listen-test"); - }); - - test("does not send when socket is CLOSED", () => { - const socket = new MockSocket(WebSocket.CLOSED); - const event = { - type: "error" as const, - message: "test error", - stop_reason: "error" as const, - session_id: "listen-test", - uuid: "test-uuid", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, event); - - expect(socket.sentPayloads).toHaveLength(0); - }); - - test("emits RecoveryMessage with recovery_type", () => { - const socket = new MockSocket(WebSocket.OPEN); - const event: Parameters[1] = { - type: "recovery", - recovery_type: "approval_pending", - message: "Detected pending approval conflict", - session_id: "listen-abc", - uuid: "recovery-123", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, event); - - expect(socket.sentPayloads).toHaveLength(1); - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("recovery"); - expect(sent.recovery_type).toBe("approval_pending"); - expect(sent.session_id).toBe("listen-abc"); - }); - - test("emits AutoApprovalMessage with tool_call shape", () => { - const socket = new MockSocket(WebSocket.OPEN); - const event = { - type: "auto_approval" as const, - tool_call: { - name: "Write", - tool_call_id: "call-123", - arguments: '{"file_path": "/test.ts"}', - }, - reason: "auto-approved", - matched_rule: "auto-approved", - session_id: "listen-test", - uuid: "auto-approval-call-123", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, event); - - expect(socket.sentPayloads).toHaveLength(1); - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("auto_approval"); - expect(sent.tool_call.name).toBe("Write"); - expect(sent.tool_call.tool_call_id).toBe("call-123"); - }); - - test("emits RetryMessage with attempt/delay details", () => { - const socket = new MockSocket(WebSocket.OPEN); - const event = { - type: "retry" as const, - reason: "llm_api_error" as const, - attempt: 1, - max_attempts: 3, - delay_ms: 1000, - session_id: "listen-test", - uuid: "retry-123", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, event); - - expect(socket.sentPayloads).toHaveLength(1); - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("retry"); - expect(sent.attempt).toBe(1); - expect(sent.max_attempts).toBe(3); - expect(sent.delay_ms).toBe(1000); - }); - - test("emits rich ResultMessage with full metadata", () => { - const socket = new MockSocket(WebSocket.OPEN); - const event = { - type: "result" as const, - subtype: "success" as const, - agent_id: "agent-123", - conversation_id: "conv-456", - duration_ms: 1500, - duration_api_ms: 0, - num_turns: 2, - result: null, - run_ids: ["run-1", "run-2"], - usage: null, - session_id: "listen-test", - uuid: "result-123", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, event); - - expect(socket.sentPayloads).toHaveLength(1); - const sent = JSON.parse(socket.sentPayloads[0] as string); - expect(sent.type).toBe("result"); - expect(sent.subtype).toBe("success"); - expect(sent.agent_id).toBe("agent-123"); - expect(sent.num_turns).toBe(2); - expect(sent.run_ids).toEqual(["run-1", "run-2"]); - }); - +describe("listen-client runtime metadata", () => { test("runtime sessionId is stable and uses listen- prefix", () => { const runtime = __listenClientTestUtils.createRuntime(); expect(runtime.sessionId).toMatch(/^listen-/); - // Verify it's a UUID format after the prefix expect(runtime.sessionId.length).toBeGreaterThan(10); }); +}); - test("emits approval lifecycle events", () => { - const socket = new MockSocket(WebSocket.OPEN); - const requested = { - type: "approval_requested" as const, - request_id: "perm-tool-1", - tool_call_id: "tool-1", - tool_name: "Bash", - run_id: "run-1", - session_id: "listen-test", - uuid: "approval-requested-1", +describe("listen-client retry delta emission", () => { + test("emits retry message text alongside structured retry metadata", () => { + const runtime = __listenClientTestUtils.createRuntime(); + runtime.activeAgentId = "agent-1"; + runtime.activeConversationId = "default"; + const socket = new MockSocket(); + + __listenClientTestUtils.emitRetryDelta( + socket as unknown as WebSocket, + runtime, + { + message: "Anthropic API is overloaded, retrying...", + reason: "error", + attempt: 1, + maxAttempts: 3, + delayMs: 1000, + agentId: "agent-1", + conversationId: "default", + }, + ); + + expect(socket.sentPayloads).toHaveLength(1); + const [firstPayload] = socket.sentPayloads; + expect(firstPayload).toBeDefined(); + const payload = JSON.parse(firstPayload as string) as { + type: string; + delta: Record; }; - const received = { - type: "approval_received" as const, - request_id: "perm-tool-1", - tool_call_id: "tool-1", - decision: "allow" as const, - reason: "Approved via WebSocket", - run_id: "run-1", - session_id: "listen-test", - uuid: "approval-received-1", - }; - - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, requested); - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, received); - - expect(socket.sentPayloads).toHaveLength(2); - const sentRequested = JSON.parse(socket.sentPayloads[0] as string); - const sentReceived = JSON.parse(socket.sentPayloads[1] as string); - expect(sentRequested.type).toBe("approval_requested"); - expect(sentRequested.request_id).toBe("perm-tool-1"); - expect(sentReceived.type).toBe("approval_received"); - expect(sentReceived.decision).toBe("allow"); + expect(payload.type).toBe("stream_delta"); + expect(payload.delta).toMatchObject({ + message_type: "retry", + message: "Anthropic API is overloaded, retrying...", + reason: "error", + attempt: 1, + max_attempts: 3, + delay_ms: 1000, + }); }); +}); - test("emits tool execution lifecycle events", () => { - const socket = new MockSocket(WebSocket.OPEN); - const started = { - type: "tool_execution_started" as const, - tool_call_id: "tool-2", - run_id: "run-2", - session_id: "listen-test", - uuid: "tool-exec-started-2", - }; - const finished = { - type: "tool_execution_finished" as const, - tool_call_id: "tool-2", - status: "success" as const, - run_id: "run-2", - session_id: "listen-test", - uuid: "tool-exec-finished-2", - }; +describe("listen-client queue event emission", () => { + test("queue enqueue/dequeue emits queue snapshots without loop-status jitter", async () => { + const runtime = __listenClientTestUtils.createRuntime(); + const socket = new MockSocket(); + runtime.socket = socket as unknown as WebSocket; - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, started); - __listenClientTestUtils.emitToWS(socket as unknown as WebSocket, finished); + runtime.queueRuntime.enqueue({ + kind: "message", + source: "user", + content: "hello", + clientMessageId: "cm-1", + agentId: "agent-1", + conversationId: "default", + } as Parameters[0]); - expect(socket.sentPayloads).toHaveLength(2); - const sentStarted = JSON.parse(socket.sentPayloads[0] as string); - const sentFinished = JSON.parse(socket.sentPayloads[1] as string); - expect(sentStarted.type).toBe("tool_execution_started"); - expect(sentStarted.tool_call_id).toBe("tool-2"); - expect(sentFinished.type).toBe("tool_execution_finished"); - expect(sentFinished.status).toBe("success"); + await Promise.resolve(); + + const dequeued = runtime.queueRuntime.consumeItems(1); + expect(dequeued).not.toBeNull(); + + await Promise.resolve(); + + const payloadTypes = socket.sentPayloads.map((payload) => { + const parsed = JSON.parse(payload) as { type: string }; + return parsed.type; + }); + + expect(payloadTypes.length).toBeGreaterThan(0); + expect(new Set(payloadTypes)).toEqual(new Set(["update_queue"])); }); }); @@ -945,6 +1223,55 @@ describe("listen-client post-stop approval recovery policy", () => { }); }); +describe("listen-client approval continuation recovery disposition", () => { + test("retries the original continuation when recovery handled nothing", () => { + expect( + __listenClientTestUtils.getApprovalContinuationRecoveryDisposition(null), + ).toBe("retry"); + }); + + test("treats drained recovery turns as handled", () => { + expect( + __listenClientTestUtils.getApprovalContinuationRecoveryDisposition({ + stopReason: "end_turn", + lastRunId: "run-1", + apiDurationMs: 0, + }), + ).toBe("handled"); + }); +}); + +describe("listen-client approval continuation run handoff", () => { + test("clears stale active run ids once an approval continuation is accepted", () => { + const runtime = __listenClientTestUtils.createRuntime(); + runtime.activeRunId = "run-1"; + + __listenClientTestUtils.markAwaitingAcceptedApprovalContinuationRunId( + runtime, + [{ type: "approval", approvals: [] }], + ); + + expect(runtime.activeRunId).toBeNull(); + }); + + test("preserves active run ids for non-approval sends", () => { + const runtime = __listenClientTestUtils.createRuntime(); + runtime.activeRunId = "run-1"; + + __listenClientTestUtils.markAwaitingAcceptedApprovalContinuationRunId( + runtime, + [ + { + role: "user", + content: "hello", + }, + ], + ); + + expect(runtime.activeRunId).toBe("run-1"); + }); +}); + describe("listen-client interrupt persistence normalization", () => { test("forces interrupted in-flight tool results to status=error when cancelRequested", () => { const runtime = __listenClientTestUtils.createRuntime(); diff --git a/src/tests/websocket/listen-interrupt-queue.test.ts b/src/tests/websocket/listen-interrupt-queue.test.ts index 4ea1220..fe7215d 100644 --- a/src/tests/websocket/listen-interrupt-queue.test.ts +++ b/src/tests/websocket/listen-interrupt-queue.test.ts @@ -210,6 +210,8 @@ describe("extractInterruptToolReturns", () => { test("emitInterruptToolReturnMessage emits deterministic per-tool terminal messages", () => { const runtime = createRuntime(); const socket = new MockSocket(WebSocket.OPEN) as unknown as WebSocket; + runtime.activeAgentId = "agent-1"; + runtime.activeConversationId = "default"; const approvals: ApprovalResult[] = [ { type: "tool", @@ -231,32 +233,45 @@ describe("extractInterruptToolReturns", () => { JSON.parse(raw), ); const toolReturnFrames = parsed.filter( - (payload) => payload.message_type === "tool_return_message", + (payload) => + payload.type === "stream_delta" && + payload.delta?.message_type === "tool_return_message", ); expect(toolReturnFrames).toHaveLength(2); expect(toolReturnFrames[0]).toMatchObject({ - run_id: "run-1", + delta: { + run_id: "run-1", + tool_returns: [ + { tool_call_id: "call-a", status: "success", tool_return: "704" }, + ], + }, + }); + expect(toolReturnFrames[1]).toMatchObject({ + delta: { + run_id: "run-1", + tool_returns: [ + { + tool_call_id: "call-b", + status: "error", + tool_return: "User interrupted the stream", + }, + ], + }, + }); + expect(toolReturnFrames[0].delta).toMatchObject({ tool_returns: [ { tool_call_id: "call-a", status: "success", tool_return: "704" }, ], }); - expect(toolReturnFrames[1]).toMatchObject({ - run_id: "run-1", - tool_returns: [ - { - tool_call_id: "call-b", - status: "error", - tool_return: "User interrupted the stream", - }, - ], - }); - expect(toolReturnFrames[0]).not.toHaveProperty("tool_call_id"); - expect(toolReturnFrames[0]).not.toHaveProperty("status"); - expect(toolReturnFrames[0]).not.toHaveProperty("tool_return"); - expect(toolReturnFrames[1]).not.toHaveProperty("tool_call_id"); - expect(toolReturnFrames[1]).not.toHaveProperty("status"); - expect(toolReturnFrames[1]).not.toHaveProperty("tool_return"); + expect(toolReturnFrames[0].delta.tool_call_id).toBe("call-a"); + expect(toolReturnFrames[0].delta.status).toBe("success"); + expect(toolReturnFrames[0].delta.tool_return).toBe("704"); + expect(toolReturnFrames[1].delta.tool_call_id).toBe("call-b"); + expect(toolReturnFrames[1].delta.status).toBe("error"); + expect(toolReturnFrames[1].delta.tool_return).toBe( + "User interrupted the stream", + ); }); }); @@ -791,9 +806,8 @@ describe("stale Path-B IDs: clearing after successful send prevents re-denial", describe("cancel-induced stop reason reclassification", () => { /** * Mirrors the effectiveStopReason computation from the Case 3 stream path. - * Both the legacy (sendClientMessage) and modern (emitToWS) branches now - * use effectiveStopReason — this test verifies the reclassification logic - * that both branches depend on. + * Both the legacy and canonical listener branches use effectiveStopReason. + * This test verifies the reclassification logic those branches depend on. */ function computeEffectiveStopReason( cancelRequested: boolean, diff --git a/src/tests/websocket/listen-queue-events.test.ts b/src/tests/websocket/listen-queue-events.test.ts index 167d659..afd9b7d 100644 --- a/src/tests/websocket/listen-queue-events.test.ts +++ b/src/tests/websocket/listen-queue-events.test.ts @@ -7,10 +7,9 @@ * - Single message: enqueued → dequeued, no blocked, real queue_len * - Two rapid synchronous arrivals: second gets blocked(runtime_busy) * because pendingTurns is incremented before the .then() chain - * - Connection close: queue_cleared("shutdown") emitted once - * - Per-turn error: no queue_cleared — queue continues for remaining turns + * - Connection close: queue clear still happens once in QueueRuntime + * - Per-turn error: no queue clear — queue continues for remaining turns * - ApprovalCreate payloads (no `content` field) are not enqueued - * - QueueLifecycleEvent is assignable to WsProtocolEvent (type-level) */ import { describe, expect, test } from "bun:test"; @@ -23,17 +22,6 @@ import type { QueueItem, } from "../../queue/queueRuntime"; import { QueueRuntime } from "../../queue/queueRuntime"; -import type { QueueLifecycleEvent } from "../../types/protocol"; -import type { WsProtocolEvent } from "../../websocket/listen-client"; - -// ── Type-level assertion: QueueLifecycleEvent ⊆ WsProtocolEvent ── -// Imports the real WsProtocolEvent from listen-client. If QueueLifecycleEvent -// is ever removed from that union, this assertion fails at compile time. -type _AssertAssignable = QueueLifecycleEvent extends WsProtocolEvent - ? true - : never; -const _typeCheck: _AssertAssignable = true; -void _typeCheck; // suppress unused warning // ── Helpers ─────────────────────────────────────────────────────── @@ -264,7 +252,7 @@ describe("ApprovalCreate payloads", () => { }); describe("connection close", () => { - test("clear(shutdown) emits queue_cleared exactly once for intentional close", () => { + test("clear(shutdown) reports a single clear callback for intentional close", () => { const { q, rec } = buildRuntime(); q.clear("shutdown"); expect(rec.cleared).toHaveLength(1); @@ -302,13 +290,13 @@ describe("per-turn error — no queue_cleared", () => { // First turn: simulate error — finally still runs simulateTurnStart(q, turns, arrival1, skipIds); simulateTurnEnd(q, turns); // error path still hits finally - expect(rec.cleared).toHaveLength(0); // no queue_cleared + expect(rec.cleared).toHaveLength(0); // no queue clear // Second callback no-ops; first turn already consumed coalesced batch. simulateTurnStart(q, turns, arrival2, skipIds); expect(rec.dequeued).toHaveLength(1); simulateTurnEnd(q, turns); expect(turns.value).toBe(0); - expect(rec.cleared).toHaveLength(0); // still no queue_cleared + expect(rec.cleared).toHaveLength(0); // still no queue clear }); }); diff --git a/src/types/protocol.ts b/src/types/protocol.ts index b5246fc..80ffe5d 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -365,6 +365,35 @@ export type QueueItemKind = | "approval_result" | "overlay_action"; +/** + * Canonical queue item wire shape used by listener state snapshots + * and queue lifecycle transport events. + */ +export interface QueueRuntimeItemWire { + /** Stable queue item identifier. */ + id: string; + /** Correlates this queue item back to the originating client submit payload. */ + client_message_id: string; + kind: QueueItemKind; + source: QueueItemSource; + /** Full queue item content; renderers may truncate for display. */ + content: MessageCreate["content"] | string; + /** ISO8601 UTC enqueue timestamp. */ + enqueued_at: string; +} + +/** + * Queue item shape used by static queue_snapshot events. + * Includes legacy item_id for compatibility and allows optional expanded fields. + */ +export interface QueueSnapshotItem + extends Omit, "kind" | "source"> { + /** @deprecated Use `id` when present. */ + item_id: string; + kind: QueueItemKind; + source: QueueItemSource; +} + /** * Emitted synchronously when an item enters the queue. * A queue item is a discrete, submitted unit of work (post-Enter for user @@ -377,13 +406,13 @@ export interface QueueItemEnqueuedEvent extends MessageEnvelope { /** @deprecated Use `id`. */ item_id: string; /** Correlates this queue item back to the originating client submit payload. */ - client_message_id: string; + client_message_id: QueueRuntimeItemWire["client_message_id"]; source: QueueItemSource; kind: QueueItemKind; /** Full queue item content; renderers may truncate for display. */ - content?: MessageCreate["content"] | string; + content?: QueueRuntimeItemWire["content"]; /** ISO8601 UTC enqueue timestamp. */ - enqueued_at?: string; + enqueued_at?: QueueRuntimeItemWire["enqueued_at"]; queue_len: number; } @@ -782,14 +811,7 @@ export interface TranscriptBackfillMessage extends MessageEnvelope { export interface QueueSnapshotMessage extends MessageEnvelope { type: "queue_snapshot"; /** Items currently in the queue, in enqueue order. */ - items: Array<{ - /** Stable queue item identifier. Preferred field. */ - id?: string; - /** @deprecated Use `id`. */ - item_id: string; - kind: QueueItemKind; - source: QueueItemSource; - }>; + items: QueueSnapshotItem[]; } /** diff --git a/src/types/protocol_v2.ts b/src/types/protocol_v2.ts new file mode 100644 index 0000000..6a71f63 --- /dev/null +++ b/src/types/protocol_v2.ts @@ -0,0 +1,372 @@ +/** + * Protocol V2 (alpha hard-cut contract) + * + * This file defines the runtime-scoped websocket contract for device-mode UIs. + * It is intentionally self-defined and does not import transport/event shapes + * from the legacy protocol.ts surface. + */ + +import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; +import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages"; +import type { StopReasonType } from "@letta-ai/letta-client/resources/runs/runs"; + +/** + * Runtime identity for all state and delta events. + */ +export interface RuntimeScope { + agent_id: string; + conversation_id: string; +} + +/** + * Base envelope shared by all v2 websocket messages. + */ +export interface RuntimeEnvelope { + runtime: RuntimeScope; + event_seq: number; + emitted_at: string; + idempotency_key: string; +} + +export type DevicePermissionMode = + | "default" + | "acceptEdits" + | "plan" + | "bypassPermissions"; + +export type ToolsetName = + | "codex" + | "codex_snake" + | "default" + | "gemini" + | "gemini_snake" + | "none"; + +export type ToolsetPreference = ToolsetName | "auto"; + +export interface AvailableSkillSummary { + id: string; + name: string; + description: string; + path: string; + source: "bundled" | "global" | "agent" | "project"; +} + +export interface BashBackgroundProcessSummary { + process_id: string; + kind: "bash"; + command: string; + started_at_ms: number | null; + status: string; + exit_code: number | null; +} + +export interface AgentTaskBackgroundProcessSummary { + process_id: string; + kind: "agent_task"; + task_type: string; + description: string; + started_at_ms: number; + status: string; + subagent_id: string | null; + error?: string; +} + +export type BackgroundProcessSummary = + | BashBackgroundProcessSummary + | AgentTaskBackgroundProcessSummary; + +export interface DiffHunkLine { + type: "context" | "add" | "remove"; + content: string; +} + +export interface DiffHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: DiffHunkLine[]; +} + +export type DiffPreview = + | { mode: "advanced"; fileName: string; hunks: DiffHunk[] } + | { mode: "fallback"; fileName: string; reason: string } + | { mode: "unpreviewable"; fileName: string; reason: string }; + +export interface CanUseToolControlRequestBody { + subtype: "can_use_tool"; + tool_name: string; + input: Record; + tool_call_id: string; + permission_suggestions: string[]; + blocked_path: string | null; + diffs?: DiffPreview[]; +} + +export type ControlRequestBody = CanUseToolControlRequestBody; + +export interface ControlRequest { + type: "control_request"; + request_id: string; + request: ControlRequestBody; + agent_id?: string; + conversation_id?: string; +} + +export interface PendingControlRequest { + request_id: string; + request: ControlRequestBody; +} + +/** + * Bottom-bar and device execution context state. + */ +export interface DeviceStatus { + current_connection_id: string | null; + connection_name: string | null; + is_online: boolean; + is_processing: boolean; + current_permission_mode: DevicePermissionMode; + current_working_directory: string | null; + letta_code_version: string | null; + current_toolset: ToolsetName | null; + current_toolset_preference: ToolsetPreference; + current_loaded_tools: string[]; + current_available_skills: AvailableSkillSummary[]; + background_processes: BackgroundProcessSummary[]; + pending_control_requests: PendingControlRequest[]; +} + +export type LoopStatus = + | "SENDING_API_REQUEST" + | "WAITING_FOR_API_RESPONSE" + | "RETRYING_API_REQUEST" + | "PROCESSING_API_RESPONSE" + | "EXECUTING_CLIENT_SIDE_TOOL" + | "EXECUTING_COMMAND" + | "WAITING_ON_APPROVAL" + | "WAITING_ON_INPUT"; + +export type QueueMessageKind = + | "message" + | "task_notification" + | "approval_result" + | "overlay_action"; + +export type QueueMessageSource = + | "user" + | "task_notification" + | "subagent" + | "system"; + +export interface QueueMessage { + id: string; + client_message_id: string; + kind: QueueMessageKind; + source: QueueMessageSource; + content: MessageCreate["content"] | string; + enqueued_at: string; +} + +/** + * Loop state is intentionally small and finite. + * Message-level details are projected from runtime deltas. + * + * Queue state is delivered separately via `update_queue` messages. + */ +export interface LoopState { + status: LoopStatus; + active_run_ids: string[]; +} + +export interface DeviceStatusUpdateMessage extends RuntimeEnvelope { + type: "update_device_status"; + device_status: DeviceStatus; +} + +export interface LoopStatusUpdateMessage extends RuntimeEnvelope { + type: "update_loop_status"; + loop_status: LoopState; +} + +/** + * Full snapshot of the turn queue. + * Emitted on every queue mutation (enqueue, dequeue, clear, drop). + * Queue is typically 0-5 items so full snapshot is cheap and idempotent. + */ +export interface QueueUpdateMessage extends RuntimeEnvelope { + type: "update_queue"; + queue: QueueMessage[]; +} + +/** + * Standard Letta message delta forwarded through the stream channel. + */ +export type MessageDelta = { type: "message" } & LettaStreamingResponse; + +export interface UmiLifecycleMessageBase { + id: string; + date: string; + message_type: string; + run_id?: string; +} + +export interface ClientToolStartMessage extends UmiLifecycleMessageBase { + message_type: "client_tool_start"; + tool_call_id: string; +} + +export interface ClientToolEndMessage extends UmiLifecycleMessageBase { + message_type: "client_tool_end"; + tool_call_id: string; + status: "success" | "error"; +} + +export interface CommandStartMessage extends UmiLifecycleMessageBase { + message_type: "command_start"; + command_id: string; + input: string; +} + +export interface CommandEndMessage extends UmiLifecycleMessageBase { + message_type: "command_end"; + command_id: string; + input: string; + output: string; + success: boolean; + dim_output?: boolean; + preformatted?: boolean; +} + +export interface StatusMessage extends UmiLifecycleMessageBase { + message_type: "status"; + message: string; + level: "info" | "success" | "warning"; +} + +export interface RetryMessage extends UmiLifecycleMessageBase { + message_type: "retry"; + message: string; + reason: StopReasonType; + attempt: number; + max_attempts: number; + delay_ms: number; +} + +export interface LoopErrorMessage extends UmiLifecycleMessageBase { + message_type: "loop_error"; + message: string; + stop_reason: StopReasonType; + is_terminal: boolean; + api_error?: LettaStreamingResponse.LettaErrorMessage; +} + +/** + * Expanded message-delta union. + * stream_delta is the only message stream event the WS server emits in v2. + */ +export type StreamDelta = + | MessageDelta + | ClientToolStartMessage + | ClientToolEndMessage + | CommandStartMessage + | CommandEndMessage + | StatusMessage + | RetryMessage + | LoopErrorMessage; + +export interface StreamDeltaMessage extends RuntimeEnvelope { + type: "stream_delta"; + delta: StreamDelta; +} + +export interface ApprovalResponseAllowDecision { + behavior: "allow"; + updated_input?: Record | null; + updated_permissions?: string[]; +} + +export interface ApprovalResponseDenyDecision { + behavior: "deny"; + message: string; +} + +export type ApprovalResponseDecision = + | ApprovalResponseAllowDecision + | ApprovalResponseDenyDecision; + +export type ApprovalResponseBody = + | { + request_id: string; + decision: ApprovalResponseDecision; + } + | { + request_id: string; + error: string; + }; + +/** + * Controller -> execution-environment commands. + * In v2, the WS server accepts only: + * - input (chat-loop ingress envelope) + * - change_device_state (device runtime mutation) + * - abort_message (abort request) + */ +export interface InputCreateMessagePayload { + kind: "create_message"; + messages: Array; +} + +export type InputApprovalResponsePayload = { + kind: "approval_response"; +} & ApprovalResponseBody; + +export type InputPayload = + | InputCreateMessagePayload + | InputApprovalResponsePayload; + +export interface InputCommand { + type: "input"; + runtime: RuntimeScope; + payload: InputPayload; +} + +export interface ChangeDeviceStatePayload { + mode?: DevicePermissionMode; + cwd?: string; + agent_id?: string | null; + conversation_id?: string | null; +} + +export interface ChangeDeviceStateCommand { + type: "change_device_state"; + runtime: RuntimeScope; + payload: ChangeDeviceStatePayload; +} + +export interface AbortMessageCommand { + type: "abort_message"; + runtime: RuntimeScope; + request_id?: string; + run_id?: string | null; +} + +export interface SyncCommand { + type: "sync"; + runtime: RuntimeScope; +} + +export type WsProtocolCommand = + | InputCommand + | ChangeDeviceStateCommand + | AbortMessageCommand + | SyncCommand; + +export type WsProtocolMessage = + | DeviceStatusUpdateMessage + | LoopStatusUpdateMessage + | QueueUpdateMessage + | StreamDeltaMessage; + +export type { StopReasonType }; diff --git a/src/websocket/helpers/listenerQueueAdapter.ts b/src/websocket/helpers/listenerQueueAdapter.ts index 9f66377..8f16bc5 100644 --- a/src/websocket/helpers/listenerQueueAdapter.ts +++ b/src/websocket/helpers/listenerQueueAdapter.ts @@ -1,4 +1,4 @@ -import type { QueueBlockedReason } from "../../types/protocol"; +import type { QueueBlockedReason } from "../../queue/queueRuntime"; export type ListenerQueueGatingConditions = { isProcessing: boolean; diff --git a/src/websocket/listen-client.ts b/src/websocket/listen-client.ts index bc32a56..11a508c 100644 --- a/src/websocket/listen-client.ts +++ b/src/websocket/listen-client.ts @@ -4,7 +4,7 @@ */ import { existsSync } from "node:fs"; -import { mkdir, readdir, realpath, stat, writeFile } from "node:fs/promises"; +import { mkdir, realpath, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { APIError } from "@letta-ai/letta-client/core/error"; @@ -16,6 +16,7 @@ import type { } from "@letta-ai/letta-client/resources/agents/messages"; import WebSocket from "ws"; import { + type ApprovalDecision, type ApprovalResult, executeApprovalBatch, } from "../agent/approval-execution"; @@ -29,16 +30,23 @@ import { getPreStreamErrorAction, getRetryDelayMs, isApprovalPendingError, + isEmptyResponseRetryable, isInvalidToolCallIdsError, parseRetryAfterHeaderMs, rebuildInputWithFreshDenials, shouldAttemptApprovalRecovery, + shouldRetryRunMetadataError, } from "../agent/turn-recovery-policy"; import { createBuffers } from "../cli/helpers/accumulator"; import { classifyApprovals } from "../cli/helpers/approvalClassification"; +import { getRetryStatusMessage } from "../cli/helpers/errorFormatter"; import { resizeImageIfNeeded } from "../cli/helpers/imageResize"; import { generatePlanFilePath } from "../cli/helpers/planName"; -import { drainStreamWithResume } from "../cli/helpers/stream"; +import type { ApprovalRequest } from "../cli/helpers/stream"; +import { + discoverFallbackRunIdWithTimeout, + drainStreamWithResume, +} from "../cli/helpers/stream"; import { INTERRUPTED_BY_USER } from "../constants"; import { computeDiffPreviews } from "../helpers/diffPreview"; import { permissionMode } from "../permissions/mode"; @@ -61,38 +69,37 @@ import { } from "../reminders/state"; import { settingsManager } from "../settings-manager"; import { isInteractiveApprovalTool } from "../tools/interactivePolicy"; -import { loadTools } from "../tools/manager"; +import { getToolNames, loadTools } from "../tools/manager"; import type { - ApprovalReceivedMessage, - ApprovalRequestedMessage, - AutoApprovalMessage, - CancelAckMessage, - CanUseToolResponse, + AbortMessageCommand, + ApprovalResponseBody, + ApprovalResponseDecision, + ChangeDeviceStateCommand, + ClientToolEndMessage, + ClientToolStartMessage, ControlRequest, - ControlResponseBody, - ErrorMessage, - MessageWire, - ResultMessage as ProtocolResultMessage, - QueueLifecycleEvent, - QueueSnapshotMessage, - RecoveryMessage, + DeviceStatus, + DeviceStatusUpdateMessage, + InputCommand, + LoopState, + LoopStatus, + LoopStatusUpdateMessage, + PendingControlRequest, + QueueMessage, + QueueUpdateMessage, RetryMessage, + RuntimeScope, + StatusMessage, StopReasonType, - SyncCompleteMessage, - ToolExecutionFinishedMessage, - ToolExecutionStartedMessage, - TranscriptBackfillMessage, - TranscriptSupplementMessage, -} from "../types/protocol"; + StreamDelta, + StreamDeltaMessage, + SyncCommand, + WsProtocolCommand, + WsProtocolMessage, +} from "../types/protocol_v2"; import { isDebugEnabled } from "../utils/debug"; import { getListenerBlockedReason } from "./helpers/listenerQueueAdapter"; -import { - handleTerminalInput, - handleTerminalKill, - handleTerminalResize, - handleTerminalSpawn, - killAllTerminals, -} from "./terminalHandler"; +import { killAllTerminals } from "./terminalHandler"; interface StartListenerOptions { connectionId: string; @@ -121,21 +128,6 @@ interface StartListenerOptions { ) => void; } -interface PingMessage { - type: "ping"; -} - -interface PongMessage { - type: "pong"; -} - -interface StatusMessage { - type: "status"; - currentMode: "default" | "acceptEdits" | "plan" | "bypassPermissions"; - lastStopReason: string | null; - isProcessing: boolean; -} - interface IncomingMessage { type: "message"; agentId?: string; @@ -145,227 +137,47 @@ interface IncomingMessage { >; } -interface RunStartedMessage { - type: "run_started"; - runId: string; - batch_id: string; - event_seq?: number; - session_id?: string; - agent_id?: string; - conversation_id?: string; -} - -interface RunRequestErrorMessage { - type: "run_request_error"; - error: { - status?: number; - body?: Record; - message?: string; - }; - batch_id?: string; - event_seq?: number; - session_id?: string; - agent_id?: string; - conversation_id?: string; -} - -interface ModeChangeMessage { - type: "mode_change"; +interface ModeChangePayload { mode: "default" | "acceptEdits" | "plan" | "bypassPermissions"; } -interface WsControlResponse { - type: "control_response"; - response: ControlResponseBody; -} - -interface ModeChangedMessage { - type: "mode_changed"; - mode: "default" | "acceptEdits" | "plan" | "bypassPermissions"; - success: boolean; - error?: string; - event_seq?: number; - session_id?: string; -} - -interface GetStatusMessage { - type: "get_status"; -} - -interface GetStateMessage { - type: "get_state"; - agentId?: string | null; - conversationId?: string | null; -} - interface ChangeCwdMessage { - type: "change_cwd"; agentId?: string | null; conversationId?: string | null; cwd: string; } -interface ListFoldersInDirectoryMessage { - type: "list_folders_in_directory"; - path: string; - agentId?: string | null; - conversationId?: string | null; -} - -interface ListFoldersInDirectoryResponseMessage { - type: "list_folders_in_directory_response"; - path: string; - folders: string[]; - hasMore: boolean; - success: boolean; - error?: string; - event_seq?: number; - session_id?: string; -} - -interface CancelRunMessage { - type: "cancel_run"; - request_id?: string; - run_id?: string | null; -} - -interface TerminalSpawnMessage { - type: "terminal_spawn"; - terminal_id: string; - cols: number; - rows: number; - agentId?: string | null; - conversationId?: string | null; -} - -interface TerminalInputMessage { - type: "terminal_input"; - terminal_id: string; - data: string; -} - -interface TerminalResizeMessage { - type: "terminal_resize"; - terminal_id: string; - cols: number; - rows: number; -} - -interface TerminalKillMessage { - type: "terminal_kill"; - terminal_id: string; -} - -interface RecoverPendingApprovalsMessage { - type: "recover_pending_approvals"; - agentId?: string; - conversationId?: string; -} - type InboundMessagePayload = | (MessageCreate & { client_message_id?: string }) | ApprovalCreate; -interface StatusResponseMessage { - type: "status_response"; - currentMode: "default" | "acceptEdits" | "plan" | "bypassPermissions"; - lastStopReason: string | null; - isProcessing: boolean; - event_seq?: number; - session_id?: string; -} - -interface StateResponseMessage { - type: "state_response"; - schema_version: 1; - session_id: string; - snapshot_id: string; - generated_at: string; - state_seq: number; - cwd: string; - configured_cwd: string; - active_turn_cwd: string | null; - cwd_agent_id: string | null; - cwd_conversation_id: string | null; - mode: "default" | "acceptEdits" | "plan" | "bypassPermissions"; - is_processing: boolean; - last_stop_reason: string | null; - control_response_capable: boolean; - tool_lifecycle_capable: boolean; - active_run: { - run_id: string | null; - agent_id: string | null; - conversation_id: string | null; - started_at: string | null; - }; - pending_control_requests: Array<{ - 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; - items: Array<{ - id: string; - client_message_id: string; - kind: string; - source: string; - content: unknown; - enqueued_at: string; - }>; - }; - event_seq?: number; -} - -interface CwdChangedMessage { - type: "cwd_changed"; - agent_id: string | null; - conversation_id: string; - cwd: string; - success: boolean; - error?: string; - event_seq?: number; - session_id?: string; -} - -type ServerMessage = - | PongMessage - | StatusMessage - | IncomingMessage - | ModeChangeMessage - | GetStatusMessage - | GetStateMessage - | ChangeCwdMessage - | ListFoldersInDirectoryMessage - | CancelRunMessage - | RecoverPendingApprovalsMessage - | WsControlResponse - | TerminalSpawnMessage - | TerminalInputMessage - | TerminalResizeMessage - | TerminalKillMessage; -type ClientMessage = - | PingMessage - | RunStartedMessage - | RunRequestErrorMessage - | ModeChangedMessage - | CwdChangedMessage - | ListFoldersInDirectoryResponseMessage - | StatusResponseMessage - | StateResponseMessage; +type ServerMessage = WsProtocolCommand; +type InvalidInputCommand = { + type: "__invalid_input"; + runtime: RuntimeScope; + reason: string; +}; +type ParsedServerMessage = ServerMessage | InvalidInputCommand; type PendingApprovalResolver = { - resolve: (response: ControlResponseBody) => void; + resolve: (response: ApprovalResponseBody) => void; reject: (reason: Error) => void; controlRequest?: ControlRequest; }; +type RecoveredPendingApproval = { + approval: ApprovalRequest; + controlRequest: ControlRequest; +}; + +type RecoveredApprovalState = { + agentId: string; + conversationId: string; + approvalsByRequestId: Map; + pendingRequestIds: Set; + responsesByRequestId: Map; +}; + type ListenerRuntime = { socket: WebSocket | null; heartbeatInterval: NodeJS.Timeout | null; @@ -374,6 +186,7 @@ type ListenerRuntime = { hasSuccessfulConnection: boolean; messageQueue: Promise; pendingApprovalResolvers: Map; + recoveredApprovalState: RecoveredApprovalState | null; /** Stable session ID for MessageEnvelope-based emissions (scoped to runtime lifecycle). */ sessionId: string; /** Monotonic event sequence for all outbound status/protocol events. */ @@ -390,7 +203,7 @@ type ListenerRuntime = { activeRunStartedAt: string | null; /** Abort controller for the currently active message turn. */ activeAbortController: AbortController | null; - /** True when a cancel_run request has been issued for the active turn. */ + /** True when an abort_message request has been issued for the active turn. */ cancelRequested: boolean; /** Queue lifecycle tracking — parallel tracking layer, does not affect message processing. */ queueRuntime: QueueRuntime; @@ -400,12 +213,20 @@ type ListenerRuntime = { queuePumpActive: boolean; /** Dedupes queue pump scheduling onto messageQueue chain. */ queuePumpScheduled: boolean; + /** Coalesces rapid queue mutations into a single update_queue emit. */ + queueEmitScheduled: boolean; + pendingQueueEmitScope?: { + agent_id?: string | null; + conversation_id?: string | null; + }; /** Queue backlog metric for state snapshot visibility. */ pendingTurns: number; /** Optional debug hook for WS event logging. */ onWsEvent?: StartListenerOptions["onWsEvent"]; /** Prevent duplicate concurrent pending-approval recovery passes. */ isRecoveringApprovals: boolean; + /** Canonical loop phase for update_loop_status emission. */ + loopStatus: LoopStatus; /** * Correlates pending approval tool_call_ids to the originating dequeued batch. * Used to preserve run attachment continuity across approval recovery. @@ -434,6 +255,8 @@ type ListenerRuntime = { reminderState: SharedReminderState; bootWorkingDirectory: string; workingDirectoryByConversation: Map; + connectionId: string | null; + connectionName: string | null; }; // Listen mode supports one active connection per process. @@ -442,7 +265,15 @@ let activeRuntime: ListenerRuntime | null = null; /** * Handle mode change request from cloud */ -function handleModeChange(msg: ModeChangeMessage, socket: WebSocket): void { +function handleModeChange( + msg: ModeChangePayload, + socket: WebSocket, + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { try { permissionMode.setMode(msg.mode); @@ -452,23 +283,18 @@ function handleModeChange(msg: ModeChangeMessage, socket: WebSocket): void { permissionMode.setPlanFilePath(planFilePath); } - // Send success acknowledgment - sendClientMessage(socket, { - type: "mode_changed", - mode: msg.mode, - success: true, - }); + emitDeviceStatusUpdate(socket, runtime, scope); if (isDebugEnabled()) { console.log(`[Listen] Mode changed to: ${msg.mode}`); } } catch (error) { - // Send failure acknowledgment - sendClientMessage(socket, { - type: "mode_changed", - mode: msg.mode, - success: false, - error: error instanceof Error ? error.message : "Mode change failed", + emitLoopErrorDelta(socket, runtime, { + message: error instanceof Error ? error.message : "Mode change failed", + stopReason: "error", + isTerminal: false, + agentId: scope?.agent_id, + conversationId: scope?.conversation_id, }); if (isDebugEnabled()) { @@ -528,93 +354,21 @@ async function handleCwdChange( conversationId, normalizedPath, ); - sendClientMessage( - socket, - { - type: "cwd_changed", - agent_id: agentId, - conversation_id: conversationId, - cwd: normalizedPath, - success: true, - }, - runtime, - ); - sendStateSnapshot(socket, runtime, agentId, conversationId); + emitDeviceStatusUpdate(socket, runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); } catch (error) { - sendClientMessage( - socket, - { - type: "cwd_changed", - agent_id: agentId, - conversation_id: conversationId, - cwd: msg.cwd, - success: false, - error: - error instanceof Error - ? error.message - : "Working directory change failed", - }, - runtime, - ); - } -} - -const MAX_LIST_FOLDERS = 100; - -async function handleListFoldersInDirectory( - msg: ListFoldersInDirectoryMessage, - socket: WebSocket, - runtime: ListenerRuntime, -): Promise { - try { - const requestedPath = msg.path?.trim(); - if (!requestedPath) { - throw new Error("Path cannot be empty"); - } - - const resolvedPath = path.isAbsolute(requestedPath) - ? requestedPath - : path.resolve(process.cwd(), requestedPath); - const normalizedPath = await realpath(resolvedPath); - const stats = await stat(normalizedPath); - if (!stats.isDirectory()) { - throw new Error(`Not a directory: ${normalizedPath}`); - } - - const entries = await readdir(normalizedPath, { withFileTypes: true }); - const allFolders = entries - .filter((e) => e.isDirectory() && !e.name.startsWith(".")) - .map((e) => e.name) - .sort(); - - const folders = allFolders.slice(0, MAX_LIST_FOLDERS); - const hasMore = allFolders.length > MAX_LIST_FOLDERS; - - sendClientMessage( - socket, - { - type: "list_folders_in_directory_response", - path: normalizedPath, - folders, - hasMore, - success: true, - }, - runtime, - ); - } catch (error) { - sendClientMessage( - socket, - { - type: "list_folders_in_directory_response", - path: msg.path, - folders: [], - hasMore: false, - success: false, - error: - error instanceof Error ? error.message : "Failed to list folders", - }, - runtime, - ); + emitLoopErrorDelta(socket, runtime, { + message: + error instanceof Error + ? error.message + : "Working directory change failed", + stopReason: "error", + isTerminal: false, + agentId, + conversationId, + }); } } @@ -661,6 +415,7 @@ function createRuntime(): ListenerRuntime { hasSuccessfulConnection: false, messageQueue: Promise.resolve(), pendingApprovalResolvers: new Map(), + recoveredApprovalState: null, sessionId: `listen-${crypto.randomUUID()}`, eventSeqCounter: 0, lastStopReason: null, @@ -673,6 +428,7 @@ function createRuntime(): ListenerRuntime { activeAbortController: null, cancelRequested: false, isRecoveringApprovals: false, + loopStatus: "WAITING_ON_INPUT", pendingApprovalBatchByToolCallId: new Map(), pendingInterruptedResults: null, pendingInterruptedContext: null, @@ -682,9 +438,13 @@ function createRuntime(): ListenerRuntime { reminderState: createSharedReminderState(), bootWorkingDirectory, workingDirectoryByConversation: loadPersistedCwdMap(), + connectionId: null, + connectionName: null, queuedMessagesByItemId: new Map(), queuePumpActive: false, queuePumpScheduled: false, + queueEmitScheduled: false, + pendingQueueEmitScope: undefined, pendingTurns: 0, // queueRuntime assigned below — needs runtime ref in callbacks queueRuntime: null as unknown as QueueRuntime, @@ -693,79 +453,28 @@ function createRuntime(): ListenerRuntime { callbacks: { onEnqueued: (item, queueLen) => { runtime.pendingTurns = queueLen; - if (runtime.socket?.readyState === WebSocket.OPEN) { - const content = item.kind === "message" ? item.content : item.text; - emitToWS(runtime.socket, { - type: "queue_item_enqueued", - id: item.id, - item_id: item.id, - client_message_id: item.clientMessageId ?? `cm-${item.id}`, - source: item.source, - kind: item.kind, - content, - enqueued_at: new Date(item.enqueuedAt).toISOString(), - queue_len: queueLen, - session_id: runtime.sessionId, - uuid: `q-enq-${item.id}`, - ...getQueueItemScope(item), - }); - } + const scope = getQueueItemScope(item); + scheduleQueueEmit(runtime, scope); }, onDequeued: (batch) => { runtime.pendingTurns = batch.queueLenAfter; - if (runtime.socket?.readyState === WebSocket.OPEN) { - emitToWS(runtime.socket, { - type: "queue_batch_dequeued", - batch_id: batch.batchId, - item_ids: batch.items.map((i) => i.id), - merged_count: batch.mergedCount, - queue_len_after: batch.queueLenAfter, - session_id: runtime.sessionId, - uuid: `q-deq-${batch.batchId}`, - ...getQueueItemsScope(batch.items), - }); - } + const scope = getQueueItemsScope(batch.items); + scheduleQueueEmit(runtime, scope); }, - onBlocked: (reason, queueLen) => { - if (runtime.socket?.readyState === WebSocket.OPEN) { - emitToWS(runtime.socket, { - type: "queue_blocked", - reason, - queue_len: queueLen, - session_id: runtime.sessionId, - uuid: `q-blk-${crypto.randomUUID()}`, - ...getQueueItemScope(runtime.queueRuntime.items[0]), - }); - } + onBlocked: (_reason, _queueLen) => { + const scope = getQueueItemScope(runtime.queueRuntime.items[0]); + scheduleQueueEmit(runtime, scope); }, - onCleared: (reason, clearedCount, items) => { + onCleared: (_reason, _clearedCount, items) => { runtime.pendingTurns = 0; - if (runtime.socket?.readyState === WebSocket.OPEN) { - emitToWS(runtime.socket, { - type: "queue_cleared", - reason, - cleared_count: clearedCount, - session_id: runtime.sessionId, - uuid: `q-clr-${crypto.randomUUID()}`, - ...getQueueItemsScope(items), - }); - } + const scope = getQueueItemsScope(items); + scheduleQueueEmit(runtime, scope); }, - onDropped: (item, reason, queueLen) => { + onDropped: (item, _reason, queueLen) => { runtime.pendingTurns = queueLen; runtime.queuedMessagesByItemId.delete(item.id); - if (runtime.socket?.readyState === WebSocket.OPEN) { - emitToWS(runtime.socket, { - type: "queue_item_dropped", - id: item.id, - item_id: item.id, - reason, - queue_len: queueLen, - session_id: runtime.sessionId, - uuid: `q-drp-${item.id}`, - ...getQueueItemScope(item), - }); - } + const scope = getQueueItemScope(item); + scheduleQueueEmit(runtime, scope); }, }, }); @@ -868,6 +577,113 @@ function clearActiveRunState(runtime: ListenerRuntime): void { runtime.activeAbortController = null; } +function clearRecoveredApprovalState(runtime: ListenerRuntime): void { + runtime.recoveredApprovalState = null; +} + +function clearRecoveredApprovalStateForScope( + runtime: ListenerRuntime, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + const recovered = getRecoveredApprovalStateForScope(runtime, params); + if (recovered) { + clearRecoveredApprovalState(runtime); + } +} + +function getRecoveredApprovalStateForScope( + runtime: ListenerRuntime, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): RecoveredApprovalState | null { + const scopedAgentId = resolveScopedAgentId(runtime, params); + if (!scopedAgentId) { + return null; + } + const scopedConversationId = resolveScopedConversationId(runtime, params); + const recovered = runtime.recoveredApprovalState; + if (!recovered) { + return null; + } + return recovered.agentId === scopedAgentId && + recovered.conversationId === scopedConversationId + ? recovered + : null; +} + +function getPendingControlRequests( + runtime: ListenerRuntime, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): PendingControlRequest[] { + const scopedAgentId = resolveScopedAgentId(runtime, params); + const scopedConversationId = resolveScopedConversationId(runtime, params); + const requests: PendingControlRequest[] = []; + + for (const pending of runtime.pendingApprovalResolvers.values()) { + const request = pending.controlRequest; + if (!request) continue; + if ( + scopedAgentId && + (request.agent_id ?? scopedAgentId) !== scopedAgentId + ) { + continue; + } + if ( + scopedConversationId && + (request.conversation_id ?? scopedConversationId) !== scopedConversationId + ) { + continue; + } + requests.push({ + request_id: request.request_id, + request: request.request, + }); + } + + const recovered = getRecoveredApprovalStateForScope(runtime, params); + if (recovered) { + for (const requestId of recovered.pendingRequestIds) { + const entry = recovered.approvalsByRequestId.get(requestId); + if (!entry) continue; + requests.push({ + request_id: entry.controlRequest.request_id, + request: entry.controlRequest.request, + }); + } + } + + return requests; +} + +function getPendingControlRequestCount( + runtime: ListenerRuntime, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): number { + return getPendingControlRequests(runtime, params).length; +} + +function emitRuntimeStateUpdates( + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + emitLoopStatusIfOpen(runtime, scope); + emitDeviceStatusIfOpen(runtime, scope); +} + function rememberPendingApprovalBatchIds( runtime: ListenerRuntime, pendingApprovals: Array<{ toolCallId: string }>, @@ -950,6 +766,7 @@ function stopRuntime( runtime.pendingInterruptedContext = null; runtime.pendingInterruptedToolCallIds = null; runtime.activeExecutingToolCallIds = []; + runtime.loopStatus = "WAITING_ON_INPUT"; runtime.continuationEpoch++; if (!runtime.socket) { @@ -972,52 +789,249 @@ function stopRuntime( } } -function isValidControlResponseBody( +function isValidApprovalResponseBody( value: unknown, -): value is ControlResponseBody { +): value is ApprovalResponseBody { if (!value || typeof value !== "object") { return false; } const maybeResponse = value as { - subtype?: unknown; request_id?: unknown; + decision?: unknown; + error?: unknown; }; + if (typeof maybeResponse.request_id !== "string") { + return false; + } + if (maybeResponse.error !== undefined) { + return typeof maybeResponse.error === "string"; + } + if (!maybeResponse.decision || typeof maybeResponse.decision !== "object") { + return false; + } + const decision = maybeResponse.decision as { + behavior?: unknown; + message?: unknown; + updated_input?: unknown; + updated_permissions?: unknown; + }; + if (decision.behavior === "allow") { + const hasUpdatedInput = + decision.updated_input === undefined || + decision.updated_input === null || + typeof decision.updated_input === "object"; + const hasUpdatedPermissions = + decision.updated_permissions === undefined || + (Array.isArray(decision.updated_permissions) && + decision.updated_permissions.every( + (entry) => typeof entry === "string", + )); + return hasUpdatedInput && hasUpdatedPermissions; + } + if (decision.behavior === "deny") { + return typeof decision.message === "string"; + } + return false; +} + +function isRuntimeScope(value: unknown): value is RuntimeScope { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { agent_id?: unknown; conversation_id?: unknown }; return ( - typeof maybeResponse.subtype === "string" && - typeof maybeResponse.request_id === "string" + typeof candidate.agent_id === "string" && + candidate.agent_id.length > 0 && + typeof candidate.conversation_id === "string" && + candidate.conversation_id.length > 0 ); } +function isInputCommand(value: unknown): value is InputCommand { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + type?: unknown; + runtime?: unknown; + payload?: unknown; + }; + if (candidate.type !== "input" || !isRuntimeScope(candidate.runtime)) { + return false; + } + if (!candidate.payload || typeof candidate.payload !== "object") { + return false; + } + + const payload = candidate.payload as { + kind?: unknown; + messages?: unknown; + request_id?: unknown; + decision?: unknown; + error?: unknown; + }; + if (payload.kind === "create_message") { + return Array.isArray(payload.messages); + } + if (payload.kind === "approval_response") { + return isValidApprovalResponseBody(payload); + } + return false; +} + +function getInvalidInputReason(value: unknown): { + runtime: RuntimeScope; + reason: string; +} | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as { + type?: unknown; + runtime?: unknown; + payload?: unknown; + }; + if (candidate.type !== "input" || !isRuntimeScope(candidate.runtime)) { + return null; + } + if (!candidate.payload || typeof candidate.payload !== "object") { + return { + runtime: candidate.runtime, + reason: "Protocol violation: input.payload must be an object", + }; + } + const payload = candidate.payload as { + kind?: unknown; + messages?: unknown; + request_id?: unknown; + decision?: unknown; + error?: unknown; + }; + if (payload.kind === "create_message") { + if (!Array.isArray(payload.messages)) { + return { + runtime: candidate.runtime, + reason: + "Protocol violation: input.kind=create_message requires payload.messages[]", + }; + } + return null; + } + if (payload.kind === "approval_response") { + if (!isValidApprovalResponseBody(payload)) { + return { + runtime: candidate.runtime, + reason: + "Protocol violation: input.kind=approval_response requires payload.request_id and either payload.decision or payload.error", + }; + } + return null; + } + return { + runtime: candidate.runtime, + reason: `Unsupported input payload kind: ${String(payload.kind)}`, + }; +} + +function isChangeDeviceStateCommand( + value: unknown, +): value is ChangeDeviceStateCommand { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + type?: unknown; + runtime?: unknown; + payload?: unknown; + }; + if ( + candidate.type !== "change_device_state" || + !isRuntimeScope(candidate.runtime) + ) { + return false; + } + if (!candidate.payload || typeof candidate.payload !== "object") { + return false; + } + const payload = candidate.payload as { + mode?: unknown; + cwd?: unknown; + agent_id?: unknown; + conversation_id?: unknown; + }; + const hasMode = + payload.mode === undefined || typeof payload.mode === "string"; + const hasCwd = payload.cwd === undefined || typeof payload.cwd === "string"; + const hasAgentId = + payload.agent_id === undefined || + payload.agent_id === null || + typeof payload.agent_id === "string"; + const hasConversationId = + payload.conversation_id === undefined || + payload.conversation_id === null || + typeof payload.conversation_id === "string"; + return hasMode && hasCwd && hasAgentId && hasConversationId; +} + +function isAbortMessageCommand(value: unknown): value is AbortMessageCommand { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + type?: unknown; + runtime?: unknown; + request_id?: unknown; + run_id?: unknown; + }; + if ( + candidate.type !== "abort_message" || + !isRuntimeScope(candidate.runtime) + ) { + return false; + } + const hasRequestId = + candidate.request_id === undefined || + typeof candidate.request_id === "string"; + const hasRunId = + candidate.run_id === undefined || + candidate.run_id === null || + typeof candidate.run_id === "string"; + return hasRequestId && hasRunId; +} + +function isSyncCommand(value: unknown): value is SyncCommand { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as { + type?: unknown; + runtime?: unknown; + }; + return candidate.type === "sync" && isRuntimeScope(candidate.runtime); +} + export function parseServerMessage( data: WebSocket.RawData, -): ServerMessage | null { +): ParsedServerMessage | null { try { const raw = typeof data === "string" ? data : data.toString(); - const parsed = JSON.parse(raw) as { type?: string; response?: unknown }; + const parsed = JSON.parse(raw) as unknown; if ( - parsed.type === "pong" || - parsed.type === "status" || - parsed.type === "message" || - parsed.type === "mode_change" || - parsed.type === "get_status" || - parsed.type === "get_state" || - parsed.type === "change_cwd" || - parsed.type === "list_folders_in_directory" || - parsed.type === "terminal_spawn" || - parsed.type === "terminal_input" || - parsed.type === "terminal_resize" || - parsed.type === "terminal_kill" || - parsed.type === "cancel_run" || - parsed.type === "recover_pending_approvals" + isInputCommand(parsed) || + isChangeDeviceStateCommand(parsed) || + isAbortMessageCommand(parsed) || + isSyncCommand(parsed) ) { - return parsed as ServerMessage; + return parsed; } - if ( - parsed.type === "control_response" && - isValidControlResponseBody(parsed.response) - ) { - return parsed as ServerMessage; + const invalidInput = getInvalidInputReason(parsed); + if (invalidInput) { + return { + type: "__invalid_input", + runtime: invalidInput.runtime, + reason: invalidInput.reason, + }; } return null; } catch { @@ -1046,7 +1060,7 @@ function nextEventSeq(runtime: ListenerRuntime | null): number | null { return runtime.eventSeqCounter; } -function getQueueItemContent(item: QueueItem): unknown { +function getQueueItemContent(item: QueueItem): QueueMessage["content"] { return item.kind === "message" ? item.content : item.text; } @@ -1246,9 +1260,12 @@ function shouldQueueInboundMessage(parsed: IncomingMessage): boolean { function computeListenerQueueBlockedReason( runtime: ListenerRuntime, ): QueueBlockedReason | null { + const activeScope = resolveRuntimeScope(runtime); return getListenerBlockedReason({ isProcessing: runtime.isProcessing, - pendingApprovalsLen: runtime.pendingApprovalResolvers.size, + pendingApprovalsLen: activeScope + ? getPendingControlRequestCount(runtime, activeScope) + : 0, cancelRequested: runtime.cancelRequested, isRecoveringApprovals: runtime.isRecoveringApprovals, }); @@ -1291,6 +1308,10 @@ async function drainQueuedMessages( continue; } + // Emit the user message as a stream_delta so the web can display it + // immediately when the turn starts (before the API call). + emitDequeuedUserMessage(socket, runtime, queuedTurn, dequeuedBatch); + opts.onStatusChange?.("receiving", opts.connectionId); await handleIncomingMessage( queuedTurn, @@ -1333,25 +1354,150 @@ function scheduleQueuePump( }); } -function buildStateResponse( +function resolveScopedAgentId( + runtime: ListenerRuntime | null, + params?: { + agent_id?: string | null; + }, +): string | null { + return ( + normalizeCwdAgentId(params?.agent_id) ?? runtime?.activeAgentId ?? null + ); +} + +function resolveScopedConversationId( + runtime: ListenerRuntime | null, + params?: { + conversation_id?: string | null; + }, +): string { + return normalizeConversationId( + params?.conversation_id ?? runtime?.activeConversationId, + ); +} + +function resolveRuntimeScope( + runtime: ListenerRuntime | null, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): RuntimeScope | null { + const resolvedAgentId = resolveScopedAgentId(runtime, params); + if (!resolvedAgentId) { + return null; + } + const resolvedConversationId = resolveScopedConversationId(runtime, params); + return { + agent_id: resolvedAgentId, + conversation_id: resolvedConversationId, + }; +} + +/** + * Returns true when the requested scope matches the conversation that is + * currently executing on the device. When the device is idle (not processing) + * every scope is trivially "active" — the flag is only meaningful while a run + * is in progress, so we return `true` for the idle case to let callers report + * the real (idle) device state rather than a synthetic zero state. + */ +function isScopeCurrentlyActive( runtime: ListenerRuntime, - stateSeq: number, - agentId?: string | null, - conversationId?: string | null, -): StateResponseMessage { - const scopedAgentId = normalizeCwdAgentId(agentId); - const scopedConversationId = normalizeConversationId(conversationId); - const configuredWorkingDirectory = getConversationWorkingDirectory( + agentId: string | null, + conversationId: string, +): boolean { + if (!runtime.isProcessing) return true; + + const activeAgent = runtime.activeAgentId; + const activeConvo = normalizeConversationId(runtime.activeConversationId); + + if (agentId && activeAgent && agentId !== activeAgent) return false; + if (conversationId !== activeConvo) return false; + + return true; +} + +function buildDeviceStatus( + runtime: ListenerRuntime, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): DeviceStatus { + const scopedAgentId = resolveScopedAgentId(runtime, params); + const scopedConversationId = resolveScopedConversationId(runtime, params); + const scopeActive = isScopeCurrentlyActive( runtime, scopedAgentId, scopedConversationId, ); - const activeTurnWorkingDirectory = - runtime.activeAgentId === scopedAgentId && - runtime.activeConversationId === scopedConversationId - ? runtime.activeWorkingDirectory - : null; - const queueItems = runtime.queueRuntime.items.map((item) => ({ + const toolsetPreference = (() => { + if (!scopedAgentId) { + return "auto" as const; + } + try { + return settingsManager.getToolsetPreference(scopedAgentId); + } catch { + // Tests and early boot can query status before settings are initialized. + return "auto" as const; + } + })(); + return { + current_connection_id: runtime.connectionId, + connection_name: runtime.connectionName, + is_online: runtime.socket?.readyState === WebSocket.OPEN, + is_processing: scopeActive && runtime.isProcessing, + current_permission_mode: permissionMode.getMode(), + current_working_directory: getConversationWorkingDirectory( + runtime, + scopedAgentId, + scopedConversationId, + ), + letta_code_version: process.env.npm_package_version || null, + current_toolset: toolsetPreference === "auto" ? null : toolsetPreference, + current_toolset_preference: toolsetPreference, + current_loaded_tools: getToolNames(), + current_available_skills: [], + background_processes: [], + pending_control_requests: getPendingControlRequests(runtime, params), + }; +} + +function buildLoopStatus( + runtime: ListenerRuntime, + params?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): LoopState { + const scopedAgentId = resolveScopedAgentId(runtime, params); + const scopedConversationId = resolveScopedConversationId(runtime, params); + const scopeActive = isScopeCurrentlyActive( + runtime, + scopedAgentId, + scopedConversationId, + ); + + // If the requested scope is NOT the one currently executing, report idle. + if (!scopeActive) { + return { status: "WAITING_ON_INPUT", active_run_ids: [] }; + } + + const recovered = getRecoveredApprovalStateForScope(runtime, params); + const status = + recovered && + recovered.pendingRequestIds.size > 0 && + runtime.loopStatus === "WAITING_ON_INPUT" + ? "WAITING_ON_APPROVAL" + : runtime.loopStatus; + return { + status, + active_run_ids: runtime.activeRunId ? [runtime.activeRunId] : [], + }; +} + +function buildQueueSnapshot(runtime: ListenerRuntime): QueueMessage[] { + return runtime.queueRuntime.items.map((item) => ({ id: item.id, client_message_id: item.clientMessageId ?? `cm-${item.id}`, kind: item.kind, @@ -1359,234 +1505,465 @@ function buildStateResponse( content: getQueueItemContent(item), enqueued_at: new Date(item.enqueuedAt).toISOString(), })); +} - const pendingControlRequests = Array.from( - runtime.pendingApprovalResolvers.entries(), - ).flatMap(([requestId, pending]) => { - if (!pending.controlRequest) { - return []; - } - return [ - { - request_id: requestId, - request: pending.controlRequest.request, - }, - ]; +function isApprovalOnlyInput( + input: Array, +): boolean { + return ( + input.length === 1 && + input[0] !== undefined && + "type" in input[0] && + input[0].type === "approval" + ); +} + +function markAwaitingAcceptedApprovalContinuationRunId( + runtime: ListenerRuntime, + input: Array, +): void { + if (isApprovalOnlyInput(input)) { + runtime.activeRunId = null; + } +} + +function setLoopStatus( + runtime: ListenerRuntime, + status: LoopStatus, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + if (runtime.loopStatus === status) { + return; + } + runtime.loopStatus = status; + emitLoopStatusIfOpen(runtime, scope); +} + +function emitProtocolV2Message( + socket: WebSocket, + runtime: ListenerRuntime | null, + message: Omit< + WsProtocolMessage, + "runtime" | "event_seq" | "emitted_at" | "idempotency_key" + >, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + if (socket.readyState !== WebSocket.OPEN) { + return; + } + const runtimeScope = resolveRuntimeScope(runtime, scope); + if (!runtimeScope) { + return; + } + const eventSeq = nextEventSeq(runtime); + if (eventSeq === null) { + return; + } + const outbound: WsProtocolMessage = { + ...message, + runtime: runtimeScope, + event_seq: eventSeq, + emitted_at: new Date().toISOString(), + idempotency_key: `${message.type}:${eventSeq}:${crypto.randomUUID()}`, + } as WsProtocolMessage; + try { + socket.send(JSON.stringify(outbound)); + } catch (error) { + console.error( + `[Listen V2] Failed to emit ${message.type} (seq=${eventSeq})`, + error, + ); + safeEmitWsEvent("send", "lifecycle", { + type: "_ws_send_error", + message_type: message.type, + event_seq: eventSeq, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + console.log(`[Listen V2] Emitting ${message.type} (seq=${eventSeq})`); + safeEmitWsEvent("send", "protocol", outbound); +} + +function emitDeviceStatusUpdate( + socket: WebSocket, + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + const message: Omit< + DeviceStatusUpdateMessage, + "runtime" | "event_seq" | "emitted_at" | "idempotency_key" + > = { + type: "update_device_status", + device_status: buildDeviceStatus(runtime, scope), + }; + emitProtocolV2Message(socket, runtime, message, scope); +} + +function emitLoopStatusUpdate( + socket: WebSocket, + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + const message: Omit< + LoopStatusUpdateMessage, + "runtime" | "event_seq" | "emitted_at" | "idempotency_key" + > = { + type: "update_loop_status", + loop_status: buildLoopStatus(runtime, scope), + }; + emitProtocolV2Message(socket, runtime, message, scope); +} + +function emitLoopStatusIfOpen( + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + if (runtime.socket?.readyState === WebSocket.OPEN) { + emitLoopStatusUpdate(runtime.socket, runtime, scope); + } +} + +function emitDeviceStatusIfOpen( + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + if (runtime.socket?.readyState === WebSocket.OPEN) { + emitDeviceStatusUpdate(runtime.socket, runtime, scope); + } +} + +function emitQueueUpdate( + socket: WebSocket, + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + const scopedAgentId = resolveScopedAgentId(runtime, scope); + const scopedConversationId = resolveScopedConversationId(runtime, scope); + const scopeActive = isScopeCurrentlyActive( + runtime, + scopedAgentId, + scopedConversationId, + ); + + const message: Omit< + QueueUpdateMessage, + "runtime" | "event_seq" | "emitted_at" | "idempotency_key" + > = { + type: "update_queue", + queue: scopeActive ? buildQueueSnapshot(runtime) : [], + }; + emitProtocolV2Message(socket, runtime, message, scope); +} + +const SYSTEM_REMINDER_RE = /[\s\S]*?<\/system-reminder>/g; + +function isSystemReminderPart(part: unknown): boolean { + if (!part || typeof part !== "object") return false; + if (!("type" in part) || (part as { type: string }).type !== "text") + return false; + if (!("text" in part) || typeof (part as { text: string }).text !== "string") + return false; + const trimmed = (part as { text: string }).text.trim(); + return ( + trimmed.startsWith("") && + trimmed.endsWith("") + ); +} + +/** + * Emit a synthetic user_message stream_delta when a queued turn is about to + * be submitted to the API. This lets the web display the user message + * immediately in the transcript without waiting for a poll or API echo. + * + * Preserves the original content format (string → string, array → array) + * and strips system-reminder content before emitting. + * + * The client_message_id from the original submit payload is used as the otid + * so that the optimistic message (if any) gets deduplicated. + */ +function emitDequeuedUserMessage( + socket: WebSocket, + runtime: ListenerRuntime, + incoming: IncomingMessage, + batch: DequeuedBatch, +): void { + const firstUserPayload = incoming.messages.find( + (payload): payload is MessageCreate & { client_message_id?: string } => + "content" in payload, + ); + if (!firstUserPayload) return; + + const rawContent = firstUserPayload.content; + let content: MessageCreate["content"]; + + if (typeof rawContent === "string") { + // String content — strip system-reminder blocks via regex + content = rawContent.replace(SYSTEM_REMINDER_RE, "").trim(); + } else if (Array.isArray(rawContent)) { + // Array content — filter out system-reminder text parts + content = rawContent.filter((part) => !isSystemReminderPart(part)); + } else { + return; + } + + // Check if there's meaningful content left + const hasContent = + typeof content === "string" + ? content.length > 0 + : Array.isArray(content) && content.length > 0; + if (!hasContent) return; + + const otid = firstUserPayload.client_message_id ?? batch.batchId; + + emitCanonicalMessageDelta( + socket, + runtime, + { + type: "message", + id: `user-msg-${crypto.randomUUID()}`, + date: new Date().toISOString(), + message_type: "user_message", + content, + otid, + } as StreamDelta, + { + agent_id: incoming.agentId, + conversation_id: incoming.conversationId, + }, + ); +} + +function emitQueueUpdateIfOpen( + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + if (runtime.socket?.readyState === WebSocket.OPEN) { + emitQueueUpdate(runtime.socket, runtime, scope); + } +} + +function emitStateSync( + socket: WebSocket, + runtime: ListenerRuntime, + scope: RuntimeScope, +): void { + emitDeviceStatusUpdate(socket, runtime, scope); + emitLoopStatusUpdate(socket, runtime, scope); + emitQueueUpdate(socket, runtime, scope); +} + +/** + * Coalesces rapid queue mutations into a single `update_queue` emit. + * Uses `queueMicrotask` so that enqueue + immediate dequeue within the + * same tick produce only one WS message with the final queue state, + * preventing a visible flash of transient queue items. + */ +function scheduleQueueEmit( + runtime: ListenerRuntime, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + // Last writer wins — keep the most recent scope + runtime.pendingQueueEmitScope = scope; + + if (runtime.queueEmitScheduled) return; + runtime.queueEmitScheduled = true; + + queueMicrotask(() => { + runtime.queueEmitScheduled = false; + const emitScope = runtime.pendingQueueEmitScope; + runtime.pendingQueueEmitScope = undefined; + emitQueueUpdateIfOpen(runtime, emitScope); }); +} +function createLifecycleMessageBase( + messageType: TMessageType, + runId?: string | null, +): { + id: string; + date: string; + message_type: TMessageType; + run_id?: string; +} { return { - type: "state_response", - schema_version: 1, - session_id: runtime.sessionId, - snapshot_id: `snapshot-${crypto.randomUUID()}`, - generated_at: new Date().toISOString(), - state_seq: stateSeq, - event_seq: stateSeq, - cwd: configuredWorkingDirectory, - configured_cwd: configuredWorkingDirectory, - active_turn_cwd: activeTurnWorkingDirectory, - cwd_agent_id: scopedAgentId, - cwd_conversation_id: scopedConversationId, - mode: permissionMode.getMode(), - is_processing: runtime.isProcessing, - last_stop_reason: runtime.lastStopReason, - control_response_capable: true, - tool_lifecycle_capable: true, - active_run: { - run_id: runtime.activeRunId, - agent_id: runtime.activeAgentId, - conversation_id: runtime.activeConversationId, - started_at: runtime.activeRunStartedAt, - }, - pending_control_requests: pendingControlRequests, - pending_interrupt: buildPendingInterruptState(runtime), - queue: { - queue_len: runtime.queueRuntime.length, - pending_turns: runtime.pendingTurns, - items: queueItems, - }, + id: `message-${crypto.randomUUID()}`, + date: new Date().toISOString(), + message_type: messageType, + ...(runId ? { run_id: runId } : {}), }; } -function sendStateSnapshot( +function emitCanonicalMessageDelta( socket: WebSocket, - runtime: ListenerRuntime, - agentId?: string | null, - conversationId?: string | null, + runtime: ListenerRuntime | null, + delta: StreamDelta, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, ): void { - const stateSeq = nextEventSeq(runtime); - if (stateSeq === null) { - return; - } - const stateResponse = buildStateResponse( - runtime, - stateSeq, - agentId, - conversationId, - ); - sendClientMessage(socket, stateResponse, runtime); + emitStreamDelta(socket, runtime, delta, scope); } -function emitCancelAck( +function emitLoopErrorDelta( socket: WebSocket, - runtime: ListenerRuntime, + runtime: ListenerRuntime | null, params: { - requestId: string; - accepted: boolean; - reason?: string; + message: string; + stopReason: StopReasonType; + isTerminal: boolean; runId?: string | null; agentId?: string | null; conversationId?: string | null; }, ): void { - emitToWS(socket, { - type: "cancel_ack", - request_id: params.requestId, - accepted: params.accepted, - reason: params.reason, - run_id: params.runId ?? runtime.activeRunId, - agent_id: params.agentId ?? runtime.activeAgentId ?? undefined, - conversation_id: - params.conversationId ?? runtime.activeConversationId ?? undefined, - session_id: runtime.sessionId, - uuid: `cancel-ack-${params.requestId}`, - } as CancelAckMessage); + emitCanonicalMessageDelta( + socket, + runtime, + { + ...createLifecycleMessageBase("loop_error", params.runId), + message: params.message, + stop_reason: params.stopReason, + is_terminal: params.isTerminal, + } as StreamDelta, + { + agent_id: params.agentId, + conversation_id: params.conversationId, + }, + ); } -function emitTurnResult( +function emitRetryDelta( socket: WebSocket, runtime: ListenerRuntime, params: { - subtype: ProtocolResultMessage["subtype"]; - agentId: string; - conversationId: string; - durationMs: number; - numTurns: number; - runIds: string[]; - stopReason?: StopReasonType; + message: string; + reason: StopReasonType; + attempt: number; + maxAttempts: number; + delayMs: number; + runId?: string | null; + agentId?: string | null; + conversationId?: string | null; }, ): void { - emitToWS(socket, { - type: "result", - subtype: params.subtype, + const delta: RetryMessage = { + ...createLifecycleMessageBase("retry", params.runId), + message: params.message, + reason: params.reason, + attempt: params.attempt, + max_attempts: params.maxAttempts, + delay_ms: params.delayMs, + }; + emitCanonicalMessageDelta(socket, runtime, delta, { agent_id: params.agentId, conversation_id: params.conversationId, - duration_ms: params.durationMs, - duration_api_ms: 0, - num_turns: params.numTurns, - result: null, - run_ids: params.runIds, - usage: null, - ...(params.stopReason ? { stop_reason: params.stopReason } : {}), - session_id: runtime.sessionId, - uuid: `result-${crypto.randomUUID()}`, }); } -function sendClientMessage( +function emitStatusDelta( socket: WebSocket, - payload: ClientMessage, - runtime: ListenerRuntime | null = activeRuntime, + runtime: ListenerRuntime | null, + params: { + message: string; + level: StatusMessage["level"]; + runId?: string | null; + agentId?: string | null; + conversationId?: string | null; + }, ): void { - if (socket.readyState === WebSocket.OPEN) { - let outbound = payload as unknown as Record; - if (payload.type !== "ping") { - const hasEventSeq = typeof outbound.event_seq === "number"; - if (!hasEventSeq) { - const eventSeq = nextEventSeq(runtime); - if (eventSeq !== null) { - outbound = { - ...outbound, - event_seq: eventSeq, - session_id: - typeof outbound.session_id === "string" - ? outbound.session_id - : runtime?.sessionId, - }; - } - } else if ( - typeof outbound.session_id !== "string" && - runtime?.sessionId - ) { - outbound = { - ...outbound, - session_id: runtime.sessionId, - }; - } - } - safeEmitWsEvent("send", "client", outbound); - socket.send(JSON.stringify(outbound)); - } + const delta: StatusMessage = { + ...createLifecycleMessageBase("status", params.runId), + message: params.message, + level: params.level, + }; + emitCanonicalMessageDelta(socket, runtime, delta, { + agent_id: params.agentId, + conversation_id: params.conversationId, + }); } -function sendControlMessageOverWebSocket( +export function emitInterruptedStatusDelta( socket: WebSocket, - payload: ControlRequest, - runtime: ListenerRuntime | null = activeRuntime, + runtime: ListenerRuntime | null, + params: { + runId?: string | null; + agentId?: string | null; + conversationId?: string | null; + }, ): void { - // Central hook for protocol-only outbound WS messages so future - // filtering/mutation can be added without touching approval flow. - const eventSeq = nextEventSeq(runtime); - const outbound = - eventSeq === null - ? payload - : { - ...payload, - event_seq: eventSeq, - session_id: runtime?.sessionId, - }; - safeEmitWsEvent("send", "control", outbound); - socket.send(JSON.stringify(outbound)); + emitStatusDelta(socket, runtime, { + message: "Interrupted", + level: "warning", + runId: params.runId, + agentId: params.agentId ?? undefined, + conversationId: params.conversationId ?? undefined, + }); } -// ── Typed protocol event adapter ──────────────────────────────── - -export type WsProtocolEvent = - | MessageWire - | ApprovalRequestedMessage - | ApprovalReceivedMessage - | ToolExecutionStartedMessage - | ToolExecutionFinishedMessage - | AutoApprovalMessage - | CancelAckMessage - | ErrorMessage - | RetryMessage - | RecoveryMessage - | ProtocolResultMessage - | QueueLifecycleEvent - | TranscriptBackfillMessage - | QueueSnapshotMessage - | SyncCompleteMessage - | TranscriptSupplementMessage; - -/** - * Single adapter for all outbound typed protocol events. - * Passthrough for now — provides a seam for future filtering/versioning/redacting. - */ -function emitToWS(socket: WebSocket, event: WsProtocolEvent): void { - if (socket.readyState === WebSocket.OPEN) { - const runtime = activeRuntime; - const eventSeq = nextEventSeq(runtime); - const eventRecord = event as unknown as Record; - const outbound = - eventSeq === null - ? eventRecord - : { - ...eventRecord, - event_seq: eventSeq, - session_id: - typeof eventRecord.session_id === "string" - ? eventRecord.session_id - : runtime?.sessionId, - }; - safeEmitWsEvent("send", "protocol", outbound); - socket.send(JSON.stringify(outbound)); - } +function emitStreamDelta( + socket: WebSocket, + runtime: ListenerRuntime | null, + delta: StreamDelta, + scope?: { + agent_id?: string | null; + conversation_id?: string | null; + }, +): void { + const message: Omit< + StreamDeltaMessage, + "runtime" | "event_seq" | "emitted_at" | "idempotency_key" + > = { + type: "stream_delta", + delta, + }; + emitProtocolV2Message(socket, runtime, message, scope); } const LLM_API_ERROR_MAX_RETRIES = 3; +const EMPTY_RESPONSE_MAX_RETRIES = 2; const MAX_PRE_STREAM_RECOVERY = 2; const MAX_POST_STOP_APPROVAL_RECOVERY = 2; +const NO_AWAITING_APPROVAL_DETAIL_FRAGMENT = + "no tool call is currently awaiting approval"; + +function isApprovalToolCallDesyncError(detail: unknown): boolean { + if (isInvalidToolCallIdsError(detail) || isApprovalPendingError(detail)) { + return true; + } + return ( + typeof detail === "string" && + detail.toLowerCase().includes(NO_AWAITING_APPROVAL_DETAIL_FRAGMENT) + ); +} function shouldAttemptPostStopApprovalRecovery(params: { stopReason: string | null | undefined; @@ -1595,12 +1972,9 @@ function shouldAttemptPostStopApprovalRecovery(params: { runErrorDetail: string | null; latestErrorText: string | null; }): boolean { - const invalidToolCallIdsDetected = - isInvalidToolCallIdsError(params.runErrorDetail) || - isInvalidToolCallIdsError(params.latestErrorText); - const approvalPendingDetected = - isApprovalPendingError(params.runErrorDetail) || - isApprovalPendingError(params.latestErrorText); + const approvalDesyncDetected = + isApprovalToolCallDesyncError(params.runErrorDetail) || + isApprovalToolCallDesyncError(params.latestErrorText); // Heuristic fallback: // If the stream stops with generic "error" before any run_id was emitted, @@ -1609,15 +1983,181 @@ function shouldAttemptPostStopApprovalRecovery(params: { params.stopReason === "error" && params.runIdsSeen === 0; return shouldAttemptApprovalRecovery({ - approvalPendingDetected: - invalidToolCallIdsDetected || - approvalPendingDetected || - genericNoRunError, + approvalPendingDetected: approvalDesyncDetected || genericNoRunError, retries: params.retries, maxRetries: MAX_POST_STOP_APPROVAL_RECOVERY, }); } +async function isRetriablePostStopError( + stopReason: StopReasonType, + lastRunId: string | null | undefined, +): Promise { + if (stopReason === "llm_api_error") { + return true; + } + + const nonRetriableReasons: StopReasonType[] = [ + "cancelled", + "requires_approval", + "max_steps", + "max_tokens_exceeded", + "context_window_overflow_in_system_prompt", + "end_turn", + "tool_rule", + "no_tool_call", + ]; + if (nonRetriableReasons.includes(stopReason)) { + return false; + } + + if (!lastRunId) { + return false; + } + + try { + const client = await getClient(); + const run = await client.runs.retrieve(lastRunId); + const metaError = run.metadata?.error as + | { + error_type?: string; + detail?: string; + error?: { error_type?: string; detail?: string }; + } + | undefined; + + const errorType = metaError?.error_type ?? metaError?.error?.error_type; + const detail = metaError?.detail ?? metaError?.error?.detail ?? ""; + return shouldRetryRunMetadataError(errorType, detail); + } catch { + return false; + } +} + +async function drainRecoveryStreamWithEmission( + recoveryStream: Stream, + socket: WebSocket, + runtime: ListenerRuntime, + params: { + agentId?: string | null; + conversationId: string; + abortSignal: AbortSignal; + }, +): Promise>> { + let recoveryRunIdSent = false; + + return drainStreamWithResume( + recoveryStream, + createBuffers(params.agentId || ""), + () => {}, + params.abortSignal, + undefined, + ({ chunk, shouldOutput, errorInfo }) => { + const maybeRunId = (chunk as { run_id?: unknown }).run_id; + if (typeof maybeRunId === "string") { + if (runtime.activeRunId !== maybeRunId) { + runtime.activeRunId = maybeRunId; + } + if (!recoveryRunIdSent) { + recoveryRunIdSent = true; + emitLoopStatusUpdate(socket, runtime, { + agent_id: params.agentId ?? undefined, + conversation_id: params.conversationId, + }); + } + } + + if (errorInfo) { + emitLoopErrorDelta(socket, runtime, { + message: errorInfo.message || "Stream error", + stopReason: (errorInfo.error_type as StopReasonType) || "error", + isTerminal: false, + runId: runtime.activeRunId || errorInfo.run_id, + agentId: params.agentId ?? undefined, + conversationId: params.conversationId, + }); + } + + if (shouldOutput) { + const normalizedChunk = normalizeToolReturnWireMessage( + chunk as unknown as Record, + ); + if (normalizedChunk) { + emitCanonicalMessageDelta( + socket, + runtime, + { + ...normalizedChunk, + type: "message", + } as StreamDelta, + { + agent_id: params.agentId ?? undefined, + conversation_id: params.conversationId, + }, + ); + } + } + + return undefined; + }, + ); +} + +function finalizeHandledRecoveryTurn( + runtime: ListenerRuntime, + socket: WebSocket, + params: { + drainResult: Awaited>; + agentId?: string | null; + conversationId: string; + }, +): void { + const scope = { + agent_id: params.agentId ?? null, + conversation_id: params.conversationId, + }; + + if (params.drainResult.stopReason === "end_turn") { + runtime.lastStopReason = "end_turn"; + runtime.isProcessing = false; + setLoopStatus(runtime, "WAITING_ON_INPUT", scope); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, scope); + return; + } + + if (params.drainResult.stopReason === "cancelled") { + runtime.lastStopReason = "cancelled"; + runtime.isProcessing = false; + emitInterruptedStatusDelta(socket, runtime, { + runId: runtime.activeRunId, + agentId: params.agentId ?? undefined, + conversationId: params.conversationId, + }); + setLoopStatus(runtime, "WAITING_ON_INPUT", scope); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, scope); + return; + } + + const terminalStopReason = + (params.drainResult.stopReason as StopReasonType) || "error"; + runtime.lastStopReason = terminalStopReason; + runtime.isProcessing = false; + setLoopStatus(runtime, "WAITING_ON_INPUT", scope); + const runId = runtime.activeRunId; + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, scope); + emitLoopErrorDelta(socket, runtime, { + message: `Recovery continuation ended unexpectedly: ${terminalStopReason}`, + stopReason: terminalStopReason, + isTerminal: true, + runId: runId || undefined, + agentId: params.agentId ?? undefined, + conversationId: params.conversationId, + }); +} + // --------------------------------------------------------------------------- // Interrupt queue helpers — extracted for testability. // These are the ONLY places that read/write pendingInterruptedResults. @@ -1646,38 +2186,6 @@ 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; @@ -1723,6 +2231,12 @@ function normalizeToolReturnValue(value: unknown): string { } } +function getApprovalContinuationRecoveryDisposition( + drainResult: Awaited> | null, +): "handled" | "retry" { + return drainResult ? "handled" : "retry"; +} + function normalizeInterruptedApprovalsForQueue( approvals: ApprovalResult[] | null, interruptedToolCallIds: string[], @@ -1752,6 +2266,135 @@ function normalizeExecutionResultsForInterruptParity( }); } +function collectApprovalResultToolCallIds( + approvals: ApprovalResult[], +): string[] { + return approvals + .map((approval) => { + if ( + approval && + typeof approval === "object" && + "tool_call_id" in approval && + typeof approval.tool_call_id === "string" + ) { + return approval.tool_call_id; + } + return null; + }) + .filter((toolCallId): toolCallId is string => !!toolCallId); +} + +function collectDecisionToolCallIds( + decisions: Array<{ + approval: { + toolCallId: string; + }; + }>, +): string[] { + return decisions + .map((decision) => decision.approval.toolCallId) + .filter((toolCallId) => toolCallId.length > 0); +} + +function validateApprovalResultIds( + decisions: Array<{ + approval: { + toolCallId: string; + }; + }>, + approvals: ApprovalResult[], +): void { + if (!process.env.DEBUG) { + return; + } + + const expectedIds = new Set(collectDecisionToolCallIds(decisions)); + const sendingIds = new Set(collectApprovalResultToolCallIds(approvals)); + const setsEqual = + expectedIds.size === sendingIds.size && + [...expectedIds].every((toolCallId) => sendingIds.has(toolCallId)); + + if (setsEqual) { + return; + } + + console.error( + "[Listen][DEBUG] Approval ID mismatch detected", + JSON.stringify( + { + expected: [...expectedIds], + sending: [...sendingIds], + }, + null, + 2, + ), + ); + throw new Error("Approval ID mismatch - refusing to send mismatched IDs"); +} + +async function debugLogApprovalResumeState( + runtime: ListenerRuntime, + params: { + agentId: string; + conversationId: string; + expectedToolCallIds: string[]; + sentToolCallIds: string[]; + }, +): Promise { + if (!process.env.DEBUG) { + return; + } + + try { + const client = await getClient(); + const agent = await client.agents.retrieve(params.agentId); + const isExplicitConversation = + params.conversationId.length > 0 && params.conversationId !== "default"; + const lastInContextId = isExplicitConversation + ? (( + await client.conversations.retrieve(params.conversationId) + ).in_context_message_ids?.at(-1) ?? null) + : (agent.message_ids?.at(-1) ?? null); + const lastInContextMessages = lastInContextId + ? await client.messages.retrieve(lastInContextId) + : []; + const resumeData = await getResumeData( + client, + agent, + params.conversationId, + { + includeMessageHistory: false, + }, + ); + + console.log( + "[Listen][DEBUG] Post-approval continuation resume snapshot", + JSON.stringify( + { + conversationId: params.conversationId, + activeRunId: runtime.activeRunId, + expectedToolCallIds: params.expectedToolCallIds, + sentToolCallIds: params.sentToolCallIds, + pendingApprovalToolCallIds: (resumeData.pendingApprovals ?? []).map( + (approval) => approval.toolCallId, + ), + lastInContextMessageId: lastInContextId, + lastInContextMessageTypes: lastInContextMessages.map( + (message) => message.message_type, + ), + }, + null, + 2, + ), + ); + } catch (error) { + console.warn( + "[Listen][DEBUG] Failed to capture post-approval resume snapshot:", + error instanceof Error ? error.message : String(error), + ); + } +} + function extractCanonicalToolReturnsFromWire( payload: Record, ): InterruptToolReturn[] { @@ -1937,26 +2580,33 @@ function emitInterruptToolReturnMessage( const resolvedRunId = runId ?? runtime.activeRunId ?? undefined; for (const toolReturn of toolReturns) { - emitToWS(socket, { - type: "message", - message_type: "tool_return_message", - id: `message-${crypto.randomUUID()}`, - date: new Date().toISOString(), - run_id: resolvedRunId, - agent_id: runtime.activeAgentId ?? undefined, - tool_returns: [ - { - tool_call_id: toolReturn.tool_call_id, - status: toolReturn.status, - tool_return: toolReturn.tool_return, - ...(toolReturn.stdout ? { stdout: toolReturn.stdout } : {}), - ...(toolReturn.stderr ? { stderr: toolReturn.stderr } : {}), - }, - ], - session_id: runtime.sessionId, - uuid: `${uuidPrefix}-${crypto.randomUUID()}`, - conversation_id: runtime.activeConversationId ?? undefined, - } as unknown as MessageWire); + emitCanonicalMessageDelta( + socket, + runtime, + { + type: "message", + message_type: "tool_return_message", + id: `message-${uuidPrefix}-${crypto.randomUUID()}`, + date: new Date().toISOString(), + run_id: resolvedRunId, + status: toolReturn.status, + tool_call_id: toolReturn.tool_call_id, + tool_return: toolReturn.tool_return, + tool_returns: [ + { + tool_call_id: toolReturn.tool_call_id, + status: toolReturn.status, + tool_return: toolReturn.tool_return, + ...(toolReturn.stdout ? { stdout: toolReturn.stdout } : {}), + ...(toolReturn.stderr ? { stderr: toolReturn.stderr } : {}), + }, + ], + }, + { + agent_id: runtime.activeAgentId ?? undefined, + conversation_id: runtime.activeConversationId ?? undefined, + }, + ); } } @@ -1971,12 +2621,11 @@ function emitToolExecutionStartedEvents( }, ): void { for (const toolCallId of params.toolCallIds) { - emitToWS(socket, { - type: "tool_execution_started", + const delta: ClientToolStartMessage = { + ...createLifecycleMessageBase("client_tool_start", params.runId), tool_call_id: toolCallId, - ...(params.runId ? { run_id: params.runId } : {}), - session_id: runtime.sessionId, - uuid: `tool-exec-started-${toolCallId}`, + }; + emitCanonicalMessageDelta(socket, runtime, delta, { agent_id: params.agentId, conversation_id: params.conversationId, }); @@ -1995,13 +2644,12 @@ function emitToolExecutionFinishedEvents( ): void { const toolReturns = extractInterruptToolReturns(params.approvals); for (const toolReturn of toolReturns) { - emitToWS(socket, { - type: "tool_execution_finished", + const delta: ClientToolEndMessage = { + ...createLifecycleMessageBase("client_tool_end", params.runId), tool_call_id: toolReturn.tool_call_id, status: toolReturn.status, - ...(params.runId ? { run_id: params.runId } : {}), - session_id: runtime.sessionId, - uuid: `tool-exec-finished-${toolReturn.tool_call_id}`, + }; + emitCanonicalMessageDelta(socket, runtime, delta, { agent_id: params.agentId, conversation_id: params.conversationId, }); @@ -2171,16 +2819,50 @@ function consumeInterruptQueue( }; } + const queuedToolCallIds = collectApprovalResultToolCallIds( + runtime.pendingInterruptedResults, + ); + // Atomic clear — always, regardless of context match. // Stale results for wrong context are discarded, not retried. runtime.pendingInterruptedResults = null; runtime.pendingInterruptedContext = null; runtime.pendingInterruptedToolCallIds = null; - runtime.pendingApprovalBatchByToolCallId.clear(); + for (const toolCallId of queuedToolCallIds) { + runtime.pendingApprovalBatchByToolCallId.delete(toolCallId); + } return result; } +function stashRecoveredApprovalInterrupts( + runtime: ListenerRuntime, + recovered: RecoveredApprovalState, +): boolean { + const approvals = [...recovered.approvalsByRequestId.values()].map( + (entry) => entry.approval, + ); + if (approvals.length === 0) { + clearRecoveredApprovalState(runtime); + return false; + } + + runtime.pendingInterruptedResults = approvals.map((approval) => ({ + type: "approval" as const, + tool_call_id: approval.toolCallId, + approve: false, + reason: "User interrupted the stream", + })); + runtime.pendingInterruptedContext = { + agentId: recovered.agentId, + conversationId: recovered.conversationId, + continuationEpoch: runtime.continuationEpoch, + }; + runtime.pendingInterruptedToolCallIds = null; + clearRecoveredApprovalState(runtime); + return true; +} + /** * Attempt to resolve stale pending approvals by fetching them from the backend * and auto-denying. This is the Phase 3 bounded recovery mechanism — it does NOT @@ -2188,9 +2870,10 @@ function consumeInterruptQueue( */ async function resolveStaleApprovals( runtime: ListenerRuntime, + socket: WebSocket, abortSignal: AbortSignal, -): Promise { - if (!runtime.activeAgentId) return; +): Promise> | null> { + if (!runtime.activeAgentId) return null; const client = await getClient(); let agent: Awaited>; @@ -2199,7 +2882,7 @@ async function resolveStaleApprovals( } catch (err) { // 404 = agent deleted, 422 = invalid ID — both mean nothing to recover if (err instanceof APIError && (err.status === 404 || err.status === 422)) { - return; + return null; } throw err; } @@ -2216,50 +2899,497 @@ async function resolveStaleApprovals( } catch (err) { // getResumeData rethrows 404/422 for conversations — treat as no approvals if (err instanceof APIError && (err.status === 404 || err.status === 422)) { - return; + return null; } throw err; } - const pendingApprovals = resumeData.pendingApprovals || []; - if (pendingApprovals.length === 0) return; + let pendingApprovals = resumeData.pendingApprovals || []; + if (pendingApprovals.length === 0) return null; if (abortSignal.aborted) throw new Error("Cancelled"); - const denialResults: ApprovalResult[] = pendingApprovals.map((approval) => ({ - type: "approval" as const, - tool_call_id: approval.toolCallId, - approve: false, - reason: "Auto-denied during pre-stream approval recovery", - })); - const recoveryConversationId = runtime.activeConversationId || "default"; - const recoveryStream = await sendMessageStream( - recoveryConversationId, - [{ type: "approval", approvals: denialResults }], - { + const recoveryWorkingDirectory = + runtime.activeWorkingDirectory ?? + getConversationWorkingDirectory( + runtime, + runtime.activeAgentId, + recoveryConversationId, + ); + const scope = { + agent_id: runtime.activeAgentId, + conversation_id: recoveryConversationId, + } as const; + + while (pendingApprovals.length > 0) { + const recoveryBatchId = resolveRecoveryBatchId(runtime, pendingApprovals); + if (!recoveryBatchId) { + throw new Error( + "Ambiguous pending approval batch mapping during recovery", + ); + } + rememberPendingApprovalBatchIds(runtime, pendingApprovals, recoveryBatchId); + + const { autoAllowed, autoDenied, needsUserInput } = await classifyApprovals( + pendingApprovals, + { + alwaysRequiresUserInput: isInteractiveApprovalTool, + requireArgsForAutoApprove: true, + missingNameReason: "Tool call incomplete - missing name", + workingDirectory: recoveryWorkingDirectory, + }, + ); + + const decisions: ApprovalDecision[] = [ + ...autoAllowed.map((ac) => ({ + type: "approve" as const, + approval: ac.approval, + })), + ...autoDenied.map((ac) => ({ + type: "deny" as const, + approval: ac.approval, + reason: ac.denyReason || ac.permission.reason || "Permission denied", + })), + ]; + + if (needsUserInput.length > 0) { + runtime.lastStopReason = "requires_approval"; + setLoopStatus(runtime, "WAITING_ON_APPROVAL", scope); + emitRuntimeStateUpdates(runtime, scope); + + for (const ac of needsUserInput) { + if (abortSignal.aborted) throw new Error("Cancelled"); + + const requestId = `perm-${ac.approval.toolCallId}`; + const diffs = await computeDiffPreviews( + ac.approval.toolName, + ac.parsedArgs, + recoveryWorkingDirectory, + ); + const controlRequest: ControlRequest = { + type: "control_request", + request_id: requestId, + request: { + subtype: "can_use_tool", + tool_name: ac.approval.toolName, + input: ac.parsedArgs, + tool_call_id: ac.approval.toolCallId, + permission_suggestions: [], + blocked_path: null, + ...(diffs.length > 0 ? { diffs } : {}), + }, + agent_id: runtime.activeAgentId, + conversation_id: recoveryConversationId, + }; + + const responseBody = await requestApprovalOverWS( + runtime, + socket, + requestId, + controlRequest, + ); + + if ("decision" in responseBody) { + const response = responseBody.decision as ApprovalResponseDecision; + if (response.behavior === "allow") { + decisions.push({ + type: "approve", + approval: response.updated_input + ? { + ...ac.approval, + toolArgs: JSON.stringify(response.updated_input), + } + : ac.approval, + }); + } else { + decisions.push({ + type: "deny", + approval: ac.approval, + reason: response.message || "Denied via WebSocket", + }); + } + } else { + decisions.push({ + type: "deny", + approval: ac.approval, + reason: responseBody.error, + }); + } + } + } + + if (decisions.length === 0) { + clearPendingApprovalBatchIds(runtime, pendingApprovals); + return null; + } + + const approvedToolCallIds = decisions + .filter( + ( + decision, + ): decision is Extract => + decision.type === "approve", + ) + .map((decision) => decision.approval.toolCallId); + + runtime.activeExecutingToolCallIds = [...approvedToolCallIds]; + setLoopStatus(runtime, "EXECUTING_CLIENT_SIDE_TOOL", scope); + emitRuntimeStateUpdates(runtime, scope); + emitToolExecutionStartedEvents(socket, runtime, { + toolCallIds: approvedToolCallIds, + runId: runtime.activeRunId ?? undefined, agentId: runtime.activeAgentId, - streamTokens: true, - background: true, - workingDirectory: - runtime.activeWorkingDirectory ?? + conversationId: recoveryConversationId, + }); + + try { + const approvalResults = await executeApprovalBatch(decisions, undefined, { + abortSignal, + workingDirectory: recoveryWorkingDirectory, + }); + emitToolExecutionFinishedEvents(socket, runtime, { + approvals: approvalResults, + runId: runtime.activeRunId ?? undefined, + agentId: runtime.activeAgentId, + conversationId: recoveryConversationId, + }); + emitInterruptToolReturnMessage( + socket, + runtime, + approvalResults, + runtime.activeRunId ?? undefined, + "tool-return", + ); + + const recoveryStream = await sendApprovalContinuationWithRetry( + recoveryConversationId, + [{ type: "approval", approvals: approvalResults }], + { + agentId: runtime.activeAgentId, + streamTokens: true, + background: true, + workingDirectory: recoveryWorkingDirectory, + }, + socket, + runtime, + abortSignal, + { allowApprovalRecovery: false }, + ); + if (!recoveryStream) { + throw new Error( + "Approval recovery send resolved without a continuation stream", + ); + } + + const drainResult = await drainRecoveryStreamWithEmission( + recoveryStream as Stream, + socket, + runtime, + { + agentId: runtime.activeAgentId, + conversationId: recoveryConversationId, + abortSignal, + }, + ); + + if (drainResult.stopReason === "error") { + throw new Error("Pre-stream approval recovery drain ended with error"); + } + clearPendingApprovalBatchIds( + runtime, + decisions.map((decision) => decision.approval), + ); + if (drainResult.stopReason !== "requires_approval") { + return drainResult; + } + pendingApprovals = drainResult.approvals || []; + } finally { + runtime.activeExecutingToolCallIds = []; + } + } + + return null; +} + +function parseApprovalInput(toolArgs: string): Record { + if (!toolArgs) return {}; + try { + const parsed = JSON.parse(toolArgs) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +async function recoverApprovalStateForSync( + runtime: ListenerRuntime, + scope: RuntimeScope, +): Promise { + const sameActiveScope = + runtime.activeAgentId === scope.agent_id && + resolveScopedConversationId(runtime, { + conversation_id: runtime.activeConversationId, + }) === scope.conversation_id; + + if ( + sameActiveScope && + (runtime.isProcessing || runtime.loopStatus !== "WAITING_ON_INPUT") + ) { + clearRecoveredApprovalState(runtime); + return; + } + + if (runtime.pendingApprovalResolvers.size > 0 && sameActiveScope) { + clearRecoveredApprovalState(runtime); + return; + } + + const client = await getClient(); + let agent: Awaited>; + try { + agent = await client.agents.retrieve(scope.agent_id); + } catch (error) { + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + clearRecoveredApprovalState(runtime); + return; + } + throw error; + } + + let resumeData: Awaited>; + try { + resumeData = await getResumeData(client, agent, scope.conversation_id, { + includeMessageHistory: false, + }); + } catch (error) { + if ( + error instanceof APIError && + (error.status === 404 || error.status === 422) + ) { + clearRecoveredApprovalState(runtime); + return; + } + throw error; + } + + const pendingApprovals = resumeData.pendingApprovals ?? []; + if (pendingApprovals.length === 0) { + clearRecoveredApprovalState(runtime); + return; + } + + const approvalsByRequestId = new Map(); + await Promise.all( + pendingApprovals.map(async (approval) => { + const requestId = `perm-${approval.toolCallId}`; + const input = parseApprovalInput(approval.toolArgs); + const diffs = await computeDiffPreviews( + approval.toolName, + input, getConversationWorkingDirectory( runtime, - runtime.activeAgentId, - recoveryConversationId, + scope.agent_id, + scope.conversation_id, ), - }, - { maxRetries: 0, signal: abortSignal }, + ); + + approvalsByRequestId.set(requestId, { + approval, + controlRequest: { + type: "control_request", + request_id: requestId, + request: { + subtype: "can_use_tool", + tool_name: approval.toolName, + input, + tool_call_id: approval.toolCallId, + permission_suggestions: [], + blocked_path: null, + ...(diffs.length > 0 ? { diffs } : {}), + }, + agent_id: scope.agent_id, + conversation_id: scope.conversation_id, + }, + }); + }), ); - const drainResult = await drainStreamWithResume( - recoveryStream as Stream, - createBuffers(runtime.activeAgentId), - () => {}, - abortSignal, - ); + runtime.recoveredApprovalState = { + agentId: scope.agent_id, + conversationId: scope.conversation_id, + approvalsByRequestId, + pendingRequestIds: new Set(approvalsByRequestId.keys()), + responsesByRequestId: new Map(), + }; +} - if (drainResult.stopReason === "error") { - throw new Error("Pre-stream approval recovery drain ended with error"); +async function resolveRecoveredApprovalResponse( + runtime: ListenerRuntime, + socket: WebSocket, + opts: StartListenerOptions, + response: ApprovalResponseBody, +): Promise { + const requestId = response.request_id; + if (typeof requestId !== "string" || requestId.length === 0) { + return false; + } + + const recovered = runtime.recoveredApprovalState; + if (!recovered || !recovered.approvalsByRequestId.has(requestId)) { + return false; + } + + recovered.responsesByRequestId.set(requestId, response); + recovered.pendingRequestIds.delete(requestId); + + if (recovered.pendingRequestIds.size > 0) { + emitRuntimeStateUpdates(runtime, { + agent_id: recovered.agentId, + conversation_id: recovered.conversationId, + }); + return true; + } + + const decisions: ApprovalDecision[] = []; + for (const [id, entry] of recovered.approvalsByRequestId) { + const approvalResponse = recovered.responsesByRequestId.get(id); + if (!approvalResponse) { + continue; + } + + if ("decision" in approvalResponse) { + const decision = approvalResponse.decision as ApprovalResponseDecision; + if (decision.behavior === "allow") { + decisions.push({ + type: "approve", + approval: decision.updated_input + ? { + ...entry.approval, + toolArgs: JSON.stringify(decision.updated_input), + } + : entry.approval, + }); + } else { + decisions.push({ + type: "deny", + approval: entry.approval, + reason: decision.message || "Denied via WebSocket", + }); + } + } else { + decisions.push({ + type: "deny", + approval: entry.approval, + reason: approvalResponse.error, + }); + } + } + + const scope = { + agent_id: recovered.agentId, + conversation_id: recovered.conversationId, + } as const; + const approvedToolCallIds = decisions + .filter( + (decision): decision is Extract => + decision.type === "approve", + ) + .map((decision) => decision.approval.toolCallId); + + // Mirror the normal approval loop behavior: + // the approval is resolved immediately from the UI's perspective, then the + // approved tool transitions into execution / processing state. + recovered.pendingRequestIds.clear(); + emitRuntimeStateUpdates(runtime, scope); + + runtime.isProcessing = true; + runtime.activeAgentId = recovered.agentId; + runtime.activeConversationId = recovered.conversationId; + runtime.activeWorkingDirectory = getConversationWorkingDirectory( + runtime, + recovered.agentId, + recovered.conversationId, + ); + runtime.activeExecutingToolCallIds = [...approvedToolCallIds]; + setLoopStatus(runtime, "EXECUTING_CLIENT_SIDE_TOOL", scope); + emitRuntimeStateUpdates(runtime, scope); + emitToolExecutionStartedEvents(socket, runtime, { + toolCallIds: approvedToolCallIds, + runId: runtime.activeRunId ?? undefined, + agentId: recovered.agentId, + conversationId: recovered.conversationId, + }); + const recoveryAbortController = new AbortController(); + runtime.activeAbortController = recoveryAbortController; + try { + const approvalResults = await executeApprovalBatch(decisions, undefined, { + abortSignal: recoveryAbortController.signal, + workingDirectory: getConversationWorkingDirectory( + runtime, + recovered.agentId, + recovered.conversationId, + ), + }); + + emitToolExecutionFinishedEvents(socket, runtime, { + approvals: approvalResults, + runId: runtime.activeRunId ?? undefined, + agentId: recovered.agentId, + conversationId: recovered.conversationId, + }); + emitInterruptToolReturnMessage( + socket, + runtime, + approvalResults, + runtime.activeRunId ?? undefined, + "tool-return", + ); + + runtime.activeAbortController = null; + setLoopStatus(runtime, "SENDING_API_REQUEST", scope); + emitRuntimeStateUpdates(runtime, scope); + + await handleIncomingMessage( + { + type: "message", + agentId: recovered.agentId, + conversationId: recovered.conversationId, + messages: [ + { + type: "approval", + approvals: approvalResults, + }, + ], + }, + socket, + runtime, + opts.onStatusChange, + opts.connectionId, + `batch-recovered-${crypto.randomUUID()}`, + ); + + clearRecoveredApprovalState(runtime); + return true; + } catch (error) { + recovered.pendingRequestIds = new Set( + recovered.approvalsByRequestId.keys(), + ); + recovered.responsesByRequestId.clear(); + runtime.activeAbortController = null; + runtime.isProcessing = false; + runtime.activeExecutingToolCallIds = []; + setLoopStatus(runtime, "WAITING_ON_INPUT", scope); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: recovered.agentId, + conversation_id: recovered.conversationId, + }); + throw error; } } @@ -2279,12 +3409,18 @@ async function sendMessageStreamWithRetry( let conversationBusyRetries = 0; let preStreamRecoveryAttempts = 0; const MAX_CONVERSATION_BUSY_RETRIES = 3; + const requestStartedAtMs = Date.now(); // eslint-disable-next-line no-constant-condition while (true) { if (abortSignal?.aborted) { throw new Error("Cancelled by user"); } + runtime.isRecoveringApprovals = false; + setLoopStatus(runtime, "WAITING_FOR_API_RESPONSE", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); try { return await sendMessageStream( @@ -2315,7 +3451,16 @@ async function sendMessageStreamWithRetry( }, ); - if (action === "resolve_approval_pending") { + const approvalConflictDetected = + action === "resolve_approval_pending" || + isApprovalToolCallDesyncError(errorDetail); + + if (approvalConflictDetected) { + runtime.isRecoveringApprovals = true; + setLoopStatus(runtime, "RETRYING_API_REQUEST", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); // Abort check first — don't let recovery mask a user cancel if (abortSignal?.aborted) throw new Error("Cancelled by user"); @@ -2327,7 +3472,7 @@ async function sendMessageStreamWithRetry( ) { preStreamRecoveryAttempts++; try { - await resolveStaleApprovals(runtime, abortSignal); + await resolveStaleApprovals(runtime, socket, abortSignal); continue; // Retry send after resolving } catch (_recoveryError) { if (abortSignal.aborted) throw new Error("Cancelled by user"); @@ -2339,11 +3484,16 @@ async function sendMessageStreamWithRetry( const detail = await fetchRunErrorDetail(runtime.activeRunId); throw new Error( detail || - `Pre-stream approval conflict (resolve_approval_pending) after ${preStreamRecoveryAttempts} recovery attempts`, + `Pre-stream approval conflict after ${preStreamRecoveryAttempts} recovery attempts`, ); } if (action === "retry_transient") { + runtime.isRecoveringApprovals = true; + setLoopStatus(runtime, "RETRYING_API_REQUEST", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); const attempt = transientRetries + 1; const retryAfterMs = preStreamError instanceof APIError @@ -2359,17 +3509,18 @@ async function sendMessageStreamWithRetry( }); transientRetries = attempt; - emitToWS(socket, { - type: "retry", - reason: "llm_api_error", - attempt, - max_attempts: LLM_API_ERROR_MAX_RETRIES, - delay_ms: delayMs, - session_id: runtime.sessionId, - uuid: `retry-${crypto.randomUUID()}`, - agent_id: runtime.activeAgentId ?? undefined, - conversation_id: conversationId, - } as RetryMessage); + const retryMessage = getRetryStatusMessage(errorDetail); + if (retryMessage) { + emitRetryDelta(socket, runtime, { + message: retryMessage, + reason: "error", + attempt, + maxAttempts: LLM_API_ERROR_MAX_RETRIES, + delayMs, + agentId: runtime.activeAgentId ?? undefined, + conversationId, + }); + } await new Promise((resolve) => setTimeout(resolve, delayMs)); if (abortSignal?.aborted) { @@ -2379,13 +3530,46 @@ async function sendMessageStreamWithRetry( } if (action === "retry_conversation_busy") { - // TODO: Add pre-stream resume logic for parity with App.tsx. - // Before waiting, attempt to discover the in-flight run via - // discoverFallbackRunIdWithTimeout() and resume its stream with - // client.runs.messages.stream() + drainStream(). This avoids - // blind wait/retry cycles when the server already created a run - // from the original request. See App.tsx retry_conversation_busy - // handler for reference implementation. + runtime.isRecoveringApprovals = true; + setLoopStatus(runtime, "RETRYING_API_REQUEST", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); + try { + const client = await getClient(); + const discoveredRunId = await discoverFallbackRunIdWithTimeout( + client, + { + conversationId, + resolvedConversationId: conversationId, + agentId: runtime.activeAgentId, + requestStartedAtMs, + }, + ); + + if (discoveredRunId) { + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + return await client.runs.messages.stream(discoveredRunId, { + starting_after: 0, + batch_size: 1000, + }); + } + } catch (resumeError) { + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + if (process.env.DEBUG) { + console.warn( + "[Listen] Pre-stream resume failed, falling back to wait/retry:", + resumeError instanceof Error + ? resumeError.message + : String(resumeError), + ); + } + } + const attempt = conversationBusyRetries + 1; const delayMs = getRetryDelayMs({ category: "conversation_busy", @@ -2393,17 +3577,15 @@ async function sendMessageStreamWithRetry( }); conversationBusyRetries = attempt; - emitToWS(socket, { - type: "retry", + emitRetryDelta(socket, runtime, { + message: "Conversation is busy, waiting and retrying…", reason: "error", attempt, - max_attempts: MAX_CONVERSATION_BUSY_RETRIES, - delay_ms: delayMs, - session_id: runtime.sessionId, - uuid: `retry-${crypto.randomUUID()}`, - agent_id: runtime.activeAgentId ?? undefined, - conversation_id: conversationId, - } as RetryMessage); + maxAttempts: MAX_CONVERSATION_BUSY_RETRIES, + delayMs, + agentId: runtime.activeAgentId ?? undefined, + conversationId, + }); await new Promise((resolve) => setTimeout(resolve, delayMs)); if (abortSignal?.aborted) { @@ -2418,9 +3600,197 @@ async function sendMessageStreamWithRetry( } } +async function sendApprovalContinuationWithRetry( + conversationId: string, + messages: Parameters[1], + opts: Parameters[2], + socket: WebSocket, + runtime: ListenerRuntime, + abortSignal?: AbortSignal, + retryOptions: { + allowApprovalRecovery?: boolean; + } = {}, +): Promise> | null> { + const allowApprovalRecovery = retryOptions.allowApprovalRecovery ?? true; + let transientRetries = 0; + let conversationBusyRetries = 0; + let preStreamRecoveryAttempts = 0; + const MAX_CONVERSATION_BUSY_RETRIES = 3; + const requestStartedAtMs = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + runtime.isRecoveringApprovals = false; + setLoopStatus(runtime, "WAITING_FOR_API_RESPONSE", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); + + try { + return await sendMessageStream( + conversationId, + messages, + opts, + abortSignal + ? { maxRetries: 0, signal: abortSignal } + : { maxRetries: 0 }, + ); + } catch (preStreamError) { + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + + const errorDetail = extractConflictDetail(preStreamError); + const action = getPreStreamErrorAction( + errorDetail, + conversationBusyRetries, + MAX_CONVERSATION_BUSY_RETRIES, + { + status: + preStreamError instanceof APIError + ? preStreamError.status + : undefined, + transientRetries, + maxTransientRetries: LLM_API_ERROR_MAX_RETRIES, + }, + ); + + const approvalConflictDetected = + action === "resolve_approval_pending" || + isApprovalToolCallDesyncError(errorDetail); + + if (approvalConflictDetected) { + runtime.isRecoveringApprovals = true; + setLoopStatus(runtime, "RETRYING_API_REQUEST", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); + + if ( + allowApprovalRecovery && + abortSignal && + preStreamRecoveryAttempts < MAX_PRE_STREAM_RECOVERY + ) { + preStreamRecoveryAttempts++; + const drainResult = await resolveStaleApprovals( + runtime, + socket, + abortSignal, + ); + if ( + drainResult && + getApprovalContinuationRecoveryDisposition(drainResult) === + "handled" + ) { + finalizeHandledRecoveryTurn(runtime, socket, { + drainResult, + agentId: runtime.activeAgentId, + conversationId, + }); + return null; + } + continue; + } + + const detail = await fetchRunErrorDetail(runtime.activeRunId); + throw new Error( + detail || + `Approval continuation conflict after ${preStreamRecoveryAttempts} recovery attempts`, + ); + } + + if (action === "retry_transient") { + runtime.isRecoveringApprovals = true; + setLoopStatus(runtime, "RETRYING_API_REQUEST", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); + const attempt = transientRetries + 1; + const retryAfterMs = + preStreamError instanceof APIError + ? parseRetryAfterHeaderMs( + preStreamError.headers?.get("retry-after"), + ) + : null; + const delayMs = getRetryDelayMs({ + category: "transient_provider", + attempt, + detail: errorDetail, + retryAfterMs, + }); + transientRetries = attempt; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + continue; + } + + if (action === "retry_conversation_busy") { + conversationBusyRetries += 1; + runtime.isRecoveringApprovals = true; + setLoopStatus(runtime, "RETRYING_API_REQUEST", { + agent_id: runtime.activeAgentId, + conversation_id: conversationId, + }); + + try { + const client = await getClient(); + const discoveredRunId = await discoverFallbackRunIdWithTimeout( + client, + { + conversationId, + resolvedConversationId: conversationId, + agentId: runtime.activeAgentId, + requestStartedAtMs, + }, + ); + + if (discoveredRunId) { + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + return await client.runs.messages.stream(discoveredRunId, { + starting_after: 0, + batch_size: 1000, + }); + } + } catch (resumeError) { + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + if (process.env.DEBUG) { + console.warn( + "[Listen] Approval continuation pre-stream resume failed, falling back to wait/retry:", + resumeError instanceof Error + ? resumeError.message + : String(resumeError), + ); + } + } + + const retryDelayMs = getRetryDelayMs({ + category: "conversation_busy", + attempt: conversationBusyRetries, + }); + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + if (abortSignal?.aborted) { + throw new Error("Cancelled by user"); + } + continue; + } + + throw preStreamError; + } + } +} + export function resolvePendingApprovalResolver( runtime: ListenerRuntime, - response: ControlResponseBody, + response: ApprovalResponseBody, ): boolean { const requestId = response.request_id; if (typeof requestId !== "string" || requestId.length === 0) { @@ -2433,7 +3803,15 @@ export function resolvePendingApprovalResolver( } runtime.pendingApprovalResolvers.delete(requestId); + if (runtime.pendingApprovalResolvers.size === 0) { + setLoopStatus( + runtime, + runtime.isProcessing ? "PROCESSING_API_RESPONSE" : "WAITING_ON_INPUT", + ); + } pending.resolve(response); + emitLoopStatusIfOpen(runtime); + emitDeviceStatusIfOpen(runtime); return true; } @@ -2445,6 +3823,12 @@ export function rejectPendingApprovalResolvers( pending.reject(new Error(reason)); } runtime.pendingApprovalResolvers.clear(); + setLoopStatus( + runtime, + runtime.isProcessing ? "PROCESSING_API_RESPONSE" : "WAITING_ON_INPUT", + ); + emitLoopStatusIfOpen(runtime); + emitDeviceStatusIfOpen(runtime); } export function requestApprovalOverWS( @@ -2452,287 +3836,23 @@ export function requestApprovalOverWS( socket: WebSocket, requestId: string, controlRequest: ControlRequest, -): Promise { +): Promise { if (socket.readyState !== WebSocket.OPEN) { return Promise.reject(new Error("WebSocket not open")); } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { runtime.pendingApprovalResolvers.set(requestId, { resolve, reject, controlRequest, }); - try { - sendControlMessageOverWebSocket(socket, controlRequest); - } catch (error) { - runtime.pendingApprovalResolvers.delete(requestId); - reject(error instanceof Error ? error : new Error(String(error))); - } + setLoopStatus(runtime, "WAITING_ON_APPROVAL"); + emitLoopStatusIfOpen(runtime); + emitDeviceStatusIfOpen(runtime); }); } -async function recoverPendingApprovals( - runtime: ListenerRuntime, - socket: WebSocket, - msg: RecoverPendingApprovalsMessage, -): Promise { - console.debug( - "[listener] recover_pending_approvals received", - JSON.stringify({ - agentId: msg.agentId, - conversationId: msg.conversationId ?? null, - isProcessing: runtime.isProcessing, - isRecovering: runtime.isRecoveringApprovals, - batchMapSize: runtime.pendingApprovalBatchByToolCallId.size, - }), - ); - - if (runtime.isProcessing || runtime.isRecoveringApprovals) { - return; - } - - runtime.isRecoveringApprovals = true; - try { - const agentId = msg.agentId; - if (!agentId) { - return; - } - - const requestedConversationId = msg.conversationId || undefined; - const conversationId = requestedConversationId ?? "default"; - const recoveryAgentId = normalizeCwdAgentId(agentId); - const recoveryWorkingDirectory = - runtime.activeAgentId === recoveryAgentId && - runtime.activeConversationId === conversationId && - runtime.activeWorkingDirectory - ? runtime.activeWorkingDirectory - : getConversationWorkingDirectory( - runtime, - recoveryAgentId, - conversationId, - ); - - const client = await getClient(); - const agent = await client.agents.retrieve(agentId); - - let resumeData: Awaited>; - try { - resumeData = await getResumeData(client, agent, requestedConversationId, { - includeMessageHistory: false, - }); - } catch (error) { - if ( - error instanceof APIError && - (error.status === 404 || error.status === 422) - ) { - return; - } - throw error; - } - - const pendingApprovals = resumeData.pendingApprovals || []; - if (pendingApprovals.length === 0) { - return; - } - - const recoveryBatchId = resolveRecoveryBatchId(runtime, pendingApprovals); - if (!recoveryBatchId) { - emitToWS(socket, { - type: "error", - message: - "Unable to recover pending approvals: ambiguous batch correlation", - stop_reason: "error", - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, - agent_id: agentId, - conversation_id: conversationId, - }); - runtime.lastStopReason = "requires_approval"; - return; - } - - type Decision = - | { - type: "approve"; - approval: { - toolCallId: string; - toolName: string; - toolArgs: string; - }; - } - | { - type: "deny"; - approval: { - toolCallId: string; - toolName: string; - toolArgs: string; - }; - reason: string; - }; - - const { autoAllowed, autoDenied, needsUserInput } = await classifyApprovals( - pendingApprovals, - { - alwaysRequiresUserInput: isInteractiveApprovalTool, - treatAskAsDeny: false, - requireArgsForAutoApprove: true, - workingDirectory: recoveryWorkingDirectory, - }, - ); - - for (const ac of autoAllowed) { - emitToWS(socket, { - type: "auto_approval", - tool_call: { - name: ac.approval.toolName, - tool_call_id: ac.approval.toolCallId, - arguments: ac.approval.toolArgs, - }, - reason: ac.permission.reason || "auto-approved", - matched_rule: - "matchedRule" in ac.permission && ac.permission.matchedRule - ? ac.permission.matchedRule - : "auto-approved", - session_id: runtime.sessionId, - uuid: `auto-approval-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - } as AutoApprovalMessage); - } - - const decisions: Decision[] = [ - ...autoAllowed.map((ac) => ({ - type: "approve" as const, - approval: ac.approval, - })), - ...autoDenied.map((ac) => ({ - type: "deny" as const, - approval: ac.approval, - reason: ac.denyReason || ac.permission.reason || "Permission denied", - })), - ]; - - if (needsUserInput.length > 0) { - // Reflect approval-wait state in runtime snapshot while control - // requests are pending, so state_response queries see - // requires_approval even during the WS round-trip. - runtime.lastStopReason = "requires_approval"; - - for (const ac of needsUserInput) { - const requestId = `perm-${ac.approval.toolCallId}`; - const diffs = await computeDiffPreviews( - ac.approval.toolName, - ac.parsedArgs, - recoveryWorkingDirectory, - ); - - const controlRequest: ControlRequest = { - type: "control_request", - request_id: requestId, - request: { - subtype: "can_use_tool", - tool_name: ac.approval.toolName, - input: ac.parsedArgs, - tool_call_id: ac.approval.toolCallId, - permission_suggestions: [], - blocked_path: null, - ...(diffs.length > 0 ? { diffs } : {}), - }, - agent_id: agentId, - conversation_id: conversationId, - }; - - const responseBody = await requestApprovalOverWS( - runtime, - socket, - requestId, - controlRequest, - ); - - if (responseBody.subtype === "success") { - const response = responseBody.response as - | CanUseToolResponse - | undefined; - if (response?.behavior === "allow") { - const finalApproval = response.updatedInput - ? { - ...ac.approval, - toolArgs: JSON.stringify(response.updatedInput), - } - : ac.approval; - decisions.push({ type: "approve", approval: finalApproval }); - - emitToWS(socket, { - type: "auto_approval", - tool_call: { - name: finalApproval.toolName, - tool_call_id: finalApproval.toolCallId, - arguments: finalApproval.toolArgs, - }, - reason: "Approved via WebSocket", - matched_rule: "canUseTool callback", - session_id: runtime.sessionId, - uuid: `auto-approval-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - } as AutoApprovalMessage); - } else { - decisions.push({ - type: "deny", - approval: ac.approval, - reason: response?.message || "Denied via WebSocket", - }); - } - } else { - decisions.push({ - type: "deny", - approval: ac.approval, - reason: - responseBody.subtype === "error" - ? responseBody.error - : "Unknown error", - }); - } - } - } - - if (decisions.length === 0) { - runtime.lastStopReason = "requires_approval"; - return; - } - - const executionResults = await executeApprovalBatch(decisions, undefined, { - workingDirectory: recoveryWorkingDirectory, - }); - clearPendingApprovalBatchIds( - runtime, - decisions.map((decision) => decision.approval), - ); - - await handleIncomingMessage( - { - type: "message", - agentId, - conversationId, - messages: [ - { - type: "approval", - approvals: executionResults, - }, - ], - }, - socket, - runtime, - undefined, - undefined, - recoveryBatchId, - ); - } finally { - runtime.isRecoveringApprovals = false; - } -} - /** * Start the listener WebSocket client with automatic retry. */ @@ -2746,6 +3866,8 @@ export async function startListenerClient( const runtime = createRuntime(); runtime.onWsEvent = opts.onWsEvent; + runtime.connectionId = opts.connectionId; + runtime.connectionName = opts.connectionName; activeRuntime = runtime; await connectWithRetry(runtime, opts); @@ -2826,19 +3948,17 @@ async function connectWithRetry( runtime.hasSuccessfulConnection = true; opts.onConnected(opts.connectionId); - // Send current mode state to cloud for UI sync - sendClientMessage(socket, { - type: "mode_changed", - mode: permissionMode.getMode(), - success: true, - }); + emitDeviceStatusUpdate(socket, runtime); + emitLoopStatusUpdate(socket, runtime); runtime.heartbeatInterval = setInterval(() => { - sendClientMessage(socket, { type: "ping" }); + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "ping" })); + } }, 30000); }); - socket.on("message", (data: WebSocket.RawData) => { + socket.on("message", async (data: WebSocket.RawData) => { const raw = data.toString(); const parsed = parseServerMessage(data); if (parsed) { @@ -2860,119 +3980,204 @@ async function connectWithRetry( return; } - if (parsed.type === "control_response") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - if (resolvePendingApprovalResolver(runtime, parsed.response)) { - scheduleQueuePump(runtime, socket, opts); - } - return; - } - - // Handle status updates from cloud (response to ping) - if (parsed.type === "status") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - // Update runtime state from cloud's view - // Only update lastStopReason if we're not currently processing - if (!runtime.isProcessing && parsed.lastStopReason !== undefined) { - runtime.lastStopReason = parsed.lastStopReason; - } - return; - } - - // Handle mode change messages immediately (not queued) - if (parsed.type === "mode_change") { - handleModeChange(parsed, socket); - return; - } - - if (parsed.type === "change_cwd") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - void handleCwdChange(parsed, socket, runtime); - return; - } - - if (parsed.type === "list_folders_in_directory") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - void handleListFoldersInDirectory(parsed, socket, runtime); - return; - } - - // Handle terminal (PTY) messages - if (parsed.type === "terminal_spawn") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - const cwd = getConversationWorkingDirectory( - runtime, - parsed.agentId, - parsed.conversationId, - ); - handleTerminalSpawn(parsed, socket, cwd); - return; - } - - if (parsed.type === "terminal_input") { - handleTerminalInput(parsed); - return; - } - - if (parsed.type === "terminal_resize") { - handleTerminalResize(parsed); - return; - } - - if (parsed.type === "terminal_kill") { - handleTerminalKill(parsed); - return; - } - - // Handle status request from cloud (immediate response) - if (parsed.type === "get_status") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - sendClientMessage(socket, { - type: "status_response", - currentMode: permissionMode.getMode(), - lastStopReason: runtime.lastStopReason, - isProcessing: runtime.isProcessing, + if (parsed.type === "__invalid_input") { + emitLoopErrorDelta(socket, runtime, { + message: parsed.reason, + stopReason: "error", + isTerminal: false, + agentId: parsed.runtime.agent_id, + conversationId: parsed.runtime.conversation_id, }); return; } - if (parsed.type === "cancel_run") { + if (parsed.type === "sync") { + console.log( + `[Listen V2] Received sync command for runtime=${parsed.runtime.agent_id}/${parsed.runtime.conversation_id}`, + ); + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + console.log(`[Listen V2] Dropping sync: runtime mismatch or closed`); + return; + } + await recoverApprovalStateForSync(runtime, parsed.runtime); + emitStateSync(socket, runtime, parsed.runtime); + return; + } + + if (parsed.type === "input") { + console.log( + `[Listen V2] Received input command, kind=${parsed.payload?.kind}`, + ); + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + console.log(`[Listen V2] Dropping input: runtime mismatch or closed`); + return; + } + + if (parsed.payload.kind === "approval_response") { + if (resolvePendingApprovalResolver(runtime, parsed.payload)) { + scheduleQueuePump(runtime, socket, opts); + return; + } + if ( + await resolveRecoveredApprovalResponse( + runtime, + socket, + opts, + parsed.payload, + ) + ) { + scheduleQueuePump(runtime, socket, opts); + } + return; + } + + const inputPayload = parsed.payload; + if (inputPayload.kind !== "create_message") { + emitLoopErrorDelta(socket, runtime, { + message: `Unsupported input payload kind: ${String((inputPayload as { kind?: unknown }).kind)}`, + stopReason: "error", + isTerminal: false, + agentId: parsed.runtime.agent_id, + conversationId: parsed.runtime.conversation_id, + }); + return; + } + + const incoming: IncomingMessage = { + type: "message", + agentId: parsed.runtime.agent_id, + conversationId: parsed.runtime.conversation_id, + messages: inputPayload.messages, + }; + const hasApprovalPayload = incoming.messages.some( + (payload): payload is ApprovalCreate => + "type" in payload && payload.type === "approval", + ); + if (hasApprovalPayload) { + emitLoopErrorDelta(socket, runtime, { + message: + "Protocol violation: approval payloads are not allowed in input.kind=create_message. Use input.kind=approval_response.", + stopReason: "error", + isTerminal: false, + agentId: parsed.runtime.agent_id, + conversationId: parsed.runtime.conversation_id, + }); + return; + } + + if (shouldQueueInboundMessage(incoming)) { + const firstUserPayload = incoming.messages.find( + ( + payload, + ): payload is MessageCreate & { client_message_id?: string } => + "content" in payload, + ); + if (firstUserPayload) { + const enqueuedItem = runtime.queueRuntime.enqueue({ + kind: "message", + source: "user", + content: firstUserPayload.content, + clientMessageId: + firstUserPayload.client_message_id ?? + `cm-submit-${crypto.randomUUID()}`, + agentId: parsed.runtime.agent_id, + conversationId: parsed.runtime.conversation_id || "default", + } as Parameters[0]); + if (enqueuedItem) { + runtime.queuedMessagesByItemId.set(enqueuedItem.id, incoming); + } + } + scheduleQueuePump(runtime, socket, opts); + return; + } + + runtime.messageQueue = runtime.messageQueue + .then(async () => { + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + opts.onStatusChange?.("receiving", opts.connectionId); + await handleIncomingMessage( + incoming, + socket, + runtime, + opts.onStatusChange, + opts.connectionId, + ); + opts.onStatusChange?.("idle", opts.connectionId); + scheduleQueuePump(runtime, socket, opts); + }) + .catch((error: unknown) => { + if (process.env.DEBUG) { + console.error("[Listen] Error handling queued input:", error); + } + opts.onStatusChange?.("idle", opts.connectionId); + scheduleQueuePump(runtime, socket, opts); + }); + return; + } + + if (parsed.type === "change_device_state") { + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + const scope = { + agent_id: + parsed.payload.agent_id ?? parsed.runtime.agent_id ?? undefined, + conversation_id: + parsed.payload.conversation_id ?? + parsed.runtime.conversation_id ?? + undefined, + }; + const shouldTrackCommand = + !runtime.isProcessing && + getPendingControlRequestCount(runtime, scope) === 0; + if (shouldTrackCommand) { + setLoopStatus(runtime, "EXECUTING_COMMAND", scope); + } + try { + if (parsed.payload.mode) { + handleModeChange( + { mode: parsed.payload.mode }, + socket, + runtime, + scope, + ); + } + if (parsed.payload.cwd) { + await handleCwdChange( + { + agentId: scope.agent_id ?? null, + conversationId: scope.conversation_id ?? null, + cwd: parsed.payload.cwd, + }, + socket, + runtime, + ); + } else if (!parsed.payload.mode) { + emitDeviceStatusUpdate(socket, runtime, scope); + } + } finally { + if (shouldTrackCommand) { + setLoopStatus(runtime, "WAITING_ON_INPUT", scope); + } + } + return; + } + + if (parsed.type === "abort_message") { if (runtime !== activeRuntime || runtime.intentionallyClosed) { return; } - const requestId = - typeof parsed.request_id === "string" && parsed.request_id.length > 0 - ? parsed.request_id - : `cancel-${crypto.randomUUID()}`; - const requestedRunId = - typeof parsed.run_id === "string" ? parsed.run_id : runtime.activeRunId; - const hasPendingApprovals = runtime.pendingApprovalResolvers.size > 0; + const hasPendingApprovals = + getPendingControlRequestCount(runtime, { + agent_id: parsed.runtime.agent_id, + conversation_id: parsed.runtime.conversation_id, + }) > 0; const hasActiveTurn = runtime.isProcessing; if (!hasActiveTurn && !hasPendingApprovals) { - emitCancelAck(socket, runtime, { - requestId, - accepted: false, - reason: "no_active_turn", - runId: requestedRunId, - }); return; } @@ -3007,10 +4212,28 @@ async function connectWithRetry( ) { runtime.activeAbortController.abort(); } + const recoveredApprovalState = getRecoveredApprovalStateForScope( + runtime, + { + agent_id: parsed.runtime.agent_id, + conversation_id: parsed.runtime.conversation_id, + }, + ); + if (recoveredApprovalState && !hasActiveTurn) { + stashRecoveredApprovalInterrupts(runtime, recoveredApprovalState); + } if (hasPendingApprovals) { rejectPendingApprovalResolvers(runtime, "Cancelled by user"); } + if (!hasActiveTurn && hasPendingApprovals) { + emitInterruptedStatusDelta(socket, runtime, { + runId: runtime.activeRunId, + agentId: parsed.runtime.agent_id, + conversationId: parsed.runtime.conversation_id, + }); + } + // Backend cancel parity with TUI (App.tsx:5932-5941). // Fire-and-forget — local cancel + queued results are the primary mechanism. const cancelConversationId = runtime.activeConversationId; @@ -3029,172 +4252,9 @@ async function connectWithRetry( }); } - emitCancelAck(socket, runtime, { - requestId, - accepted: true, - runId: requestedRunId, - }); scheduleQueuePump(runtime, socket, opts); return; } - - if (parsed.type === "get_state") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - const requestedConversationId = normalizeConversationId( - parsed.conversationId, - ); - const requestedAgentId = normalizeCwdAgentId(parsed.agentId); - - // If we're blocked on an approval callback, don't queue behind the - // pending turn; respond immediately so refreshed clients can render the - // approval card needed to unblock execution. - if (runtime.pendingApprovalResolvers.size > 0) { - sendStateSnapshot( - socket, - runtime, - requestedAgentId, - requestedConversationId, - ); - return; - } - - // Serialize snapshot generation with the same message queue used for - // message processing so reconnect snapshots cannot race in-flight turns. - runtime.messageQueue = runtime.messageQueue - .then(async () => { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - sendStateSnapshot( - socket, - runtime, - requestedAgentId, - requestedConversationId, - ); - }) - .catch((error: unknown) => { - if (isDebugEnabled()) { - console.error("[Listen] Error handling queued get_state:", error); - } - }); - return; - } - - if (parsed.type === "recover_pending_approvals") { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - // Serialize recovery with normal message handling to avoid concurrent - // handleIncomingMessage execution when user messages arrive concurrently. - runtime.messageQueue = runtime.messageQueue - .then(async () => { - try { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - - await recoverPendingApprovals(runtime, socket, parsed); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - emitToWS(socket, { - type: "error", - message: `Pending approval recovery failed: ${errorMessage}`, - stop_reason: "error", - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, - agent_id: runtime.activeAgentId ?? undefined, - conversation_id: runtime.activeConversationId ?? undefined, - }); - } finally { - scheduleQueuePump(runtime, socket, opts); - } - }) - .catch((error: unknown) => { - if (isDebugEnabled()) { - console.error( - "[Listen] Error handling queued pending approval recovery:", - error, - ); - } - }); - return; - } - - // Handle incoming messages (queued for sequential processing) - if (parsed.type === "message") { - const hasApprovalPayload = parsed.messages.some( - (payload): payload is ApprovalCreate => - "type" in payload && payload.type === "approval", - ); - if (hasApprovalPayload) { - emitToWS(socket, { - type: "error", - message: - "Protocol violation: device websocket no longer accepts approval payloads inside message frames. Send control_response instead.", - stop_reason: "error", - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, - agent_id: runtime.activeAgentId ?? undefined, - conversation_id: runtime.activeConversationId ?? undefined, - }); - return; - } - - if (shouldQueueInboundMessage(parsed)) { - const firstUserPayload = parsed.messages.find( - ( - payload, - ): payload is MessageCreate & { client_message_id?: string } => - "content" in payload, - ); - if (firstUserPayload) { - const enqueuedItem = runtime.queueRuntime.enqueue({ - kind: "message", - source: "user", - content: firstUserPayload.content, - clientMessageId: - firstUserPayload.client_message_id ?? - `cm-submit-${crypto.randomUUID()}`, - agentId: parsed.agentId ?? undefined, - conversationId: parsed.conversationId || "default", - } as Parameters[0]); - if (enqueuedItem) { - runtime.queuedMessagesByItemId.set(enqueuedItem.id, parsed); - } - } - scheduleQueuePump(runtime, socket, opts); - return; - } - - runtime.messageQueue = runtime.messageQueue - .then(async () => { - if (runtime !== activeRuntime || runtime.intentionallyClosed) { - return; - } - opts.onStatusChange?.("receiving", opts.connectionId); - await handleIncomingMessage( - parsed, - socket, - runtime, - opts.onStatusChange, - opts.connectionId, - ); - opts.onStatusChange?.("idle", opts.connectionId); - scheduleQueuePump(runtime, socket, opts); - }) - .catch((error: unknown) => { - if (isDebugEnabled()) { - console.error("[Listen] Error handling queued message:", error); - } - opts.onStatusChange?.("idle", opts.connectionId); - scheduleQueuePump(runtime, socket, opts); - }); - } }); socket.on("close", (code: number, reason: Buffer) => { @@ -3208,7 +4268,7 @@ async function connectWithRetry( reason: reason.toString(), }); - // Single authoritative queue_cleared emission for all close paths + // Single authoritative queue clear for all close paths // (intentional and unintentional). Must fire before early returns. runtime.queuedMessagesByItemId.clear(); runtime.queueRuntime.clear("shutdown"); @@ -3293,10 +4353,11 @@ async function handleIncomingMessage( normalizedAgentId, conversationId, ); - const msgStartTime = performance.now(); - let msgTurnCount = 0; const msgRunIds: string[] = []; let postStopApprovalRecoveryRetries = 0; + let llmApiErrorRetries = 0; + let emptyResponseRetries = 0; + let lastApprovalContinuationAccepted = false; // Track last approval-loop state for cancel-time queueing (Phase 1.2). // Hoisted before try so the cancel catch block can access them. @@ -3313,11 +4374,29 @@ async function handleIncomingMessage( runtime.activeRunId = null; runtime.activeRunStartedAt = new Date().toISOString(); runtime.activeExecutingToolCallIds = []; + setLoopStatus(runtime, "SENDING_API_REQUEST", { + agent_id: agentId ?? null, + conversation_id: conversationId, + }); + clearRecoveredApprovalStateForScope(runtime, { + agent_id: agentId ?? null, + conversation_id: conversationId, + }); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId ?? null, + conversation_id: conversationId, + }); try { if (!agentId) { runtime.isProcessing = false; + setLoopStatus(runtime, "WAITING_ON_INPUT", { + conversation_id: conversationId, + }); clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + conversation_id: conversationId, + }); return; } @@ -3376,28 +4455,52 @@ async function handleIncomingMessage( } let currentInput = messagesToSend; - const sendOptions: Parameters[2] = { + let pendingNormalizationInterruptedToolCallIds = [ + ...queuedInterruptedToolCallIds, + ]; + const buildSendOptions = (): Parameters[2] => ({ agentId, streamTokens: true, background: true, workingDirectory: turnWorkingDirectory, - ...(queuedInterruptedToolCallIds.length > 0 + ...(pendingNormalizationInterruptedToolCallIds.length > 0 ? { approvalNormalization: { - interruptedToolCallIds: queuedInterruptedToolCallIds, + interruptedToolCallIds: + pendingNormalizationInterruptedToolCallIds, }, } : {}), - }; + }); - let stream = await sendMessageStreamWithRetry( - conversationId, - currentInput, - sendOptions, - socket, - runtime, - runtime.activeAbortController.signal, - ); + const isPureApprovalContinuation = isApprovalOnlyInput(currentInput); + + let stream = isPureApprovalContinuation + ? await sendApprovalContinuationWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ) + : await sendMessageStreamWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ); + if (!stream) { + return; + } + pendingNormalizationInterruptedToolCallIds = []; + markAwaitingAcceptedApprovalContinuationRunId(runtime, currentInput); + setLoopStatus(runtime, "PROCESSING_API_RESPONSE", { + agent_id: agentId, + conversation_id: conversationId, + }); turnToolContextId = getStreamToolContextId( stream as Stream, @@ -3409,7 +4512,6 @@ async function handleIncomingMessage( // Approval loop: continue until end_turn or error // eslint-disable-next-line no-constant-condition while (true) { - msgTurnCount++; runIdSent = false; let latestErrorText: string | null = null; const result = await drainStreamWithResume( @@ -3428,10 +4530,7 @@ async function handleIncomingMessage( if (!runIdSent) { runIdSent = true; msgRunIds.push(maybeRunId); - sendClientMessage(socket, { - type: "run_started", - runId: maybeRunId, - batch_id: dequeuedBatchId, + emitLoopStatusUpdate(socket, runtime, { agent_id: agentId, conversation_id: conversationId, }); @@ -3441,37 +4540,34 @@ async function handleIncomingMessage( // Emit in-stream errors if (errorInfo) { latestErrorText = errorInfo.message || latestErrorText; - emitToWS(socket, { - type: "error", + emitLoopErrorDelta(socket, runtime, { message: errorInfo.message || "Stream error", - stop_reason: (errorInfo.error_type as StopReasonType) || "error", - run_id: runId || errorInfo.run_id, - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, - agent_id: agentId, - conversation_id: conversationId, + stopReason: (errorInfo.error_type as StopReasonType) || "error", + isTerminal: false, + runId: runId || errorInfo.run_id, + agentId, + conversationId, }); } // Emit chunk as MessageWire for protocol consumers if (shouldOutput) { - const chunkWithIds = chunk as typeof chunk & { - otid?: string; - id?: string; - }; const normalizedChunk = normalizeToolReturnWireMessage( chunk as unknown as Record, ); if (normalizedChunk) { - emitToWS(socket, { - ...normalizedChunk, - type: "message", - session_id: runtime.sessionId, - uuid: - chunkWithIds.otid || chunkWithIds.id || crypto.randomUUID(), - agent_id: agentId, - conversation_id: conversationId, - } as unknown as MessageWire); + emitCanonicalMessageDelta( + socket, + runtime, + { + ...normalizedChunk, + type: "message", + } as StreamDelta, + { + agent_id: agentId, + conversation_id: conversationId, + }, + ); } } @@ -3481,21 +4577,22 @@ async function handleIncomingMessage( const stopReason = result.stopReason; const approvals = result.approvals || []; + lastApprovalContinuationAccepted = false; // Case 1: Turn ended normally if (stopReason === "end_turn") { runtime.lastStopReason = "end_turn"; runtime.isProcessing = false; - clearActiveRunState(runtime); - - emitTurnResult(socket, runtime, { - subtype: "success", - agentId, - conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, + setLoopStatus(runtime, "WAITING_ON_INPUT", { + agent_id: agentId, + conversation_id: conversationId, }); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); + break; } @@ -3503,25 +4600,31 @@ async function handleIncomingMessage( if (stopReason === "cancelled") { runtime.lastStopReason = "cancelled"; runtime.isProcessing = false; - clearActiveRunState(runtime); - - emitTurnResult(socket, runtime, { - subtype: "interrupted", + emitInterruptedStatusDelta(socket, runtime, { + runId: runId || runtime.activeRunId, agentId, conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, - stopReason: "cancelled", }); + setLoopStatus(runtime, "WAITING_ON_INPUT", { + agent_id: agentId, + conversation_id: conversationId, + }); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); + break; } // Case 3: Error (or cancel-induced error) if (stopReason !== "requires_approval") { - const errorDetail = await fetchRunErrorDetail( - runId || runtime.activeRunId || msgRunIds[msgRunIds.length - 1], - ).catch(() => null); + const lastRunId = + runId || runtime.activeRunId || msgRunIds[msgRunIds.length - 1]; + const errorDetail = await fetchRunErrorDetail(lastRunId).catch( + () => null, + ); if ( !runtime.cancelRequested && @@ -3534,17 +4637,14 @@ async function handleIncomingMessage( }) ) { postStopApprovalRecoveryRetries += 1; - emitToWS(socket, { - type: "recovery", - recovery_type: "approval_pending", + emitStatusDelta(socket, runtime, { message: "Recovering from stale approval conflict after interrupted/reconnected turn", - run_id: runId || msgRunIds[msgRunIds.length - 1] || undefined, - session_id: runtime.sessionId, - uuid: `recovery-${crypto.randomUUID()}`, - agent_id: agentId, - conversation_id: conversationId, - } as RecoveryMessage); + level: "warning", + runId: runId || msgRunIds[msgRunIds.length - 1] || undefined, + agentId, + conversationId, + }); try { const client = await getClient(); @@ -3564,14 +4664,194 @@ async function handleIncomingMessage( currentInput = rebuildInputWithFreshDenials(currentInput, [], ""); } - stream = await sendMessageStreamWithRetry( - conversationId, - currentInput, - sendOptions, - socket, - runtime, - runtime.activeAbortController.signal, + setLoopStatus(runtime, "SENDING_API_REQUEST", { + agent_id: agentId, + conversation_id: conversationId, + }); + stream = + currentInput.length === 1 && + currentInput[0] !== undefined && + "type" in currentInput[0] && + currentInput[0].type === "approval" + ? await sendApprovalContinuationWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ) + : await sendMessageStreamWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ); + if (!stream) { + return; + } + pendingNormalizationInterruptedToolCallIds = []; + markAwaitingAcceptedApprovalContinuationRunId(runtime, currentInput); + setLoopStatus(runtime, "PROCESSING_API_RESPONSE", { + agent_id: agentId, + conversation_id: conversationId, + }); + turnToolContextId = getStreamToolContextId( + stream as Stream, ); + continue; + } + + if ( + isEmptyResponseRetryable( + stopReason === "llm_api_error" ? "llm_error" : undefined, + errorDetail, + emptyResponseRetries, + EMPTY_RESPONSE_MAX_RETRIES, + ) + ) { + emptyResponseRetries += 1; + const attempt = emptyResponseRetries; + const delayMs = getRetryDelayMs({ + category: "empty_response", + attempt, + }); + + if (attempt >= EMPTY_RESPONSE_MAX_RETRIES) { + currentInput = [ + ...currentInput, + { + type: "message" as const, + role: "system" as const, + content: + "The previous response was empty. Please provide a response with either text content or a tool call.", + }, + ]; + } + + emitRetryDelta(socket, runtime, { + message: `Empty LLM response, retrying (attempt ${attempt}/${EMPTY_RESPONSE_MAX_RETRIES})...`, + reason: "llm_api_error", + attempt, + maxAttempts: EMPTY_RESPONSE_MAX_RETRIES, + delayMs, + runId: lastRunId || undefined, + agentId, + conversationId, + }); + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (runtime.activeAbortController.signal.aborted) { + throw new Error("Cancelled by user"); + } + + setLoopStatus(runtime, "SENDING_API_REQUEST", { + agent_id: agentId, + conversation_id: conversationId, + }); + stream = + currentInput.length === 1 && + currentInput[0] !== undefined && + "type" in currentInput[0] && + currentInput[0].type === "approval" + ? await sendApprovalContinuationWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ) + : await sendMessageStreamWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ); + if (!stream) { + return; + } + pendingNormalizationInterruptedToolCallIds = []; + markAwaitingAcceptedApprovalContinuationRunId(runtime, currentInput); + setLoopStatus(runtime, "PROCESSING_API_RESPONSE", { + agent_id: agentId, + conversation_id: conversationId, + }); + turnToolContextId = getStreamToolContextId( + stream as Stream, + ); + continue; + } + + const retriable = await isRetriablePostStopError( + (stopReason as StopReasonType) || "error", + lastRunId, + ); + if (retriable && llmApiErrorRetries < LLM_API_ERROR_MAX_RETRIES) { + llmApiErrorRetries += 1; + const attempt = llmApiErrorRetries; + const delayMs = getRetryDelayMs({ + category: "transient_provider", + attempt, + detail: errorDetail, + }); + const retryMessage = + getRetryStatusMessage(errorDetail) || + `LLM API error encountered, retrying (attempt ${attempt}/${LLM_API_ERROR_MAX_RETRIES})...`; + emitRetryDelta(socket, runtime, { + message: retryMessage, + reason: "llm_api_error", + attempt, + maxAttempts: LLM_API_ERROR_MAX_RETRIES, + delayMs, + runId: lastRunId || undefined, + agentId, + conversationId, + }); + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (runtime.activeAbortController.signal.aborted) { + throw new Error("Cancelled by user"); + } + + setLoopStatus(runtime, "SENDING_API_REQUEST", { + agent_id: agentId, + conversation_id: conversationId, + }); + stream = + currentInput.length === 1 && + currentInput[0] !== undefined && + "type" in currentInput[0] && + currentInput[0].type === "approval" + ? await sendApprovalContinuationWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ) + : await sendMessageStreamWithRetry( + conversationId, + currentInput, + buildSendOptions(), + socket, + runtime, + runtime.activeAbortController.signal, + ); + if (!stream) { + return; + } + pendingNormalizationInterruptedToolCallIds = []; + markAwaitingAcceptedApprovalContinuationRunId(runtime, currentInput); + setLoopStatus(runtime, "PROCESSING_API_RESPONSE", { + agent_id: agentId, + conversation_id: conversationId, + }); turnToolContextId = getStreamToolContextId( stream as Stream, ); @@ -3591,45 +4871,46 @@ async function handleIncomingMessage( if (effectiveStopReason === "cancelled") { runtime.lastStopReason = "cancelled"; runtime.isProcessing = false; - clearActiveRunState(runtime); - - emitTurnResult(socket, runtime, { - subtype: "interrupted", + emitInterruptedStatusDelta(socket, runtime, { + runId: runId || runtime.activeRunId, agentId, conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, - stopReason: "cancelled", }); + setLoopStatus(runtime, "WAITING_ON_INPUT", { + agent_id: agentId, + conversation_id: conversationId, + }); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); + break; } runtime.lastStopReason = effectiveStopReason; runtime.isProcessing = false; + setLoopStatus(runtime, "WAITING_ON_INPUT", { + agent_id: agentId, + conversation_id: conversationId, + }); clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); const errorMessage = errorDetail || `Unexpected stop reason: ${stopReason}`; - emitToWS(socket, { - type: "error", + emitLoopErrorDelta(socket, runtime, { message: errorMessage, - stop_reason: effectiveStopReason, - run_id: runId, - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, - agent_id: agentId, - conversation_id: conversationId, - }); - emitTurnResult(socket, runtime, { - subtype: "error", + stopReason: effectiveStopReason, + isTerminal: true, + runId: runId, agentId, conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, - stopReason: effectiveStopReason, }); break; } @@ -3639,25 +4920,22 @@ async function handleIncomingMessage( // Unexpected: requires_approval but no approvals runtime.lastStopReason = "error"; runtime.isProcessing = false; - clearActiveRunState(runtime); - - emitToWS(socket, { - type: "error", - message: "requires_approval stop returned no approvals", - stop_reason: "error", - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, + setLoopStatus(runtime, "WAITING_ON_INPUT", { agent_id: agentId, conversation_id: conversationId, }); - emitTurnResult(socket, runtime, { - subtype: "error", + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); + + emitLoopErrorDelta(socket, runtime, { + message: "requires_approval stop returned no approvals", + stopReason: "error", + isTerminal: true, agentId, conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, - stopReason: "error", }); break; } @@ -3674,6 +4952,7 @@ async function handleIncomingMessage( alwaysRequiresUserInput: isInteractiveApprovalTool, treatAskAsDeny: false, // Let cloud UI handle approvals requireArgsForAutoApprove: true, + missingNameReason: "Tool call incomplete - missing name", workingDirectory: turnWorkingDirectory, }); @@ -3704,27 +4983,6 @@ async function handleIncomingMessage( reason: string; }; - // Emit auto-approval events for auto-allowed tools - for (const ac of autoAllowed) { - emitToWS(socket, { - type: "auto_approval", - tool_call: { - name: ac.approval.toolName, - tool_call_id: ac.approval.toolCallId, - arguments: ac.approval.toolArgs, - }, - reason: ac.permission.reason || "auto-approved", - matched_rule: - "matchedRule" in ac.permission && ac.permission.matchedRule - ? ac.permission.matchedRule - : "auto-approved", - session_id: runtime.sessionId, - uuid: `auto-approval-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - } as AutoApprovalMessage); - } - const decisions: Decision[] = [ ...autoAllowed.map((ac) => ({ type: "approve" as const, @@ -3740,6 +4998,10 @@ async function handleIncomingMessage( // Handle tools that need user input if (needsUserInput.length > 0) { runtime.lastStopReason = "requires_approval"; + setLoopStatus(runtime, "WAITING_ON_APPROVAL", { + agent_id: agentId, + conversation_id: conversationId, + }); // Block in-loop via the control protocol for all device approvals. for (const ac of needsUserInput) { @@ -3749,9 +5011,6 @@ async function handleIncomingMessage( ac.parsedArgs, turnWorkingDirectory, ); - const lifecycleRunId = - runId || runtime.activeRunId || msgRunIds[msgRunIds.length - 1]; - const controlRequest: ControlRequest = { type: "control_request", request_id: requestId, @@ -3768,18 +5027,6 @@ async function handleIncomingMessage( conversation_id: conversationId, }; - emitToWS(socket, { - type: "approval_requested", - request_id: requestId, - tool_call_id: ac.approval.toolCallId, - tool_name: ac.approval.toolName, - ...(lifecycleRunId ? { run_id: lifecycleRunId } : {}), - session_id: runtime.sessionId, - uuid: `approval-requested-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - }); - const responseBody = await requestApprovalOverWS( runtime, socket, @@ -3787,87 +5034,30 @@ async function handleIncomingMessage( controlRequest, ); - if (responseBody.subtype === "success") { - const response = responseBody.response as - | CanUseToolResponse - | undefined; - if (response?.behavior === "allow") { - const finalApproval = response.updatedInput + if ("decision" in responseBody) { + const response = responseBody.decision as ApprovalResponseDecision; + if (response.behavior === "allow") { + const finalApproval = response.updated_input ? { ...ac.approval, - toolArgs: JSON.stringify(response.updatedInput), + toolArgs: JSON.stringify(response.updated_input), } : ac.approval; decisions.push({ type: "approve", approval: finalApproval }); - - // Emit auto-approval event for WS-callback-approved tool - emitToWS(socket, { - type: "auto_approval", - tool_call: { - name: finalApproval.toolName, - tool_call_id: finalApproval.toolCallId, - arguments: finalApproval.toolArgs, - }, - reason: "Approved via WebSocket", - matched_rule: "canUseTool callback", - session_id: runtime.sessionId, - uuid: `auto-approval-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - } as AutoApprovalMessage); - emitToWS(socket, { - type: "approval_received", - request_id: requestId, - tool_call_id: ac.approval.toolCallId, - decision: "allow", - reason: "Approved via WebSocket", - ...(lifecycleRunId ? { run_id: lifecycleRunId } : {}), - session_id: runtime.sessionId, - uuid: `approval-received-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - }); } else { decisions.push({ type: "deny", approval: ac.approval, reason: response?.message || "Denied via WebSocket", }); - emitToWS(socket, { - type: "approval_received", - request_id: requestId, - tool_call_id: ac.approval.toolCallId, - decision: "deny", - reason: response?.message || "Denied via WebSocket", - ...(lifecycleRunId ? { run_id: lifecycleRunId } : {}), - session_id: runtime.sessionId, - uuid: `approval-received-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - }); } } else { - const denyReason = - responseBody.subtype === "error" - ? responseBody.error - : "Unknown error"; + const denyReason = responseBody.error; decisions.push({ type: "deny", approval: ac.approval, reason: denyReason, }); - emitToWS(socket, { - type: "approval_received", - request_id: requestId, - tool_call_id: ac.approval.toolCallId, - decision: "deny", - reason: denyReason, - ...(lifecycleRunId ? { run_id: lifecycleRunId } : {}), - session_id: runtime.sessionId, - uuid: `approval-received-${ac.approval.toolCallId}`, - agent_id: agentId, - conversation_id: conversationId, - }); } } } @@ -3881,6 +5071,14 @@ async function handleIncomingMessage( ) .map((decision) => decision.approval.toolCallId); runtime.activeExecutingToolCallIds = [...lastExecutingToolCallIds]; + setLoopStatus(runtime, "EXECUTING_CLIENT_SIDE_TOOL", { + agent_id: agentId, + conversation_id: conversationId, + }); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); const executionRunId = runId || runtime.activeRunId || msgRunIds[msgRunIds.length - 1]; emitToolExecutionStartedEvents(socket, runtime, { @@ -3906,6 +5104,14 @@ async function handleIncomingMessage( executionResults, lastExecutingToolCallIds, ); + validateApprovalResultIds( + decisions.map((decision) => ({ + approval: { + toolCallId: decision.approval.toolCallId, + }, + })), + persistedExecutionResults, + ); emitToolExecutionFinishedEvents(socket, runtime, { approvals: persistedExecutionResults, runId: executionRunId, @@ -3925,11 +5131,6 @@ async function handleIncomingMessage( undefined, "tool-return", ); - clearPendingApprovalBatchIds( - runtime, - decisions.map((decision) => decision.approval), - ); - // Create fresh approval stream for next iteration currentInput = [ { @@ -3937,22 +5138,56 @@ async function handleIncomingMessage( approvals: persistedExecutionResults, }, ]; - stream = await sendMessageStreamWithRetry( + setLoopStatus(runtime, "SENDING_API_REQUEST", { + agent_id: agentId, + conversation_id: conversationId, + }); + stream = await sendApprovalContinuationWithRetry( conversationId, currentInput, - sendOptions, + buildSendOptions(), socket, runtime, runtime.activeAbortController.signal, ); + if (!stream) { + return; + } + pendingNormalizationInterruptedToolCallIds = []; + clearPendingApprovalBatchIds( + runtime, + decisions.map((decision) => decision.approval), + ); + await debugLogApprovalResumeState(runtime, { + agentId, + conversationId, + expectedToolCallIds: collectDecisionToolCallIds( + decisions.map((decision) => ({ + approval: { + toolCallId: decision.approval.toolCallId, + }, + })), + ), + sentToolCallIds: collectApprovalResultToolCallIds( + persistedExecutionResults, + ), + }); + markAwaitingAcceptedApprovalContinuationRunId(runtime, currentInput); + setLoopStatus(runtime, "PROCESSING_API_RESPONSE", { + agent_id: agentId, + conversation_id: conversationId, + }); - // Results were successfully submitted to the backend — clear both so a - // cancel during the subsequent stream drain won't queue already-sent - // results (Path A) or re-deny already-resolved tool calls (Path B). - lastExecutionResults = null; - lastExecutingToolCallIds = []; - lastNeedsUserInputToolCallIds = []; + // The continuation request has been accepted by the backend, but do not + // drop the local approval snapshots until that continuation stream yields + // a stable stop. Catch/interrupt paths still need to distinguish + // "already submitted" from "not yet submitted". + lastApprovalContinuationAccepted = true; runtime.activeExecutingToolCallIds = []; + emitRuntimeStateUpdates(runtime, { + agent_id: agentId, + conversation_id: conversationId, + }); turnToolContextId = getStreamToolContextId( stream as Stream, @@ -3960,101 +5195,84 @@ async function handleIncomingMessage( } } catch (error) { if (runtime.cancelRequested) { - // Queue interrupted tool-call resolutions for the next message turn. - populateInterruptQueue(runtime, { - lastExecutionResults, - lastExecutingToolCallIds, - lastNeedsUserInputToolCallIds, - agentId: agentId || "", - conversationId, - }); - const approvalsForEmission = getInterruptApprovalsForEmission(runtime, { - lastExecutionResults, - agentId: agentId || "", - conversationId, - }); - if (approvalsForEmission) { - emitToolExecutionFinishedEvents(socket, runtime, { - approvals: approvalsForEmission, - runId: runtime.activeRunId || msgRunIds[msgRunIds.length - 1], + if (!lastApprovalContinuationAccepted) { + // Queue interrupted tool-call resolutions for the next message turn + // only if the approval continuation has not yet been accepted. + populateInterruptQueue(runtime, { + lastExecutionResults, + lastExecutingToolCallIds, + lastNeedsUserInputToolCallIds, agentId: agentId || "", conversationId, }); - emitInterruptToolReturnMessage( - socket, - runtime, - approvalsForEmission, - runtime.activeRunId || msgRunIds[msgRunIds.length - 1] || undefined, - ); + const approvalsForEmission = getInterruptApprovalsForEmission(runtime, { + lastExecutionResults, + agentId: agentId || "", + conversationId, + }); + if (approvalsForEmission) { + emitToolExecutionFinishedEvents(socket, runtime, { + approvals: approvalsForEmission, + runId: runtime.activeRunId || msgRunIds[msgRunIds.length - 1], + agentId: agentId || "", + conversationId, + }); + emitInterruptToolReturnMessage( + socket, + runtime, + approvalsForEmission, + runtime.activeRunId || msgRunIds[msgRunIds.length - 1] || undefined, + ); + } } runtime.lastStopReason = "cancelled"; runtime.isProcessing = false; - clearActiveRunState(runtime); - - emitTurnResult(socket, runtime, { - subtype: "interrupted", - agentId: agentId || "", + emitInterruptedStatusDelta(socket, runtime, { + runId: runtime.activeRunId || msgRunIds[msgRunIds.length - 1], + agentId: agentId || null, conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, - stopReason: "cancelled", }); + setLoopStatus(runtime, "WAITING_ON_INPUT", { + agent_id: agentId || null, + conversation_id: conversationId, + }); + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId || null, + conversation_id: conversationId, + }); + return; } runtime.lastStopReason = "error"; runtime.isProcessing = false; - clearActiveRunState(runtime); - - // If no run_started was ever sent, the initial POST failed (e.g. 429, 402). - // Emit run_request_error so the web UI can correlate with the optimistic run. - if (msgRunIds.length === 0) { - const errorPayload: RunRequestErrorMessage["error"] = { - message: error instanceof Error ? error.message : String(error), - }; - if (error instanceof APIError) { - errorPayload.status = error.status; - if (error.error && typeof error.error === "object") { - errorPayload.body = error.error as Record; - } - } - sendClientMessage(socket, { - type: "run_request_error", - error: errorPayload, - batch_id: dequeuedBatchId, - agent_id: agentId, - conversation_id: conversationId, - }); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - emitToWS(socket, { - type: "error", - message: errorMessage, - stop_reason: "error", - session_id: runtime.sessionId, - uuid: `error-${crypto.randomUUID()}`, - agent_id: agentId || undefined, + setLoopStatus(runtime, "WAITING_ON_INPUT", { + agent_id: agentId || null, conversation_id: conversationId, }); - emitTurnResult(socket, runtime, { - subtype: "error", - agentId: agentId || "", - conversationId, - durationMs: performance.now() - msgStartTime, - numTurns: msgTurnCount, - runIds: msgRunIds, - stopReason: "error", + clearActiveRunState(runtime); + emitRuntimeStateUpdates(runtime, { + agent_id: agentId || null, + conversation_id: conversationId, }); + const errorMessage = error instanceof Error ? error.message : String(error); + emitLoopErrorDelta(socket, runtime, { + message: errorMessage, + stopReason: "error", + isTerminal: true, + agentId: agentId || undefined, + conversationId, + }); if (isDebugEnabled()) { console.error("[Listen] Error handling message:", error); } } finally { runtime.activeAbortController = null; runtime.cancelRequested = false; + runtime.isRecoveringApprovals = false; runtime.activeExecutingToolCallIds = []; } } @@ -4082,10 +5300,13 @@ export function stopListenerClient(): void { export const __listenClientTestUtils = { createRuntime, stopRuntime, - buildStateResponse, + resolveRuntimeScope, + buildDeviceStatus, + buildLoopStatus, + buildQueueSnapshot, + emitDeviceStatusUpdate, + emitLoopStatusUpdate, handleCwdChange, - emitToWS, - emitCancelAck, getConversationWorkingDirectory, rememberPendingApprovalBatchIds, resolvePendingApprovalBatchId, @@ -4094,12 +5315,20 @@ export const __listenClientTestUtils = { populateInterruptQueue, setConversationWorkingDirectory, consumeInterruptQueue, + stashRecoveredApprovalInterrupts, extractInterruptToolReturns, emitInterruptToolReturnMessage, + emitInterruptedStatusDelta, + emitRetryDelta, getInterruptApprovalsForEmission, normalizeToolReturnWireMessage, normalizeExecutionResultsForInterruptParity, shouldAttemptPostStopApprovalRecovery, + getApprovalContinuationRecoveryDisposition, + markAwaitingAcceptedApprovalContinuationRunId, normalizeMessageContentImages, normalizeInboundMessages, + recoverApprovalStateForSync, + clearRecoveredApprovalStateForScope, + emitStateSync, };