Skip to content

fix(hosted): live-view rendered each assistant reply twice (result frame echo) - #596

Merged
schubydoo merged 5 commits into
mainfrom
hosted-live-view-renders-the-assistant-reply-twi
Jun 25, 2026
Merged

fix(hosted): live-view rendered each assistant reply twice (result frame echo)#596
schubydoo merged 5 commits into
mainfrom
hosted-live-view-renders-the-assistant-reply-twi

Conversation

@schubydoo

Copy link
Copy Markdown
Owner

Problem

In the hosted browser live-view, every assistant reply rendered twice — once as white pre-wrap paragraphs, then again immediately below as a green run-on block with the same text.

Root cause (corrected from the issue)

The issue (#591) attributed the duplicate to --include-partial-messages. Empirically (one assistant turn = system×9, stream_event×6, user×1, assistant×1, rate_limit_event×1, result×1), the streamed stream_event partials are already dropped by _hostedFrameToItems (they fall through to []). The real duplicate is the trailing result frame: frame.result repeats the final assistant text, and it was rendered as a kind:"result" item (text-success, no pre-wrap) — the green run-on block.

Fix

  • result branch of _hostedFrameToItems now collapses a successful turn to a · turn complete · marker (no text echo), so the reply renders exactly once.
  • Text is preserved only on the error path, where result carries content no assistant frame emits (e.g. Not logged in · Please run /login). Gated on is_error, not subtype (which is "success" even on auth failure).
  • Removed the now-unreachable green text-success result renderer from dashboard.html.

Test

No JS test harness in CI, so a static guard on the event→items mapping (matching the existing test_no_xshow_element_carries_important_display_utility precedent) asserts the result branch collapses on success, gates text on is_error, and that the green renderer is gone. Full test_app.py + test_hosted.py + test_app_hosted.py (166 tests) green.

Closes #591

🤖 Generated with Claude Code

…ame echo)

