Skip to content

feat(bichat): resilient run client — request_id, text blocks, active-run SSE#24

Merged
diyor28 merged 5 commits into
mainfrom
feat/bichat-resilient-runs-732
Apr 15, 2026
Merged

feat(bichat): resilient run client — request_id, text blocks, active-run SSE#24
diyor28 merged 5 commits into
mainfrom
feat/bichat-resilient-runs-732

Conversation

@diyor28

@diyor28 diyor28 commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Client companion to iota-uz/iota-sdk#747 (resilient bichat generation pipeline, #732). Pure additions to `@iota-uz/sdk/bichat`; no wire break. All features degrade gracefully when the backend doesn't implement the matching endpoints (older SDK commits).

Changes

1. Idempotent send via `request_id`

  • Every `POST /bi-chat/stream` now carries a client-generated `requestId`.
  • Backend's SetNX dedupe collapses duplicate sends (double-click / cross-tab) onto a single run within the ~30 min window.
  • Callers can override via `SendMessageOptions.requestId` for deterministic retries; otherwise auto-minted per call via `crypto.randomUUID` with a Math.random fallback for older WebViews.

2. Cursor-based reconnect via native EventSource

  • New `MessageTransport.subscribeRunEvents` + `HttpDataSource.subscribeRunEvents` open an EventSource against `GET /bi-chat/stream/events?sessionId&runId`.
  • Browser handles reconnect + `Last-Event-ID` automatically on wifi drops / tab sleep.
  • We listen for every documented server event type (`content` / `tool_start` / `tool_end` / `text_block_end` / `snapshot` / `interrupt` / `usage` / `done` / `error` / `stream_started`) and settle the returned promise on a terminal event.
  • Callers abort via an `AbortSignal` to close the EventSource on unmount.

3. Intermediate text blocks

  • Added `text_block_end` to `StreamChunk` / `StreamEvent` / `sseParser`.
  • New `utils/textBlocks.ts` exports:
    • `splitIntoTextBlocks(content, offsets)` — split accumulated assistant content at server-emitted byte offsets into ordered `AssistantTextBlock[]`.
    • `readTextBlockOffsets(partialMetadata)` — safely pull the `text_block_offsets` array out of a snapshot's `partialMetadata`.
  • Components can render `text → tool → text → tool → final_text` as distinct blocks instead of one merged paragraph.

4. Sidebar status fan-out

  • New `ActiveRunDelivery` type + `subscribeActiveRuns` on `MessageTransport` / `HttpDataSource` / `ChatDataSource`.
  • New `useActiveRuns` hook: subscribes on mount, coalesces the initial HGETALL snapshot batch with a 16ms microtask, then applies live `update` deltas and removes entries on terminal status. Consumers read a `{ sessionId → { runId, status, updatedAt } }` map for O(1) `status(sessionId)` lookup.
  • Backend contract: `GET /bi-chat/stream/active-runs` emits `snapshot` rows on connect then live `update` deltas. When the backend doesn't implement it the hook is a no-op.

Public API additions

```ts
// From @iota-uz/sdk/bichat
export type ActiveRunDelivery;
export { splitIntoTextBlocks, readTextBlockOffsets, type AssistantTextBlock };
export {
useActiveRuns,
type UseActiveRunsOptions,
type UseActiveRunsResult,
type ActiveRunSnapshot,
};

// HttpDataSource methods
subscribeRunEvents(sessionId, runId, { lastEventId?, onChunk, onError?, signal? });
subscribeActiveRuns({ onEvent, onError?, signal? });

// SendMessageOptions
requestId?: string;

// ChatDataSource optional methods
subscribeRunEvents?(…);
subscribeActiveRuns?(…);
```

`StreamEvent` gains one variant: `{ type: "text_block_end"; seq: number }`.

Test plan

  • `pnpm run typecheck` clean.
  • `pnpm run build:js` green.
  • `eslint ui/` zero errors. Four pre-existing `react-hooks/exhaustive-deps` warnings in untouched files.
  • `vitest run` — 71 tests, all passing (8 new in `textBlocks.test.ts`, 2 new in `MessageTransport.test.ts`).
  • `pnpm run build:js` bundle delta <1%.
  • Manual: once the SDK PR lands and staging is bumped — confirm:
    • Duplicate send across two tabs with same `requestId` attaches to the same run.
    • Tab close mid-generation → reopen → `GET /stream/events` replays from cursor and continues to terminal.
    • Sidebar dots update live as runs transition queued → streaming → completed/failed.
    • `text_block_end` events render as distinct markdown blocks interleaved with tool UIs.

