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",