One assistant turn emits both the streamed `assistant` frame and a trailing
`result` frame whose `result` field repeats the same text. `_hostedFrameToItems`
rendered both — the message as white pre-wrap paragraphs and the result echo as a
green (`text-success`) run-on block — so every reply double-rendered (#591).

The streamed `stream_event` partials were already dropped (they fall through to
`[]`), so the duplicate was the `result` frame, not the partials. Collapse a
successful result to a "· turn complete ·" marker and keep text only on the error
path, where `result` carries content no assistant frame emits (e.g.
"Not logged in · Please run /login"); gate on `is_error`, not `subtype` (which is
"success" even on auth failure). Remove the now-unreachable green result renderer.

Adds a static guard test on the event→items mapping (no JS harness in CI).

Closes #591

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes the hosted live-view double-render bug (#591) where every assistant reply appeared twice — once as white pre-wrap paragraphs (from the assistant frame) and again as a green run-on block (from the result frame that echoes the same text). The result branch of _hostedFrameToItems now collapses successful turns to a · turn complete · boundary marker and surfaces error text only when frame.is_error is truthy, removing the only path that produced the kind: "result" item.

  • _dashboard_script.html: _hostedFrameToItems result branch split on frame.is_error: error path emits kind: "error" with the error text (or "turn failed" as fallback); success path emits a kind: "marker" — no text echo.
  • dashboard.html: Removed the kind === 'result' (text-success) renderer, which is now unreachable. The pre-existing kind === 'error' renderer handles the error path.
  • tests/test_app.py: Static source-text guard asserts the four invariants: no kind: "result" emission, is_error gating, marker on success, kind: "error" on error path — consistent with the test_no_xshow_element_carries_important_display_utility precedent for pure-JS logic with no CI harness.

Confidence Score: 5/5

The change is narrow and well-bounded: only the result-frame branch of _hostedFrameToItems is touched, the removed renderer was truly unreachable, and the new marker path cannot drop any user-facing error because is_error is the SDK's canonical flag for that case.

The fix correctly targets the root cause, the is_error gate is empirically grounded and documented in both the comment and the PR description, the deleted renderer was dead code, and the static test covers all four invariants. No remaining gaps were identified.

No files require special attention. All four changed files are consistent and correct.

Important Files Changed

Filename Overview
src/clauster/templates/_dashboard_script.html Core fix: _hostedFrameToItems result branch now collapses successful turns to a · turn complete · marker and only surfaces text (as kind: "error") when frame.is_error is truthy, stopping the double-render of assistant replies.
src/clauster/templates/dashboard.html Removed the now-unreachable kind === 'result' (green text-success) renderer; the pre-existing kind === 'error' renderer correctly handles the error path.
tests/test_app.py New static guard test extracts the result branch by regex and asserts: no kind: "result" emission, is_error gating, collapse to a marker on success, and kind: "error" on the error path.
.changeset/591-hosted-result-frame-double-render.md Changeset entry correctly tagged as patch; description accurately summarises the root cause and fix.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[WebSocket event] --> B{event.type}
    B -- frame --> C[_hostedFrameToItems]
    C --> D{frame.type}
    D -- system --> E["kind: marker"]
    D -- result --> F{frame.is_error?}
    F -- true --> G["kind: error"]
    F -- false --> H["kind: marker - turn complete - fix #591"]
    D -- assistant/user --> I[Parse message.content]
    I --> J["kind: msg / tool / toolresult"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[WebSocket event] --> B{event.type}
    B -- frame --> C[_hostedFrameToItems]
    C --> D{frame.type}
    D -- system --> E["kind: marker"]
    D -- result --> F{frame.is_error?}
    F -- true --> G["kind: error"]
    F -- false --> H["kind: marker - turn complete - fix #591"]
    D -- assistant/user --> I[Parse message.content]
    I --> J["kind: msg / tool / toolresult"]
Loading

Reviews (5): Last reviewed commit: "Merge main into hosted-live-view-renders..." | Re-trigger Greptile

Comment thread tests/test_app.py
Comment thread src/clauster/templates/_dashboard_script.html
schubydoo and others added 4 commits June 25, 2026 09:40
Address Greptile review feedback (greploop):
- Add a guard assertion that the result error branch emits a `kind:"error"`
  item — a kind with a real renderer — so a future flip to a marker (or any
  rendererless kind) can't silently swallow auth-failure text.
- Document in _hostedFrameToItems that `is_error` is the SDK's canonical (and
  only reliable) error flag on the result message, and that a falsy is_error
  always means `result` is the assistant-text duplicate (rate-limit / quota /
  teardown errors all set is_error: true), so collapsing it never drops a
  user-facing error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@schubydoo
schubydoo merged commit 28a7866 into main Jun 25, 2026
24 checks passed
@schubydoo
schubydoo deleted the hosted-live-view-renders-the-assistant-reply-twi branch June 25, 2026 17:23
schubydoo pushed a commit that referenced this pull request Jun 25, 2026
Merging this PR tags and publishes **v0.12.4**.

Generated by knope from the `.changeset/` fragments — don't edit it by
hand; add or amend a changeset on the source PR instead.

---

## Fixes

- Fix the live bridge-log tail going dead after a service restart +
bridge reattach (the upgrade path). A reattached bridge was rebuilt
without its `bridge_debug_log_path`, so `/ws/bridge-log/{instance_id}`
closed every connection immediately — the tail flickered through a few
reconnect attempts and then gave up with "Live tail disconnected",
leaving the operator blind to a bridge that was actually alive. Both
reattach paths now re-bind the tail to the log the bridge is still
writing: a pty survivor derives it from its keeper sidecar's shared
spawn-set stem, and a standard survivor recovers the newest debug log it
wrote. The Reconnect button also resets the consecutive-failure counter
on a manual retry so it can no longer be a no-op once auto-reconnect has
capped out. ([#595](#595))
- Fix `clauster install-service` so a frozen/standalone binary (or
`clauster` console-script) install no longer emits a service unit with
an invalid `clauster -m clauster run` command — the unit now invokes the
clauster entry point directly across systemd, launchd, and Windows/nssm,
and only a bare `python -m clauster` interpreter keeps the module
prefix. ([#588](#588))
- Fix `clauster install-service` so the generated systemd and launchd
units bake a `PATH` (the run-as user's `~/.local/bin` plus the standard
system dirs) instead of leaving the service with a minimal default.
Clauster propagates its environment to every spawned bridge, so
previously a bridge agent couldn't resolve user-local tools
(`uv`/`ruff`/`pytest`, etc.) that work fine in an interactive shell. A
unit comment now points operators at `claude.path_append` / `claude.env`
for shell-managed toolchains (nvm/pyenv/cargo/go).
([#593](#593))
- Fix the hosted live-view rendering each assistant reply twice. One
assistant turn emits both the streamed `assistant` frame and a trailing
`result` frame whose `result` field repeats the same text, and the
live-view rendered both — the message as white paragraphs and the result
echo as a green run-on block. The `result` frame now collapses a
successful turn to a "turn complete" marker and surfaces text only on
the error path (where `result` carries content no assistant frame emits,
e.g. "Not logged in · Please run /login").
([#596](#596))
- Fix clauster-spawned hosted (claustrum) sessions being misclassified
as `EXTERNAL`/"unmanaged" by the `claude agents --json` cross-check,
which also left a stale Active card alongside the Stopped one after Stop
— the poll loop now recognizes the hosted registry (by claustrum agent
pid, with a workspace-cwd fallback for pre-CT-1 daemons, plus CL-8
orphan survivors) and attributes those sessions to Clauster instead of
surfacing them as external.
([#594](#594))

Co-authored-by: clauster-ci[bot] <289303168+clauster-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hosted live-view renders the assistant reply twice (streaming partials + final message)

1 participant