Skip to content

feat(chat): stream-lifecycle Zustand store + pure reducer (LUM-1743 phase 1)#31354

Merged
ashleeradka merged 4 commits into
mainfrom
claude/lum-1743-LfmiK
May 20, 2026
Merged

feat(chat): stream-lifecycle Zustand store + pure reducer (LUM-1743 phase 1)#31354
ashleeradka merged 4 commits into
mainfrom
claude/lum-1743-LfmiK

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Summary

Phase 1 of LUM-1743 — codify the implicit state machine spread across use-event-stream.ts, use-message-reconciliation.ts, and use-stream-event-handler.ts (plus the four shared refs streamRef / streamEpochRef / streamContextRef / reconcileAfterNextStreamOpenRef) as an explicit, typed lifecycle.

Pure addition. No wiring. The three legacy hooks are untouched and chat-page.tsx is unchanged. Phases 2 (useStreamLifecycle hook + fault-injection tests) and 3 (migrate chat-page.tsx, delete legacy hooks) follow.

States

closed | opening | open | waiting | reconciling | retrying

Events

OPEN_REQUEST | OPEN_SUCCESS | OPEN_FAILURE | RECONCILE_REQUEST | RECONCILE_SUCCESS | RECONCILE_FAILURE | CLOSE_REQUEST | APP_LIFECYCLE_CHANGE

(EVENT_RECEIVED was in the issue sketch but is intentionally omitted — it does not drive phase transitions; the stream-event-handler routes events on its own. Add later if a real need arises.)

Convention notes

Per the state-management updates landed today, the file follows the turn-store.ts pattern exactly:

  • Zustand store, direct named actions (requestOpen, onOpenSuccess, onOpenFailure, requestReconcile, onReconcileSuccess, onReconcileFailure, requestClose, onAppLifecycleChange). No dispatch(event) — that's the Redux-holdover anti-pattern called out in CONVENTIONS.md.
  • Pure streamLifecycleReducer exported alongside as a parallel implementation tests exercise in isolation, mirroring how turnReducer is tested.
  • Wrapped with createSelectors for atomic .use.field() selectors. No useShallow in new code.
  • Co-located .test.ts using bun:test, structured like turn-store.test.ts (applyEvents helper, one describe per event, plus canonical end-to-end sequences).

Concrete fixes the design encodes

(Delivered when the hook lands in phase 2 — phase 1 is the state-machine substrate.)

  • Single backoff counter (consecutiveFailures) replaces the brittle burst-limiter refs. Resets only on OPEN_SUCCESS.
  • State-gated app-lifecycle dedup: one APP_LIFECYCLE_CHANGE event with { source, online } replaces the 1 s clock-based dedup with separate refs for visibilitychange vs Capacitor appStateChange. The reducer dedups by phase, eliminating the cross-source race.
  • Reconcile-before-reopen ordering enforced at the state-machine level: open → RECONCILE_REQUEST → reconciling → RECONCILE_SUCCESS → opening. Fixes the "premature stream reopen when conversationExistsOnServer flips" bug.
  • Epoch ownership: monotonic counter bumped on every new open attempt; stale OPEN_SUCCESS / OPEN_FAILURE callbacks from prior streams are dropped by the reducer.

Test coverage

54 tests, 138 assertions. Covers:

  • Initial state + derived predicates (isStreamOpen, isStreamConnecting, isStreamPaused)
  • Each event from each valid origin phase
  • No-op guards (stale epochs, wrong phase, same-context dedup)
  • Canonical sequences: happy path, transient failure + recovery, background + resume, conversation switch, stale-callback-after-teardown, unmount during reconciling

Test plan

  • bun test src/domains/chat/stream-lifecycle-store.test.ts — 54 pass
  • bunx tsc --noEmit — clean
  • bun run lint on new files — clean
  • No production code path consumes the store yet; runtime smoke test is deferred to phase 2

https://claude.ai/code/session_018S9mQ5rmtUAvixYCcmXJaZ


Generated by Claude Code

…43 phase 1)

Codifies the implicit state machine currently spread across
use-event-stream.ts, use-message-reconciliation.ts, and
use-stream-event-handler.ts as an explicit, typed lifecycle:
closed | opening | open | waiting | reconciling | retrying.

Phase 1 is purely additive — no wiring yet. The store follows the
turn-store.ts convention (direct named actions on the Zustand store,
pure streamLifecycleReducer exported alongside for unit-testable
transitions). 54 tests cover every transition.

