mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix: preserve streamed text before terminal errors (#108509)
This commit is contained in:
@@ -1821,6 +1821,37 @@ describe("handleChatGatewayEvent", () => {
|
|||||||
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
|
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves streamed text before a differing terminal assistant message", () => {
|
||||||
|
const terminalMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Configure provider auth, then try again." }],
|
||||||
|
timestamp: 101,
|
||||||
|
};
|
||||||
|
const state = createState({
|
||||||
|
sessionKey: "main",
|
||||||
|
chatRunId: "run-1",
|
||||||
|
chatStream: "Partial answer before gateway error.",
|
||||||
|
chatStreamStartedAt: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
handleChatGatewayEvent(state, {
|
||||||
|
runId: "run-1",
|
||||||
|
sessionKey: "main",
|
||||||
|
state: "error",
|
||||||
|
errorMessage: "gateway disconnected",
|
||||||
|
message: terminalMessage,
|
||||||
|
}),
|
||||||
|
).toBe("error");
|
||||||
|
expect(state.chatMessages).toHaveLength(2);
|
||||||
|
expectTextChatMessage(
|
||||||
|
state.chatMessages[0],
|
||||||
|
"assistant",
|
||||||
|
"Partial answer before gateway error.",
|
||||||
|
);
|
||||||
|
expect(state.chatMessages[1]).toEqual(terminalMessage);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves terminal extensions after a tool splits the stream", () => {
|
it("preserves terminal extensions after a tool splits the stream", () => {
|
||||||
const message = {
|
const message = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -1855,6 +1886,37 @@ describe("handleChatGatewayEvent", () => {
|
|||||||
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
|
expect(state.chatRunError).toEqual({ summary: "Error: gateway disconnected" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a split stream when the terminal message only overlaps its prefix", () => {
|
||||||
|
const terminalMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "First thought. Configure provider auth." }],
|
||||||
|
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: terminalMessage,
|
||||||
|
}),
|
||||||
|
).toBe("error");
|
||||||
|
expect(state.chatMessages).toHaveLength(3);
|
||||||
|
expectTextChatMessage(state.chatMessages[0], "assistant", "First thought.");
|
||||||
|
expectTextChatMessage(state.chatMessages[1], "assistant", "After tool.");
|
||||||
|
expect(state.chatMessages[2]).toEqual(terminalMessage);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves terminal extensions when split stream punctuation is adjacent", () => {
|
it("preserves terminal extensions when split stream punctuation is adjacent", () => {
|
||||||
const state = createState({
|
const state = createState({
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
appendTerminalAssistantMessage,
|
appendTerminalAssistantMessage,
|
||||||
clearToolStreamSegments,
|
clearToolStreamSegments,
|
||||||
hasVisibleStreamParts,
|
hasVisibleStreamParts,
|
||||||
|
terminalMessageReplacesVisibleStream,
|
||||||
} from "./stream-reconciliation.ts";
|
} from "./stream-reconciliation.ts";
|
||||||
import {
|
import {
|
||||||
authoritativeHistoryAppliedForRun,
|
authoritativeHistoryAppliedForRun,
|
||||||
@@ -310,23 +311,41 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
|||||||
);
|
);
|
||||||
if (hadActiveRunBeforeEvent) {
|
if (hadActiveRunBeforeEvent) {
|
||||||
if (visiblePayloadMessage && !projectedErrorMessage) {
|
if (visiblePayloadMessage && !projectedErrorMessage) {
|
||||||
if (
|
const replacesVisibleStream = terminalMessageReplacesVisibleStream(
|
||||||
hasVisibleStreamParts(state, {
|
visiblePayloadMessage,
|
||||||
includeCurrent: false,
|
state,
|
||||||
|
{
|
||||||
isHiddenStreamText: isHiddenAssistantStreamText,
|
isHiddenStreamText: isHiddenAssistantStreamText,
|
||||||
})
|
persistCommentary: state.settings?.chatPersistCommentary === true,
|
||||||
) {
|
},
|
||||||
|
);
|
||||||
|
if (replacesVisibleStream) {
|
||||||
|
if (
|
||||||
|
hasVisibleStreamParts(state, {
|
||||||
|
includeCurrent: false,
|
||||||
|
isHiddenStreamText: isHiddenAssistantStreamText,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
state.chatMessages = materializeVisibleAssistantStreamMessages(
|
||||||
|
state.chatMessages,
|
||||||
|
state,
|
||||||
|
{ includeCurrent: false },
|
||||||
|
);
|
||||||
|
clearToolStreamSegments(state);
|
||||||
|
}
|
||||||
|
state.chatMessages = appendTerminalAssistantMessage(
|
||||||
|
state.chatMessages,
|
||||||
|
visiblePayloadMessage,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
state.chatMessages = materializeVisibleAssistantStreamMessages(
|
state.chatMessages = materializeVisibleAssistantStreamMessages(
|
||||||
state.chatMessages,
|
state.chatMessages,
|
||||||
state,
|
state,
|
||||||
{ includeCurrent: false },
|
{ includeCurrent: true },
|
||||||
);
|
);
|
||||||
clearToolStreamSegments(state);
|
clearToolStreamSegments(state);
|
||||||
|
state.chatMessages = [...state.chatMessages, visiblePayloadMessage];
|
||||||
}
|
}
|
||||||
state.chatMessages = appendTerminalAssistantMessage(
|
|
||||||
state.chatMessages,
|
|
||||||
visiblePayloadMessage,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, {
|
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, {
|
||||||
includeCurrent: true,
|
includeCurrent: true,
|
||||||
|
|||||||
@@ -462,6 +462,37 @@ export function hasVisibleStreamParts(
|
|||||||
return visibleAssistantStreamParts(state, opts).length > 0;
|
return visibleAssistantStreamParts(state, opts).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function terminalMessageReplacesVisibleStream(
|
||||||
|
message: unknown,
|
||||||
|
state: StreamReconciliationState,
|
||||||
|
opts: Pick<MaterializeVisibleStreamOptions, "isHiddenStreamText" | "persistCommentary">,
|
||||||
|
): boolean {
|
||||||
|
const terminalText = extractText(message)?.trim();
|
||||||
|
if (!terminalText) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parts = visibleAssistantStreamParts(state, {
|
||||||
|
includeCurrent: true,
|
||||||
|
isHiddenStreamText: opts.isHiddenStreamText,
|
||||||
|
}).filter((part) => opts.persistCommentary === true || !part.itemId);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchStart = 0;
|
||||||
|
for (const [index, part] of parts.entries()) {
|
||||||
|
const text = part.text.trim();
|
||||||
|
const matchIndex = terminalText.indexOf(text, searchStart);
|
||||||
|
// A terminal replacement must preserve the complete visible stream in order,
|
||||||
|
// beginning with its first part. Otherwise both outputs are user-visible.
|
||||||
|
if (matchIndex < 0 || (index === 0 && matchIndex !== 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
searchStart = matchIndex + text.length;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function currentToolStreamMessageIndex(
|
function currentToolStreamMessageIndex(
|
||||||
messages: unknown[],
|
messages: unknown[],
|
||||||
state: StreamReconciliationState,
|
state: StreamReconciliationState,
|
||||||
|
|||||||
Reference in New Issue
Block a user