Skip to content

feat(daemon,dashboard): snapshot.request endpoint for late-joining clients (#432)#448

Merged
xinhuagu merged 16 commits into
mainfrom
feat/432-snapshot-api
Apr 29, 2026
Merged

feat(daemon,dashboard): snapshot.request endpoint for late-joining clients (#432)#448
xinhuagu merged 16 commits into
mainfrom
feat/432-snapshot-api

Conversation

@xinhuagu

@xinhuagu xinhuagu commented Apr 29, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #432.

A browser tab connecting mid-execution (clicking a session in the sidebar, refresh, tab switch) used to see only events emitted after it connected — root nodes, in-flight tools, and assistant text were all missing. This adds a snapshot endpoint so the dashboard can rebuild its tree from history on first paint.

Architecture decision (deviation from issue): events-list snapshot, not state-object snapshot. The reducer is the source of truth for "events → tree"; reproducing that logic in Java would double the surface area. Shipping the buffered envelopes lets the existing reducer build state once. Cost: snapshot 10-100KB per Tier 1 session vs <10KB state object — fine for interactive sessions, capped at 5000 envelopes per session with drop-oldest eviction.

Changes

Daemon

  • New SessionEventBuffer: per-session capped ring buffer of broadcast envelopes
  • WebSocketBridge.broadcast appends to buffer unconditionally (even with zero clients) so tabs that open AFTER an event was emitted still see it on replay
  • New WS inbound method: snapshot.request {sessionId}snapshot.response {sessionId, lastEventId, events:[]}. Point-to-point reply (sibling of sessions.list from feat(dashboard): multi-session discovery — sessions.list endpoint + SessionList sidebar #445)
  • Buffer cleared in setSessionEndCallback after session_ended broadcast lands so the brief race window still includes session_ended in the snapshot

Dashboard

  • useExecutionTree.onopen sends snapshot.request; reconnects re-issue automatically
  • onmessage detects snapshot.response and replays each envelope through the same validator as live frames
  • Existing reducer dedup (eventId <= state.lastEventId) handles snapshot/live overlap, regardless of arrival order

Bugs this fixes (all from #445 testing)

  • Clicking into a session shows no root node → snapshot includes session_started
  • Switching sessions wipes the dashboard → new sessionId re-fetches snapshot
  • Refresh loses everything → reconnect re-fetches snapshot

Test plan

  • SessionEventBufferTest: append/snapshot order, cap-and-evict, clear isolation, concurrent appends + reader (5 cases)
  • WebSocketBridgeTest: broadcast-populates-buffer-with-zero-clients, clearSession isolation, snapshot.request E2E with cross-session filtering, empty-snapshot reply (4 new cases)
  • snapshotReplay.test.ts: tree shape after replay, eventId<=lastEventId dedup, eventId>lastEventId apply, malformed rejection, forward-compat unknown method (5 cases)
  • All daemon tests pass (./gradlew :aceclaw-daemon:test --rerun-tasks)
  • All dashboard tests pass (npm test -- --run → 64/64)
  • Live smoke test: snapshot.request via Python WebSocket against rebuilt daemon → correct empty reply for unknown session
  • Live browser test: open dashboard mid-session, verify root node + tool boxes + text response all present (user verification pending)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added snapshot replay functionality enabling clients to request and restore past events upon reconnection, ensuring no event history is lost during temporary disconnections.
    • Implemented event buffering to preserve recent activity during zero-client periods for later retrieval.
  • Bug Fixes

    • Improved session cleanup to prevent stale snapshot data from being delivered to subsequent reconnections.
  • Tests

    • Added comprehensive test coverage for snapshot request/response behavior and concurrent event handling.

…ients (#432)

A browser tab that connects mid-execution (first paint after the user
clicks a session in the sidebar, refresh, tab switch) used to see only
events emitted AFTER it connected. Root nodes, in-flight tools, plans,
and assistant text were all missing — the dashboard was silently broken
for the most common interaction.

Daemon side:
- New SessionEventBuffer: per-session ring buffer of broadcast envelopes
  (capped 5000 per session, drop-oldest with single WARN log on first
  overflow). Threadsafe via a per-session synchronized ArrayDeque under
  an outer ConcurrentHashMap.
- WebSocketBridge.broadcast appends every envelope to the buffer
  unconditionally — even with zero connected clients — so a tab that
  opens AFTER an event was emitted can still replay it. Builds the
  envelope before the clients-empty short-circuit.
- New WS inbound method snapshot.request {sessionId} → snapshot.response
  {sessionId, lastEventId, events:[envelope...]}. Reply is point-to-point
  (ctx.send) like sessions.list, not envelope-wrapped — semantically a
  request-response, not a stream event.
- Buffer cleared in setSessionEndCallback after the session_ended
  broadcast lands (so the snapshot still includes session_ended for the
  brief race window) and before SessionManager teardown.

Dashboard side:
- useExecutionTree's onopen now sends snapshot.request for the current
  sessionId. Reconnects after a drop go through the same path, so a
  daemon restart or laptop wake doesn't strand the tree state.
- onmessage detects snapshot.response (non-enveloped, sessionId-matched)
  and replays each envelope through the same parseEnvelope gate as live
  frames. The reducer's eventId<=lastEventId dedup (already in #435)
  handles overlap with the live stream — works regardless of arrival
  order between snapshot.response and live broadcasts on the same socket.
- parseEnvelope refactored: shared validation extracted to
  parseEnvelopeFromObject so the snapshot path doesn't pay for a JSON
  round-trip per event.

Bugs this fixes (all reported by user testing #445):
- "Click into session, no root node visible" → snapshot includes
  session_started.
- "Switch sessions in sidebar, dashboard goes blank" → new sessionId
  triggers fresh snapshot.request, tree fills immediately.
- "Refresh loses everything" → reconnect re-fetches snapshot.

Tests:
- SessionEventBufferTest: append/snapshot order, cap-and-evict, clear
  isolation, concurrent appends + reader.
- WebSocketBridgeTest: broadcast populates buffer with zero clients,
  clearSession isolation, snapshot.request E2E with cross-session
  filtering, empty-snapshot reply for unknown session.
- snapshotReplay.test.ts: replay tree-shape, dedup live=lastEventId,
  apply live>lastEventId, malformed-envelope rejection, forward-compat
  unknown method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@greptile-apps greptile-apps 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.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@xinhuagu has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 33 minutes and 10 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 865816c9-c6cb-42e8-8707-0eae8371e493

📥 Commits

Reviewing files that changed from the base of the PR and between d4fb86b and 57b5e2c.

📒 Files selected for processing (13)
  • aceclaw-daemon/src/main/java/dev/aceclaw/daemon/SessionEventBuffer.java
  • aceclaw-daemon/src/test/java/dev/aceclaw/daemon/SessionEventBufferTest.java
  • aceclaw-dashboard/src/components/ExecutionTree.tsx
  • aceclaw-dashboard/src/components/GrowingEdge.tsx
  • aceclaw-dashboard/src/components/GrowingNode.tsx
  • aceclaw-dashboard/src/components/StatusIcon.tsx
  • aceclaw-dashboard/src/hooks/useExecutionTree.ts
  • aceclaw-dashboard/src/hooks/useTreeLayout.ts
  • aceclaw-dashboard/src/reducers/treeReducer.ts
  • aceclaw-dashboard/src/types/tree.ts
  • aceclaw-dashboard/tests/treeReducer.test.ts
  • aceclaw-dashboard/tests/useExecutionTreeSnapshotGate.test.tsx
  • aceclaw-dashboard/tests/useTreeLayout.test.ts
📝 Walkthrough

Walkthrough

This PR implements a session event buffer and snapshot API for late-joining WebSocket clients. The daemon now buffers broadcast events per session in a bounded deque, handles snapshot.request by returning buffered events with a computed lastEventId, and clears buffers when sessions end. The dashboard requests snapshots on WebSocket connection and replays buffered events through the reducer to reconstruct the execution tree.

Changes

Cohort / File(s) Summary
Event Buffering Infrastructure
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/SessionEventBuffer.java
New class storing broadcast ObjectNode envelopes per sessionId in insertion-ordered deques with configurable bounded capacity (default 5000). Provides thread-safe append(), snapshot(), and clear() operations with automatic oldest-envelope eviction and single-warn-per-session overflow logging.
WebSocket Bridge Integration
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/WebSocketBridge.java
Modified broadcast() to always buffer events to SessionEventBuffer even when no clients connected, ensuring snapshot reconstruction can replay events during zero-client periods. Exposes eventBuffer() getter and adds clearSession(sessionId) for cleanup.
Daemon Request Routing
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawDaemon.java
Refactored WebSocket inbound handler with switch-based method routing. New handleSnapshotRequest validates sessionId, fetches buffered envelopes from bridge, computes lastEventId from newest envelope, and responds with snapshot.response. Session cleanup now calls bridge.clearSession() after broadcasting stream.session_ended.
Backend Tests
aceclaw-daemon/src/test/java/dev/aceclaw/daemon/SessionEventBuffer...Test.java, aceclaw-daemon/src/test/java/dev/aceclaw/daemon/WebSocketBridgeTest.java
Comprehensive test coverage: SessionEventBuffer validates snapshot ordering, unknown-session handling, capacity eviction, concurrent safety, and invalid capacity rejection. WebSocketBridge tests verify buffering during zero-client periods, session-specific cleanup, snapshot request/response contract, and unknown-session replies.
Frontend Snapshot Replay
aceclaw-dashboard/src/hooks/useExecutionTree.ts
WebSocket connection now sends snapshot.request on onopen. Incoming frames are JSON-parsed and special-cased: snapshot.response messages iterate through events, validate via new parseEnvelopeFromObject, advance watermark for unknown events, and dispatch known envelopes to reducer. New exported parseEnvelopeFromObject function enables validation of both live frames and snapshot replay without redundant JSON parsing.
Frontend Test Suite
aceclaw-dashboard/tests/snapshotReplay.test.ts
New test suite validating snapshot-to-reducer boundary. Exercises synthetic WebSocket envelope building, snapshot replay sequencing (stream.session_startedstream.turn_startedstream.tool_use), watermark-based deduplication (ignores eventId ≤ watermark), forward-compatible unknown methods, and malformed envelope rejection.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Late-Joining Browser
    participant WS as WebSocket Handler
    participant Buffer as SessionEventBuffer
    participant Reducer as Execution Tree Reducer
    
    Client->>WS: Open connection (onopen)
    Client->>WS: snapshot.request {sessionId}
    WS->>Buffer: snapshot(sessionId)
    Buffer-->>WS: List[envelopes]
    WS->>WS: Compute lastEventId from newest
    WS-->>Client: snapshot.response {events[], lastEventId}
    
    Client->>Reducer: Load snapshot → build initial tree
    Client->>Client: Store lastEventId as watermark
    
    Note over WS,Buffer: Concurrent: daemon continues broadcasting
    WS->>Buffer: append(sessionId, envelope)
    WS-->>Client: snapshot.response events replay
    Client->>Reducer: parseEnvelopeFromObject(event)
    Reducer->>Reducer: Dispatch if eventId > watermark
    
    WS-->>Client: Live stream.tool_use {eventId: 42}
    Client->>Reducer: Parse & check: 42 > watermark?
    Reducer->>Reducer: Apply event, update watermark
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • #432: feat(daemon): Session state snapshot API for late-joining WebSocket clients — This PR directly implements the snapshot request/response API and snapshot replay mechanism described in the issue.

Possibly related PRs

  • PR #443: Extends WebSocket snapshot/replay protocol by modifying useExecutionTree.ts to send snapshot.request and handle parseEnvelopeFromObject, building directly on the snapshot infrastructure introduced here.
  • PR #441: Introduces WebSocketBridge modifications that this PR extends by adding per-session SessionEventBuffer, making broadcast always buffer, and exposing eventBuffer()/clearSession().
  • PR #447: Modifies AceClawDaemon WebSocket inbound handling with session.ended cleanup; this PR builds on that foundation by integrating snapshot buffering and clearSession() into the cleanup flow.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The PR implementation meets most acceptance criteria: snapshot.request/response endpoints work point-to-point, SessionEventBuffer maintains event history per-session, dashboard replay through reducer handles dedup via eventId, and empty sessions return valid snapshots. However, the issue specifies a SessionExecutionState object with many fields (activeRequestId, currentTurn, activeTools, activePlan, pendingPermission, usage) that are not directly addressed—instead the PR replays raw events through the reducer. Clarify whether replaying buffered events through the existing reducer satisfies the requirement to 'include active tools, active plan steps, pending permissions' or if explicit state object fields are required. Verify the 10ms performance target is met and that the chosen event-replay-through-reducer approach fully reconstructs the tree.
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature added: a snapshot.request endpoint enabling late-joining clients to rebuild execution state, which is the primary objective of this PR.
Out of Scope Changes check ✅ Passed All changes are scoped to snapshot functionality: SessionEventBuffer (new), WebSocketBridge buffering/clearSession (snapshot support), AceClawDaemon routing (snapshot.request handler), useExecutionTree snapshot replay, and comprehensive tests. No unrelated refactors or feature creep detected.
Block Major Correctness And Security Risks ✅ Passed Session end handler broadcasts session_ended event before clearing buffer, preventing data loss. Broadcast method appends to buffer before checking client count, ensuring buffering with zero clients.
Require Test Coverage For New Logic ✅ Passed All new business logic in src/main has corresponding test coverage: SessionEventBuffer (new class) has SessionEventBufferTest with 6 tests; WebSocketBridge modifications have 4 new tests in WebSocketBridgeTest; AceClawDaemon snapshot handling covered by integration tests; useExecutionTree.ts has snapshotReplay.test.ts with 7+ test cases.
No Api Breaking Changes Without Version Bump ✅ Passed The PR introduces only additive changes to public APIs with no modifications to existing signatures, meeting the custom check requirements.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/432-snapshot-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 33 minutes and 10 seconds.

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

Self-review caught: plain FIFO eviction in SessionEventBuffer would
drop the oldest envelope first when the cap is hit. The oldest
envelope is always stream.session_started (by daemon construction),
so a long session would lose its root node from the snapshot — the
dashboard reducer would then rebuild an empty tree on snapshot
replay, regressing the exact bug #432 was meant to fix.

Fix: pinned-aware eviction. PINNED_METHODS holds the structural
events the reducer needs to build the tree skeleton (session, turn,
plan, tool, subagent boundaries plus terminal states). On overflow
we walk the deque oldest→newest and remove the first non-pinned
envelope; high-volume noisy events (text, thinking, heartbeat,
usage, compaction) get evicted preferentially while structure
survives arbitrary session length.

Trade-off documented: long sessions lose some text history from
earlier turns, but the tree shape and tool calls remain intact.
The "click into a session, see the tree" UX is what the dashboard
contract depends on; partial text is recoverable from session
history later, lost root is not.

All-pinned fallback: degrades to plain FIFO. Practically impossible
at the 5000 default cap (real sessions generate far more text than
structural events) but keeps the append path deterministic.

Tests:
- evictionPreservesStructuralEventsLikeSessionStarted: cap=3, with
  session_started + 3 text deltas, the 4th append evicts the oldest
  TEXT (eventId=2), not session_started (eventId=1).
- evictionFallsBackToFifoWhenEveryEnvelopeIsPinned: pathological
  buffer of all tool_use, cap=2, third append evicts oldest tool_use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
aceclaw-dashboard/src/hooks/useExecutionTree.ts (1)

182-245: ⚠️ Potential issue | 🔴 Critical

Buffer same-session live frames until snapshot replay finishes.

A live frame for this session can arrive after snapshot.request is sent but before snapshot.response is processed. In that ordering, the reducer advances lastEventId from the live frame first, and the later snapshot replay gets dropped by the existing eventId <= lastEventId dedup gate. The tab then never reconstructs the missing root/turn/tool history.

Possible fix
   useEffect(() => {
     let ws: WebSocket | null = null;
     let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
     let backoffMs = RECONNECT_INITIAL_MS;
     let cancelled = false;
+    let snapshotPending = true;
+    const queuedLive: ParseResult[] = [];
+
+    const applyParsed = (result: ParseResult) => {
+      if (!result || result.sessionId !== sessionId) return;
+      if (result.kind === 'unknown') {
+        setTree((prev) =>
+          prev.lastEventId >= result.eventId
+            ? prev
+            : { ...prev, lastEventId: result.eventId },
+        );
+        return;
+      }
+      dispatch(result.envelope);
+    };
 
     const connect = (): void => {
       if (cancelled) return;
       setStatus('connecting');
       ws = new WebSocket(wsUrl);
@@
         if (
           isPlainObject(data) &&
           data['method'] === 'snapshot.response' &&
           data['sessionId'] === sessionId &&
           Array.isArray(data['events'])
         ) {
           for (const env of data['events']) {
             const parsed = parseEnvelopeFromObject(env);
-            if (!parsed) continue;
-            if (parsed.sessionId !== sessionId) continue;
-            if (parsed.kind === 'unknown') {
-              setTree((prev) =>
-                prev.lastEventId >= parsed.eventId
-                  ? prev
-                  : { ...prev, lastEventId: parsed.eventId },
-              );
-              continue;
-            }
-            dispatch(parsed.envelope);
+            applyParsed(parsed);
           }
+          snapshotPending = false;
+          for (const queued of queuedLive) applyParsed(queued);
+          queuedLive.length = 0;
           return;
         }
 
         const result = parseEnvelopeFromObject(data);
-        if (!result) return;
-        if (result.sessionId !== sessionId) return;
-        if (result.kind === 'unknown') {
-          setTree((prev) =>
-            prev.lastEventId >= result.eventId
-              ? prev
-              : { ...prev, lastEventId: result.eventId },
-          );
+        if (!result || result.sessionId !== sessionId) return;
+        if (snapshotPending) {
+          queuedLive.push(result);
           return;
         }
-        dispatch(result.envelope);
+        applyParsed(result);
       };

Please add a regression test for the live frame arrives before snapshot.response ordering as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-dashboard/src/hooks/useExecutionTree.ts` around lines 182 - 245, Live
envelopes for the same session can arrive after sending snapshot.request but
before processing snapshot.response, causing live frames to advance lastEventId
and then drop replayed snapshot events; fix by introducing a "snapshotPending"
flag (e.g., a ref set when sending snapshot.request) and a small buffer (e.g.,
pendingLiveEnvelopes ref) referenced from ws.onmessage: if a parsed envelope
(from parseEnvelopeFromObject) has result.sessionId === sessionId and
snapshotPending is true and the message is a live envelope (not
snapshot.response), push it into pendingLiveEnvelopes instead of dispatching or
bumping lastEventId; when snapshot.response handling completes (after
replaying/parsing its events and updating setTree/dispatch), clear
snapshotPending and flush pendingLiveEnvelopes in arrival order applying the
same dedup logic (setTree lastEventId checks and dispatch) so live frames are
processed only after snapshot replay; add a regression test that simulates
receiving a live envelope before snapshot.response to verify the buffer and
flush behavior.
🧹 Nitpick comments (1)
aceclaw-daemon/src/main/java/dev/aceclaw/daemon/WebSocketBridge.java (1)

322-323: Null-guard sessionId at the public boundary.

clearSession() currently relies on SessionEventBuffer.clear() to fail on null. Guarding here keeps this public API consistent with the rest of the class and makes the contract explicit.

Suggested change
 public void clearSession(String sessionId) {
+    Objects.requireNonNull(sessionId, "sessionId");
     eventBuffer.clear(sessionId);
 }

As per coding guidelines "Method parameters: Use Objects.requireNonNull(param, "param") on parameters used in .equals() or passed to downstream calls".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/WebSocketBridge.java` around
lines 322 - 323, Add a null-check on the public parameter sessionId in
clearSession by calling Objects.requireNonNull(sessionId, "sessionId") before
delegating to eventBuffer.clear(sessionId); this ensures the method enforces the
non-null contract consistently and prevents relying on SessionEventBuffer.clear
to throw on null.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@aceclaw-dashboard/src/hooks/useExecutionTree.ts`:
- Around line 182-245: Live envelopes for the same session can arrive after
sending snapshot.request but before processing snapshot.response, causing live
frames to advance lastEventId and then drop replayed snapshot events; fix by
introducing a "snapshotPending" flag (e.g., a ref set when sending
snapshot.request) and a small buffer (e.g., pendingLiveEnvelopes ref) referenced
from ws.onmessage: if a parsed envelope (from parseEnvelopeFromObject) has
result.sessionId === sessionId and snapshotPending is true and the message is a
live envelope (not snapshot.response), push it into pendingLiveEnvelopes instead
of dispatching or bumping lastEventId; when snapshot.response handling completes
(after replaying/parsing its events and updating setTree/dispatch), clear
snapshotPending and flush pendingLiveEnvelopes in arrival order applying the
same dedup logic (setTree lastEventId checks and dispatch) so live frames are
processed only after snapshot replay; add a regression test that simulates
receiving a live envelope before snapshot.response to verify the buffer and
flush behavior.

---

Nitpick comments:
In `@aceclaw-daemon/src/main/java/dev/aceclaw/daemon/WebSocketBridge.java`:
- Around line 322-323: Add a null-check on the public parameter sessionId in
clearSession by calling Objects.requireNonNull(sessionId, "sessionId") before
delegating to eventBuffer.clear(sessionId); this ensures the method enforces the
non-null contract consistently and prevents relying on SessionEventBuffer.clear
to throw on null.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0958088f-33fb-435f-b37b-b64aca8bf700

📥 Commits

Reviewing files that changed from the base of the PR and between 8644722 and d4fb86b.

📒 Files selected for processing (7)
  • aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawDaemon.java
  • aceclaw-daemon/src/main/java/dev/aceclaw/daemon/SessionEventBuffer.java
  • aceclaw-daemon/src/main/java/dev/aceclaw/daemon/WebSocketBridge.java
  • aceclaw-daemon/src/test/java/dev/aceclaw/daemon/SessionEventBufferTest.java
  • aceclaw-daemon/src/test/java/dev/aceclaw/daemon/WebSocketBridgeTest.java
  • aceclaw-dashboard/src/hooks/useExecutionTree.ts
  • aceclaw-dashboard/tests/snapshotReplay.test.ts

Xinhua Gu and others added 4 commits April 29, 2026 22:46
User testing #432 found a missing case the existing #435 reducer
doesn't cover: a turn shows tool nodes but no reasoning step. Daemon
emits stream.thinking with {delta} (StreamingAgentHandler#3066) and
useExecutionTree validates it (it's in KNOWN_METHODS), but the
treeReducer switch had no case for it — it fell through default and
got dropped. The user's "看不到 reasoning 这步" was the fact that
thinking blocks left no node behind.

Mirror the text path:
- Add 'thinking' to ExecutionNodeType.
- thinkingNodeId(turnId) — one thinking child per turn, like text.
- appendThinkingToCurrentTurn — same shape as appendTextToCurrentTurn
  (find running turn, append-or-create child, concat deltas into one
  node so the renderer shows a single bubble).
- Reducer switch case routes stream.thinking to the new function.

Visual contract: thinking node renders identically to text/tool/etc
through GrowingNode (no type-based switch in the renderer), with
label="thinking" so the user can distinguish it from "response".
Layout-wise it sits under the same turn as text and tools — dagre
LR places siblings vertically.

Tests:
- rolls thinking deltas into a single thinking child of the running
  turn, deltas concatenate.
- thinking + text coexist as siblings under the same turn (real
  shape: a turn typically thinks, then responds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ations) (#432)

User caught the right semantic: thinking is the cause, tool calls are
the effects. Previously tools attached to the running turn directly,
making thinking and tools look like unrelated siblings — but in a
ReAct loop, the model thinks, then chooses tools (potentially in
parallel within one LLM response), then receives results, then thinks
again. The tree should mirror that:

  Turn
  ├── thinking (iteration 1)
  │   ├── tool A   ← parallel from one LLM response
  │   └── tool B
  ├── thinking (iteration 2)   ← new LLM call
  │   └── tool C
  └── response (text)

Implementation: reducer state tracks a thinking anchor.

- ExecutionTree gains currentThinkingId + thinkingSealed.
- A new turn resets both (fresh ReAct loop).
- A thinking delta with anchor empty OR sealed → mints a new thinking
  node; otherwise appends to the current one. So a thinking delta
  arriving after a tool_use creates a fresh iteration.
- A tool_use attaches under currentThinkingId when present, else falls
  back to the running turn (extended thinking disabled). It seals the
  anchor so the NEXT thinking delta starts a new node — but parallel
  tool_uses from one LLM response don't get a thinking delta between
  them, so they correctly attach to the same parent.
- A text delta also seals (response signals end of current iteration).
- Multiple thinking nodes per turn get unique ids by counting existing
  thinking descendants — first uses the original `${turnId}:thinking`,
  later iterations append a counter.
- Parallel detection still flags the turn (sidebar stats consistency)
  but checks the anchor's children, whether that's the turn or the
  thinking node.

Tests (5 new cases on top of the existing 2):
- tool_use under most-recent thinking, not the turn
- parallel tools share one thinking parent (one LLM response)
- new thinking after tool_use mints a new node (iteration boundary)
- no thinking → tools fall back to attaching to the turn
- turn boundary resets the anchor

71/71 dashboard tests green; existing thinking concatenation test
still passes (no tool_use between deltas, anchor not sealed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ings (#432)

User caught: solid edges should mean containment ("属于"), but the same
solid edges currently double as time-order between turns. Splitting
the visual semantic:

- Solid bezier (containment): parent-child relationship from the
  reducer's tree shape — session→turn, turn→thinking, thinking→tool,
  etc. Status-coloured.
- Dashed straight line (sequence): temporal order between same-rank
  siblings whose ordering is meaningful — turns under a session,
  thinking nodes under a turn (one per ReAct iteration). Muted
  neutral so it recedes behind containment edges.

Tools intentionally don't get sequence edges. Within one thinking
parent, parallel tool_uses come from a single LLM response and are
parallel by construction (no edge between them = parallel). Outside a
thinking parent, we can't tell parallel from sequential without
execution-order metadata, and a wrong sequence edge is worse than no
edge.

Implementation:
- LayoutEdge gains a `kind: 'containment' | 'sequence'` discriminator.
- useTreeLayout: dagre handles containment as before. After layout, a
  pre-order tree walk pushes a sequence edge between every pair of
  consecutive same-type siblings whose type is in SEQUENCE_TYPES
  (turn, thinking). Edge geometry is a vertical straight line from
  the bottom of `prev` to the top of `curr` (LR layout siblings stack
  vertically at the same x).
- GrowingEdge: branch on kind. Sequence variant is a straight line,
  4-4 dash, 1.25 stroke, 0.5 opacity (vs containment's bezier, solid,
  1.75 stroke, 0.85 opacity).
- Sequence edges are NOT added to dagre's input graph — that would
  push siblings into different ranks and break the LR "siblings stack
  vertically" contract.

Tests:
- useTreeLayout: dashed sequence edge between consecutive sibling
  turns; no sequence edges between sibling tools.
- treeReducer test fixup: TurnCompletedParams requires toolCount.

73/73 dashboard tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#432)

User pushed back on sibling-stacked thinkings: they wanted the actual
ReAct pipeline visible — thinking spawns tools, tool results merge
into the next thinking, last tools merge into the response. The
previous round drew thinking siblings vertically with a dashed
connector between them; this expresses ReAct as the linear pipeline
it actually is.

Layout per turn now:
  turn ── thinking1 ─┬─ toolA ─ ─ ─┐
                     └─ toolB ─ ─ ─┴─ thinking2 ─── toolC ─ ─ ─ response

Implementation in useTreeLayout:
- populateGraph skips redundant turn→thinking[i] containment edges for
  i > 0 and turn→response when any thinking exists; the flow chain
  reaches them. Without the skip, dagre would draw a long containment
  line from turn to thinking[i+1] crossing the chain visually for no
  semantic gain.
- New addReActFlowEdges adds dagre flow edges with { flow: true } label:
  - For each consecutive thinking pair under a turn: every tool of
    thinking[i] gets an edge to thinking[i+1]. Multiple parallel tools
    all merge into the next thinking, mirroring "tool results feed the
    next LLM call".
  - For the response: every tool of the final thinking gets an edge
    to response (or the final thinking itself when it had no tools).
- When iterating dagre.edges() we read the edge label and mark
  kind='sequence' for flow edges so GrowingEdge renders dashed.

Sibling-sequence (vertical dashed) now applies only to turns under a
session/step. Thinking-sibling sequence is replaced by the horizontal
flow chain.

Tests:
- chains thinking → tools → next thinking via flow edges: pins the
  3 flow edges (toolA→th2, toolB→th2, toolC→resp) and confirms the
  redundant turn→th2 and turn→resp containment edges are absent.
- existing "dashed sequence between sibling turns" test still passes
  (vertical sibling sequence preserved for turns).

74/74 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4417a48a19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +227 to 230
const result = parseEnvelopeFromObject(data);
if (!result) return;
// Cross-session filter: the bridge broadcasts every session's events
// to every connected client. A multi-tenant browser tab on its own

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Gate live envelopes until snapshot replay completes

This path dispatches live envelopes immediately, even before the first snapshot.response has been applied. If a newly-connected tab receives a newer live event first (for example eventId=120) and then receives snapshot events 1..120, the reducer watermark will already be 120, so replayed historical events are dropped by dedup and the tree can never reconstruct missing structure (session/turn roots, prior tools/text). Add a handshake state that buffers or ignores live events until initial snapshot replay for that session is finished, then apply queued live events in order.

Useful? React with 👍 / 👎.

Xinhua Gu and others added 8 commits April 29, 2026 23:16
Two UX nits the user caught while watching live execution:

1. Dashed flow/sequence edges had no direction indicator. With
   parallel tools merging into the next thinking, multiple converging
   dashes look ambiguous — readers couldn't tell which way the data
   flowed at a glance. Added an SVG <marker> with a small triangle
   (orient=auto-start-reverse so it follows the path tangent at the
   endpoint). markerEnd applied only to sequence edges; containment
   edges keep clean — the bezier shape plus left-to-right convention
   already implies direction, and arrows on every edge would be noise.

2. Auto-fit only re-ran when dagre's bounding box changed. Status-only
   updates (tool_completed, plan_step_completed) leave width×height
   alone, so the diagram could grow visible-content-wise without
   retriggering the fit. Switch the layoutSignature key from
   "WxH" to "WxH#nodeCount" so every new node refits — userControlledRef
   still gates this so manual pan/zoom locks autopilot out.

74/74 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live testing exposed the bug: userControlledRef was set to true on the
first pointer/wheel event and never reset, so a tab that had ever been
panned silently stopped auto-fitting forever — the user typed a new
prompt and watched the tree grow off-screen with no feedback.

The original gate was meant to "respect operator intent". For a static
diagram that's right; for a streaming execution tree it's wrong — the
user explicitly wants the view to follow new nodes. Drop the ref
entirely.

The new gate is simply "are we mid-drag?" — checked via dragRef
(already tracked for pan). A wheel zoom is instantaneous, so there's
nothing to interrupt; an active drag is the only state where yanking
the view would visibly fight the user's gesture. After drag release,
the next event re-fits.

74/74 dashboard tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live testing turned up that the previous behaviour was re-fitting the
WHOLE bounding box on every new node — so as the tree grew, the scale
shrank and the final response landed in a zoomed-out fit-everything
view. The user's feedback: "after response don't jump back, just stop
where you are". They want the camera to follow the action at a fixed
scale, not zoom out to keep fitting more.

Restructure:
- Initial auto-fit: runs ONCE per session on the first render with
  content. Picks a scale that shows the whole tree at that moment.
  Reset on sessionId change so picking a new session re-fits to its
  dimensions (lastFitRef tracks per-session state).
- Auto-scroll: pans to the active node whenever activeNodeId or the
  layout.nodes array identity changes. Scale stays where the initial
  fit left it — no rescale per event. Once the turn completes and
  activeNodeId stops moving, the camera sits on the last active node
  and stays put. That's the "stop after response" behaviour.

The dragRef gate stays — pan-mid-gesture still skips both effects so
the view doesn't fight the user's drag.

74/74 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…432)

Two visual fixes the user caught while watching live execution:

1. Thinking node never stopped pulsing. The reducer minted thinking
   nodes with status='running' but had no transition to 'completed' —
   tools manage their own status via stream.tool_completed, but
   thinking is delta-only with no explicit "ended" event. The pulse
   glow stayed on forever, even after the turn moved to tool execution
   or response.

   Fix: thinking transitions to 'completed' the moment something seals
   the anchor. addToolNode and appendTextToCurrentTurn now flip
   currentThinkingId.status from running to completed before returning
   the new state. completeTurnNode also sweeps any STILL-running
   thinking/text descendants on turn close (covers the rare case where
   the turn ends without ever firing a tool_use or text after a
   thinking — the sweep makes sure no node is stuck pulsing past the
   turn boundary).

2. Thinking node looked the same as tools at a glance — both share the
   default blue/green status palette, so the eye couldn't tell model
   reasoning from tool work. Added a TYPE_BG / TYPE_BORDER override
   for thinking: indigo/violet palette (running=#5b21b6 violet-800,
   completed=#1e1b4b indigo-950 muted) with matching borders. Other
   types fall through to the existing status palette unchanged.

Tests:
- thinking marked completed on tool_use seal
- thinking marked completed on text seal
- remaining running thinking/text swept on turn_completed (the no-tool,
  no-text edge case)

77/77 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Response (text) nodes shared the default green completed-state colour
with finished tools, so the eye couldn't tell where in the diagram
the model's actual answer landed. Rose/pink palette gives them their
own visual identity (pink-700 while streaming, pink-900 muted at
rest) — distinct from tools (blue/green) and thinking (purple).

Now the three node-type cues read at a glance:
  - tool:     blue → green   (the workhorse default)
  - thinking: indigo/violet  (model reasoning)
  - response: rose/pink      (final answer)

77/77 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pink response was a clash, not a system. Replace with amber so all
three node types form one harmonious jewel-tone family at matched
depth (Tailwind 700/800 running, 900/950 completed):

  - tool:     blue → green   (workhorse default)
  - thinking: indigo/violet  (cool jewel — model reasoning)
  - response: amber/gold     (warm jewel — final answer)

Hue separates type; brightness separates running vs done. The pink
broke that system because it was at the same warm pole as amber would
be — picking one warm hue (amber) and one cool (indigo/violet) makes
the contrast intentional rather than noisy.

77/77 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dark jewel-tone backgrounds (700-800 fills) read heavy and clashy.
Switch to the modern dashboard idiom: every node is a tinted-glass
card — accent colour at low fill opacity, full-opacity accent border,
running pops via higher alpha. Looks closer to Linear/Vercel/Notion
dark mode — light, airy, unified.

Palette is now Tailwind 200-400 range (pastel) instead of 700-900
(jewel):

  - tool:     blue-400 / green-400  (sky → fresh)
  - thinking: violet-400 / 300       (lavender, paler when sealed)
  - response: amber-400 / 200        (gold, paler when done)

Status now reads via TWO contrasts on the same dark base:
  1. hue family (which TYPE)
  2. fill alpha (running 0.22 vs completed 0.12) AND palette shift
     (running brighter, completed muted)

Both signals pull in the same direction so a quick glance can tell
type AND state. Border stays full-opacity accent so node outlines
remain crisp regardless of fill alpha.

77/77 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The completed-state checkmark was hardcoded to a single green
regardless of node type, so a finished thinking and a finished
response both showed the same green ✓ — fighting the type-coloured
border the rest of the node carries. Pull the icon colour off the
node's accent palette via a new optional `color` prop on StatusIcon
that overrides the default per-status colour. GrowingNode now passes
its computed accent down, so:

  - completed tool      → green ✓ (unchanged — its accent IS green)
  - completed thinking  → violet ✓
  - completed response  → amber ✓

The icon and the border now read as one piece, and the eye sees
"this whole box is type X, in state Y" rather than two competing
colour languages.

77/77 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3748cd4def

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +807 to +813
// Drop the snapshot buffer for this session (issue #432). The
// session_ended event was just buffered above, so a tab that
// opens between this point and the buffer being cleared still
// gets a snapshot containing session_ended; afterward the
// session is gone from sessionManager, so sessions.list won't
// surface it and snapshot.request returns an empty list.
bridge.clearSession(session.id());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep session snapshot until reconnect can replay end event

Clearing the session buffer immediately after broadcasting stream.session_ended makes snapshot.request effectively return empty for that session in almost all reconnect/refresh cases, because the clear happens synchronously right after broadcast. If a tab disconnects around session shutdown and misses the live stream.session_ended, it cannot recover that terminal event via snapshot replay and may keep rendering the session as still running indefinitely. Retain the buffer at least until clients have a chance to reconnect (e.g., TTL or delayed clear) so the end-state can be replayed.

Useful? React with 👍 / 👎.

Xinhua Gu and others added 2 commits April 29, 2026 23:50
Two activeNodeId problems chained into one bad UX after turn close:

1. appendTextToCurrentTurn never updated activeNodeId, so the camera
   stayed centred on the LAST tool while the model was streaming the
   response — user couldn't see the response form.

2. completeTurnNode bubbled activeNodeId up to the turn's parent
   (session/step) on close, so the moment the turn ended the auto-
   scroll yanked the camera to the session box. The response had
   just finished streaming, then the user lost it. The complaint was
   "response 结束就结束, don't jump back".

Fix:
- appendTextToCurrentTurn sets activeNodeId to the response node id
  (newly minted on first delta, or the existing one on subsequent
  deltas). Camera now follows the response while it streams; once
  the response stops growing, activeNodeId stops changing and the
  camera sits there.
- completeTurnNode no longer touches activeNodeId. The leaf the
  user was watching (last tool, or response) stays the focus point
  through and after the turn close. The auto-scroll re-fires on the
  layout-change tick from the turn flipping to completed, but its
  target is the same leaf so the pan is a no-op.

77/77 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex P1 on PR #448: a live envelope arriving on the socket BEFORE
the snapshot.response advances the reducer's lastEventId watermark
past every snapshot envelope. The snapshot replay then gets entirely
dedup'd by that watermark, leaving the tree with no structural roots
(session/turn/tool) and the user staring at an empty diagram even
though the daemon shipped the snapshot correctly.

Race window: client connects, sends snapshot.request; daemon's
broadcast (in another Jetty thread) calls client.send for an
unrelated live event before the inbound handler's ctx.send for the
snapshot reply. WS-level write ordering is by send-call order, so
either can win — small window but real.

Fix: snapshot handshake gate.

- Two ref-scoped state vars in the WS effect: snapshotPending: bool
  and pendingLive: envelope[]. Reset on every reconnect (fresh
  socket = fresh handshake).
- onopen sets snapshotPending=true and clears the queue, then sends
  snapshot.request.
- onmessage: live envelopes during pending are pushed to the queue
  instead of dispatched. snapshot.response handler runs the snapshot
  events through the reducer, then flushes the queue in arrival
  order — the reducer's existing eventId<=lastEventId dedup correctly
  drops queued events that fell inside the snapshot's range, while
  newer ones apply.
- Unknown (forward-compat) live events during pending are skipped
  outright — they'd otherwise advance lastEventId via the watermark
  shortcut and poison the snapshot replay.

Test (useExecutionTreeSnapshotGate.test.tsx): mocks WebSocket via
vi.stubGlobal so the test drives message ordering directly. Three
cases: queued live event with eventId>snapshot's range survives
flush; queued live event whose eventId is inside snapshot range gets
dedup'd; post-snapshot live events flow without re-gating.

80/80 dashboard tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xinhuagu xinhuagu merged commit c20aee1 into main Apr 29, 2026
6 of 7 checks passed
@xinhuagu xinhuagu deleted the feat/432-snapshot-api branch April 29, 2026 22:04

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57b5e2cc1c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +282 to +284
if (snapshotPending) {
pendingLive.push(result.envelope);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add a timeout fallback for missing snapshot responses

When the socket opens, snapshotPending is set to true and only cleared inside the snapshot.response handler. If that response is never delivered (e.g. daemon error path, mixed-version daemon without snapshot.request, or a malformed reply), every known live envelope is appended to pendingLive and never dispatched, so the tree stays blank while the queue grows unbounded. Please add a bounded fallback (timeout or max queue + fail-open) so live events can resume even if snapshot replay fails.

Useful? React with 👍 / 👎.

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.

feat(daemon): Session state snapshot API for late-joining WebSocket clients

1 participant