Skip to content

feat: #835 Live Composition Increment 1 — stream the DM's scene (dark, WORLDOS_STREAM_BEATS=0) - #1043

Merged
100yenadmin merged 1 commit into
mainfrom
feat/live-composition-835
Jun 20, 2026
Merged

feat: #835 Live Composition Increment 1 — stream the DM's scene (dark, WORLDOS_STREAM_BEATS=0)#1043
100yenadmin merged 1 commit into
mainfrom
feat/live-composition-835

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Jun 20, 2026

Copy link
Copy Markdown
Member

KEYSTONE #835 Increment 1, default OFF (WORLDOS_STREAM_BEATS=0 → byte-identical to today). The viewer's streaming/latestStreamed UI was already wired; this adds the missing data pipeline: --include-partial-messages on the DM call (shared lib_beat_driver.sh helper) → scripts/stream_tailer.py (a robust partial-JSON lexer that extracts log_event narration deltas, handling chunk boundaries + kind-before/after-text + unicode escapes) → GET /beat-stream → a React poll feeding composing:true rows that the existing dedup collapses on beat completion. 18 tailer unit tests + existing JSX tests green. persist_beat is recognized-but-not-streamed (Increment 1 scope).

Summary by CodeRabbit

  • New Features
    • Added live streaming of narration and dialogue content with real-time incremental delivery to the interface during turn composition.
    • Introduced /beat-stream endpoint providing streamed content chunks with cursor-based pagination.
    • Enhanced user interface to display composing narration preview, clearing upon canonical event arrival.

…t's written (dark, WORLDOS_STREAM_BEATS default OFF)

Build the missing DATA PIPELINE for #835: the viewer's streaming/latestStreamed
UI is already wired (screen-table.jsx DmNarratingBeat); this adds the pipe that
feeds it. Everything is gated behind WORLDOS_STREAM_BEATS (default 0 = OFF), so
this is safe to merge dark — when off, the live DM `claude -p` invocation is
byte-identical to today (proven: the flag-array is empty and the splice expands
to nothing; the tailer launcher returns early).