Concrete fixes the design encodes (delivered when the hook lands in
phase 2):

- single backoff counter (consecutiveFailures) instead of brittle
  burst-limiter refs; resets only on OPEN_SUCCESS
- state-gated dedup for visibility/app_state/reachability via one
  APP_LIFECYCLE_CHANGE event, replacing the 1s clock-based dedup
  with separate refs for each signal source
- reconcile-before-reopen ordering enforced by the reducer
  (RECONCILE_REQUEST → reconciling → RECONCILE_SUCCESS → opening)
- epoch is owned by the store and bumped on every new open attempt;
  stale OPEN_SUCCESS / OPEN_FAILURE callbacks are dropped

https://claude.ai/code/session_018S9mQ5rmtUAvixYCcmXJaZ
@linear
Copy link
Copy Markdown

linear Bot commented May 20, 2026

LUM-1743

@ashleeradka ashleeradka marked this pull request as ready for review May 20, 2026 21:45
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

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

APPROVE

Value: Codifies the implicit stream lifecycle state machine that was scattered across three hooks and four mutable refs into a single, typed, fully-tested Zustand store. This eliminates the undocumented order dependencies and cross-source dedup races that phase 2 (the actual hook wiring) will inherit. Phase 1 is pure addition — no production wiring yet, so the existing code path is unchanged.


What this does

Two files, both net-new (no migrations):

stream-lifecycle-store.ts (434 lines)

  • Zustand store + pure reducer for stream network lifecycle: closed → opening → open → [waiting|retrying] → reconciling → opening with APP_LIFECYCLE_CHANGE signals dedup'd by phase
  • Six phases, eight event types, fully typed (DomainEvent union)
  • Follows canonical pattern from turn-store (via CONVENTIONS.md §Turn state): Flux-inspired action naming (requestOpen, onOpenSuccess, etc.), no dispatcher, wrapped with createSelectors for atomic .use.phase() selectors
  • Pure reducer exported separately — tests exercise state transitions without touching the Zustand store
  • Encodes three concrete fixes:
    1. Single backoff counter (consecutiveFailures) replaces four undocumented ref mutations
    2. State-gated app-lifecycle dedup (by phase, not 1s clock) eliminates cross-source race where visibilitychange and Capacitor appStateChange both trigger within the same 1s window
    3. Reconcile-before-reopen ordering enforced by the state machine itself (phase guards on RECONCILE_SUCCESS → opening)

stream-lifecycle-store.test.ts (737 lines)

  • 54 tests, 138 assertions
  • Covers: initial state + derived predicates, each event from each valid origin phase, no-op guards (stale epochs, wrong phase, same-context dedup), canonical sequences (happy path, failure + recovery, background + resume, conversation switch, stale callbacks, unmount-during-reconcile)
  • Structured like turn-store.test.ts: helpers (applyEvents, state constructors like openedState()) + one describe block per event

Code analysis

Zustand patterns — all correct:

  • createSelectors wraps the base store — consumers will call useStreamLifecycleStore.use.phase() atomically, no useShallow needed
  • ✅ Direct named actions (requestOpen, onOpenSuccess, etc.) — no dispatcher/event-dispatch anti-pattern
  • ✅ Module-level singleton created with create(), not inside a hook or provider
  • ✅ Pure reducer exported separately and tested in isolation — store implementation (set() callbacks) mirrors the reducer exactly
  • ✅ All actions return s (the unchanged state) on no-op conditions, avoiding unnecessary store updates

Reducer logic — clean and bulletproof:

  • ✅ Epoch-tagging on callbacks (OPEN_SUCCESS / OPEN_FAILURE tag the epoch and the reducer drops stale ones): if (event.epoch !== state.epoch) return state; appears before any side effects
  • ✅ Same-context dedup guards: sameContext(s.context, event.context) check before transitioning from opening/open on OPEN_REQUEST
  • ✅ Phase-gated state transitions: OPEN_FAILURE only applies in opening | open; RECONCILE_SUCCESS only applies in reconciling; APP_LIFECYCLE_CHANGE dedup is implicit (offline from waiting/retrying returns unchanged state)
  • ✅ Epoch bumps at the right moments: OPEN_REQUEST from different context, OPEN_REQUEST from opening when context changed, OPEN_REQUEST from waiting or retrying, APP_LIFECYCLE_CHANGE offline while opening (aborts in-flight), RECONCILE_SUCCESS (fresh open epoch)
  • consecutiveFailures increments on both OPEN_FAILURE and RECONCILE_FAILURE, resets on OPEN_SUCCESS, preserved across CLOSE_REQUEST (so retry counter is available on reopen)

