From 32ddbb3be23e09a6c3b99bc71eb31b3bbfa000c5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 01:38:06 -0700 Subject: [PATCH] fix(ui): chat send failures were invisible when the session pane was hidden (#124473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): always surface terminal chat send failures Terminal chat send failures were recorded only on the queue item when the owning pane was not visible (reconnect, alias drift, split-pane routing), so the operator saw nothing at all — an action ending in silence. Root cause: every error surface in the send/drain path was gated on visibleSessionMatches(...), and the FIFO outbox drain treated every chat.history reconcile rejection as a silent retryable "blocked", so a non-retryable rejection (e.g. auth loss) wedged the head — and everything behind it — permanently with no visible outcome. Fix, at the owner: - New surfaceChatDeliveryFailure() in steer-lifecycle.ts (the shared error-text owner): visible pane keeps the inline chat error; otherwise the failure routes through the existing global toast host, naming the session. All terminal failure sites in chat-send-delivery, chat-outbox-drain, chat-send-queue-state, and steer-lifecycle now use it. - reconcileStoredChatOutboxHead: a non-retryable GatewayRequestError on the head now terminally fails a never-attempted head (unblocking the lane) or parks an attempted head as unconfirmed — both with a visible outcome — instead of blocking the lane forever. - Composer disabled-reason: the reason now also renders while a draft hides the placeholder, and the cloud-startup-pending gate gets a reason instead of a silently disabled composer. Regression tests: hidden-pane terminal failure surfaces via toast; wedged head fails visibly and the lane drains the next message; attempted head parks unconfirmed; disabled reason visible with draft text present. * fix(ci): raise startup JS baseline for global failure surfacing and pin workspace-sync test clock The chat send path now imports session-display naming for the global failure toast, adding ~1 KiB gzip to startup JS (335452 B on CI's Linux builder, still well under the 358400 B committed cap). workspace-sync "never commands" asserted the exact dispatch timeoutMs (777) but the impl derives it from a Date.now() deadline, so any elapsed ms between admission and dispatch failed the exact-equality assertion on a loaded runner. Pin the clock like the sibling timeout tests do. * fix(ui): surface route-switched command failures and agent-scope global toast naming ClawSweeper review findings on #124473: - A queued local command failing after the operator navigated away hit failCommand(error) with expose=false; the dispatcher's stale-scope guard had already withheld the inline error, so a successful state write recorded the failure invisibly — the silent class this PR removes. Expose it globally when the scope is stale and the owning pane is hidden; a stale scope with the pane still visible keeps the failed queue chip (the new connection owns the inline surface). - Global session rows are agent-scoped behind one shared "global" key, so the toast row lookup could borrow another agent's label. Match the row's agentId to the failed outbox's agent for global keys. Both regression tests fail pre-fix (stash-verified). --- .../control-ui-startup-budget-baseline.json | 6 +- .../workspace-sync.test.ts | 4 + ui/src/lib/toast.ts | 6 +- ui/src/pages/chat/chat-composer.test.ts | 14 ++ ui/src/pages/chat/chat-outbox-drain.ts | 84 +++++-- ui/src/pages/chat/chat-pane-render.ts | 6 +- ui/src/pages/chat/chat-send-delivery.ts | 39 ++- ui/src/pages/chat/chat-send-queue-state.ts | 11 +- ui/src/pages/chat/chat-send.test.ts | 227 ++++++++++++++++++ ui/src/pages/chat/steer-lifecycle.ts | 64 ++++- 10 files changed, 404 insertions(+), 57 deletions(-) diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json index 14ed83f192fa..b7edfc4bca31 100644 --- a/config/control-ui-startup-budget-baseline.json +++ b/config/control-ui-startup-budget-baseline.json @@ -1,5 +1,5 @@ { - "startupJsGzipBytes": 334391, - "reason": "cumulative 2026-08-15 UI growth plus folder group defaults catalog and lazy New Session route resolution", - "updatedAt": "2026-08-15" + "startupJsGzipBytes": 335452, + "reason": "chat delivery failures surface globally: toast + session-display naming joins startup chat path", + "updatedAt": "2026-08-16" } diff --git a/src/gateway/worker-environments/workspace-sync.test.ts b/src/gateway/worker-environments/workspace-sync.test.ts index 3b08b1ad6f22..a6d2a29ffa3c 100644 --- a/src/gateway/worker-environments/workspace-sync.test.ts +++ b/src/gateway/worker-environments/workspace-sync.test.ts @@ -57,6 +57,10 @@ function createWorkspaceActions( describe("worker workspace command transport retry", () => { it("runs never commands once without changing the selected port", async () => { + // Pin the clock: the impl derives the dispatch timeout from a Date.now() + // deadline, so real elapsed ms between admission and dispatch would turn + // the exact 777 assertion below into a loaded-runner flake. + vi.spyOn(Date, "now").mockReturnValue(1_000); const run = vi.fn(async (argv: string[], _options: CommandOptions) => argv.at(-1)?.includes("never-command") ? result(255) : result(), ); diff --git a/ui/src/lib/toast.ts b/ui/src/lib/toast.ts index 5144c6065338..8dd3746cbf4c 100644 --- a/ui/src/lib/toast.ts +++ b/ui/src/lib/toast.ts @@ -113,6 +113,9 @@ class OpenClawToastHost extends OpenClawLightDomContentsElement { } export function showToast(options: ToastOptions): boolean { + if (typeof document === "undefined") { + return false; + } const host = document.querySelector("openclaw-toast-host"); if (!host) { queuedToast = options; @@ -136,7 +139,8 @@ export function showToast(options: ToastOptions): boolean { return true; } -if (!customElements.get("openclaw-toast-host")) { +// Guarded so DOM-free (node) consumers of send-failure surfacing can load this module. +if (typeof customElements !== "undefined" && !customElements.get("openclaw-toast-host")) { customElements.define("openclaw-toast-host", OpenClawToastHost); } diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index ad83d1a6263d..d1df1276b0ad 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -234,6 +234,20 @@ describe("renderChatComposer controls", () => { expect(container.querySelector("textarea")?.disabled).toBe(true); }); + it("shows the disabled reason even when draft text hides the placeholder", () => { + const reason = "This session is read-only."; + const { container } = renderComposer({ + canSend: false, + disabledReason: reason, + draft: "a draft that hides the placeholder", + }); + + // The placeholder carries the reason only for an empty composer; the + // dedicated reason row must keep the explanation visible alongside a draft. + expect(container.querySelector(".agent-chat__disabled-reason")?.textContent).toContain(reason); + expect(container.querySelector("textarea")?.disabled).toBe(true); + }); + it("switches the primary action between voice, send, queue, and stop", () => { const onToggleRealtimeTalk = vi.fn(); let view = renderComposer({ onToggleRealtimeTalk }); diff --git a/ui/src/pages/chat/chat-outbox-drain.ts b/ui/src/pages/chat/chat-outbox-drain.ts index c0ecdd8f184a..c2bf734428f6 100644 --- a/ui/src/pages/chat/chat-outbox-drain.ts +++ b/ui/src/pages/chat/chat-outbox-drain.ts @@ -30,12 +30,14 @@ import { type StoredChatOutbox, type StoredChatOutboxScope, } from "./composer-persistence.ts"; +import { formatConnectError } from "./connect-error.ts"; import { isQueuedMessageBeingEdited } from "./queued-message-edit.ts"; import { isChatBusy } from "./run-lifecycle.ts"; import { chatMessagesContainQueuedSend, OFFLINE_QUEUE_STORAGE_ERROR, preserveQueuedUserTurn, + surfaceChatDeliveryFailure, } from "./steer-lifecycle.ts"; export type QueuedChatSendResult = "sent" | "pending" | "failed"; @@ -179,16 +181,43 @@ async function readCurrentStoredChatHistory( limit: 1000, }); } catch (err) { + const connectionCurrent = + host.client === client && host.connectionEpoch === connectionEpoch && host.connected; const retryDelayMs = retryableGatewayDelayMs(err); - if ( - retryDelayMs !== null && - host.client === client && - host.connectionEpoch === connectionEpoch && - host.connected - ) { - scheduleStoredChatOutboxRetry(host, outbox, retryDelayMs, dependencies); + if (retryDelayMs !== null) { + if (connectionCurrent) { + scheduleStoredChatOutboxRetry(host, outbox, retryDelayMs, dependencies); + } + return "blocked"; } - return "blocked"; + // An authoritative non-retryable rejection (auth loss, revoked scope) will + // repeat on every drain wakeup; leaving the head silently "blocked" wedges + // the whole FIFO lane forever. Fail or park it visibly so the operator sees + // the outcome and the lane can move past a never-attempted head. + if (!connectionCurrent || !(err instanceof GatewayRequestError)) { + return "blocked"; + } + const attempted = + (item.sendAttempts ?? 0) > 0 || + item.sendRequestStartedAtMs !== undefined || + item.sendState === "unconfirmed"; + const error = attempted ? UNCONFIRMED_CHAT_SEND_ERROR : formatConnectError(err); + const targetState = attempted ? ("unconfirmed" as const) : ("failed" as const); + if (item.sendState === targetState && item.sendError === error) { + return "blocked"; + } + const parked = updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({ + ...entry, + sendError: error, + sendState: targetState, + })); + surfaceChatDeliveryFailure( + host, + outbox.sessionKey, + outbox.agentId, + parked ? error : OFFLINE_QUEUE_STORAGE_ERROR, + ); + return parked && !attempted ? "continue" : "blocked"; } const currentOutbox = readStoredChatOutbox(host, outbox); const currentItem = currentOutbox?.queue.find((entry) => entry.id === item.id); @@ -272,8 +301,13 @@ async function reconcileStoredChatOutboxHead( sendError: UNCONFIRMED_CHAT_SEND_ERROR, sendState: "unconfirmed", })); - if (parked && visibleSessionMatches(host, outbox.sessionKey, outbox.agentId)) { - dependencies.setChatError(host, UNCONFIRMED_CHAT_SEND_ERROR); + if (parked) { + surfaceChatDeliveryFailure( + host, + outbox.sessionKey, + outbox.agentId, + UNCONFIRMED_CHAT_SEND_ERROR, + ); } return "blocked"; } @@ -411,12 +445,13 @@ async function drainStoredChatOutbox( visibleSessionMatches(host, outbox.sessionKey, outbox.agentId); const failCommand = (error: string, expose = false): "blocked" => { const updated = setCommandState("failed", error); - if (commandScopeIsCurrent()) { - if (!updated) { - dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - } else if (expose) { - dependencies.setChatError(host, error); - } + if (!updated || expose) { + surfaceChatDeliveryFailure( + host, + outbox.sessionKey, + outbox.agentId, + updated ? error : OFFLINE_QUEUE_STORAGE_ERROR, + ); } return "blocked"; }; @@ -435,11 +470,17 @@ async function drainStoredChatOutbox( return "blocked"; } if (dispatchResult === "failed") { + // A still-current scope already saw the dispatcher's inline error. + // After a route switch the dispatcher withholds it and the pane is + // gone, so the terminal failure must surface globally. A stale scope + // with the pane still visible (connection replaced) keeps the failed + // queue chip instead: the new connection owns the inline surface. const commandStillCurrent = commandScopeIsCurrent(); const error = (commandStillCurrent ? host.lastError : null) ?? `Command /${item.localCommandName} failed.`; - return failCommand(error); + const paneHidden = !visibleSessionMatches(host, outbox.sessionKey, outbox.agentId); + return failCommand(error, !commandStillCurrent && paneHidden); } if (dispatchResult === "uncertain") { const currentOutbox = readStoredChatOutbox(host, outbox); @@ -466,9 +507,12 @@ async function drainStoredChatOutbox( } } if (!removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey)) { - if (commandScopeIsCurrent()) { - dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure( + host, + outbox.sessionKey, + outbox.agentId, + OFFLINE_QUEUE_STORAGE_ERROR, + ); return "blocked"; } if (dispatchResult === "uncertain") { diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index cc98a9966fa6..791b373783a7 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -207,11 +207,15 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender { selectedSession.sharingRole === "viewer" && isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.add") === true && isGatewayMethodAdvertised(gatewaySnapshot, "session.suggestions.list") === true; + // Every composer-disabling gate needs a visible reason here or a banner in + // sessionDisabledBanner; a silently disabled composer is a silent failure. const disabledReason = modelUnavailable ? `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}` : sessionParticipationBlocked && !suggestionViewer ? t("chat.sessionSharing.readOnlyNotice") - : null; + : cloudStartupPending + ? t("newSession.starting") + : null; const typingEnabled = multiIdentity && hasOperatorWriteAccess(gatewaySnapshot.hello?.auth ?? null) && diff --git a/ui/src/pages/chat/chat-send-delivery.ts b/ui/src/pages/chat/chat-send-delivery.ts index da71216916f0..ea6a26377e78 100644 --- a/ui/src/pages/chat/chat-send-delivery.ts +++ b/ui/src/pages/chat/chat-send-delivery.ts @@ -63,7 +63,11 @@ import { resetChatInputHistoryNavigation } from "./input-history.ts"; import { controlUiNowMs, roundedControlUiDurationMs } from "./performance.ts"; import { hasAbortableSessionRun, isChatBusy, reconcileChatRunLifecycle } from "./run-lifecycle.ts"; import { resetChatScroll, scheduleChatScroll } from "./scroll.ts"; -import { formatTerminalChatSendAckError, OFFLINE_QUEUE_STORAGE_ERROR } from "./steer-lifecycle.ts"; +import { + formatTerminalChatSendAckError, + OFFLINE_QUEUE_STORAGE_ERROR, + surfaceChatDeliveryFailure, +} from "./steer-lifecycle.ts"; import { resetToolStream } from "./tool-stream.ts"; import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; @@ -224,9 +228,7 @@ async function sendQueuedChatMessage( const access = readChatResetTargetAccess(host, options.target); if (!access.allowed) { setState("failed", access.reason); - if (visibleSessionMatches(host, sessionKey, prepared.agentId)) { - setChatError(host, access.reason); - } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, access.reason); return "failed"; } } @@ -245,9 +247,7 @@ async function sendQueuedChatMessage( } if (!waiting) { setState("failed", OFFLINE_QUEUE_STORAGE_ERROR); - if (visibleSessionMatches(host, sessionKey, prepared.agentId)) { - setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR); } return "pending"; } @@ -267,9 +267,7 @@ async function sendQueuedChatMessage( agentId: prepared.agentId, })); if (!sendingItem) { - if (visibleSessionMatches(host, sessionKey, prepared.agentId)) { - setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR); return "pending"; } registerChatSendTiming(host, sendingItem, runId, requestStartedAtMs); @@ -349,9 +347,9 @@ async function sendQueuedChatMessage( publishRunStatus: false, armLocalTerminalReconcile: ack.runId === runId, }); - setChatError(host, error); restoreComposer(host, options ?? {}); } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, error); recordChatSendTiming(host, sendingItem, "failed", sendingItem.sendSubmittedAtMs, { error, ackStatus: ack.status, @@ -431,9 +429,7 @@ async function sendQueuedChatMessage( } discardChatAttachmentDataUrls(excludeComposerAttachments(host, attachments)); if (retirementFailed) { - if (isVisible()) { - setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR); return "pending"; } return retireOnAck ? "sent" : "pending"; @@ -478,9 +474,12 @@ async function sendQueuedChatMessage( sendState: safelyRejected ? "failed" : "unconfirmed", })); } - if (isVisible()) { - setChatError(host, restore ? error : OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure( + host, + sessionKey, + prepared.agentId, + restore ? error : OFFLINE_QUEUE_STORAGE_ERROR, + ); recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, { error: restore ? error : OFFLINE_QUEUE_STORAGE_ERROR, }); @@ -503,9 +502,7 @@ async function sendQueuedChatMessage( sendState: "failed", })); } - if (isVisible()) { - setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, OFFLINE_QUEUE_STORAGE_ERROR); recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, { error: OFFLINE_QUEUE_STORAGE_ERROR, }); @@ -534,12 +531,12 @@ async function sendQueuedChatMessage( } setState("failed", error); if (isVisible()) { - setChatError(host, error); restoreComposer(host, options ?? {}); if (activeLeafChanged) { void Promise.all([loadChatHistory(host), loadChatBranches(host)]); } } + surfaceChatDeliveryFailure(host, sessionKey, prepared.agentId, error); recordChatSendTiming(host, prepared, "failed", prepared.sendSubmittedAtMs, { error }); return "failed"; } finally { diff --git a/ui/src/pages/chat/chat-send-queue-state.ts b/ui/src/pages/chat/chat-send-queue-state.ts index b7a3aca6b885..0c2c3ee2be5d 100644 --- a/ui/src/pages/chat/chat-send-queue-state.ts +++ b/ui/src/pages/chat/chat-send-queue-state.ts @@ -20,7 +20,7 @@ import { storedChatOutboxScopeKey, type StoredChatOutboxScope } from "./composer import { controlUiNowMs } from "./performance.ts"; import { hasAbortableSessionRun, isChatBusy } from "./run-lifecycle.ts"; import { scheduleChatScroll } from "./scroll.ts"; -import { OFFLINE_QUEUE_STORAGE_ERROR } from "./steer-lifecycle.ts"; +import { OFFLINE_QUEUE_STORAGE_ERROR, surfaceChatDeliveryFailure } from "./steer-lifecycle.ts"; const SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR = "Skill Workshop revision request cancelled because the Gateway connection changed."; @@ -132,9 +132,12 @@ export function failSkillWorkshopRevisionConnectionChange( sessionKey, item.id, )("failed", SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR); - if (visibleSessionMatches(host, sessionKey, item.agentId)) { - setChatError(host, SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR); - } + surfaceChatDeliveryFailure( + host, + sessionKey, + item.agentId, + SKILL_WORKSHOP_CONNECTION_CHANGED_ERROR, + ); return "failed"; } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 6391b3fe674c..11c1a4d80ab1 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -9414,6 +9414,233 @@ describe("handleSendChat", () => { expect(getChatAttachmentDataUrl(attachment)).toBeNull(); expect(revokeObjectURL).toHaveBeenCalledWith("blob:queued"); }); + + it("surfaces a terminal send failure through the global toast when the pane is not visible", async () => { + const toastHost = document.createElement("openclaw-toast-host"); + document.body.append(toastHost); + const host = makeChatHost({ + requestHandlers: { + "chat.history": idleChatHistory("agent:other"), + "chat.send": () => { + throw new GatewayRequestError({ + code: "UNAUTHORIZED", + message: "gateway auth failed", + retryable: false, + }); + }, + }, + chatQueue: [ + { + id: "hidden-terminal-failure", + text: "fails while another session is visible", + createdAt: 1, + sendAttempts: 0, + sendRunId: "hidden-terminal-run", + sendState: "waiting-idle", + sessionKey: "agent:other", + agentId: "other", + }, + ], + sessionKey: "agent:main", + sessionsResult: createSessionsResult([ + row("agent:other", { hasActiveRun: false, status: "done" }), + ]), + }); + admitHostQueueItems(host); + + await retryReconnectableQueuedChatSends(host); + + // The invisible pane's inline error stays untouched; the failure surfaces globally. + expect(host.lastError).toBeNull(); + await waitForFast(() => expect(document.body.textContent).toContain("gateway auth failed")); + expect( + listStoredChatOutboxes(host) + .flatMap((outbox) => outbox.queue) + .find((entry) => entry.id === "hidden-terminal-failure"), + ).toMatchObject({ sendState: "failed", sendError: "gateway auth failed" }); + document.body.replaceChildren(); + }); + + it("fails a never-attempted head visibly and unblocks the lane when head reconcile is rejected as non-retryable", async () => { + const sends: string[] = []; + let historyCalls = 0; + const host = makeChatHost({ + requestHandlers: { + "chat.history": () => { + historyCalls += 1; + if (historyCalls === 1) { + throw new GatewayRequestError({ + code: "UNAUTHORIZED", + message: "gateway auth failed", + retryable: false, + }); + } + return idleChatHistory(); + }, + "chat.send": (params: unknown) => { + const payload = requireRecord(params, "post-unblock send payload"); + sends.push(String(payload.message)); + return { runId: payload.idempotencyKey, status: "ok" }; + }, + }, + chatQueue: [ + { + id: "wedged-head", + text: "head the gateway rejects", + createdAt: 1, + sendAttempts: 0, + sendRunId: "wedged-head-run", + sendState: "waiting-idle", + sessionKey: "agent:main", + }, + { + id: "queued-behind-head", + text: "message stuck behind the head", + createdAt: 2, + sendAttempts: 0, + sendRunId: "queued-behind-run", + sendState: "waiting-idle", + sessionKey: "agent:main", + }, + ], + }); + admitHostQueueItems(host); + const surfacedErrors: (string | null)[] = []; + let trackedError: string | null = host.lastError ?? null; + Object.defineProperty(host, "lastError", { + get: () => trackedError, + set: (value: string | null) => { + trackedError = value; + surfacedErrors.push(value); + }, + }); + + await retryReconnectableQueuedChatSends(host); + + // Pre-fix: the head stayed silently "blocked" forever and nothing surfaced. + expect(surfacedErrors).toContain("gateway auth failed"); + const stored = listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue); + expect(stored.find((entry) => entry.id === "wedged-head")).toMatchObject({ + sendState: "failed", + sendError: "gateway auth failed", + }); + // The lane moved past the terminally failed head instead of wedging. + await waitForFast(() => expect(sends).toContain("message stuck behind the head")); + }); + + it("parks an attempted head as unconfirmed instead of failing it on a non-retryable reconcile rejection", async () => { + const host = makeChatHost({ + requestHandlers: { + "chat.history": () => { + throw new GatewayRequestError({ + code: "UNAUTHORIZED", + message: "gateway auth failed", + retryable: false, + }); + }, + }, + chatQueue: [ + { + id: "attempted-head", + text: "head that may have reached the server", + createdAt: 1, + sendAttempts: 1, + sendRunId: "attempted-head-run", + sendState: "waiting-reconnect", + sessionKey: "agent:main", + }, + ], + }); + admitHostQueueItems(host); + + await retryReconnectableQueuedChatSends(host); + + // An attempted head may already be a server-side turn; it must park for + // review rather than fail-and-release, and the outcome must be visible. + expect(host.lastError).toBe( + "Delivery could not be confirmed after reconnect. Check the conversation before retrying.", + ); + const stored = listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue); + expect(stored.find((entry) => entry.id === "attempted-head")).toMatchObject({ + sendState: "unconfirmed", + }); + expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(0); + }); + + it("surfaces a failed local command globally after a route switch", async () => { + const toastHost = document.createElement("openclaw-toast-host"); + document.body.append(toastHost); + const item = createQueuedLocalCommand("route-switched-command", "/think", { + sessionKey: "agent:main:first", + }); + // The dispatcher reports failure after the operator navigated away, so its + // stale-scope guard withholds the inline error. + executeSlashCommandMock.mockImplementation(async () => { + host.sessionKey = "agent:main:second"; + return { failed: true, content: "think mode rejected" }; + }); + const host = makeChatHost({ + requestHandlers: { + "chat.history": () => idleChatHistory("agent:main:first"), + }, + chatQueue: [item], + sessionKey: item.sessionKey, + }); + admitHostQueueItems(host); + + await retryReconnectableQueuedChatSends(host); + + // Pre-fix: the failure was recorded on the queue item with no visible outcome. + expect(host.lastError).toBeNull(); + await waitForFast(() => expect(document.body.textContent).toContain("Command /think failed.")); + expect(listStoredChatOutboxes(host).flatMap((outbox) => outbox.queue)).toEqual([ + expect.objectContaining({ id: item.id, sendState: "failed" }), + ]); + document.body.replaceChildren(); + }); + + it("names the failed agent's global session in the toast, not another agent's row", async () => { + const toastHost = document.createElement("openclaw-toast-host"); + document.body.append(toastHost); + const host = makeChatHost({ + requestHandlers: { + "chat.history": idleChatHistory("global"), + "chat.send": () => { + throw new GatewayRequestError({ + code: "UNAUTHORIZED", + message: "gateway auth failed", + retryable: false, + }); + }, + }, + chatQueue: [ + { + id: "global-agent-scoped-failure", + text: "fails on the second agent's global session", + createdAt: 1, + sendAttempts: 0, + sendRunId: "global-agent-scoped-run", + sendState: "waiting-idle", + sessionKey: "global", + agentId: "writer", + }, + ], + sessionKey: "agent:main:elsewhere", + sessionsResult: createSessionsResult([ + row("global", { agentId: "main", label: "Main global chat" }), + row("global", { agentId: "writer", label: "Writer global chat" }), + ]), + }); + admitHostQueueItems(host); + + await retryReconnectableQueuedChatSends(host); + + await waitForFast(() => expect(document.body.textContent).toContain("gateway auth failed")); + // Global rows share one key; the toast must borrow the failed agent's label. + expect(document.body.textContent).toContain("Writer global chat"); + expect(document.body.textContent).not.toContain("Main global chat"); + document.body.replaceChildren(); + }); }); describe("handleAbortChat", () => { diff --git a/ui/src/pages/chat/steer-lifecycle.ts b/ui/src/pages/chat/steer-lifecycle.ts index ad4161170c9d..7753dede9237 100644 --- a/ui/src/pages/chat/steer-lifecycle.ts +++ b/ui/src/pages/chat/steer-lifecycle.ts @@ -4,8 +4,15 @@ import type { SessionsListResult } from "../../api/types.ts"; import { setLastActiveSessionKey } from "../../app/settings.ts"; import { compareChatQueueOrder } from "../../lib/chat/chat-queue-order.ts"; import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { resolveSessionDisplayName } from "../../lib/session-display.ts"; import { visibleSessionMatches } from "../../lib/sessions/index.ts"; -import { uiSessionRowMatchesSelectedChat } from "../../lib/sessions/session-key.ts"; +import { + areUiSessionKeysEquivalent, + isUiGlobalSessionKey, + normalizeAgentId, + uiSessionRowMatchesSelectedChat, +} from "../../lib/sessions/session-key.ts"; +import { showToast } from "../../lib/toast.ts"; import { generateUUID } from "../../lib/uuid.ts"; import { getChatAttachmentDataUrl, @@ -313,6 +320,43 @@ function setChatError(host: SteerLifecycleHost, error: string | null): void { host.chatError = error; } +type ChatDeliveryFailureHost = Parameters[0] & { + lastError?: string | null; + chatError?: string | null; + sessionsResult?: SessionsListResult | null; +}; + +/** + * Terminal delivery failures must always end in a visible outcome. The pane + * showing this session keeps the inline chat error; after reconnect or alias + * drift the owning pane may no longer be on screen, so anything else surfaces + * a global toast naming the session instead of recording the error only on + * the queued row where nobody sees it. + */ +export function surfaceChatDeliveryFailure( + host: ChatDeliveryFailureHost, + sessionKey: string, + agentId: string | undefined, + error: string, +): void { + if (visibleSessionMatches(host, sessionKey, agentId)) { + host.lastError = error; + host.chatError = error; + return; + } + // Global rows are agent-scoped while sharing one "global" key, so an + // agent-less equivalence match could borrow another agent's label. + const scopedAgentId = agentId ? normalizeAgentId(agentId) : undefined; + const row = host.sessionsResult?.sessions.find( + (session) => + areUiSessionKeysEquivalent(session.key, sessionKey) && + (!isUiGlobalSessionKey(sessionKey) || + !scopedAgentId || + (session.agentId !== undefined && normalizeAgentId(session.agentId) === scopedAgentId)), + ); + showToast({ message: `${resolveSessionDisplayName(sessionKey, row)}: ${error}` }); +} + export async function sendQueuedChatMessageWithQueueMode( host: SteerSendHost, id: string, @@ -446,9 +490,12 @@ export async function sendQueuedChatMessageWithQueueMode( sendError: result.error, sendState: "failed", })); - if (itemStillVisible) { - setChatError(host, failed ? result.error : OFFLINE_QUEUE_STORAGE_ERROR); - } + surfaceChatDeliveryFailure( + host, + itemSessionKey, + item.agentId, + failed ? result.error : OFFLINE_QUEUE_STORAGE_ERROR, + ); return; } const ack = result; @@ -462,9 +509,12 @@ export async function sendQueuedChatMessageWithQueueMode( setChatError(host, unconfirmedError); } } else { - if (itemStillVisible) { - setChatError(host, formatTerminalChatSendAckError(ack, isSteer ? "steer" : "chat")); - } + surfaceChatDeliveryFailure( + host, + itemSessionKey, + item.agentId, + formatTerminalChatSendAckError(ack, isSteer ? "steer" : "chat"), + ); dependencies.resumeRestoredOutbox(host, id); } return;