Session Projection

De-duplicated, gap-aware, memory-bounded session view for gateway clients

Turn a raw gateway event stream into a correct, resumable session view — no per-client de-dup, gap-healing or bounded retention to re-implement.

python
from praisonai.gateway import GatewayClient

client = GatewayClient(url="ws://localhost:8765", agent_id="assistant")
await client.connect()

async for state in client.project():
    ui.render(state)   # de-duped, gap-aware, memory-bounded
Diagram

📸 Snapshot

🧮 SessionProjection

📡 GatewayEvents

✅ SessionProjectionState

🖥 UI renders once

Quick Start

  1. Simplest usage

    Iterate the projected state and render it — the reducer folds every event for you.

    python
    from praisonai.gateway import GatewayClient
    
    client = GatewayClient(url="ws://localhost:8765", agent_id="assistant")
    await client.connect()
    
    async for state in client.project():
        ui.render(state)
  2. With a resume snapshot

    Seed the projection with a snapshot on reconnect so the view converges instead of drifting.

    python
    from praisonai.gateway import GatewayClient
    
    client = GatewayClient(url="ws://localhost:8765", agent_id="assistant")
    await client.connect()
    
    snapshot = {"messages": [...], "cursor": 42}
    async for state in client.project(snapshot=snapshot):
        ui.render(state)
  3. Bound retention on a long-lived session

    Cap how many finished runs stay tracked; active streams are never evicted.

    python
    async for state in client.project(max_tracked_runs=50):
        ui.render(state)
  4. Pure-core usage without the wrapper

    Drive the reducer directly for third-party clients or tests — no transport required.

    python
    from praisonaiagents.gateway import SessionProjection
    
    proj = SessionProjection(max_tracked_runs=200)
    state = proj.apply_snapshot({"messages": [...], "cursor": 5})
    
    for event in my_event_source():
        state = proj.apply(event)
        render(state)

How It Works

The reducer folds a snapshot plus live events into an immutable state, reconciling every duplicate along the way.

Diagram
UISessionProjectionServerUISessionProjectionServersnapshot (messages + cursor)entries builtdelta_text (run_id=r1)RunView.text accumulatesstream_end (message_id=m1)run done, final_message_id=m1message_persisted (message_id=m1)identity de-dup, renders onceevent (sequence gap)has_transport_gap=Trueapply_snapshot (resync)converges, gap cleared
StepInputEffect
Snapshotmessages / entries / transcript + cursor / sequenceRebuilds entries, resets gap tracking
DeltaTOKEN_STREAM / delta_textAccumulates RunView.text per run_id
Stream endSTREAM_ENDMarks run done, sets final_message_id
Persisted rowMESSAGE / message_persistedUpserts transcript entry, de-duped by message_id
Sequence gapevent.sequence > expectedFlips has_transport_gap = True
Resyncapply_snapshot()Converges without duplicates, clears the gap flag

De-duplication Behaviours

Three distinct reconciliations keep the view correct, each keyed on a different identifier.

Identity de-dup (message_id)

A final assistant message that arrives both as a STREAM_END and as a persisted transcript row renders once, keyed by message_id.

Diagram

stream_end message_id=m1

🧮 Projection

message_persisted message_id=m1

✅ one entry

Optimistic reconciliation (request_id)

A locally-echoed outbound message is replaced — not duplicated — by its durable counterpart with the same request_id. The echo’s provisional message_id is dropped so a re-delivered echo cannot later overwrite the durable row.

Diagram

local echo request_id=r1

🧮 Projection

durable row request_id=r1

✅ durable entry replaces echo

Snapshot convergence after a gap

A sequence hole sets has_transport_gap=True; a resync apply_snapshot() rebuilds entries from the persisted rows and clears the flag — without appending duplicates.

Diagram

sequence gap

🧮 Projection

âš  has_transport_gap

resync snapshot

✅ converged, gap cleared


Configuration Options

The reducer constructor takes a single option.

OptionTypeDefaultDescription
max_tracked_runsint200Cap on tracked finished runs (LRU). Active/unfinished streams are never evicted. Must be positive; raises ValueError otherwise.