Derived predicates:

  • ✅ Three exports (isStreamOpen, isStreamConnecting, isStreamPaused) with precise phase semantics
  • ✅ Names are clear and match the semantic intent

Type safety:

  • ✅ Discriminated union for DomainEvent — type checker ensures every event handler in the reducer covers its payload
  • StreamContext properly typed (two required fields)
  • StreamLifecyclePhase and StreamLifecycleState exported for consumers and tests
  • ✅ Action interface (StreamLifecycleActions) fully typed with correct parameter shapes

Test coverage:

  • ✅ All six phases represented in test state constructors
  • ✅ Each event tested from every valid origin phase AND invalid origin phases (no-op guards verified)
  • ✅ Epoch staling tested explicitly: ignores stale epoch (callback from a prior open attempt)
  • ✅ Same-context dedup tested: dedups same-context request while opening and while open
  • ✅ Conversation switch tested: conversation switch while open re-opens with bumped epoch
  • ✅ App lifecycle dedup tested across sources: dedup across sources: a second offline signal within the same paused phase is a no-op
  • ✅ Canonical failure + recovery: accumulates consecutiveFailures across repeated failures
  • ✅ Phase-specific entry/exit (e.g., RECONCILE_REQUEST from reconciling is a no-op)

No anti-patterns found:

  • ✅ No inline useShallow in component render (this is a store file, not a component)
  • ✅ No Zustand for server state (this is pure client state)
  • ✅ No setState({}, true) with partial state (not relevant here — reducer logic is correct)
  • ✅ No unstable fallback values
  • ✅ No bare useStore() call (only the export with createSelectors)

Documentation:

  • ✅ Excellent module docstring explaining phases, events, and the replacement of four scattered refs
  • ✅ Per-field docstrings in StreamLifecycleState explaining the purpose of each field (epoch, consecutiveFailures, context, lastError)
  • ✅ Action descriptions in the interface
  • ✅ JSDoc references to Zustand docs

Test structure:

  • ✅ Helpers (applyEvents, state constructors) are clear and DRY
  • ✅ Test names are descriptive (e.g., conversation switch while open re-opens with bumped epoch)
  • ✅ Assertions are specific (checking both phase AND epoch AND consecutiveFailures where relevant)

Non-blocking observations

  1. Test coverage depth: 54 tests for 8 event types × ~6 phases is solid. If phase 2 wiring reveals a gap in real-world usage, revisit then (e.g., a scenario where two APP_LIFECYCLE_CHANGE events arrive microseconds apart in a specific phase).

  2. Error message preservation: lastError is captured but the store doesn't expose a way to clear it explicitly (only via OPEN_REQUEST or RECONCILE_SUCCESS). This is fine for now — phase 2 will wire the hook to decide when to call requestClose, which doesn't clear lastError. If the UI later needs to show a toast from lastError, ensure the message survives teardown until explicitly dismissed.

  3. App-lifecycle source tracking: The source field in APP_LIFECYCLE_CHANGE is tracked in the event but the state doesn't preserve it (only the dedup-gating phase logic uses it). This is intentional and clean — the reducer cares about online not source. Non-blocking, but worth noting if phase 2 needs to log which source triggered a state change.

  4. Reconcile without context: RECONCILE_REQUEST doesn't require a context and will work even if context is null (e.g., reconcile from closed will no-op). This matches the design that reconcile is a precursor to reopen, not an open itself. Clean.


Production readiness

  • ✅ Tests pass (bun test explicit in PR body)
  • ✅ TypeScript strict (bunx tsc --noEmit — confirmed clean)
  • ✅ ESLint clean (bun run lint)
  • ✅ No production code calls the store yet (phase 2 wires it)
  • ✅ Phase 2 / phase 3 roadmap documented in PR body

Merge gate:

  • ✅ Vex approves (this review)
  • ⏳ Awaiting bot review (Devin / Codex). Since this is Boss's PR: one human approval is sufficient if bots are clean, but bots haven't reviewed yet. Suggest @codex review + @devin review this PR before merge if not already triggered.

Vellum Constitution — Trust-seeking: the state machine is fully explicit, testable in isolation, and free of undocumented ref mutations or clock-based races. Every transition is phase-gated and every callback is epoch-tagged.

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 20, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

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

APPROVE

