Skip to content

Commit c2d7b4a

Browse files
fix(ui): clear chat stream before terminal commits
Fix the Control UI WebChat race where terminal assistant messages could be committed while chatStream was still live, causing history and active stream to render the same reply twice. Terminal final/aborted handling now snapshots fallback text, clears the active run/stream through the lifecycle owner, then appends the visible assistant message.\n\nFixes #71992.\n\nVerification: node scripts/run-vitest.mjs run ui/src/ui/controllers/chat.test.ts ui/src/ui/chat/run-lifecycle.test.ts ui/src/ui/chat/build-chat-items.test.ts; node scripts/run-vitest.mjs run ui/src/ui/app-chat.test.ts ui/src/ui/controllers/sessions.test.ts; node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/chat-flow.e2e.test.ts; Blacksmith Testbox tbx_01kt6a4zn7awkdy12d6b0q2d1q / run 26873514898; autoreview clean; PR CI 121 pass / 10 skipped.
1 parent 0b98aea commit c2d7b4a

2 files changed

Lines changed: 68 additions & 33 deletions

File tree

ui/src/ui/controllers/chat.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,28 @@ function createActiveStreamingState() {
8282
});
8383
}
8484

85+
function trackChatMessagesAssignments(state: ChatState) {
86+
let chatMessages = state.chatMessages;
87+
const assignments: Array<{
88+
chatRunId: string | null;
89+
chatStream: string | null;
90+
messages: unknown[];
91+
}> = [];
92+
Object.defineProperty(state, "chatMessages", {
93+
configurable: true,
94+
get: () => chatMessages,
95+
set: (messages: unknown[]) => {
96+
assignments.push({
97+
chatRunId: state.chatRunId,
98+
chatStream: state.chatStream,
99+
messages,
100+
});
101+
chatMessages = messages;
102+
},
103+
});
104+
return assignments;
105+
}
106+
85107
function createOtherRunSilentFinalPayload(text: string): ChatEventPayload {
86108
return {
87109
runId: "run-announce",
@@ -733,7 +755,10 @@ describe("handleChatEvent", () => {
733755
sessionKey: "main",
734756
state: "final",
735757
};
758+
const assignments = trackChatMessagesAssignments(state);
759+
736760
expect(handleChatEvent(state, payload)).toBe("final");
761+
expect(assignments).toMatchObject([{ chatRunId: null, chatStream: null }]);
737762
expect(state.chatRunId).toBe(null);
738763
expect(state.chatStream).toBe(null);
739764
expect(state.chatStreamStartedAt).toBe(null);
@@ -799,7 +824,7 @@ describe("handleChatEvent", () => {
799824
expect(state.chatStream).toBe(null);
800825
});
801826

802-
it("appends final payload message from own run before clearing stream state", () => {
827+
it("appends final payload message from own run after clearing stream state", () => {
803828
const state = createState({
804829
sessionKey: "main",
805830
chatRunId: "run-1",
@@ -816,7 +841,10 @@ describe("handleChatEvent", () => {
816841
timestamp: 101,
817842
},
818843
};
844+
const assignments = trackChatMessagesAssignments(state);
845+
819846
expect(handleChatEvent(state, payload)).toBe("final");
847+
expect(assignments).toMatchObject([{ chatRunId: null, chatStream: null }]);
820848
expect(state.chatMessages).toEqual([payload.message]);
821849
expect(state.chatRunId).toBe(null);
822850
expect(state.chatStream).toBe(null);
@@ -847,8 +875,10 @@ describe("handleChatEvent", () => {
847875
state: "aborted",
848876
message: partialMessage,
849877
};
878+
const assignments = trackChatMessagesAssignments(state);
850879

851880
expect(handleChatEvent(state, payload)).toBe("aborted");
881+
expect(assignments).toMatchObject([{ chatRunId: null, chatStream: null }]);
852882
expect(state.chatRunId).toBe(null);
853883
expect(state.chatStream).toBe(null);
854884
expect(state.chatStreamStartedAt).toBe(null);

ui/src/ui/controllers/chat.ts

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,45 +1138,50 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
11381138
}
11391139
} else if (payload.state === "final") {
11401140
const finalMessage = normalizeFinalAssistantMessage(payload.message);
1141-
if (finalMessage && !shouldHideAssistantChatMessage(finalMessage)) {
1142-
state.chatMessages = [...state.chatMessages, finalMessage];
1143-
} else if (
1144-
state.chatStream?.trim() &&
1145-
!isSilentReplyStream(state.chatStream) &&
1146-
!isHeartbeatAckStream(state.chatStream)
1147-
) {
1148-
state.chatMessages = [
1149-
...state.chatMessages,
1150-
{
1151-
role: "assistant",
1152-
content: [{ type: "text", text: state.chatStream }],
1153-
timestamp: Date.now(),
1154-
},
1155-
];
1156-
}
1141+
const visibleFinalMessage =
1142+
finalMessage && !shouldHideAssistantChatMessage(finalMessage) ? finalMessage : null;
1143+
const streamedText = state.chatStream ?? "";
1144+
const fallbackMessage =
1145+
!visibleFinalMessage &&
1146+
streamedText.trim() &&
1147+
!isSilentReplyStream(streamedText) &&
1148+
!isHeartbeatAckStream(streamedText)
1149+
? {
1150+
role: "assistant",
1151+
content: [{ type: "text", text: streamedText }],
1152+
timestamp: Date.now(),
1153+
}
1154+
: null;
11571155
reconcileTerminalRun("done", "done");
1156+
if (visibleFinalMessage) {
1157+
state.chatMessages = [...state.chatMessages, visibleFinalMessage];
1158+
} else if (fallbackMessage) {
1159+
state.chatMessages = [...state.chatMessages, fallbackMessage];
1160+
}
11581161
} else if (payload.state === "aborted") {
11591162
const normalizedMessage = normalizeAbortedAssistantMessage(payload.message);
1160-
if (normalizedMessage && !shouldHideAssistantChatMessage(normalizedMessage)) {
1161-
state.chatMessages = [...state.chatMessages, normalizedMessage];
1162-
} else {
1163-
const streamedText = state.chatStream ?? "";
1164-
if (
1165-
streamedText.trim() &&
1166-
!isSilentReplyStream(streamedText) &&
1167-
!isHeartbeatAckStream(streamedText)
1168-
) {
1169-
state.chatMessages = [
1170-
...state.chatMessages,
1171-
{
1163+
const visibleAbortedMessage =
1164+
normalizedMessage && !shouldHideAssistantChatMessage(normalizedMessage)
1165+
? normalizedMessage
1166+
: null;
1167+
const streamedText = state.chatStream ?? "";
1168+
const fallbackMessage =
1169+
!visibleAbortedMessage &&
1170+
streamedText.trim() &&
1171+
!isSilentReplyStream(streamedText) &&
1172+
!isHeartbeatAckStream(streamedText)
1173+
? {
11721174
role: "assistant",
11731175
content: [{ type: "text", text: streamedText }],
11741176
timestamp: Date.now(),
1175-
},
1176-
];
1177-
}
1178-
}
1177+
}
1178+
: null;
11791179
reconcileTerminalRun("interrupted", "killed");
1180+
if (visibleAbortedMessage) {
1181+
state.chatMessages = [...state.chatMessages, visibleAbortedMessage];
1182+
} else if (fallbackMessage) {
1183+
state.chatMessages = [...state.chatMessages, fallbackMessage];
1184+
}
11801185
} else if (payload.state === "error") {
11811186
const errorMessage = hadActiveRunBeforeEvent ? buildErrorAssistantMessage(payload) : null;
11821187
if (errorMessage) {

0 commit comments

Comments
 (0)