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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user