fix: show run failures above composer

This commit is contained in:
vyctorbrzezowski
2026-07-19 18:07:12 +01:00
committed by Shakker
parent 34978b5a07
commit 86b1961bcc
12 changed files with 758 additions and 102 deletions
@@ -12,7 +12,6 @@ import {
isRateLimitErrorMessage,
isTransientHttpError,
} from "../../agents/embedded-agent-helpers.js";
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
import { isFailoverError } from "../../agents/failover-error.js";
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { isFallbackSummaryError } from "../../agents/model-fallback.js";
@@ -438,20 +437,19 @@ export async function handleAgentExecutionError(params: {
failoverReason === "overloaded" ? "overloaded" : message,
)
: undefined;
const trimmedMessage = (
isTransientHttp ? sanitizeUserFacingText(message, { errorContext: true }) : message
).replace(/\.\s*$/, "");
const externalRunFailureReply =
!isBilling &&
!(isRateLimit && !isOverloaded) &&
!rateLimitOrOverloadedCopy &&
!isContextOverflow &&
!params.shouldSurfaceToControlUi
!isContextOverflow
? buildExternalRunFailureReply(
{ message, error: err },
{
includeAuthProfileId: !isNonDirectConversationContext(turn.sessionCtx),
includeDetails: isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel),
includeAuthProfileId:
!params.shouldSurfaceToControlUi && !isNonDirectConversationContext(turn.sessionCtx),
includeDetails:
!params.shouldSurfaceToControlUi &&
isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel),
isHeartbeat: turn.isHeartbeat,
replayPrevented: params.overloadRetryState.unsafeToReplay,
},
@@ -465,12 +463,10 @@ export async function handleAgentExecutionError(params: {
? rateLimitOrOverloadedCopy
: isContextOverflow
? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."
: params.shouldSurfaceToControlUi
? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
: (externalRunFailureReply?.text ??
(turn.isHeartbeat
? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
: GENERIC_EXTERNAL_RUN_FAILURE_TEXT));
: (externalRunFailureReply?.text ??
(turn.isHeartbeat
? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
: GENERIC_EXTERNAL_RUN_FAILURE_TEXT));
const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({
text: fallbackText,
sessionCtx: turn.sessionCtx,
@@ -118,7 +118,7 @@ describe("runAgentTurnWithFallback: conversation failures", () => {
}
});
it("keeps raw generic errors on internal control surfaces", async () => {
it("keeps raw failure details out of the internal control surface in verbose mode", async () => {
state.isInternalMessageChannelMock.mockReturnValue(true);
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new Error("INVALID_ARGUMENT: some other failure"),
@@ -146,14 +146,14 @@ describe("runAgentTurnWithFallback: conversation failures", () => {
isHeartbeat: false,
sessionKey: "main",
getActiveSessionEntry: () => undefined,
resolvedVerboseLevel: "off",
resolvedVerboseLevel: "full",
});
expect(result.kind).toBe("final");
if (result.kind === "final") {
expect(result.payload.text).toContain("Agent failed before reply");
expect(result.payload.text).toContain("INVALID_ARGUMENT: some other failure");
expect(result.payload.text).toContain("Logs: openclaw logs --follow");
expect(result.payload.text).toContain("Something went wrong while processing your request");
expect(result.payload.text).not.toContain("INVALID_ARGUMENT: some other failure");
expect(result.payload).not.toHaveProperty("errorDetails");
}
});
});
@@ -48,7 +48,7 @@ function selectChatSendAgentReplyPayloads(params: {
.map((entry) => entry.payload)
.filter(
(payload) =>
isSourceReplyTranscriptMirrorPayload(payload) ||
(!payload.isError && isSourceReplyTranscriptMirrorPayload(payload)) ||
(!params.hasReturnedAgentErrorPayloads && isReplyPayloadStatusNotice(payload)),
);
}
@@ -2344,7 +2344,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
);
});
it("does not broadcast an error terminal after an internal-ui source reply final", async () => {
it("broadcasts an error terminal after an internal-ui source reply final", async () => {
await createTranscriptFixture("openclaw-chat-send-agent-source-reply-error-");
mockState.triggerAgentRunStart = true;
const sourceReply = setReplyPayloadMetadata(
@@ -2375,31 +2375,88 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
const respond = vi.fn();
const context = createChatContext();
const broadcast = await runNonStreamingChatSend({
await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-agent-source-reply-error",
message: "hello from codex",
waitFor: "dedupe",
});
expect(broadcast).toMatchObject({
const broadcasts = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
([, payload]) => payload as Record<string, unknown>,
);
expect(broadcasts[0]).toMatchObject({
runId: "idem-agent-source-reply-error",
sessionKey: "main",
state: "final",
});
expect(extractFirstTextBlock(broadcast)).toBe("Codex source reply");
const errorBroadcasts = (
context.broadcast as unknown as ReturnType<typeof vi.fn>
).mock.calls.filter(([, payload]) => (payload as { state?: unknown })?.state === "error");
expect(errorBroadcasts).toStrictEqual([]);
expect(extractFirstTextBlock(broadcasts[0])).toBe("Codex source reply");
expect(broadcasts[1]).toMatchObject({
runId: "idem-agent-source-reply-error",
sessionKey: "main",
state: "error",
errorMessage: "tool warning",
});
const dedupe = context.dedupe.get("chat:idem-agent-source-reply-error");
expect(dedupe?.ok).toBe(true);
expect(dedupe?.ok).toBe(false);
expect(dedupe?.payload).toMatchObject({
runId: "idem-agent-source-reply-error",
status: "ok",
status: "error",
summary: "tool warning",
});
});
it("does not finalize an error-marked source reply as assistant transcript content", async () => {
const mirrorIdempotencyKey = "idem-agent-source-reply-marked-error:internal-source-reply:0";
await createTranscriptFixture("openclaw-chat-send-agent-source-reply-marked-error-");
await appendSourceReplyMirrorEntry({
idempotencyKey: mirrorIdempotencyKey,
text: "Original source reply",
});
mockState.triggerAgentRunStart = true;
mockState.dispatchedReplies = [
{
kind: "final",
payload: setReplyPayloadMetadata(
{
text: "Model login expired. Re-authenticate, then try again.",
isError: true,
},
{
sourceReplyTranscriptMirror: {
sessionKey: "main",
text: "Model login expired. Re-authenticate, then try again.",
idempotencyKey: mirrorIdempotencyKey,
},
},
),
},
];
const context = createChatContext();
await runNonStreamingChatSend({
context,
respond: vi.fn(),
idempotencyKey: "idem-agent-source-reply-marked-error",
waitFor: "dedupe",
});
const broadcasts = (context.broadcast as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
([, payload]) => payload as Record<string, unknown>,
);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatchObject({
state: "error",
errorMessage: "Model login expired. Re-authenticate, then try again.",
});
const assistantEntries = await readActiveAssistantTranscriptMessages();
expect(assistantEntries).toHaveLength(1);
expect(assistantEntries[0]?.content).toStrictEqual([
{ type: "text", text: "Original source reply" },
]);
});
it("broadcasts returned agent errors after status notices", async () => {
await createTranscriptFixture("openclaw-chat-send-agent-status-notice-error-");
const errorMessage = "LLM idle timeout (120s): no response from model";
+68 -7
View File
@@ -2172,20 +2172,81 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
sessionKey: "main",
state: "delta",
});
await page.getByText(partialText).waitFor({ timeout: 10_000 });
await page.locator(".chat-thread-inner").getByText(partialText).waitFor({ timeout: 10_000 });
const gatewayErrorText =
"⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.";
const errorText = gatewayErrorText.replace(/^\s*/u, "");
await gateway.emitGatewayEvent("chat", {
errorMessage: "gateway disconnected",
errorMessage: gatewayErrorText,
message: {
content: [{ text: gatewayErrorText, type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
runId,
sessionKey: "main",
state: "error",
});
await page.getByText(partialText).waitFor({ timeout: 10_000 });
await page
.locator(".chat-thread-inner")
.getByText("Error: gateway disconnected")
.waitFor({ timeout: 10_000 });
await page.locator(".chat-thread-inner").getByText(partialText).waitFor({ timeout: 10_000 });
const alert = page.locator(".chat-run-error");
await alert.getByText(errorText).waitFor({ timeout: 10_000 });
const details = alert.locator("details");
expect(await details.getAttribute("open")).toBeNull();
expect(await details.locator("pre").isVisible()).toBe(false);
expect(await alert.locator("button").count()).toBe(0);
expect(await page.locator(".chat-thread-inner").getByText(errorText).count()).toBe(0);
expect(
await alert.evaluate((element) =>
element.nextElementSibling?.classList.contains("agent-chat__composer-shell"),
),
).toBe(true);
const [alertBox, composerBox] = await Promise.all([
alert.boundingBox(),
page.locator(".agent-chat__composer-shell").boundingBox(),
]);
expect(alertBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(Math.abs((alertBox?.x ?? 0) - (composerBox?.x ?? 0))).toBeLessThan(1);
expect(Math.abs((alertBox?.width ?? 0) - (composerBox?.width ?? 0))).toBeLessThan(1);
const screenshotPath = process.env.OPENCLAW_CHAT_RUN_ERROR_SCREENSHOT?.trim();
if (screenshotPath && alertBox && composerBox) {
const padding = 12;
const x = Math.max(0, Math.min(alertBox.x, composerBox.x) - padding);
const y = Math.max(0, alertBox.y - padding);
const right = Math.max(alertBox.x + alertBox.width, composerBox.x + composerBox.width);
const bottom = Math.max(alertBox.y + alertBox.height, composerBox.y + composerBox.height);
await page.screenshot({
animations: "disabled",
caret: "hide",
clip: {
x,
y,
width: right - x + padding,
height: bottom - y + padding,
},
path: screenshotPath,
});
}
await details.locator("summary").click();
expect(await details.getAttribute("open")).not.toBeNull();
await details.locator("pre").getByText("openclaw logs --follow").waitFor();
const expandedScreenshotPath =
process.env.OPENCLAW_CHAT_RUN_ERROR_EXPANDED_SCREENSHOT?.trim();
if (expandedScreenshotPath) {
await page.screenshot({
animations: "disabled",
caret: "hide",
path: expandedScreenshotPath,
});
}
await page.locator(".agent-chat__composer-combobox textarea").fill("retry after error");
await page.getByRole("button", { name: "Send message" }).click();
await waitForRequests(gateway, "chat.send", 2);
await alert.waitFor({ state: "detached", timeout: 10_000 });
} finally {
await closeBrowserContext(context);
}
+3
View File
@@ -3712,6 +3712,9 @@ export const en: TranslationMap = {
switchedSession: "Switched to {session}",
actions: {
dismissError: "Dismiss error",
showErrorDetails: "Show details",
hideErrorDetails: "Hide details",
errorLogsCommand: "Logs: openclaw logs --follow",
exitFocusMode: "Exit focus mode",
scrollToLatest: "Scroll to latest",
},
+414 -30
View File
@@ -754,6 +754,7 @@ describe("handleChatGatewayEvent", () => {
const state = createState({
sessionKey: "agent:main:feishu:direct:peer-1",
chatRunId: null,
chatRunError: { summary: "Previous run failed" },
chatStream: null,
chatStreamStartedAt: null,
});
@@ -769,6 +770,7 @@ describe("handleChatGatewayEvent", () => {
expect(handleChatGatewayEvent(state, payload)).toBe("delta");
expect(state.chatRunId).toBe("run-feishu-1");
expect(state.chatRunError).toBeNull();
expect(state.chatStream).toBe("Observed reply");
expect(state.chatStreamStartedAt).toEqual(expect.any(Number));
});
@@ -1667,7 +1669,7 @@ describe("handleChatGatewayEvent", () => {
expect(state.chatMessages).toEqual([existingMessage]);
});
it("appends visible assistant text for error events with an error message", () => {
it("keeps error events outside the assistant transcript", () => {
const existingMessage = {
role: "user",
content: [{ type: "text", text: "Ping" }],
@@ -1683,17 +1685,19 @@ describe("handleChatGatewayEvent", () => {
sessionKey: "main",
state: "error",
errorMessage: 'No API key found for provider "openai".',
message: {
role: "assistant",
content: [{ type: "text", text: 'Error: No API key found for provider "openai".' }],
timestamp: 10,
},
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatRunId).toBe(null);
expect(state.chatMessages).toHaveLength(2);
expectTextChatMessage(
state.chatMessages[1],
"assistant",
'Error: No API key found for provider "openai".',
);
expect(state.lastError).toBe('No API key found for provider "openai".');
expect(state.chatMessages).toEqual([existingMessage]);
expect(state.chatRunError).toEqual({
summary: 'Error: No API key found for provider "openai".',
});
});
it("keeps streamed assistant text visible when an error ends the run", () => {
@@ -1720,21 +1724,20 @@ describe("handleChatGatewayEvent", () => {
expect(state.chatRunId).toBe(null);
expect(state.chatStream).toBe(null);
expect(state.chatStreamStartedAt).toBe(null);
expect(state.chatMessages).toHaveLength(3);
expect(state.chatMessages).toHaveLength(2);
expect(state.chatMessages[0]).toEqual(existingMessage);
expectTextChatMessage(
state.chatMessages[1],
"assistant",
"Partial answer before gateway error.",
);
expectTextChatMessage(state.chatMessages[2], "assistant", "Error: gateway disconnected");
expect(state.lastError).toBe("gateway disconnected");
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
});
it("does not duplicate streamed text when the error payload already carries it", () => {
it("keeps streamed text without appending the error payload message", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "Partial answer before gateway error." }],
content: [{ type: "text", text: "Error: gateway disconnected" }],
timestamp: 101,
metadata: { source: "gateway" },
};
@@ -1753,10 +1756,92 @@ describe("handleChatGatewayEvent", () => {
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([message]);
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(
state.chatMessages[0],
"assistant",
"Partial answer before gateway error.",
);
});
it("does not keep partial stream when the error payload contains the fuller text", () => {
it("uses the gateway error when the payload message repeats the streamed text", () => {
const partialText = "Partial answer before gateway error.";
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: partialText,
chatStreamStartedAt: 100,
});
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "gateway disconnected",
message: {
role: "assistant",
content: [{ type: "text", text: partialText }],
timestamp: 101,
},
}),
).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", partialText);
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
});
it("drops an error projection that extends a short stream", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Error",
chatStreamStartedAt: 100,
});
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "provider unavailable",
message: {
role: "assistant",
content: [{ type: "text", text: "Error: provider unavailable" }],
timestamp: 101,
},
}),
).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.chatRunError).toEqual({ summary: "Error: provider unavailable" });
});
it("drops partially streamed error guidance that differs from the raw gateway error", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Error: provider unavail",
chatStreamStartedAt: 100,
});
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "provider request failed",
message: {
role: "assistant",
content: [{ type: "text", text: "Error: provider unavailable" }],
timestamp: 101,
},
}),
).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.chatRunError).toEqual({ summary: "Error: provider unavailable" });
});
it("preserves terminal assistant content that extends the streamed text", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "Partial answer before gateway error. Final detail." }],
@@ -1777,7 +1862,76 @@ describe("handleChatGatewayEvent", () => {
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([message]);
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(
state.chatMessages[0],
"assistant",
"Partial answer before gateway error. Final detail.",
);
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
});
it("preserves terminal extensions after a tool splits the stream", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "First thought. After tool. Final detail." }],
timestamp: 101,
};
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "After tool.",
chatStreamStartedAt: 100,
}) as ChatState & {
chatStreamSegments: Array<{ text: string; ts: number; toolCallId: string }>;
};
state.chatStreamSegments = [{ text: "First thought.", ts: 90, toolCallId: "call-1" }];
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "gateway disconnected",
message,
}),
).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(
state.chatMessages[0],
"assistant",
"First thought. After tool. Final detail.",
);
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
});
it("preserves terminal extensions when split stream punctuation is adjacent", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: ", world",
chatStreamStartedAt: 100,
}) as ChatState & {
chatStreamSegments: Array<{ text: string; ts: number; toolCallId: string }>;
};
state.chatStreamSegments = [{ text: "Hello", ts: 90, toolCallId: "call-1" }];
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "gateway disconnected",
message: {
role: "assistant",
content: [{ type: "text", text: "Hello, world!" }],
timestamp: 101,
},
}),
).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", "Hello, world!");
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
});
it("keeps stream segments visible when an error ends after a tool event", () => {
@@ -1802,16 +1956,15 @@ describe("handleChatGatewayEvent", () => {
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toHaveLength(3);
expect(state.chatMessages).toHaveLength(2);
expect(state.chatMessages[0]).toEqual(existingMessage);
expectTextChatMessage(state.chatMessages[1], "assistant", "Visible text before tool.");
expectTextChatMessage(state.chatMessages[2], "assistant", "Error: gateway disconnected");
});
it("does not treat substring matches as stream replacement", () => {
it("does not let a substring-matching error projection replace streamed text", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "Error: provider said NOT OK yet." }],
content: [{ type: "text", text: "Error: provider said NOT OK yet" }],
timestamp: 101,
};
const state = createState({
@@ -1829,15 +1982,40 @@ describe("handleChatGatewayEvent", () => {
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toHaveLength(2);
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", "OK");
expect(state.chatMessages[1]).toEqual(message);
});
it("does not duplicate post-tool stream tail when error payload has full text", () => {
it("does not treat error guidance containing the stream as assistant content", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "auth",
chatStreamStartedAt: 100,
});
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "provider request failed",
message: {
role: "assistant",
content: [{ type: "text", text: "Configure auth, then retry." }],
timestamp: 101,
},
}),
).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", "auth");
expect(state.chatRunError).toEqual({ summary: "Configure auth, then retry." });
});
it("keeps the post-tool stream tail without appending the error projection", () => {
const message = {
role: "assistant",
content: [{ type: "text", text: "First thought. After tool. Final detail." }],
content: [{ type: "text", text: "Error: gateway disconnected" }],
timestamp: 101,
};
const state = createState({
@@ -1856,17 +2034,46 @@ describe("handleChatGatewayEvent", () => {
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([message]);
expect(state.chatMessages).toHaveLength(2);
expectTextChatMessage(state.chatMessages[0], "assistant", "First thought.");
expectTextChatMessage(state.chatMessages[1], "assistant", "After tool.");
});
it("prefers server-provided assistant error messages", () => {
it("keeps post-tool assistant content without materializing a projected error tail", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Useful answer. Error: provider unavail",
chatStreamStartedAt: 100,
}) as ChatState & { chatStreamSegments: Array<{ text: string; ts: number }> };
state.chatStreamSegments = [{ text: "Useful answer.", ts: 90 }];
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "provider unavailable",
message: {
role: "assistant",
content: [{ type: "text", text: "Error: provider unavailable" }],
timestamp: 101,
},
}),
).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", "Useful answer.");
expect(state.chatRunError).toEqual({ summary: "Error: provider unavailable" });
});
it("does not append legacy assistant-shaped error projections", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
});
const message = {
role: "assistant",
content: [{ type: "text", text: "Configure provider auth, then try again." }],
content: [{ type: "text", text: "Error: raw gateway error" }],
timestamp: 10,
};
const payload: ChatEventPayload = {
@@ -1878,8 +2085,168 @@ describe("handleChatGatewayEvent", () => {
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([message]);
expect(state.lastError).toBe("raw gateway error");
expect(state.chatMessages).toEqual([]);
expect(state.lastError).toBeNull();
expect(state.chatRunError).toEqual({ summary: "Error: raw gateway error" });
});
it("uses a legacy error payload message as alert text when errorMessage is absent", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "error",
message: {
role: "assistant",
content: [{ type: "text", text: "Error: legacy gateway failure" }],
timestamp: 10,
},
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.lastError).toBeNull();
expect(state.chatRunError).toEqual({ summary: "Error: legacy gateway failure" });
});
it("keeps a streamed legacy error projection out of the transcript", () => {
const errorText = "Error: legacy gateway failure";
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: errorText,
chatStreamStartedAt: 9,
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "error",
message: {
role: "assistant",
content: [{ type: "text", text: errorText }],
timestamp: 10,
},
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.chatRunError).toEqual({ summary: errorText });
});
it("drops a partially streamed legacy error projection", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Error: legacy gateway fail",
chatStreamStartedAt: 9,
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "error",
message: {
role: "assistant",
content: [{ type: "text", text: "Error: legacy gateway failure" }],
timestamp: 10,
},
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.chatRunError).toEqual({ summary: "Error: legacy gateway failure" });
});
it("preserves a legacy terminal message that completes streamed assistant content", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
chatStream: "Partial answer",
chatStreamStartedAt: 9,
});
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "error",
message: {
role: "assistant",
content: [{ type: "text", text: "Partial answer. Final detail." }],
timestamp: 10,
},
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", "Partial answer. Final detail.");
expect(state.chatRunError).toEqual({ summary: "chat error" });
});
it("uses server-provided error guidance as the alert copy", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
});
const message = {
role: "assistant",
content: [{ type: "text", text: "⚠️ Configure provider auth, then try again." }],
timestamp: 10,
};
const payload: ChatEventPayload = {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "raw gateway error",
message,
};
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.lastError).toBeNull();
expect(state.chatRunError).toEqual({
summary: "Configure provider auth, then try again.",
});
});
it("uses server guidance when an error follows a source-reply final", () => {
const state = createState({
sessionKey: "main",
chatRunId: "run-1",
});
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Source reply delivered." }],
timestamp: 9,
},
}),
).toBe("final");
expect(state.chatRunId).toBeNull();
expect(
handleChatGatewayEvent(state, {
runId: "run-1",
sessionKey: "main",
state: "error",
errorMessage: "raw provider failure",
message: {
role: "assistant",
content: [{ type: "text", text: "Configure provider auth, then try again." }],
timestamp: 10,
},
}),
).toBe("error");
expect(state.chatMessages).toHaveLength(1);
expectTextChatMessage(state.chatMessages[0], "assistant", "Source reply delivered.");
expect(state.chatRunError).toEqual({
summary: "Configure provider auth, then try again.",
});
});
it("does not append an orphan error bubble when no run was active", () => {
@@ -1903,7 +2270,24 @@ describe("handleChatGatewayEvent", () => {
expect(handleChatGatewayEvent(state, payload)).toBe("error");
expect(state.chatMessages).toEqual([existingMessage]);
expect(state.chatRunId).toBe(null);
expect(state.lastError).toBe("request failed before start");
expect(state.lastError).toBeNull();
expect(state.chatRunError).toEqual({ summary: "request failed before start" });
});
it("uses the generic alert fallback for a blank orphan error", () => {
const state = createState({ sessionKey: "main", chatRunId: null });
expect(
handleChatGatewayEvent(state, {
runId: "run-failed-before-start",
sessionKey: "main",
state: "error",
errorMessage: " ",
}),
).toBe("error");
expect(state.chatMessages).toEqual([]);
expect(state.lastError).toBeNull();
expect(state.chatRunError).toEqual({ summary: "chat error" });
});
it("drops NO_REPLY final payload from another run", () => {
+161 -35
View File
@@ -20,6 +20,8 @@ import {
appendTerminalAssistantMessage,
clearToolStreamSegments,
hasVisibleStreamParts,
visibleAssistantStreamTextParts,
visibleCurrentAssistantStreamTail,
} from "./stream-reconciliation.ts";
import {
authoritativeHistoryAppliedForRun,
@@ -35,9 +37,8 @@ type AssistantMessageNormalizationOptions = {
allowTextField?: boolean;
};
function setChatError(state: ChatState, error: string | null) {
state.lastError = error;
state.chatError = error;
function setChatRunError(state: ChatState, summary: string) {
state.chatRunError = { summary };
}
function chatEventSessionMatches(state: ChatState, payload: ChatEventPayload): boolean {
@@ -123,25 +124,131 @@ function normalizeFinalAssistantMessage(message: unknown): Record<string, unknow
});
}
function buildErrorAssistantMessage(payload: ChatEventPayload): Record<string, unknown> | null {
const normalized = normalizeFinalAssistantMessage(payload.message);
if (normalized && !shouldHideAssistantChatMessage(normalized)) {
return normalized;
function stripChatErrorMarker(text: string): string {
return text.replace(/^\s*/u, "");
}
function resolveGatewayErrorText(payload: ChatEventPayload): string {
const errorText = payload.errorMessage?.trim();
if (!errorText) {
return "chat error";
}
const error = payload.errorMessage?.trim();
if (!error) {
return errorText.startsWith("⚠️") || errorText.startsWith("Error:")
? stripChatErrorMarker(errorText)
: `Error: ${errorText}`;
}
function payloadMessageIsErrorProjection(
payload: ChatEventPayload,
message: Record<string, unknown>,
): boolean {
const messageText = extractText(message)?.trim();
if (!messageText) {
return false;
}
const errorText = payload.errorMessage?.trim();
if (messageText.startsWith("⚠️") || messageText.startsWith("Error:")) {
return true;
}
if (!errorText) {
return false;
}
const projectedErrorText =
errorText.startsWith("⚠️") || errorText.startsWith("Error:")
? errorText
: `Error: ${errorText}`;
return stripChatErrorMarker(messageText) === stripChatErrorMarker(projectedErrorText);
}
function resolveChatErrorText(payload: ChatEventPayload): string {
const message = normalizeFinalAssistantMessage(payload.message);
if (message && !shouldHideAssistantChatMessage(message)) {
const messageText = extractText(message)?.trim();
if (messageText) {
return stripChatErrorMarker(messageText);
}
}
return resolveGatewayErrorText(payload);
}
function errorPayloadMessageProjectsVisibleStream(
state: ChatState,
payload: ChatEventPayload,
): boolean {
const message = normalizeFinalAssistantMessage(payload.message);
if (!message || shouldHideAssistantChatMessage(message)) {
return false;
}
const messageText = extractText(message)?.replace(/\s+/gu, " ").trim();
if (!messageText) {
return false;
}
const streamParts = visibleAssistantStreamTextParts(state, isHiddenAssistantStreamText)
.map((part) => part.replace(/\s+/gu, " ").trim())
.filter(Boolean);
return streamParts.some((part) => messageText.startsWith(part) || part.startsWith(messageText));
}
function errorPayloadMessageProjectsCurrentStreamTail(
state: ChatState,
payload: ChatEventPayload,
): boolean {
const message = normalizeFinalAssistantMessage(payload.message);
if (!message || shouldHideAssistantChatMessage(message)) {
return false;
}
const messageText = extractText(message)?.replace(/\s+/gu, " ").trim();
const currentTail = visibleCurrentAssistantStreamTail(state, isHiddenAssistantStreamText)
?.replace(/\s+/gu, " ")
.trim();
return Boolean(
messageText &&
currentTail &&
(messageText.startsWith(currentTail) || currentTail.startsWith(messageText)),
);
}
function resolveExtendedErrorAssistantMessage(
state: ChatState,
payload: ChatEventPayload,
): Record<string, unknown> | null {
const message = normalizeFinalAssistantMessage(payload.message);
if (
!message ||
shouldHideAssistantChatMessage(message) ||
payloadMessageIsErrorProjection(payload, message)
) {
return null;
}
return {
role: "assistant",
content: [
{
type: "text",
text: error.startsWith("⚠️") || error.startsWith("Error:") ? error : `Error: ${error}`,
},
],
timestamp: Date.now(),
};
const messageText = extractText(message)?.trim();
const normalizedMessageText = messageText?.replace(/\s+/gu, " ").trim();
if (!normalizedMessageText) {
return null;
}
const streamParts = visibleAssistantStreamTextParts(state, isHiddenAssistantStreamText)
.map((part) => part.replace(/\s+/gu, " ").trim())
.filter(Boolean);
let searchIndex = 0;
let matchedLength = 0;
for (const [partIndexInStream, part] of streamParts.entries()) {
const partIndex = normalizedMessageText.indexOf(part, searchIndex);
// Reconstruct from the start with separators only; a loose substring match
// can promote unrelated recovery guidance into the assistant transcript.
if (
partIndex < 0 ||
(partIndexInStream === 0 && partIndex !== 0) ||
(partIndexInStream > 0 &&
!/^[\s.,;:!?-]*$/u.test(normalizedMessageText.slice(searchIndex, partIndex)))
) {
return null;
}
searchIndex = partIndex + part.length;
matchedLength += part.length;
}
if (streamParts.length === 0 || normalizedMessageText.length <= matchedLength) {
return null;
}
return message;
}
function appendCachedChatMessage(
@@ -185,6 +292,7 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
}
if (!state.chatRunId && sessionMatches && typeof payload.runId === "string") {
state.chatRunId = payload.runId;
state.chatRunError = null;
state.chatStreamStartedAt ??= Date.now();
}
@@ -281,30 +389,48 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
}
reconcileTerminalRun("interrupted", "killed");
} else if (payload.state === "error") {
const payloadMessage = hadActiveRunBeforeEvent
? normalizeFinalAssistantMessage(payload.message)
const payloadMessage = normalizeFinalAssistantMessage(payload.message);
const payloadMessageIsProjection = Boolean(
payloadMessage && payloadMessageIsErrorProjection(payload, payloadMessage),
);
const extendedAssistantMessage = hadActiveRunBeforeEvent
? resolveExtendedErrorAssistantMessage(state, payload)
: null;
const visiblePayloadMessage =
payloadMessage && !shouldHideAssistantChatMessage(payloadMessage) ? payloadMessage : null;
if (visiblePayloadMessage) {
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, {
replacementMessages: [visiblePayloadMessage],
});
const payloadMessageProjectsStream =
hadActiveRunBeforeEvent && errorPayloadMessageProjectsVisibleStream(state, payload);
const errorProjectionProjectsStream =
payloadMessageProjectsStream && payloadMessageIsProjection;
const errorProjectionProjectsCurrentTail =
errorProjectionProjectsStream && errorPayloadMessageProjectsCurrentStreamTail(state, payload);
if (extendedAssistantMessage) {
// A terminal payload may complete genuine prose that only partially
// streamed. Preserve that fuller answer before presenting the run error.
state.chatMessages = appendTerminalAssistantMessage(
state.chatMessages,
visiblePayloadMessage,
extendedAssistantMessage,
);
} else {
const errorMessage = hadActiveRunBeforeEvent ? buildErrorAssistantMessage(payload) : null;
if (hadActiveRunBeforeEvent) {
} else if (hadActiveRunBeforeEvent) {
if (errorProjectionProjectsCurrentTail) {
// The current tail is the projected failure, but earlier tool-split
// assistant content remains genuine transcript output.
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, {
includeCurrent: false,
});
} else if (!errorProjectionProjectsStream) {
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state);
}
if (errorMessage) {
state.chatMessages = appendTerminalAssistantMessage(state.chatMessages, errorMessage);
}
}
reconcileTerminalRun("interrupted", "failed");
setChatError(state, payload.errorMessage ?? "chat error");
setChatRunError(
state,
hadActiveRunBeforeEvent
? extendedAssistantMessage || (payloadMessageProjectsStream && !payloadMessageIsProjection)
? resolveGatewayErrorText(payload)
: resolveChatErrorText(payload)
: payload.message
? resolveChatErrorText(payload)
: payload.errorMessage?.trim() || "chat error",
);
}
return payload.state;
}
+6
View File
@@ -71,6 +71,7 @@ import {
type ChatMessageCache,
} from "./session-message-cache.ts";
import {
assembledVisibleAssistantStreamText,
clearToolStreamSegments,
currentLiveToolCallIds,
hasVisibleStreamParts,
@@ -263,6 +264,10 @@ export function materializeVisibleAssistantStreamMessages(
});
}
export function assembledVisibleChatStreamText(state: ChatState): string | null {
return assembledVisibleAssistantStreamText(state, isHiddenAssistantStreamText);
}
function chatPersistCommentaryEnabled(state: ChatState): boolean {
return state.settings?.chatPersistCommentary === true;
}
@@ -341,6 +346,7 @@ export type ChatState = {
planStatus?: PlanStatus | null;
lastError: string | null;
chatError?: string | null;
chatRunError?: { summary: string } | null;
/** Completed side-chat turns (oldest first); follow-ups accumulate here. */
chatSideChatTurns?: ChatSideResult[];
chatSideResultPending?: ChatSideResultPending | null;
+3
View File
@@ -121,6 +121,7 @@ export type ChatHost = ChatInputHistoryState &
chatRunId: string | null;
chatSending: boolean;
chatSendingScopeKey?: string | null;
chatRunError?: { summary: string } | null;
lastError?: string | null;
chatError?: string | null;
hello: GatewayHelloOk | null;
@@ -2175,6 +2176,8 @@ export async function handleSendChat(
return;
}
host.chatRunError = null;
if (shouldInterpretChatCommands) {
// Natural words such as "wait" and "exit" are stop aliases only while a
// run exists. Keep the explicit /stop command available at any time.
+2
View File
@@ -538,6 +538,7 @@ export function resetChatStateForRouteSession(
state.chatSideChatHidden = false;
state.lastError = null;
state.chatError = null;
state.chatRunError = null;
state.chatAvatarUrl = null;
state.chatAvatarSource = null;
state.chatAvatarStatus = null;
@@ -1266,6 +1267,7 @@ export function createPageState(
chatStreamStartedAt: null,
lastError: null,
chatError: null,
chatRunError: null,
agentsError: null,
chatStreamSegments: [] as Array<{ text: string; ts: number }>,
chatSideChatTurns: [] as ChatSideResult[],
@@ -376,6 +376,24 @@ function visibleAssistantStreamParts(
return parts;
}
export function assembledVisibleAssistantStreamText(
state: StreamReconciliationState,
isHiddenStreamText: StreamVisibility,
): string | null {
const text = visibleAssistantStreamTextParts(state, isHiddenStreamText)
.map((part) => part.trim())
.filter(Boolean)
.join(" ");
return text || null;
}
export function visibleAssistantStreamTextParts(
state: StreamReconciliationState,
isHiddenStreamText: StreamVisibility,
): string[] {
return visibleAssistantStreamParts(state, { isHiddenStreamText }).map((part) => part.text);
}
export function visibleCurrentAssistantStreamTail(
state: StreamReconciliationState,
isHiddenStreamText: StreamVisibility,