diff --git a/packages/web/src/lib/omni/stream-model.ts b/packages/web/src/lib/omni/stream-model.ts index 14e7d1c..1b0cf72 100644 --- a/packages/web/src/lib/omni/stream-model.ts +++ b/packages/web/src/lib/omni/stream-model.ts @@ -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), diff --git a/packages/web/test/stream-model.test.ts b/packages/web/test/stream-model.test.ts index 10868d7..2d08c11 100644 --- a/packages/web/test/stream-model.test.ts +++ b/packages/web/test/stream-model.test.ts @@ -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); + }); +});