Value: Replaces four undocumented shared refs (streamRef, streamEpochRef, streamContextRef, reconcileAfterNextStreamOpenRef) and the implicit state machine sprawled across three hooks with one explicit, typed, epoch-guarded lifecycle store. Pure addition — no consumer changes, no regressions possible. Phase 2 (hook wiring) and Phase 3 (legacy hook deletion) follow.


Pattern compliance — all correct:

  • createSelectors(useStreamLifecycleStoreBase) ✅ — canonical pattern; consumers will call .use.phase(), .use.epoch() etc.
  • Module-level singleton ✅ — not inside a component or provider
  • INITIAL_STREAM_LIFECYCLE_STATE exported ✅ — enables setState(INITIAL_STATE, true) reset in tests and on mount
  • Functional set(s => ...) with reference-stable no-op returns (return s) ✅ — essential for Zustand; same-reference return means no re-renders on no-ops
  • No useShallow in new code ✅

Store ↔ reducer parity: Each action delegates to the same logic as its switch case in the pure reducer. Spot-checked all 8 branches — Zustand's merge semantics (partial return from set(fn) = shallow merge) and the reducer's { ...state, ... } spread are semantically equivalent throughout.

Epoch design is correct. Stale callback rejection (event.epoch !== s.epoch) fires in OPEN_SUCCESS, OPEN_FAILURE. APP_LIFECYCLE_CHANGE going-offline while opening bumps epoch to abort the in-flight fetch — this is the right fix for the pre-existing race where a slow open callback could resolve after a forced close. RECONCILE_SUCCESS also bumps epoch so any pending open callbacks from before the reconcile are discarded.

Test coverage: 47+ cases across all 8 event types, all 6 phases, all derived helpers, and 11 canonical end-to-end sequences — including the edge cases that matter most: stale epoch discard, same-context dedup, multi-source offline dedup, backoff counter reset, and unmount-during-reconcile. The applyEvents helper and state-builder functions mirror turn-store.test.ts exactly.


Non-blocking notes:

  1. Test name vs behavior mismatch — the test "from reconciling: open with same context is a no-op (let reconcile complete first)" asserts phase === "opening" with a bumped epoch (not a no-op). The inline comment inside the test correctly explains the actual behavior ("explicit open overrides any pending reconcile"). The test name is a relic of an earlier design sketch. Consider renaming to "from reconciling: OPEN_REQUEST overrides pending reconcile and transitions to opening" before phase 3 when test file churn is higher.

  2. requestClose ignores source parameter. CloseRequest["source"] is declared in the action signature but the implementation uses () => (underscore not present, just ignored). Fine for now — source is in the domain event for future observability — but worth a (_source) prefix for clarity, matching the onAppLifecycleChange: (_source, online) pattern used in the same file.

Phase 2 flag (not blocking phase 1): When useStreamLifecycleStore is wired into ChatPage, a useLayoutEffect reset to INITIAL_STREAM_LIFECYCLE_STATE on mount is required — otherwise stale consecutiveFailures or a non-closed phase from a prior session persist across navigations. KB reference: anti-patterns-web.md useEffect for Zustand resets; the ChatPage reset in PR #31144 was useEffect (wrong — fires after paint) — Phase 2 should use useLayoutEffect. Also make sure the INITIAL_STREAM_LIFECYCLE_STATE reset clears lastError and context, not just phase.


No bot reviews yet at HEAD. Suggest @codex review + @devin review this PR for second approval signal.

- rename misleading test name (asserts opening, not no-op) per Vex review
- prefix unused _source param in requestClose to match
  onAppLifecycleChange(_source, online) style

https://claude.ai/code/session_018S9mQ5rmtUAvixYCcmXJaZ
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

…ocstrings

Adds a STYLE_GUIDE rule: comments and docstrings describe what the
code is and does now, not what it replaced. Migration narrative
belongs in PR descriptions and commit messages — it rots immediately
and adds nothing for a reader who never saw the prior code.

Applies the rule to stream-lifecycle-store.ts: rewrites the module
docstring, the epoch / consecutiveFailures field docstrings, and a
couple of action and test comments to describe current behavior
instead of referencing the use-event-stream.ts refs they replace.

https://claude.ai/code/session_018S9mQ5rmtUAvixYCcmXJaZ
@ashleeradka ashleeradka merged commit c065ae3 into main May 20, 2026
7 checks passed
@ashleeradka ashleeradka deleted the claude/lum-1743-LfmiK branch May 20, 2026 22:04
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.

2 participants