mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-17 16:12:21 -06:00
fix(ui): chat send failures were invisible when the session pane was hidden (#124473)
* 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).
This commit is contained in:
committed by
GitHub
parent
b097aaab20
commit
32ddbb3be2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
+5
-1
@@ -113,6 +113,9 @@ class OpenClawToastHost extends OpenClawLightDomContentsElement {
|
||||
}
|
||||
|
||||
export function showToast(options: ToastOptions): boolean {
|
||||
if (typeof document === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const host = document.querySelector<OpenClawToastHost>("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);
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,20 @@ describe("renderChatComposer controls", () => {
|
||||
expect(container.querySelector<HTMLTextAreaElement>("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<HTMLTextAreaElement>("textarea")?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("switches the primary action between voice, send, queue, and stop", () => {
|
||||
const onToggleRealtimeTalk = vi.fn();
|
||||
let view = renderComposer({ onToggleRealtimeTalk });
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<typeof visibleSessionMatches>[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;
|
||||
|
||||
Reference in New Issue
Block a user