SessionProjectionState — the immutable value returned by every apply call:

FieldTypeDefaultDescription
entriesTuple[GatewayMessage, ...]()Ordered transcript messages, de-duplicated by identity.
runsDict[str, RunView]{}Per-run streaming views, keyed by run_id.
has_transport_gapboolFalseTrue when a sequence gap was observed and a resync has not yet reconciled it.

RunView — the immutable per-run streaming view:

FieldTypeDefaultDescription
run_idstr—The run/turn identifier the streamed deltas belong to.
textstr""Accumulated streamed text so far.
doneboolFalseWhether the stream has ended (STREAM_END) for this run.
final_message_idOptional[str]Nonemessage_id of the persisted final row, once reconciled in.

GatewayClient.project()

ParameterTypeDefaultDescription
snapshotOptional[Dict[str, Any]]NoneInitial snapshot ({"messages": [...], "cursor": N}) to seed the projection before folding live events. When omitted, the view starts empty.
max_tracked_runsint200Cap on tracked finished runs (LRU) for long-lived sessions; active streams are never evicted.

Yields a SessionProjectionState after the initial snapshot (if any) and after every subsequent event.


TypeScript Parity

The praisonai-ts SDK ships the same reducer, importing from the package root. It accepts both camelCase (SDK) and snake_case (wire) keys — messageId/message_id, requestId/request_id, senderId/sender_id, runId/run_id — so wire frames from the Python gateway drop straight in with identical de-dup, reconciliation, gap and retention semantics.

Simplest usage

ts
import { SessionProjection } from 'praisonai';

const proj = new SessionProjection({ maxTrackedRuns: 200 });

for (const event of myEventSource) {
  const state = proj.apply(event);
  render(state);   // state.entries / state.runs / state.hasTransportGap
}

With snapshot

ts
import {
  SessionProjection,
  type RunView,
  type ProjectionEntry,
  type SessionProjectionState,
  type SessionSnapshot,
} from 'praisonai';

const proj = new SessionProjection({ maxTrackedRuns: 200 });
const s0 = proj.applySnapshot({ messages: [...], cursor: 5 });

for (const event of myEventSource) {
  const state = proj.apply(event);
  render(state);
}

Common Patterns

Render a dashboard from a live session

python
from praisonai.gateway import GatewayClient

client = GatewayClient(url="ws://localhost:8765", agent_id="assistant")
await client.connect()

async for state in client.project():
    dashboard.render(state.entries, state.runs)

Resume after reconnect

python
from praisonai.gateway import GatewayClient

client = GatewayClient(url="ws://localhost:8765", agent_id="assistant")
await client.connect()

snapshot = {"messages": stored_transcript, "cursor": last_cursor}
async for state in client.project(snapshot=snapshot):
    if state.has_transport_gap:
        await client.resync()
    ui.render(state)

Third-party client without the wrapper

python
from praisonaiagents.gateway import SessionProjection

proj = SessionProjection(max_tracked_runs=100)
state = proj.apply_snapshot(my_snapshot)

for event in my_event_source():
    state = proj.apply(event)
    render(state)

Choose Your Entry Point

Pick the wrapper when you use the bundled client; drive the pure core everywhere else.

Diagram

Yes

No — third-party client, tests, TS SDK

Using the bundled GatewayClient?

async for state in client.project …

SessionProjection + apply_snapshot / apply


Best Practices

Feed a resume snapshot on reconnect

Pass a snapshot so the view converges instead of drifting — apply_snapshot() resets gap tracking and rebuilds the transcript from persisted rows.

Watch has_transport_gap in your UI

Pair state.has_transport_gap with client.resync() on the transport side to heal a sequence hole after a reconnect.

Tune max_tracked_runs for long-lived sessions

Lower max_tracked_runs for dashboards that run for days; leave the default 200 for a chat UI.

Treat state as read-only

Never mutate state.entries or state.runs — they are immutable snapshots. A previous state is never mutated in place by a later apply().

Prefer project() over hand-rolled event handling

client.project() is the SDK primitive that removes double-rendered answers, reconnect flicker and unbounded memory growth.


Related