fix(hosted): live-view rendered each assistant reply twice (result frame echo) - #596
Conversation
…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>
|
| 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"]
%%{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"]
Reviews (5): Last reviewed commit: "Merge main into hosted-live-view-renders..." | Re-trigger Greptile
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>
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>
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 streamedstream_eventpartials are already dropped by_hostedFrameToItems(they fall through to[]). The real duplicate is the trailingresultframe:frame.resultrepeats the final assistant text, and it was rendered as akind:"result"item (text-success, nopre-wrap) — the green run-on block.Fix
resultbranch of_hostedFrameToItemsnow collapses a successful turn to a· turn complete ·marker (no text echo), so the reply renders exactly once.resultcarries content no assistant frame emits (e.g.Not logged in · Please run /login). Gated onis_error, notsubtype(which is"success"even on auth failure).text-successresult renderer fromdashboard.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_utilityprecedent) asserts the result branch collapses on success, gates text onis_error, and that the green renderer is gone. Fulltest_app.py+test_hosted.py+test_app_hosted.py(166 tests) green.Closes #591
🤖 Generated with Claude Code