Skip to content

Commit 4d6d053

Browse files
author
scotthuang
committed
fix(ui): dedupe webchat persisted assistant finals
1 parent 32e8ac3 commit 4d6d053

3 files changed

Lines changed: 510 additions & 11 deletions

File tree

ui/src/ui/chat/stream-reconciliation.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,94 @@ function terminalMessageReplacesStreamFallback(message: unknown, fallback: unkno
152152
);
153153
}
154154

155-
export function appendTerminalAssistantMessage(messages: unknown[], message: unknown): unknown[] {
155+
function isHiddenAssistantContentBlockType(type: string): boolean {
156+
return type === "thinking" || type === "reasoning" || type === "redacted_thinking";
157+
}
158+
159+
function assistantMessageContentSignature(message: unknown): string | null {
160+
if (!message || typeof message !== "object") {
161+
return null;
162+
}
163+
const record = message as { content?: unknown; role?: unknown };
164+
const role = normalizeLowercaseStringOrEmpty(record.role);
165+
if (role !== "assistant") {
166+
return null;
167+
}
168+
if (Array.isArray(record.content)) {
169+
const displayBlocks = record.content.filter((block) => {
170+
if (!block || typeof block !== "object") {
171+
return false;
172+
}
173+
const type = normalizeLowercaseStringOrEmpty((block as { type?: unknown }).type);
174+
return (
175+
type !== "text" &&
176+
type !== "input_text" &&
177+
type !== "output_text" &&
178+
!isHiddenAssistantContentBlockType(type)
179+
);
180+
});
181+
if (displayBlocks.length > 0) {
182+
return JSON.stringify(record.content);
183+
}
184+
}
185+
const text = extractText(message)?.trim();
186+
return text ? `text:${text}` : null;
187+
}
188+
189+
function terminalMessageDuplicatesExisting(message: unknown, existing: unknown): boolean {
190+
const terminalSignature = assistantMessageContentSignature(message);
191+
return Boolean(
192+
terminalSignature && terminalSignature === assistantMessageContentSignature(existing),
193+
);
194+
}
195+
196+
function hasTranscriptMeta(message: unknown): boolean {
197+
return Boolean(
198+
message &&
199+
typeof message === "object" &&
200+
(message as { __openclaw?: unknown })["__openclaw"] &&
201+
typeof (message as { __openclaw?: unknown })["__openclaw"] === "object",
202+
);
203+
}
204+
205+
function existingMessageMatchesReplacementText(
206+
existing: unknown,
207+
replacementTexts: readonly string[],
208+
): boolean {
209+
if (replacementTexts.length === 0) {
210+
return false;
211+
}
212+
if (!existing || typeof existing !== "object") {
213+
return false;
214+
}
215+
const role = normalizeLowercaseStringOrEmpty((existing as { role?: unknown }).role);
216+
if (role !== "assistant") {
217+
return false;
218+
}
219+
if (!hasTranscriptMeta(existing)) {
220+
return false;
221+
}
222+
const text = extractText(existing)?.trim();
223+
return Boolean(text && replacementTexts.some((candidate) => text === candidate));
224+
}
225+
226+
export function appendTerminalAssistantMessage(
227+
messages: unknown[],
228+
message: unknown,
229+
opts?: { replacementTexts?: readonly string[] },
230+
): unknown[] {
231+
const replacementTexts = (opts?.replacementTexts ?? [])
232+
.map((text) => text.trim())
233+
.filter((text) => text.length > 0);
156234
const retainedMessages = messages.filter((existing, index) => {
157235
if (index <= lastUserMessageIndex(messages)) {
158236
return true;
159237
}
160-
return !terminalMessageReplacesStreamFallback(message, existing);
238+
return (
239+
!terminalMessageReplacesStreamFallback(message, existing) &&
240+
!terminalMessageDuplicatesExisting(message, existing) &&
241+
!existingMessageMatchesReplacementText(existing, replacementTexts)
242+
);
161243
});
162244
return [...retainedMessages, message];
163245
}

0 commit comments

Comments
 (0)