fix: preserve streamed text before terminal errors (#108509)

This commit is contained in:
Shakker
2026-07-19 18:07:12 +01:00
committed by Shakker
parent 1e80a0ee88
commit 0db0142ec8
3 changed files with 122 additions and 10 deletions
+62
View File
@@ -1821,6 +1821,37 @@ describe("handleChatGatewayEvent", () => {
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", () => {
const message = {
role: "assistant",
@@ -1855,6 +1886,37 @@ describe("handleChatGatewayEvent", () => {
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", () => {
const state = createState({
sessionKey: "main",
+29 -10
View File
@@ -20,6 +20,7 @@ import {
appendTerminalAssistantMessage,
clearToolStreamSegments,
hasVisibleStreamParts,
terminalMessageReplacesVisibleStream,
} from "./stream-reconciliation.ts";
import {
authoritativeHistoryAppliedForRun,
@@ -310,23 +311,41 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
);
if (hadActiveRunBeforeEvent) {
if (visiblePayloadMessage && !projectedErrorMessage) {
if (
hasVisibleStreamParts(state, {
includeCurrent: false,
const replacesVisibleStream = terminalMessageReplacesVisibleStream(
visiblePayloadMessage,
state,
{
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,
state,
{ includeCurrent: false },
{ includeCurrent: true },
);
clearToolStreamSegments(state);
state.chatMessages = [...state.chatMessages, visiblePayloadMessage];
}
state.chatMessages = appendTerminalAssistantMessage(
state.chatMessages,
visiblePayloadMessage,
);
} else {
state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, {
includeCurrent: true,
@@ -462,6 +462,37 @@ export function hasVisibleStreamParts(
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(
messages: unknown[],
state: StreamReconciliationState,