mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: show run failures above composer
This commit is contained in:
committed by
Shakker
parent
34978b5a07
commit
86b1961bcc
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user