Links

Summary by CodeRabbit

  • New Features

    • Real-time streaming for individual run events with resumed tailing and parse-resilience
    • Live tenant-level active-runs feed with coalesced snapshots and configurable retention/pruning
    • Client-generated request IDs for message idempotency
    • Text-block helpers for splitting assistant output and reading offsets
    • Exposed streaming event types and terminal-event utilities
  • Tests

    • Extensive tests covering streaming subscriptions, reconnection/error scenarios, text-block logic, and the active-runs hook

…run SSE

Complements iota-uz/iota-sdk#747 with the client pieces of the
resilient generation pipeline. Pure additions — no wire break. All
features degrade gracefully when the backend doesn't implement the
matching endpoints (older SDK commits).

## Idempotent send via request_id

Every POST /bi-chat/stream now carries a client-generated requestId.
The backend's SetNX dedupe collapses duplicate sends (double-click /
cross-tab) onto a single run within the ~30 min dedupe window. Callers
can override via SendMessageOptions.requestId for retry flows;
otherwise one is auto-minted per call via crypto.randomUUID (with a
Math.random fallback for older WebViews).

## Cursor-based reconnect via native EventSource

New MessageTransport.subscribeRunEvents + HttpDataSource.subscribeRunEvents
open a native EventSource against GET /bi-chat/stream/events. The browser
handles reconnect + Last-Event-ID automatically on wifi drops / tab
sleep — we listen for every documented server event type and settle
the promise on a terminal (done / error). Callers can abort via a
signal to close the EventSource on unmount.

## Intermediate text blocks

- Added text_block_end to StreamChunk / StreamEvent / sseParser.
- New utils/textBlocks.ts exports splitIntoTextBlocks + readTextBlockOffsets
  so components can split the accumulated assistant content at server-
  emitted byte offsets and render text → tool → text → tool → final_text
  as distinct blocks instead of one merged paragraph.

## Sidebar status fan-out

- New ActiveRunDelivery type + subscribeActiveRuns on MessageTransport /
  HttpDataSource / ChatDataSource.
- useActiveRuns hook subscribes on mount, aggregates the initial
  snapshot batch with a 16ms coalescer, then applies live update deltas
  (removing entries on terminal status). Components read one
  per-session status dictionary to render a status dot without polling
  /stream/status per session.

## Tests

- textBlocks.test.ts (8 cases): empty / no-offsets / split / clamp /
  dedup-offsets / unicode boundary / metadata parser.
- MessageTransport.test.ts: auto-generated requestId in POST body,
  caller-supplied requestId preserved verbatim.
- pnpm run typecheck clean, pnpm run build green, eslint zero errors.
@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

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

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 27 minutes and 17 seconds.

⌛ 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5b0054a9-b57f-4c5f-a799-9ac7f52e57db

📥 Commits

Reviewing files that changed from the base of the PR and between b6cebc2 and 68e0032.

📒 Files selected for processing (2)
  • ui/src/bichat/index.ts
  • ui/src/bichat/utils/eventNames.ts

Walkthrough

Adds SSE streaming support for run-tail and tenant active-runs, client-side requestId generation for sendMessage, text-block segmentation utilities, a useActiveRuns React hook, and related tests and exports.

Changes

