From c98b841b41991d24ec9519fe9f599ab2e0e883ad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 19:40:06 -0700 Subject: [PATCH] fix(ui): deliver failed steer retries as a new turn when the session is idle (#122919) A steer rejected on the run/leaf fence parks as a failed steered queue row. Retry previously refused whenever the target run was gone, leaving the user's message permanently stuck with 'that run is no longer active'. Retry now converts the parked row into a plain queued send when the session is idle, so the text delivers as a fresh turn; active-run retries keep the original steer binding, and disconnected retries keep the existing error. --- ui/src/pages/chat/chat-send-actions.ts | 40 +++++++++++++++------- ui/src/pages/chat/chat-send.test.ts | 46 +++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/ui/src/pages/chat/chat-send-actions.ts b/ui/src/pages/chat/chat-send-actions.ts index 01754a8af33d..14239c7c6241 100644 --- a/ui/src/pages/chat/chat-send-actions.ts +++ b/ui/src/pages/chat/chat-send-actions.ts @@ -168,7 +168,7 @@ export function moveQueuedChatMessage(host: ChatHost, id: string, toIndex: numbe } export async function retryQueuedChatMessage(host: ChatHost, id: string) { - const item = host.chatQueue.find((entry) => entry.id === id); + let item = host.chatQueue.find((entry) => entry.id === id); if ( !item || item.pendingRunId || @@ -180,23 +180,39 @@ export async function retryQueuedChatMessage(host: ChatHost, id: string) { return; } if (item.kind === "steered") { - if (!host.connected || !host.client || !hasAbortableSessionRun(host)) { + if (!host.connected || !host.client) { setChatError(host, t("chat.sendErrors.steerRunNoLongerActive")); return; } - const retry = updateQueuedMessage(host, id, (entry) => ({ - ...entry, - sendAttempts: 0, - sendError: undefined, - sendRequestStartedAtMs: undefined, - sendState: "waiting-idle", - })); - if (!retry) { + if (hasAbortableSessionRun(host)) { + const retry = updateQueuedMessage(host, id, (entry) => ({ + ...entry, + sendAttempts: 0, + sendError: undefined, + sendRequestStartedAtMs: undefined, + sendState: "waiting-idle", + })); + if (!retry) { + setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); + return; + } + await steerQueuedChatMessageLifecycle(host, id, steerSendDependencies); + return; + } + const converted = updateQueuedMessage(host, id, (entry) => { + const { + kind: _kind, + pendingRunId: _pendingRunId, + steerTargetRunId: _steerTargetRunId, + ...queued + } = entry; + return resetRetryState(queued, reconnectSafeQueuedSendState(host)); + }); + if (!converted) { setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); return; } - await steerQueuedChatMessageLifecycle(host, id, steerSendDependencies); - return; + item = converted; } let outbox = findStoredOutbox(host, item.id); if (!outbox) { diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index c60823c8d2ea..42b6ef5ee132 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -8797,13 +8797,14 @@ describe("handleSendChat", () => { ]); expect(host.lastError).toBe("no active turn to steer"); - host.chatRunId = null; + host.connected = false; await retryQueuedChatMessage(host, original.id); expect(payloads).toHaveLength(1); expect(host.lastError).toBe( "This steer still targets the previous run, but that run is no longer active.", ); + host.connected = true; host.chatRunId = "active-run"; host.chatDisplayedLeafEntryId = "leaf-advanced-during-tool-work"; await retryQueuedChatMessage(host, original.id); @@ -8821,6 +8822,49 @@ describe("handleSendChat", () => { ]); }); + it("retries a failed steer as a new turn when the session is idle", async () => { + const payloads: Array> = []; + const original = { + id: "idle-failed-steer", + text: "deliver this as a new turn", + createdAt: 1, + kind: "steered" as const, + sendAttempts: 1, + sendError: "The session switched branches — review and resend.", + sendRequestStartedAtMs: 123, + sendRunId: "previous-steer-request", + sendState: "failed" as const, + steerTargetRunId: "previous-run", + sessionKey: "agent:main:main", + agentId: "main", + }; + const host = makeChatHost({ + requestHandlers: { + "chat.history": idleChatHistory(original.sessionKey), + "chat.send": (params: unknown) => { + payloads.push(requireRecord(params, "idle steer retry payload")); + return { status: "ok", runId: "new-turn" }; + }, + }, + chatError: "The session switched branches — review and resend.", + chatQueue: [original], + sessionKey: original.sessionKey, + }); + expect(admitQueuedMessageForSession(host, host.sessionKey, original)).toBe(true); + + await retryQueuedChatMessage(host, original.id); + + expect(payloads).toHaveLength(1); + expect(payloads[0]).toMatchObject({ message: original.text }); + expect(payloads[0]).not.toHaveProperty("expectedRunId"); + expect(payloads[0]).not.toHaveProperty("expectedLeafEntryId"); + expect(payloads[0]).not.toHaveProperty("queueMode"); + expect(payloads[0]?.idempotencyKey).not.toBe(original.sendRunId); + expect(host.chatQueue).toEqual([]); + expect(host.chatError).toBeNull(); + expect(host.lastError).toBeNull(); + }); + it("fails a restored steer that predates durable target identity", async () => { const original = { id: "legacy-targetless-steer",