fix(replay): route step-action buttons through selected timeline + dispatch carries at_step#163
Conversation
…spatch carries at_step Two related bugs surfaced in dev1 testing on session ray-agent-85551571: Bug A — Three step-action buttons (Fork, Set up replay, Run replay) sourced their action from `step.timeline_id` (the step's physical owner) instead of the user's selected timeline. Same class of bug PR #161 fixed for the Edit button. Live repro: forked at step 1, edited the user message, clicked Run replay → resulting fork was parented to main, not to the user's edit fork. The agent re-ran the original question instead of the edited one. Fix: use the existing `contextTimelineId` (selectedTimelineId ?? rootTimelineId ?? step.timeline_id) for all three modal props. Bug B (server + SDK side) — The dispatch payload doesn't carry `at_step`, so runners can't drive multi-turn replay. The runner handler in ray-agent today re-asks the session's first user message; for sessions with multiple conversation turns, edits to user messages in turn 2+ are silently ignored. Fix (this PR — server + SDK only): the rewind dispatch payload now includes `at_step` (sourced from the timeline's `fork_at_step` = the user-clicked step). The SDK's DispatchPayload mirrors the new field. The companion ray-agent PR will use it to fetch source timeline steps 1..at_step-1, reconstruct conversation history, and invoke the agent at the right turn. Server changes: - crates/rewind-web/src/dispatcher.rs — DispatchBody adds at_step:u32 + docstring + wire-format example - crates/rewind-web/src/runners.rs — create_replay_job resolves at_step (NewContext: from request; ReuseContext: from timeline's fork_at_step), passes through to dispatcher.dispatch SDK changes: - python/rewind_agent/runner.py — DispatchPayload adds at_step:Optional[int]=None with docstring; from_json reads it with .get() for back-compat (older servers default to None) Frontend changes: - web/src/components/StepDetailPanel.tsx — three step.timeline_id references replaced with contextTimelineId Tests: - replay_jobs_tests: +2 (dispatch_payload_carries_at_step for shape A, dispatch_payload_at_step_for_reuse_context_reads_fork_at_step for shape B) - test_runner.py: +3 (decodes_at_step, defaults_to_none_for_back_compat, tolerates_extra_unknown_keys — pins forward-compat for older runners hitting newer servers) - StepDetailPanel.test.tsx: +3 (Fork/ReplaySetup/ReplayJob receive selectedTimelineId when set); existing fallback test (data-tid = step.timeline_id when selectedTimelineId is null) kept as the fallback-path coverage Counts: rewind-web 22 replay_jobs_tests (was 20), 68 recording tests (unchanged), web 186 (was 183), python 32 (was 29). Track 1 version bump 0.14.7 → 0.14.8 across the 5 files. Track 2 (rewind-agent SDK) 0.15.1 → 0.15.2, MCP 0.13.6 → 0.13.7. Docs: docs/runners.md gains a "Dispatch wire format" section documenting at_step + the timeline-context contract for the four step-action buttons. Companion PR (out of scope, tracked as follow-up): ray-agent runner reads payload.at_step, reconstructs conversation history, invokes agent at the right turn. Made-with: Cursor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
risjai
left a comment
There was a problem hiding this comment.
Review: fix(replay): route step-action buttons through selected timeline + dispatch carries at_step
Clean PR that fixes both bugs from the plan. Bug A is a 3-line frontend fix (same pattern as #161). Bug B's server+SDK side is well-structured with proper back-compat handling. Test coverage is thorough: 2 server, 3 SDK, 3 frontend.
Findings
| # | Severity | Finding |
|---|---|---|
| 1 | Low | Docstring on DispatchBody.at_step says from_step is "always 0" — misleading |
| 2 | Low | Shape B resolves at_step from timeline.fork_at_step, not from replay_context.from_step |
| 3 | Info | HMAC paragraph correctly removed from risk section |
| 4 | Info | Frontend tests use useStore.setState with afterEach cleanup — good pattern |
L1 — Docstring says from_step is "always 0"
dispatcher.rs line 83-86:
Distinct from the replay context's `from_step` which is always 0
(the agent re-runs from scratch).
from_step is NOT always 0. It's set to the user's at_step when the replay context is created (see create_replay_context which takes body.from_step). The confusion is between the replay cursor (starts at 0 and advances) vs the replay fork point (the step the user clicked). The docstring should read: "Distinct from the replay context's from_step which tracks where the cursor starts in the replay-lookup ordinal sequence."
L2 — at_step source inconsistency between shape A and B
Shape A uses a.at_step directly (from the request body — the step the user clicked). Shape B resolves it from timeline.fork_at_step:
let at_step = store.get_timelines(&session.id)...
.find(|t| t.id == ctx.timeline_id)
.and_then(|t| t.fork_at_step)
.unwrap_or(1);In practice, fork_at_step == the original at_step used to create the fork, so they're equal. But the plan review suggested resolving from the replay context's from_step (the caller already has the context in ctx). Using ctx.from_step would be more direct and avoid the extra get_timelines scan:
let at_step = ctx.from_step;Not blocking — the current code is correct. Just a slightly longer path to the same value.
I3 — Wire-format section
The PR body correctly documents: "No HMAC concern: the server signs the wire bytes; the runner verifies HMAC(secret, raw_body) == header." This is the corrected understanding from the plan review. Good.
I4 — Frontend test pattern
The useStore.setState({ selectedTimelineId: 'tl-fork' }) + afterEach cleanup pattern is clean and avoids leaking state between test suites. The existing fallback test (no selectedTimelineId set → falls back to root → data-tid = tl-main) is preserved. Good.
What I verified
- The three
step.timeline_id→contextTimelineIdsubstitutions at lines 256, 265, 272 are exactly the right changes contextTimelineIdis at component scope (line 39), so all three modal props can reference itDispatchBody.at_stepisu32(non-optional on the server — always known), while the SDK'sDispatchPayload.at_stepisOptional[int](back-compat for older servers). Correct asymmetry.- The
spawn_runner_stub_capturing_bodytest helper captures raw bytes so assertions can verify the exact wire payload - Both shape A and shape B dispatch tests assert
at_stepin the payload - Forward-compat test (
tolerates_extra_unknown_keys) pins the contract that lets future field additions not break deployed runners - Version bumps: Track 1 (0.14.7→0.14.8), Track 2 (0.15.1→0.15.2), MCP (0.13.6→0.13.7) — all correct
Verdict
Ship it. The two LOWs are docstring/style — no correctness impact. The PR cleanly closes Bug A and ships the wire-format foundation for Bug B's runner-side fix. After this merges, the ray-agent companion PR can consume payload.at_step for multi-turn replay.
…#163 L1) Made-with: Cursor
|
Thanks for the review. Folded L1 in as On L2 — I'd push back.
Both are bigger changes for the same observable behavior. Happy to revisit if a column migration becomes worth it for other reasons. CI re-running on the new commit. Will merge once green. |
Summary
Two related bugs surfaced in dev1 testing on session
ray-agent-85551571:Bug A — Three step-action buttons (Fork, Set up replay, Run replay) sourced their action from
step.timeline_id(the step's physical owner) instead of the user's selected timeline. Same class of bug PR #161 fixed for the Edit button.Bug B (server + SDK side) — The dispatch payload doesn't carry
at_step, so runners can't drive multi-turn replay. Today the runner re-asks the session's first user message; for sessions with multiple conversation turns, edits to user messages in turn 2+ are silently ignored.Scope
Server changes
crates/rewind-web/src/dispatcher.rs—DispatchBodyaddsat_step: u32+ docstring + wire-format examplecrates/rewind-web/src/runners.rs—create_replay_jobresolvesat_step(NewContext: from request; ReuseContext: from timeline'sfork_at_step), passes through todispatcher.dispatchSDK changes
python/rewind_agent/runner.py—DispatchPayloadadds `at_step: Optional[int] = None` with docstring; `from_json` reads it with `.get()` for back-compat (older servers default to None)Frontend changes
web/src/components/StepDetailPanel.tsx— three `step.timeline_id` references replaced with `contextTimelineId` (the existing fallback chainselectedTimelineId ?? rootTimelineId ?? step.timeline_idalready computed at component scope for the Edit hook)Tests
dispatch_payload_carries_at_stepfor shape A,dispatch_payload_at_step_for_reuse_context_reads_fork_at_stepfor shape B). Mock runner stub captures the body and assertsat_stepmatches.decodes_at_step,defaults_to_none_for_back_compat,tolerates_extra_unknown_keys— pins forward-compat for older runners hitting newer servers)selectedTimelineIdwhen set). Existing fallback test (data-tid = step.timeline_id when selectedTimelineId is null) kept as the fallback-path coverage.cargo test rewind-web --test replay_jobs_testscargo test rewind-web --test recording_api_testscargo test rewind-replay --libnpm test(web)pytest tests/test_runner.pyWire-format change implication
Adding
at_steptoDispatchBodyis a wire-format addition. No HMAC concern: the server signs the wire bytes; the runner verifiesHMAC(secret, raw_body) == header. Both sides use the same bytes, so the new field is signed and verified consistently. Older runner SDKs tolerate the new field (Pythonfrom_envelopeignores unknown keys — pinned by the new test).Docs
docs/runners.mdgains a "Dispatch wire format" section documentingat_stepsemantics + the timeline-context contract for all four step-action buttons.Out of scope (companion ray-agent PR)
After this PR ships, a separate small PR in
agentoptics/ray-agent:use-cases/ray-agent/src/serve/rewind_replay.pyhandler: whenpayload.at_step > 1, fetch source timeline steps1..at_step-1from rewind, reconstructconversation_historyfrom each step'srequest_body.messages, extract the user message atat_stepas the new question, invokereact_agent.run(question=..., conversation_history=..., ...).Version bump
Track 1 (rewind workspace + CLI binary): 0.14.7 → 0.14.8 across
Cargo.toml,Cargo.lock,python/rewind_cli.py,python-mcp/pyproject.toml,python-mcp/rewind_mcp_cli.py.Track 2 (rewind-agent SDK on PyPI): 0.15.1 → 0.15.2 (
python/pyproject.toml,python/rewind_agent/__init__.py).Track 3 (rewind-mcp): 0.13.6 → 0.13.7 (per-PR convention).
Test plan
cargo build -p rewind-webcleancargo test -p rewind-web --test recording_api_tests68/68cargo test -p rewind-web --test replay_jobs_tests22/22cargo test -p rewind-replay --lib13/13npm test186/186pytest tests/test_runner.py32/32at_step, dashboard's Run replay button on a fork dispatches against the fork (not main)Made with Cursor