Cohort / File(s) Summary
Message transport & SSE core
ui/src/bichat/data/MessageTransport.ts, ui/src/bichat/data/openManagedEventSource.ts
Introduced subscribeRunEvents and subscribeActiveRuns, added RunEventsConnectError, parse/emit logic for SSE frames (including unparseable sentinel), initial-connect grace handling via openManagedEventSource. Added client-side requestId generation for sendMessage.
HTTP data layer
ui/src/bichat/data/HttpDataSource.ts
Added delegating methods HttpDataSource.subscribeRunEvents and HttpDataSource.subscribeActiveRuns that forward base URL/stream endpoint and identifiers/options to MessageTransport.
Tests for transport & SSE
ui/src/bichat/data/MessageTransport.test.ts, ui/src/bichat/data/subscribeActiveRuns.test.ts
New/extended tests covering requestId behavior, FakeEventSource harness, subscribeRunEvents regressions (terminal events, malformed JSON, AbortSignal, onerror connect rejection), and subscribeActiveRuns semantics (snapshot/update ordering, malformed frames, abort/close, onError forwarding).
Active runs hook & tests
ui/src/bichat/hooks/useActiveRuns.ts, ui/src/bichat/hooks/useActiveRuns.test.tsx
Added useActiveRuns hook that subscribes to tenant active-runs, coalesces snapshots, applies immediate updates, prunes terminal runs with retain timers, supports options (enabled, retainTerminalMs, emptyStateTimeoutMs, onError). Tests exercise timing, pruning, re-subscription, and error routing.
Text-block utilities & tests
ui/src/bichat/utils/textBlocks.ts, ui/src/bichat/utils/textBlocks.test.ts
New splitIntoTextBlocks and readTextBlockOffsets plus AssistantTextBlock type. Handles clamped/sorted offsets, trailing remainder, Unicode boundary cases. Tests cover splitting, sanitization, and metadata normalization.
Event names & parser update
ui/src/bichat/utils/eventNames.ts, ui/src/bichat/utils/eventNames.test.ts, ui/src/bichat/utils/sseParser.ts
Added STREAM_EVENT_TYPES and TERMINAL_STREAM_EVENT_TYPES, isTerminalEvent type guard, added text_block_end handling in parser, and tests including a drift guard against Go SDK strings.
Types & public exports
ui/src/bichat/types/index.ts, ui/src/bichat/index.ts
Extended types: ActiveRunDelivery, StreamChunk.textBlockSeq, StreamEvent variant text_block_end, added SendMessageOptions.requestId, and optional ChatDataSource SSE methods. Re-exported new hook, text-block helpers, stream event constants, and RunEventsConnectError.
Test infra & config
package.json, vitest.config.ts
Added testing deps (@testing-library/dom, @testing-library/react, jsdom) and expanded Vitest include globs to .test.tsx.

Sequence Diagram(s)

sequenceDiagram
    participant App as App / Client
    participant Hook as useActiveRuns Hook
    participant Transport as MessageTransport
    participant Server as Backend (SSE)
    participant State as React State

    App->>Hook: mount (dataSource, options)
    Hook->>Transport: subscribeActiveRuns({ onEvent, onError, signal })
    Transport->>Server: GET /active-runs (open SSE)
    Server-->>Transport: SSE: snapshot / update
    Transport->>Hook: onEvent(ActiveRunDelivery)
    alt snapshot events (batched)
        Hook->>Hook: stage snapshots, start coalesce timer
        Hook->>State: merge snapshot batch, set ready=true
    else update event
        Hook->>State: apply update immediately
        alt terminal status
            Hook->>Hook: schedule prune or remove immediately
        end
    end
    App->>Hook: unmount / abort
    Hook->>Transport: signal abort
    Transport->>Server: close EventSource
Loading
sequenceDiagram
    participant App as Application
    participant Transport as MessageTransport
    participant Server as Backend
    participant Dedup as Server Dedup Store

    App->>Transport: sendMessage(payload, { requestId? })
    alt requestId provided
        Transport->>Transport: use provided requestId
    else
        Transport->>Transport: generate requestId (crypto.randomUUID() or fallback)
    end
    Transport->>Server: POST /messages { ..., requestId }
    Server->>Dedup: check requestId
    alt duplicate within window
        Dedup-->>Server: return cached response
    else
        Server-->>Transport: process and stream response
    end
    Transport-->>App: emit StreamChunk events
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through streams with a tiny cheer,
RequestIds tucked in, no duplicate fear.
Text blocks sliced neat, runs tracked in a row,
Hooks keep them tidy as steady events flow. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title comprehensively summarizes the PR's main features: idempotent sends via request_id, intermediate text blocks support, and active-run SSE streaming for sidebar status updates.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bichat-resilient-runs-732

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

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

diyor28 added 2 commits April 15, 2026 13:07
Native EventSource routes events with an `event:` line ONLY via
addEventListener — there is no onmessage fallback. subscribeRunEvents
registered listeners for {done, error, content, …} but not for the
`cancelled` / `failed` terminal names emitted by the backend on
cross-tab Stop or RunReaper stale-run sweeps. Effect: the client
promise never settles, EventSource auto-reconnects forever, users
see a stuck "thinking" indicator.