Layer A — wrapper flag (the ONE shared lever in qa/lib_beat_driver.sh, so the
three DM wrappers can't drift):
  * worldos_stream_flag_arg → WORLDOS_STREAM_FLAG=(--include-partial-messages)
    only when streaming is on, else empty.
  * worldos_stream_tailer_start/stop → launch/kill the per-attempt sidecar tailer
    against the DM $out (no-op when off; best-effort — a crash never affects a beat).
  * Wired into _dm_invoke in scripts/play.sh + scripts/play_party.sh, and the
    inline DM turn in qa/run_duo.sh (203-region only). Each attempt (incl. the
    retry, which re-mints $out) gets its own tailer.

Layer B — scripts/stream_tailer.py (NEW, pure stdlib): tails the DM stream-json
$out as it's written; on a content_block_start whose tool name suffix is
log_event/persist_beat it marks a CANDIDATE block, accumulates input_json_delta
partial_json, and incrementally decodes the `text` arg of narration/dialogue
log_event calls into $STATE_DIR/stream/current.jsonl ({"seq","text","ts"} rows,
truncated per beat). Handles: kind BEFORE or AFTER text (buffer-until-kind);
partial_json split across chunk boundaries incl. mid-`\"` and mid-`\uXXXX`;
non-prose kinds never streamed; MCP-prefixed names; nested vs flat row shapes.
persist_beat is recognized but its nested-events text is deferred (Increment 1).

Layer C — viewer/server.py: GET /beat-stream?since=N reads the wrapper-owned
sidecar with a line cursor (modeled on _read_events), returns
{"chunks","next","complete"}. Read-only — the engine's sole-writer invariant is
untouched (the sidecar is not campaign state).

Layer D — viewer/openworlds/app.jsx: useLiveSession gains a /beat-stream poll
(~500ms, active only while a turn is pending) that accumulates chunks into ONE
transient `composing:true` narration row and calls notePendingProgress(). The
composing row is flagged (never dedup-keyed), so the canonical seq-keyed /events
narration row REPLACES it on beat completion (the /events poll strips composing
rows) and clearPending drops it on resolution — zero duplication.

Tests — qa/test_stream_tailer.py (NEW, 18 tests, single-process -p no:xdist):
the riskiest part (partial-JSON parsing) is property-tested over every chunk-
boundary size + adversarial escape splits; non-prose suppression; ordering;
MCP names; row shapes; the file-tailing driver. All green.

Regression: existing JSX harness suites pass unchanged — test_live_narration_stream
(38), test_chronicle_dedup_opening + test_dm_beat_wait_alive (25).
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an end-to-end live composition streaming pipeline for WorldOS #835 Increment 1. A new Python sidecar (stream_tailer.py) incrementally parses DM Claude streaming output and writes prose deltas to $STATE_DIR/stream/current.jsonl. Shell beat drivers start/stop the tailer around each Claude invocation. A new /beat-stream server endpoint exposes the feed, and the viewer polls it to render a transient composing preview that is atomically replaced by the canonical narration on beat completion.

Changes

Live Composition Streaming Pipeline

Layer / File(s) Summary
StreamDecoder and partial JSON scanner
scripts/stream_tailer.py
Implements _PartialJsonScanner for incremental JSON-string extraction across arbitrary chunk boundaries, and StreamDecoder that routes stream events to per-block state, buffers text until kind resolves, and emits only log_event narration/dialogue deltas.
File-tailing driver and CLI entrypoint
scripts/stream_tailer.py
Adds _iter_new_lines (offset-tracked incremental file reader), _open_sink (truncating sink creator), tail_stream (polling loop writing {seq,text,ts} JSONL to sink), and main (CLI argument parsing and KeyboardInterrupt handling).
Shell driver helpers and per-beat invocation wiring
qa/lib_beat_driver.sh, qa/run_duo.sh, scripts/play.sh, scripts/play_party.sh
Adds worldos_stream_flag_arg, worldos_stream_tailer_start, and worldos_stream_tailer_stop helpers gated on WORLDOS_STREAM_BEATS, then wires the tailer sidecar lifecycle into all three DM beat invocation paths.
/beat-stream server endpoint
viewer/server.py
Adds _read_beat_stream to cursor-tail current.jsonl with truncation-reset and partial-line safety, and exposes a /beat-stream GET endpoint returning {chunks, next, complete}.
Viewer composing preview polling and lifecycle
viewer/openworlds/app.jsx
Adds beatStreamCursor/composingText refs, a 500ms /beat-stream polling effect that maintains a transient composing:true chatBeats row, updates clearPending/clearComposing and the /events ingestion path to atomically replace the preview with the canonical narration on beat resolution.
StreamDecoder and tail_stream tests
qa/test_stream_tailer.py
Covers kind-ordering semantics, non-prose suppression, chunk-boundary robustness (escaped quotes, unicode, tab/newline), multi-event ordering, MCP tool name recognition, assistant/persist_beat suppression, flat row shape tolerance, and integration tests for tail_stream against a real growing file including sink truncation.

Sequence Diagram(s)

sequenceDiagram
  participant Shell as Shell Driver<br>(play.sh / run_duo.sh)
  participant Tailer as stream_tailer.py<br>(background sidecar)
  participant Claude as claude -p<br>(DM invocation)
  participant Server as viewer/server.py<br>/beat-stream
  participant Viewer as app.jsx<br>useLiveSession

  Shell->>Tailer: worldos_stream_tailer_start (background)
  Shell->>Claude: worldos_timeout claude -p --include-partial-messages --output-format stream-json > out.jsonl
  Claude-->>Tailer: stream-json rows appended to out.jsonl
  Tailer->>Tailer: _iter_new_lines → StreamDecoder.feed_row()
  Tailer->>Tailer: _PartialJsonScanner decodes log_event narration/dialogue deltas
  Tailer->>Server: appends {seq,text,ts} to $STATE_DIR/stream/current.jsonl
  Viewer->>Server: GET /beat-stream?since=N (every ~500ms while pending)
  Server-->>Viewer: {chunks, next, complete}
  Viewer->>Viewer: accumulate composingText, render composing:true chatBeats row
  Claude-->>Shell: process exits
  Shell->>Tailer: worldos_stream_tailer_stop (kill PID)
  Viewer->>Server: GET /events (canonical narration arrives)
  Server-->>Viewer: seq-keyed narration beats
  Viewer->>Viewer: remove composing:true row, insert canonical beat
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • electricsheephq/WorldOS#343: Modifies useLiveSession in app.jsx for in-flight narration polling state persistence — the same hook and state management patterns extended by this PR for the /beat-stream composing preview.
  • electricsheephq/WorldOS#394: Modifies useLiveSession live-DM narration streaming and pending resolution, directly overlapping with the composing preview lifecycle added here.
  • electricsheephq/WorldOS#407: Changes /events narration ingestion to use stable seq keys — the same ingestion path this PR modifies to atomically replace the composing preview with canonical narration.

Poem

🐇 Hippity-hop, the words now flow live,
Before the full beat, the narration can thrive!
A tailer watches each JSON stream line,
Composing a preview so players don't pine.
When canon arrives, the draft fades away—
The rabbit approves of this incremental play! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary change: implementing live composition streaming for issue #835 as the first increment, with the feature disabled by default.
Description check ✅ Passed The PR description is comprehensive, covering what changed, implementation details, and testing. The CLA and validation sections from the template are not present, but the core content is detailed and complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread viewer/openworlds/app.jsx
// /events narration row carries the finished prose, so the partial composing preview must vanish to
// avoid duplication. Filtering by the `composing` flag (not by text) keeps this independent of the
// dedup sets — the composing row was never claimed in seenSeq/seenText, so removing it is clean.
const clearComposing = React.useCallback(() => {
Comment thread scripts/stream_tailer.py
finally:
try:
sink.close()
except OSError:
Comment thread scripts/stream_tailer.py
poll = float(os.environ.get("WORLDOS_STREAM_POLL_S", "0.25"))
try:
tail_stream(out_path, stream_path, poll_interval=poll)
except KeyboardInterrupt:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/stream_tailer.py`:
- Around line 389-390: In the function that opens the sink file at lines
389-390, `os.path.dirname(stream_path)` returns an empty string when stream_path
is a direct filename, which causes `os.makedirs("")` to raise an error. Before
calling os.makedirs, check if the directory path is not empty, and only create
the directories if there is an actual directory component to create. This
ensures direct filenames are handled correctly without attempting to create an
empty directory path.

In `@viewer/openworlds/app.jsx`:
- Around line 601-602: The beatStreamCursor.current is only being reset per run
(at lines 601-602) but needs to also be reset per turn/attempt in the polling
loop. Since the tailer truncates current.jsonl per beat/attempt, the stale
cursor value can skip the start of a new stream if the file regrows before the
next poll. In addition to the existing reset at the run level, reset
beatStreamCursor.current in the polling loop logic (around line 832) or wherever
turn/attempt transitions occur to ensure the cursor doesn't carry over stale
values across different attempts.

In `@viewer/server.py`:
- Around line 8473-8475: The `since` parameter parsing in the `/beat-stream`
endpoint lacks error handling when converting user input to an integer using
`int((qs.get("since") or ["0"])[0])`. Wrap this integer conversion in a
try-except block to catch ValueError exceptions that occur when the user
provides a non-numeric value like `since=abc`. When the conversion fails,
default to 0 (the same as the current fallback behavior) so the request degrades
safely instead of raising an unhandled exception. The default value of 0 is
already provided in the fallback logic, so ensure the error handling maintains
this same safe default behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 71e9207c-708b-4607-8478-153e137e405b

📥 Commits

Reviewing files that changed from the base of the PR and between 6f921be and e8a2919.

📒 Files selected for processing (8)
  • qa/lib_beat_driver.sh
  • qa/run_duo.sh
  • qa/test_stream_tailer.py
  • scripts/play.sh
  • scripts/play_party.sh
  • scripts/stream_tailer.py
  • viewer/openworlds/app.jsx
  • viewer/server.py

Comment thread scripts/stream_tailer.py
Comment on lines +389 to +390
os.makedirs(os.path.dirname(stream_path), exist_ok=True)
return open(stream_path, "w", encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle direct-file sink paths without crashing.

When stream_path is a direct filename (allowed by the CLI contract), os.path.dirname(stream_path) is empty. On Line 389, os.makedirs("") can raise and kill the sidecar before streaming starts.

🩹 Proposed fix
 def _open_sink(stream_path: str):
     """Open (and TRUNCATE) the stream sink at beat start, returning the file handle. Truncating
     here implements 'reset on each new beat/attempt': the wrapper launches a fresh tailer per
     attempt, so opening with "w" clears any prior attempt's chunks."""
-    os.makedirs(os.path.dirname(stream_path), exist_ok=True)
+    parent = os.path.dirname(stream_path)
+    if parent:
+        os.makedirs(parent, exist_ok=True)
     return open(stream_path, "w", encoding="utf-8")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
os.makedirs(os.path.dirname(stream_path), exist_ok=True)
return open(stream_path, "w", encoding="utf-8")
def _open_sink(stream_path: str):
"""Open (and TRUNCATE) the stream sink at beat start, returning the file handle. Truncating
here implements 'reset on each new beat/attempt': the wrapper launches a fresh tailer per
attempt, so opening with "w" clears any prior attempt's chunks."""
parent = os.path.dirname(stream_path)
if parent:
os.makedirs(parent, exist_ok=True)
return open(stream_path, "w", encoding="utf-8")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/stream_tailer.py` around lines 389 - 390, In the function that opens
the sink file at lines 389-390, `os.path.dirname(stream_path)` returns an empty
string when stream_path is a direct filename, which causes `os.makedirs("")` to
raise an error. Before calling os.makedirs, check if the directory path is not
empty, and only create the directories if there is an actual directory component
to create. This ensures direct filenames are handled correctly without
attempting to create an empty directory path.

Comment thread viewer/openworlds/app.jsx
Comment on lines +601 to +602
beatStreamCursor.current = 0; // #835: reset the live-composition /beat-stream tail per run
composingText.current = ""; // #835: …and its per-beat prose accumulator

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset /beat-stream cursor per turn/attempt, not only per run.

beatStreamCursor.current is reset on run changes (Lines 601-602) but reused across turns in the polling loop (Line 832). Because the tailer truncates current.jsonl per beat/attempt, a stale high cursor can skip the start of a new stream if the file regrows quickly before the next poll.

🩹 Proposed fix
   const armPending = React.useCallback((text) => {
     clearTimers();
+    // New beat/attempt starts with a freshly truncated stream sidecar.
+    beatStreamCursor.current = 0;
+    composingText.current = "";
     // `#406`: "first beat?" = no turn has RESOLVED on /chat yet (resolvedTurnsRef), NOT "no paragraph
     // has streamed" (dmBeatCountRef). So a retried cold-open — one paragraph streamed, then "Try
     // again" before the turn resolved — still gets the generous PENDING_RECOVERY_FIRST_MS window.
     const firstBeat = resolvedTurnsRef.current === 0;

Also applies to: 808-833

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/openworlds/app.jsx` around lines 601 - 602, The
beatStreamCursor.current is only being reset per run (at lines 601-602) but
needs to also be reset per turn/attempt in the polling loop. Since the tailer
truncates current.jsonl per beat/attempt, the stale cursor value can skip the
start of a new stream if the file regrows before the next poll. In addition to
the existing reset at the run level, reset beatStreamCursor.current in the
polling loop logic (around line 832) or wherever turn/attempt transitions occur
to ensure the cursor doesn't carry over stale values across different attempts.

Comment thread viewer/server.py
Comment on lines +8473 to +8475
qs = parse_qs(parsed.query)
since = int((qs.get("since") or ["0"])[0])
chunks, nxt, complete = _read_beat_stream(since)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard since parsing on /beat-stream to avoid request-time exceptions.

Line 8474 uses int(...) directly on user input. A malformed value (e.g. since=abc) can raise and fail this request path instead of degrading safely.

🩹 Proposed fix
         elif route == "/beat-stream":
             # `#835` Live Composition Increment 1 — poll the wrapper-owned live-stream sidecar
             # ($STATE_DIR/stream/current.jsonl) with a line cursor. Read-only (the tailer is the
             # sole writer of this sidecar; the engine's sole-writership of campaign state is
             # untouched). Returns {"chunks":[{seq,text,ts}...], "next":N, "complete":bool}. The
             # `campaign` query arg is accepted for symmetry with /events but the stream is a single
             # per-run sidecar (one in-flight DM beat at a time), so it isn't campaign-scoped here.
             qs = parse_qs(parsed.query)
-            since = int((qs.get("since") or ["0"])[0])
+            try:
+                since = max(0, int((qs.get("since") or ["0"])[0]))
+            except (TypeError, ValueError):
+                since = 0
             chunks, nxt, complete = _read_beat_stream(since)
             self._json({"chunks": chunks, "next": nxt, "complete": complete})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
qs = parse_qs(parsed.query)
since = int((qs.get("since") or ["0"])[0])
chunks, nxt, complete = _read_beat_stream(since)
qs = parse_qs(parsed.query)
try:
since = max(0, int((qs.get("since") or ["0"])[0]))
except (TypeError, ValueError):
since = 0
chunks, nxt, complete = _read_beat_stream(since)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/server.py` around lines 8473 - 8475, The `since` parameter parsing in
the `/beat-stream` endpoint lacks error handling when converting user input to
an integer using `int((qs.get("since") or ["0"])[0])`. Wrap this integer
conversion in a try-except block to catch ValueError exceptions that occur when
the user provides a non-numeric value like `since=abc`. When the conversion
fails, default to 0 (the same as the current fallback behavior) so the request
degrades safely instead of raising an unhandled exception. The default value of
0 is already provided in the fallback logic, so ensure the error handling
maintains this same safe default behavior.

@100yenadmin
100yenadmin merged commit 0696979 into main Jun 20, 2026
21 checks passed
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.

1 participant