The AI's text appears duplicated in the same bubble — upstream SDK bug, 4-layer defense

verified · provenanceused 0× by assistantsadk

A single AI chat bubble shows the same paragraph twice (or the whole message content doubled) even though the backend generated it once — this is a known family of upstream bugs in agentic SDKs that stream and re-aggregate LLM output, not an application-level typo, and the durable fix is a normalized-signature dedup applied at multiple layers rather than a single string-equality check.

The signal that gives it away

- One single DOM bubble element (one message container, not two separate messages) contains text that is exactly the same content repeated, back-to-back or with only whitespace/newline differences between the two copies. If you count child paragraph nodes inside the bubble: 1 paragraph = fine, 3+ paragraphs where the last one repeats the prefix of an earlier one = the bug is active. - The text length rendered in the browser is roughly 2× the length stored in the database for that same message row. This is the tell that distinguishes "backend really wrote it twice" (DB also shows 2×) from "backend wrote it once, frontend rendered it twice" (DB shows 1×, DOM shows 2×) — they need different fixes. - It correlates with streaming + tool calls: it shows up specifically on messages that involve a long-running tool call, a planning/reasoning step, or a resumed/reconnected session — not on simple one-shot text replies. That's because the duplication comes from the SDK emitting the same content through two different code paths (streamed chunks vs. the final aggregated message, or two separate processing cycles for one input) rather than from the application appending twice. - The two copies are not byte-identical — they typically differ by leading/trailing whitespace, a stray newline, or minor truncation at the boundary. A naive text1 == text2 or exact substring check will miss real duplicates and this is the single most common way a "fix" silently fails to fix anything.

How to exploit / fix it

Once you've confirmed it's upstream (see "root cause" below), the durable defense is dedup on a normalized signature, not on raw text, applied at up to four layers, ordered from cheapest/most-local to most-robust:

1. Layer 1 — intra-response dedup. Inside the processing of a single streamed response object, keep a seen set of signatures and drop any chunk whose signature was already seen before appending it to the accumulated text. Catches the case where the SDK re-emits the same chunk content within one response (e.g. once as a stream delta, once folded into an aggregated field). 2. Layer 2 — cross-response, session-scoped, with TTL. Some SDK bugs don't duplicate within one response object — they emit two *separate* response objects for what should be a single processing cycle (a full extra round-trip). Layer 1 can't see across objects, so keep a session-scoped cache of (session_id, signature) → timestamp with a short TTL (order of tens of seconds — 60s is a reasonable default) and drop the second response if its signature was already recorded for that session inside the TTL window. 3. Layer 3 — frontend defense-in-depth. Even with Layers 1-2, treat the frontend as the last line of defense, not an afterthought: on token/paragraph append, compute the same normalized signature of the incoming chunk and (a) skip if it matches a signature already appended to that bubble, and (b) do a *containment* check — skip if the new chunk's normalized text is already a substring of what's rendered, or vice versa (catches partial/truncated duplicate chunks that a strict equality check on signatures would miss). This layer exists specifically to survive: TTL expiry, cache backend being down, or a new duplication flavor introduced by an SDK upgrade that Layers 1-2 don't yet know about. 4. Layer 4 — the backend source-of-truth signature function. This is the fix that actually matters and should be written once and reused everywhere dedup logic needs a signature (the response-level dedup, any "have I already emitted this narration/status update" tracker, any "was this already shown recently" guard): normalize before truncating, in this order — collapse all whitespace to single spaces, strip, then truncate to a fixed max length, then lowercase. Concretely: re.sub(r"\s+", " ", text.strip())[:N].lower(). Applying this signature at the backend, before the value is persisted to the database, is what makes Layers 1-3 agree with what's actually stored — if the backend fix is solid, the DB never shows 2× length in the first place, and Layers 2-3 become pure safety nets instead of load-bearing.

A testable assertion worth keeping in a lint/CI check: grep the dedup code paths for any signature construction that slices/truncates before normalizing whitespace (text.strip().lower()[:N] or text.strip()[:N].lower() — note that this is not the same function as re.sub(r"\s+"," ",text.strip())[:N].lower()) and fail the build if found. The ordering bug (truncate-then-normalize vs. normalize-then-truncate) is exactly the kind of regression that reintroduces the dup silently, because it still "does" normalization, just too late to catch a whitespace shift that happens to fall inside the truncated window.

Root cause: how to confirm it's upstream, not yours

Before spending time on the dedup layers, confirm this is a known SDK-level bug class rather than an application bug: agentic SDKs that stream text and also aggregate it into a final message (or that support session pause/resume with server-managed history) have historically shipped more than one issue that produces exactly this symptom family:

- Streaming content duplication where the SDK accumulates streamed text chunks (including planning/reasoning tokens) and then also includes the same content in the field of the final aggregated message meant only to carry structured tool-call data — verified against a public upstream report: [google/adk-python#3697](https://github.com/google/adk-python/issues/3697), "LiteLLM Streaming Content Duplication in Tool Call Responses," which traces the bug to the aggregated-message construction path re-including text already sent as streamed chunks; the report itself argues this violates the OpenAI/LiteLLM convention it cites, under which a tool-call-only message's content field should be None rather than carry user-facing text — treat that as the reporter's stated interpretation of the spec, not an independently verified official requirement. - A single user input triggering the model-call/processing cycle twice, producing two near-identical responses for one logical turn — a regression (present in a later SDK version, absent in an earlier one) verified against a public upstream report: [google/adk-python#930](https://github.com/google/adk-python/issues/930), "Single input triggers duplicate LLM processing cycle." - Related flavors reported field-tested but not independently cross-referenced here: fire-and-forget long-running tool calls causing duplicate model responses via orphaned function-response re-injection; duplicate responses after an agent transfer or session resumption; "thinking" and "text" parts merging into a single part mid-stream; long-running tool events not persisted correctly over an SSE stream. Treat these as "same symptom family, check the SDK's issue tracker for the current state" rather than as verified facts — SDK-level bugs get patched and reopened, so the specific issue numbers age fast and the family of the bug (streaming + aggregation + resume produces duplicate text) is the durable insight, not any single issue number.

Practical implication: if you hit this, search the SDK's issue tracker for "duplicate" + "stream" + your provider name before assuming it's your code — and don't wait for the upstream fix to land before building Layer 4, because even after a fix ships, older duplication flavors from other code paths tend to resurface on the next SDK upgrade.

Replay recipe (when the bug recurs in production)

1. Find a live/recent session where the SDK just executed a long-running or streamed tool call (a multi-minute tool with a "still working" narrative message is the easiest to reproduce with, since it exercises both the streaming path and the aggregation path). 2. Open the chat UI for that session and locate the AI bubble carrying the narrative/status text. 3. Inspect the DOM: count the paragraph (or block-level) children of that single bubble element. One paragraph = fine. Three or more, with an earlier one's text repeated at the end = duplication is active right now. 4. Cross-check against the database: fetch the stored message row for that same turn and compare length(content) against what the DOM renders. - DB length is the smaller, correct value, DOM shows roughly double → the backend-level fix (Layer 4) is working, but something in the frontend rendering/store re-appends already-rendered content — audit Layer 3, not the backend. - DB already shows roughly double the expected length → Layer 4 failed to catch this occurrence — likely a new whitespace/truncation shape from an SDK update that the current signature normalization doesn't anticipate — audit and extend _normalize_sig (or equivalent) before touching the frontend at all.

What does NOT work

- Exact string equality on raw text as the dedup key. The two duplicate copies routinely differ by a stray space, a trailing newline, or a truncation boundary a few characters off — comparing raw strings (or even raw-string hashes) misses these near-duplicates entirely and gives false confidence that dedup is "in place" when it silently isn't. Always dedup on a normalized signature (whitespace-collapsed, case-folded), never on the raw string. - Normalizing case/whitespace but truncating first. text.strip().lower()[:N] looks identical to the correct fix at a glance but is a different function: if the whitespace difference between the two duplicate copies happens to fall *after* the truncation point N, the two signatures are still built from different substrings and won't match. Truncate last, after full whitespace normalization, not before. - Fixing only the backend and assuming the frontend is fine. A backend-only fix (Layer 4) stops new duplicate rows from being persisted, but it does nothing for a frontend rendering/store bug that independently re-renders or re-appends content already received once — these are two different bugs that happen to produce the same visible symptom, and only inspecting the DB vs. DOM length ratio (see replay recipe) tells you which one you actually have. - Treating the upstream issue number as a permanent fix target. Pinning your mitigation strategy to "wait for issue #N to be fixed upstream" is fragile: these SDKs tend to have *multiple* independent code paths that can each produce the same duplication symptom (streaming aggregation, orphaned function-response re-injection, resume-after-transfer, thinking/text part merging). Fixing one reported issue does not retire the defense layers — keep Layers 1-4 as permanent infrastructure, not a temporary patch to delete once upstream ships a fix. - A single defense layer instead of the full stack. Backend-only dedup (Layer 4) leaves you exposed the moment a *new* SDK version introduces a duplication shape your signature function doesn't yet normalize away. Frontend-only dedup (Layer 3) leaves duplicate rows sitting in the database, corrupting anything downstream that reads message history directly (analytics, replays, other consumers of the same data). The 4-layer stack is defense-in-depth precisely because each layer catches a failure mode the others miss.

Related

- The tool stays 'running' for hours: a synchronous LLM call with no timeout blocks the event loop — another ADK-family failure mode surfacing through streaming/long-running tool execution. - A `{variable}` left in an agent's system-prompt comment crashes the stream in silence — Google ADK's template injection trap — another recurring upstream-adjacent ADK bug class, this one crashing the stream instead of duplicating it. - Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) — the broader class of pause/resume pitfalls this duplication pattern often surfaces alongside (resume and streaming interact badly across several ADK-family SDKs). - Answering a gate with text + functionResponse together silently breaks Workflow resume — a related resume-time correctness bug in the same SDK family.

Verified against

11 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.