fix(web): stop rendering an empty bubble for a fidelity-only text or thinking message (#105)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 16:48:22 +08:00
committed by GitHub
parent cd1630ae8f
commit e9f032c965
2 changed files with 127 additions and 0 deletions
+32
View File
@@ -895,6 +895,17 @@ function handleComplete(
// The complete message usually follows right after a fragment's stop: prefer replacing an already-closed pending fragment, then a still-open one.
const target = model.pendingText ?? model.openText;
if (target) {
// A blank body discards the fragment instead of settling it (same fidelity-only case as
// below — core starts a text segment on the first *truthy* delta, so a whitespace-only
// segment does stream). Blanking it in place would leave the live view showing an empty
// bubble that a reload then drops. Removing the item and clearing both slots is what
// discardFragmentFor does for the dedup path, and it leaves no fragment stuck streaming.
if (!p.text.trim()) {
removeItem(model, target);
if (target === model.openText) model.openText = null;
model.pendingText = null;
return;
}
// The complete message replaces the fragment's content (this guarantees consistency).
target.text = p.text;
target.streaming = false;
@@ -905,6 +916,17 @@ function handleComplete(
model.pendingText = null;
return;
}
// Fidelity-only message: core emits a complete text/thinking message with an empty body
// when the provider attached an opaque payload to an otherwise empty part — on this text
// branch a Gemini thoughtSignature or a GPT-5 `fidelity.phase` segment marker (GPT-5's
// encrypted reasoning rides the *thinking* branch instead) — which is why the blank bubble
// showed up right after a thinking segment. The message has to exist so the fidelity
// round-trips into history, but it has nothing to show, and usually no fragment was opened
// for it either (core only starts a segment once a truthy delta arrives). Rendering it
// produced a blank "assistant:" bubble; collectTaskAssistant already skipped these when
// gathering the reply text, and with the blank-fragment discard above both the live and the
// history path now agree with it.
if (!p.text.trim()) return;
// No open fragment (history / mid-stream join): append directly.
const doneMs = tsOf(timestamp);
const item: AssistantTextItem = {
@@ -934,6 +956,13 @@ function handleComplete(
const tsMs = tsOf(timestamp);
const target = model.pendingThinking ?? model.openThinking;
if (target) {
// Blank body: discard the fragment rather than settle it (see the text branch).
if (!p.thinking.trim()) {
removeItem(model, target);
if (target === model.openThinking) model.openThinking = null;
model.pendingThinking = null;
return;
}
target.thinking = p.thinking;
target.streaming = false;
if (p.stop_reason !== undefined) target.stopReason = p.stop_reason;
@@ -942,6 +971,9 @@ function handleComplete(
model.pendingThinking = null;
return;
}
// Same fidelity-only case as the text branch above (GPT-5 encrypted reasoning): the
// message carries the payload, not a thought to show.
if (!p.thinking.trim()) return;
const item: ThinkingItem = {
kind: "thinking",
id: nextId(model),
+95
View File
@@ -1474,3 +1474,98 @@ describe("multiple calls with a repeated tool_call_id (fallback for legacy Trace
expect(cards[1]!.outputComplete).toBe(false); // new card waits for output as normal
});
});
describe("fidelity-only messages render nothing (empty assistant bubble after thinking)", () => {
// Core emits a complete text/thinking message with an empty body when a provider attaches an
// opaque payload to an otherwise empty part (Gemini's thoughtSignature on a text part, GPT-5's
// encrypted-reasoning phase markers) — see flushText / flushThinking. It must exist so the
// fidelity round-trips into history; it must not become a visible item.
it("an empty assistant text after thinking adds no item", () => {
const m = createStreamModel();
pushMessage(m, userText("hi"));
pushMessage(m, thinkingMessage("pondering"));
pushMessage(m, assistantText(""));
expect(items(m).map((i) => i.kind)).toEqual(["user_text", "thinking"]);
});
it("a whitespace-only body counts as empty too", () => {
const m = createStreamModel();
pushMessage(m, assistantText("\n \n"));
pushMessage(m, thinkingMessage(" "));
expect(items(m)).toEqual([]);
});
it("real content is unaffected, including a lone space inside real text", () => {
const m = createStreamModel();
pushMessage(m, thinkingMessage("thought"));
pushMessage(m, assistantText("answer"));
expect(items(m).map((i) => i.kind)).toEqual(["thinking", "assistant_text"]);
expect((items(m)[1] as AssistantTextItem).text).toBe("answer");
});
it("a streamed segment is still settled by its complete message, not dropped", () => {
// The guard must only skip the append path — a fragment that streamed real content is
// replaced by its complete message as before.
const m = createStreamModel();
pushMessage(m, partialText("start", "Hel"));
pushMessage(m, partialText("delta", "lo"));
pushMessage(m, partialText("stop"));
pushMessage(m, assistantText("Hello"));
const texts = items(m).filter((i) => i.kind === "assistant_text");
expect(texts).toHaveLength(1);
expect((texts[0] as AssistantTextItem).text).toBe("Hello");
expect((texts[0] as AssistantTextItem).streaming).toBe(false);
});
// A blank body can also arrive through a fragment: core starts a text segment on the first
// truthy delta (`if (!item.text) break;`), and "\n\n" is truthy, so a whitespace-only segment
// really does stream. Guarding only the append path would leave live and after-refresh
// disagreeing — the fragment kept a blank bubble that a reload then dropped.
it("a blank streamed text segment is discarded, so live matches the history rebuild", () => {
const live = createStreamModel();
pushMessage(live, thinkingMessage("pondering"));
pushMessage(live, partialText("start", "\n\n"));
pushMessage(live, partialText("stop"));
pushMessage(live, assistantText("\n\n"));
const history = createStreamModel();
pushMessage(history, thinkingMessage("pondering"));
pushMessage(history, assistantText("\n\n"));
expect(items(live).map((i) => i.kind)).toEqual(["thinking"]);
expect(items(live).map((i) => i.kind)).toEqual(items(history).map((i) => i.kind));
});
it("a blank streamed thinking segment is discarded too", () => {
const live = createStreamModel();
pushMessage(live, partialThinking("start", " "));
pushMessage(live, partialThinking("stop"));
pushMessage(live, thinkingMessage(" "));
const history = createStreamModel();
pushMessage(history, thinkingMessage(" "));
expect(items(live)).toEqual([]);
expect(items(history)).toEqual([]);
});
it("discarding a blank fragment clears the open-fragment slots, leaving no stuck spinner", () => {
// The fragment must be removed rather than blanked: a leftover openText would keep
// `streaming: true` forever (a permanent blinking cursor), and a stale pendingText would
// let the next complete message replace the wrong item.
const m = createStreamModel();
pushMessage(m, partialText("start", " "));
pushMessage(m, partialText("stop"));
pushMessage(m, assistantText(" "));
expect(items(m)).toEqual([]);
// The next real reply must append cleanly, not resurrect the discarded fragment.
pushMessage(m, partialText("start", "Hi"));
pushMessage(m, partialText("stop"));
pushMessage(m, assistantText("Hi"));
const texts = items(m).filter((i) => i.kind === "assistant_text");
expect(texts).toHaveLength(1);
expect((texts[0] as AssistantTextItem).text).toBe("Hi");
expect((texts[0] as AssistantTextItem).streaming).toBe(false);
});
});