- Register listeners for cancelled / failed / citation.
- Extend the settle predicate to the full terminal set.
- Add a 500ms initial-connect grace: when onerror fires before the
  first event arrives, settle with a typed RunEventsConnectError
  instead of spinning the auto-reconnect. Distinguishes 401/404/503
  at the boundary from transient mid-run flaps.
- Document the request_id / UUID v4 contract in the generator
  jsdoc.
- openManagedEventSource: shared primitive for the two subscribeX
  functions; centralises listener wiring, abort handling, and the
  500ms initial-connect probe so future fan-out helpers only need
  to map event names to business callbacks.
- utils/eventNames.ts: hand-mirrored version of the backend's
  pkg/httpdto.StreamEventType constants. A drift-guard test reads
  the Go file at test time (skipped when the sibling tree is absent).
- useActiveRuns: retainTerminalMs + emptyStateTimeoutMs options so
  consumers can render a "completed" pulse before the entry is
  pruned.
- Remove duplicate ActiveRunSidebarEvent type; reuse public
  ActiveRunDelivery.
- Add @testing-library/react + jsdom to devDependencies; fill in
  the missing hook + subscribeActiveRuns test coverage.
@diyor28

diyor28 commented Apr 15, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

- Snapshot rows now clear any pending terminal-removal timer for
  the same sessionId before merging into staging. Previously, when
  retainTerminalMs > 0, an in-flight deletion could wipe a newly
  reconnected streaming snapshot.
- useActiveRuns attaches .catch() on the subscribeActiveRuns promise
  so initial-connect failures (401 / 503) route through the onError
  callback instead of surfacing as unhandled rejections.
- eventNames drift-guard test resolves the sibling iota-sdk path
  relative to __dirname so it runs anywhere the repos are checked
  out side-by-side; only skips (with a console.warn) when the
  sibling file is truly absent.

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ui/src/bichat/utils/eventNames.ts`:
- Around line 46-52: The type guard isTerminalEvent currently narrows name to
StreamEventType but only checks membership in TERMINAL_STREAM_EVENT_TYPES;
change the return type to narrow to the specific terminal union (e.g., name is
TerminalStreamEventType or the exact union type represented by
TERMINAL_STREAM_EVENT_TYPES) and update the function signature accordingly so
the implementation and type predicate match (refer to isTerminalEvent,
TERMINAL_STREAM_EVENT_TYPES and StreamEventType); also update any callers that
rely on the broader StreamEventType if they need the wider type.
🪄 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

Run ID: 181cd3ae-4b16-48cc-a6af-499aaed57460

📥 Commits

Reviewing files that changed from the base of the PR and between 16e5368 and b6cebc2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • package.json
  • ui/src/bichat/data/MessageTransport.test.ts
  • ui/src/bichat/data/MessageTransport.ts
  • ui/src/bichat/data/openManagedEventSource.ts
  • ui/src/bichat/data/subscribeActiveRuns.test.ts
  • ui/src/bichat/hooks/useActiveRuns.test.tsx
  • ui/src/bichat/hooks/useActiveRuns.ts
  • ui/src/bichat/index.ts
  • ui/src/bichat/utils/eventNames.test.ts
  • ui/src/bichat/utils/eventNames.ts
  • vitest.config.ts
✅ Files skipped from review due to trivial changes (2)
  • package.json
  • vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • ui/src/bichat/data/MessageTransport.test.ts

Comment thread ui/src/bichat/utils/eventNames.ts
CodeRabbit flagged that the type guard claimed to narrow to the full
StreamEventType union while only checking membership in the 4-element
terminal subset. Callers receiving `true` now get TerminalStreamEventType,
which is the actual constrained set. TERMINAL_STREAM_EVENT_TYPES is
retyped via `as const satisfies readonly StreamEventType[]` so the
tuple's literal types power the narrower derived type without losing
the containment invariant.
@diyor28 diyor28 merged commit f5fb28d into main Apr 15, 2026
2 checks passed
@diyor28 diyor28 deleted the feat/bichat-resilient-runs-732 branch April 15, 2026 09:45
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