AG-UI transport — the thin-client wire protocol¶
Reyn's chat client is a stream-consuming UI: it draws a session's output and routes user input, and it touches the session ONLY through a transport seam. There are two transports behind that one seam — a local in-process transport, and this AG-UI transport over HTTP + Server-Sent Events (SSE). Both feed the identical renderer, so a remote client draws byte-for-byte what a local one does.
This page is the wire contract: the SSE endpoint, the reyn-frame ⇄ AG-UI-event
mapping, and the STATE_* status read-model.
Surfaces¶
The transport speaks AG-UI only — it is a UI, not an agent. (Agent↔agent is A2A; tools are MCP; observability export is OTEL. Those are separate surfaces.)
GET /agui/chat/{agent}/events— the server→client SSE stream. Each SSE block isevent: <TYPE>\ndata: <json>\n\n.POST /agui/chat/{agent}— the client→server channel. Body is a JSON object; the supported message types are:{"type": "user_message", "text": "..."}— submit a turn. The response body is{"status": "ok", "msg_id": "..."}(#3287) — the same correlation id the broadcastreyn.event.user_submittedcarries, letting the submitting client recognise its own echo BY ID (see "Local ≡ remote holds for INPUT too" below).{"type": "TOOL_CALL_RESULT", "toolCallId": "<intervention-id>", "text": "..."}or{..., "choiceId": "<id>"}— answer a pending intervention (the HITL round-trip; see "Human-in-the-loop answering" below).{"type": "cancel_inflight"}— cooperatively cancel the in-flight turn (the Ctrl-C seam).{"type": "cancel_queued", "msg_id": "..."}— cancel-by-id an UNDISPATCHED (queued, not-yet-running) inbox message (#3300 P3). A DIFFERENT intent fromcancel_inflightabove: this targets one specific queued item that has not started a turn yet, never the currently-running turn. Server-side semantics (Session.cancel_queued): queued → removed (WALinbox_canceltombstone + synchronous snapshot-prune, then aninbox_cancelaudit-event delta, see "STATE_* — the status read-model" andreyn.event.inbox_cancelbelow); already dispatched → a no-op (never escalated tocancel_inflight); idempotent (a second cancel of the same id is a no-op, safe for an at-most-once retry).{"type": "slash_command", "name": "model", "args": "strong"}— run a registered slash command (#3595 S5). The response body is{"status": "ok", "ran": true|false};ran: falsemeans this server's registry has no such command (a client on a different build), never a crash. ★ The client has ALREADY interpreted the operator's/…line and resolved the NAME against its own registry — nothing on the wire or on the server tests a leading/, which is the whole point of #3595:Sessioninterprets no string, and a client maps typed text onto published operations. A remote client sends this rather than running the command itself because it holds noSession, and the commands that still read session state can only run where that session is. Permission-gated by the sameauthorize_writecheckuser_messagepasses, and the command's reply rides the ordinary display stream.{"type": "heartbeat"}— a liveness keepalive.
An input type the server does not model is a graceful no-op (a 200 ack),
never a 500 — the server half of ignore-unknown.
- POST /agui/chat/{agent}/seize — take the active-driver token (see "Active
driver and seize").
A client can never shut the server down — there is no shutdown message; a
client's /quit is a local disconnect only. The server is the sole writer.
A connection identifies itself with a connection_id query param (or an
X-Reyn-Connection header), stable across its SSE stream and its POSTs.
Both are gated by the server's authentication context: a connection presents its
token as ?token= or an Authorization: Bearer <token> header (same-machine
UDS connections are identified by OS peer credentials instead). An
unauthenticated connection is refused with 401 before any session is attached.
The operator-facing command that opens this transport is reyn chat --connect
<url> (--token <secret> for the bearer token, falling back to the
REYN_WEB_AUTH_TOKEN environment variable).
Standard envelope, reyn-private richness¶
Every event carries both:
- a standard AG-UI field shape, so a generic AG-UI client renders the interoperable core (text / tool / run / error / state); and
- a reyn-private
_reynreconstruction block, from which the reyn client rebuilds the exact render frame.
A generic client ignores what it does not understand: an event with no _reyn
block (or a reyn CUSTOM event a generic client does not model) is skipped,
not fatal — reyn owns this ignore-unknown contract.
Event mapping¶
The client consumes one ordered SSE stream and dispatches each event back to one of the renderer's two entry points (display vs working-indicator). The mapping:
Display path (agent output → the scrollback)¶
| reyn display kind | AG-UI event | Notes |
|---|---|---|
agent |
text triplet | the assistant reply text (see text lifecycle) |
status |
text triplet | transient status line (role: status) |
reasoning |
reasoning triplet | the model's reasoning text (see reasoning lifecycle); emitted only when reasoning display is on |
error |
RUN_ERROR |
error text |
intervention |
CUSTOM |
a prompt is displayed; the reyn client draws it natively and answers it by id (see "Human-in-the-loop answering") |
presentation |
CUSTOM |
a present op's render-node model (see present-on-wire) |
__copy_last_reply__ / __rewind_list__ |
CUSTOM |
client-consumed sentinels — forwarded (see control sentinels) |
__end__ |
(filtered) | NOT forwarded (see control sentinels) |
Any other display kind still round-trips losslessly (it falls back to CUSTOM and
is reconstructed from _reyn) — a new display kind can never silently vanish on
the wire. The completeness gate that guarantees this enumerates the authoritative
producer domain — every OutboxMessage(kind=...) literal across the source
(direct constructions plus the call sites of kind-forwarder helpers), NOT a
renderer-file proxy — and asserts each producer kind is standard-mapped,
profiled, or control-filtered; anything else fails CI.
Control sentinels (forwarded vs filtered)¶
A few __…__ display kinds get a per-entry disposition, decided by where the
sentinel is consumed (never by negating a forward-set, which would wrongly drop
renderable display kinds):
- Client-consumed → forwarded (profiled
CUSTOM,_reyn-lossless): __copy_last_reply__—/copy: the client does a real client-side clipboard copy over the transport stream.__rewind_list__—/rewind: the client renders the rewind region picker.
In the thin-client model the transport is the AG-UI wire, so filtering these
would make remote /copy and /rewind silent no-ops — they must reach the wire.
- Filtered (CONTROL_FILTER_KINDS, an explicit allowlist — the emitter emits
no wire event):
- __end__ — the stream terminator (the emitter returns on it; the client's
loop also ends when the stream closes).
- __open_artifact__ — local-only by construction (launches an OS app on the
machine the client is running on; see #4482).
- Retired (#4534 PR-2 / PR-2b): __attach_request__ and
__session_switch_request__ no longer exist. /agent new, /attach, and
/session switch all go through ClientTransport.request_attach /
request_session_switch — named operations, not display-channel sentinels
(#3595 S5's own principle: the client interprets, the server executes a
named operation, the same shape run_slash_command already applied).
Earlier revisions of this doc described __attach_request__ as "forwarded,
and genuinely live" on the wire, and __session_switch_request__ as
tap-consumed-and-filtered — both accurate before their respective PRs
landed. Session-switch follow (below) no longer consumes a sentinel off
the outbox either; it subscribes to registry.add_attach_listener directly.
The session-switch barrier (reyn.event.session_attached, #3310 N1/N2)¶
/attach <name> and /session switch <sid> both flip which session's frames
reach a client — but historically nothing told the client THAT a switch had
happened (the old Textual TUI's header re-post was deleted as dead code; see
the control-sentinel dispositions above). AgentRegistry.attach/
attach_session now emit a session_attached EventFrame carrying
{agent, session_id} — the identity a client keys its display/reset cache on
— put DIRECTLY on repl_outbox (registry.py, the
_announce_session_attached helper), never routed through the just-swapped
session's own audit-events.
The barrier property is the point: the self._attached = key flip and the
repl_outbox.put_nowait(...) happen with NO await in between (both are
plain synchronous statements). A single event loop can never interleave
inside that synchronous region, so on the repl_outbox FIFO "before this
frame = old session's frames, after = new session's frames" holds BY
CONSTRUCTION, not merely by convention — mirroring the no-await
critical-section idiom Session.cancel_queued uses (#3300 Y-server /
3306). InProcessTransport._pump_outbox passes an EventFrame already on¶
repl_outbox through unchanged (never re-wraps it as a DisplayFrame).
This event is NOT an OutboxMessage display kind — the owner-ratified reason
is the same as #3288 ③b's agent_delta: a state transition rides an EventFrame
(opt-in draw — a surface with no handler drops it silently, never a garbage
row), where registering a closed-vocabulary display kind for it would be a
category error.
N2 (client-side reset + hydrate, local — textual_chat/app.py).
TextualChatApp._handle_session_attached_event consumes this event as the
reset barrier: on receipt it clears every per-session client-side state
(the retained FlowModel, running-tool tracking, pending-intervention
tabs, the sent-queue view/widget + its item-meta side table, in-flight
streamed-reply tracking) and rehydrates the retained model from the NEW
session's history.jsonl — not from a retained cache. A cached
FlowView cannot be the source of truth here: while a session is
detached, the registry forwarder drops its frames entirely (see the
control-sentinel dispositions above), so a cache would be missing
everything that happened meanwhile and would hold tool rows stuck
RUNNING. ChatReadModel.conversation_history (interfaces/repl/
read_model.py) is generalized to accept an optional (agent,
session_id) target — None/None (the pre-N2 shape) still hydrates
whichever session is currently attached; a target hydrates that specific
session instead, resolved via AgentRegistry.get_session (never a
duplicated history.jsonl path literal). Pending interventions are
forgotten, not re-fetched — the registry's attach/attach_session
already re-announce every pending intervention on attach, so the client
only needs to stop tracking the old session's entries.
Remote parity (#3310 N3). registry.repl_outbox (above) is a LOCAL-only
bus — the AG-UI/SSE _SessionFrameSource never drains it; it reads a
session's own outbox_hub/audit_events directly, bound to ONE session
object for the SSE connection's lifetime. A remote client that switched
sessions therefore had NO way to obtain the new session's scrollback at all:
the remote read-model's conversation_history is deliberately empty
(frame-sufficiency, read_model.py) and the emitter's MESSAGES_SNAPSHOT
backlog was otherwise fixed at connect time. N3 closes this by treating a
switch as a logical reconnect, entirely within the remote transport
(the registry's own attach/attach_session + repl_outbox barrier are
untouched and not involved):
_SessionFrameSource(endpoint.py) subscribes toregistry.add_attach_listener(agent_name, ...)(#4534 PR-2b — ported off the retired__session_switch_request__sentinel, whose in-band arrival onsession.outbox_hubthis source used to observe directly;attach_sessionnow flips focus out-of-band, so the source instead registers a synchronous callback the registry fires from_announce_session_attached's own no-await critical section). The callback hands the target sid to a per-connectionasyncio.Queue, which_drain_one_session's drain loop dual-waits alongsidesub.get()(asyncio.wait(..., return_when=FIRST_COMPLETED)) — the second wait source an in-flight blockedawait sub.get()needs to be interrupted by an out-of-band signal. On seeing the sid, it enqueues thesession_attachedEventFrameonto this connection's own queue BEFORE re-pointing itself at the target session (registry.get_session+ subscribing to the new session's audit-events) — the SAME vocabulary N1 defined, an independent per-connection equivalent sincerepl_outboxnever reaches a remote surface. This ordering (announce, then subscribe) is load-bearing, not incidental: the new session's audit-event subscriber does not exist yet when the announce is enqueued, so an audit-event the new session emits cannot possibly reach this connection's queue ahead of the barrier — true BY CONSTRUCTION regardless of whether anawaitis ever later introduced between the two steps (witnessed bytest_switch_announce_precedes_any_new_session_audit_event, an adversary that floods the target session's own audit-event stream the instant the switch is triggered). It never callsregistry.attach_sessionitself, so it cannot race or double-apply that side effect — it only re-points THIS connection's own view. A registry-less / agent_name-less construction (most existing unit tests) registers no listener at all, so no switch-follow ever happens for that connection.AgUiEmitter, on observing asession_attachedEventFrameflow throughstream(), re-fires the SAME reconnect protocol it uses at connect (_reconnect_snapshot_chunks:MESSAGES_SNAPSHOTthenSTATE_SNAPSHOT) — STRICTLY after the barrier event is forwarded, never before, so a client that resets its view on the barrier never sees the reset race the state the re-fire is about to deliver. The new backlog is resolved via a caller-suppliedbacklog_provider(agent, session_id); the endpoint wiressession_backlog_frames, which projects the target session's in-memoryhistory(ChatMessagelist) through the SAMEproject_restored_framesSSoT local restore-on-restart uses (#3273 P5) — read fresh at switch time, never cached, so content that accrued in the other session while this connection was elsewhere is included (no staleness). The per-connectionTextStreamTrackerandwaiting_onlabel are reset at the same point.- No per-client "which frames have I already seen" bookkeeping was introduced: the mechanism is re-subscription (which session a connection currently reads from) plus a fresh history read at switch time — never a set of previously-delivered frame/message ids consulted before forwarding.
- An ordinary connection that never switches sessions is unaffected — the
re-fire is dormant with no
session_attachedevent ever flowing through.
Both consumers are now landed: N1 provides the barrier event, N2
consumes it on the LOCAL path (InProcessTransport → TextualChatApp,
via the client-pull ChatReadModel.conversation_history seam), N3
consumes it on the REMOTE path (_SessionFrameSource/AgUiEmitter,
above, via a SERVER-push backlog_provider that re-derives the target
session's backlog directly through project_restored_frames — a
structurally separate mechanism from N2's client-pull, not a shared
call site, since a remote connection has no local ChatReadModel to
pull through). The net effect is the same for both surfaces: a switch
resets the client's view and repopulates it from that session's
authoritative history, never a retained cache.
Text lifecycle (the conforming triplet, plain and streamed)¶
The AG-UI spec mandates the text lifecycle TEXT_MESSAGE_START → one or more
TEXT_MESSAGE_CONTENT → TEXT_MESSAGE_END, all correlated by a messageId; a
bare TEXT_MESSAGE_CONTENT is invalid (a strict generic client drops it).
Streaming applies to the narrative reply call path only. RouterLoop has
three production call sites for call_llm_tools, but only the primary reply
(run_loop) passes on_content_delta; _run_structured_answer_turn (a
schema-constrained turn whose output is parsed with json.loads — partial
JSON is unparseable, so streaming it would be meaningless) and
_force_close_call (a terminal wrap-up, not body content) both omit it
intentionally.
A message that never streamed (no provider capability, ADR-0039 P3a/③a) rides
the wire as the plain whole-message triplet, with a generated per-message id
(reyn's outbox has no stable message id) and the single CONTENT's delta
carrying the full message text. Only the CONTENT event carries the _reyn
reconstruction block; START/END are generic scaffold the reyn client decodes to
None and ignores — the reconstruction invariant is one frame ⇄ one
_reyn-bearing event.
A message that DID stream (#3288 ③b emits reyn.event.agent_delta for each
raw LLM content-delta as it arrives; #3288 ③d maps it onto the STANDARD text
surface) instead gets a REAL multi-CONTENT sequence: TEXT_MESSAGE_START at the
first delta, one genuine TEXT_MESSAGE_CONTENT per delta (each carrying its OWN
_reyn, reconstructing that exact agent_delta audit-event — so a reyn client's
in-flight rendering, if any, is identical whether the frames arrived in-process
or over this wire), then TEXT_MESSAGE_END at completion. ★The completion is
mapped to END ONLY — never a second CONTENT re-sending the full text (a
client that rendered the deltas live would double-render the body). The END
instead carries its OWN _reyn, holding the completion's FULL persisted text —
the sole reconstruction authority for a streamed message; a reyn client never
reconstructs by concatenating deltas (they are non-persistent, derived,
live-only narration — the reconnect MESSAGES_SNAPSHOT backlog, built only from
persisted OutboxMessages, never reads one either). This is also what closes the
late-joiner window: a connection's per-connection TextStreamTracker state
(interfaces/transport/agui/emitter.py) reflects only what THAT connection
personally observed — a connection that witnessed zero deltas for a chain (never
connected during the stream, or connected right as it finished) instead gets the
unchanged plain whole-message triplet for the SAME completion frame (full text
on CONTENT), so either way the client ends up with the complete, persisted text.
No per-client "which deltas did you receive" bookkeeping is kept (rejected in the
issue #3288 ③d design thread — it would add state for no benefit over reading the
authoritative completion).
So the reconstruction invariant is re-decided for a streamed message
specifically: N agent_delta CONTENT events each carry their OWN _reyn (one
per delta), and the terminal END carries a further, DISTINCT _reyn (the
completion). N+1 _reyn-bearing events, not 1 — but the client's reconstruction
authority is always the LAST one. A message that never streamed is unaffected by
any of this (the plain triplet above, unchanged).
Reasoning lifecycle (the conforming triplet)¶
reyn's model reasoning rides the AG-UI Reasoning message lifecycle so a
generic client renders it as reasoning rather than as an opaque CUSTOM payload.
The canonical Reasoning category has seven events; reyn is whole-message (no token
streaming), so it maps the content-bearing inner triplet REASONING_MESSAGE_START
→ REASONING_MESSAGE_CONTENT → REASONING_MESSAGE_END, correlated by a shared
messageId with role: "reasoning" and the CONTENT delta carrying the whole
reasoning text. This mirrors the text triplet exactly: only the CONTENT event
carries the _reyn block (START/END decode to None), so the reyn client rebuilds
exactly one reasoning display frame and its render is byte-unchanged.
Two boundaries hold this signal in place:
- Display-gate by construction. A reasoning display frame only exists when
the operator's reasoning-display toggle is on — reyn emits the frame at a single
chokepoint gated on that toggle. Display off ⇒ no reasoning frame ⇒ zero
REASONING_*events on the wire. The mapping adds no new gate and cannot become a chain-of-thought exposure path that bypasses the toggle. - Reasoning is a display signal, not observability. The AG-UI display surface is an operator's connected client, where display-on is intent-to-see. Reasoning content is a transport-frame concern and is never routed to the observability export — the OTLP exporter keeps its content-off default and receives no reasoning chain-of-thought.
Working-indicator path (turn lifecycle + tool axis)¶
| reyn audit-event | AG-UI event |
|---|---|
turn_started |
RUN_STARTED |
turn_settled / turn_completed / turn_cancelled |
RUN_FINISHED |
tool_called |
TOOL_CALL_START |
tool_returned / tool_failed |
TOOL_CALL_END (with status) |
user_answered_intervention |
CUSTOM |
These eight are the exact set the renderer's working / running / waiting-for-you indicator consumes; the transport forwards precisely this set.
TOOL_CALL_END carries a standard status field ("ok" / "error") derived
from the etype — tool_failed → "error", tool_returned → "ok" — so a
generic client sees a tool failure. The reyn client still exact-recovers the
precise etype from _reyn.
Intervention frontend-tool¶
Alongside the display frame, the server emits a companion TOOL_CALL_START
frontend-tool whose toolName is reyn.intervention.<kind> and whose
toolCallId is the intervention id. A generic AG-UI client can render and
answer it as an ordinary tool call; the reyn client uses it only to know which
intervention is pending — it draws the prompt itself from the display frame,
so there is no double render. When the intervention resolves (answered or
denied) the server emits a terminal TOOL_CALL_RESULT, so a pending
frontend-tool never dangles.
Human-in-the-loop answering¶
Answering an intervention IS a permission grant, so every answer is authenticated AND authorized at delivery time. The client is untrusted: the server re-authorizes the identity and validates the answer against its OWN copy of the intervention (the id, and any choice id) — the client's echoed prompt / choices are not trusted.
Answers are delivered by id: the toolCallId in a TOOL_CALL_RESULT
names the exact intervention the operator was shown, so a grant lands on that
prompt and never on a different queued one. An unknown or already-answered id
is rejected (the client falls back to an ordinary turn); there is no
answer-the-oldest fallback.
An authenticated human operator's answer is unfenced (treated as trusted operator input). An answer arriving from an external agent peer over the internal agent-to-agent path stays fenced (a different, untrusted trust class).
Attribution: each answered grant is recorded on the audit trail with the authenticated user id and the connection it came from; attach / seize / detach are also audited.
Active driver and seize¶
Multiple terminals may attach to one session and all see the same output. Exactly one connection at a time holds the active-driver token — the authority to answer / drive. This is a UX coordination token, not a security control.
Any authorized connection may seize the token
(POST /agui/chat/{agent}/seize) with no handshake — the intended case is one
operator across a laptop and a desktop. The previous holder becomes a
non-holding equal peer and may seize back.
A seize is refused for an unauthenticated / unauthorized connection, or one with no attached surface. A deposed holder's in-flight answer is rejected at delivery (it is no longer the active driver).
Fail-close and the grace window¶
A pending intervention must never hang forever waiting on an operator who has gone. When the last answerable operator surface for an intervention is lost — an in-process detach OR a network break / heartbeat timeout — the intervention is resolved with a typed refusal (a fail-closed answer the run continues from), never left parked.
This only happens after a grace window: a brief disconnect and reconnect within the window keeps the intervention pending and resumes normally. Only a full grace window with zero surfaces triggers the refusal.
A liveness signal (a periodic heartbeat) means a half-open connection cannot hide a dead surface: a surface that stops heart-beating past the liveness timeout is detected as lost.
The heartbeat POST is a half-open backstop only — a normal disconnect (the
client closes cleanly) is caught immediately by the SSE handler's own
finally: manager.detach(...), not the heartbeat. The dedicated ping only
matters for a client that hangs without ever sending a TCP FIN. The remote thin
client (reyn chat --connect) sends a heartbeat every 25s
(REYN_AGUI_HEARTBEAT_INTERVAL_S overrides it), skipping the dedicated ping
whenever a real client→server POST (a turn, an answer, a cancel) already
landed within that window — piggybacking on real traffic instead of adding
redundant load. The server's liveness timeout is 60s
(REYN_AGUI_LIVENESS_TIMEOUT_S overrides it) — comfortably above the client
interval (the idiomatic ratio: Socket.IO 25s/60s, Phoenix 30s, SignalR
15s+2×timeout) so a live, idle client is never false-swept as dead. The client
interval MUST stay below the server timeout, which in turn stays below
timeout+grace, so the half-open backstop and the grace window together always
cover detection.
The refusal is scoped per intervention: an intervention still answerable by another live surface (for example one an external agent peer is answering) is left pending even when the operator terminals are all gone.
present-on-wire¶
A present op's render model is a list[dict] of render nodes, neutralized at
construction (every leaf string stripped of terminal control / ESC sequences),
so it is inert before it reaches any wire. It rides a CUSTOM event under the
presentation display kind, carried in meta.nodes.
The AG-UI client additionally re-runs the surface neutralizer over every node leaf at the transport edge, per connection — idempotent for a leaf the construction seam already neutralized, but load-bearing defense-in-depth for a heterogeneous-surface client whose upstream did not neutralize (or neutralized for a different surface).
STATE_* — the status read-model¶
The status bar (attached agent, model, cost, tokens, context usage, and the current WaitingOn label) is a read-model, not a file mirror: it is derived from the session's live cost / token / context accessors and the working-indicator state, and only the render-relevant subset is streamed.
STATE_SNAPSHOT— emitted on connect, the full read-model. Fields:attached_name,model,cost_agent,cost_total,agent_tokens,ctx_used,ctx_window,waiting_on,queue,turn_active,halted_reason.STATE_DELTA— emitted on change, carrying only the changed keys. An idle stream emits no deltas.
halted_reason (#2280) is Session.halted_reason — None while running, or
the fail-stop reason (e.g. "durability_failure") once the session has
halted on a persistent durability failure (#2259). Riding this same
snapshot+delta channel gives a remote client the SAME proactive surface the
local TUI status line and plain --cui bottom toolbar show — the halt is
already enforced synchronously elsewhere (DurabilityHaltError); this field
is observability only, never load-bearing for the halt itself.
queue and turn_active (#3300 P2a) publish the server-authoritative
sent-queue state: queue is the current undispatched inbox queue (each
item {msg_id, chain_id, text} — Session.queued_user_messages()), and
turn_active is whether a turn is currently dispatched
(Session.turn_active). Riding the same snapshot+delta channel makes a client
late-joiner-safe: connecting mid-turn (having missed the turn_started
audit-event that dispatched the in-flight item) still gets the correct queue +
turn-active state from the snapshot, not a partial event-derived guess. P2a
publishes this state only — rendering it as a sent-queue widget is P2b.
An item leaves queue via one of two mutually-exclusive granular audit-event
deltas on the same snapshot+delta channel — turn_started (dispatched; see
"Working-indicator path" below) or inbox_cancel (cancelled by id via the
cancel_queued client message above, #3300 P3): the server's own atomic
queued/dispatched judgement guarantees exactly one of the two ever fires for a
given item, never both. inbox_cancel carries msg_id + seq (the same
order-race-gate token user_submitted/turn_started carry — see
reyn.event.inbox_cancel below); a client merging the granular deltas removes
the item by msg_id (unlike turn_started, which matches by chain_id).
The client seeds its status view from the snapshot and merges each delta, so the remote status panel always reflects the server's values.
These are exactly the main status-line values the interactive TUI renders, so a
remote client on an interactive TTY draws the same status line as a local one
(agent · model · cost · ctx%, plus the working indicator). The drawer
panes behind that line (the cost breakdown and ctx/compaction detail, the
/model class picker, the agent/session tree, the tool/mcp/skill/hook visibility
and applicability toggles, the pipeline and cron listings) and the interactive
intervention / /rewind pickers are session-local state, not on the wire — a
remote client shows the streamed status values and degrades those to empty/—/0.
Adding any other field is an additive STATE_* key, not a client change.
Reconnect¶
On connect (or reconnect) the server replays, before any live event:
MESSAGES_SNAPSHOT— the display backlog (the messages already produced), so a reconnecting client rebuilds its scrollback; thenSTATE_SNAPSHOT— the status read-model above.
Live events (and STATE_DELTAs) follow.
Session switch = the same protocol, mid-stream (#3310 N3). A session
switch on an already-connected AG-UI stream re-fires this EXACT pair
(AgUiEmitter._reconnect_snapshot_chunks), strictly after the
reyn.event.session_attached barrier is forwarded — see the session-switch
barrier above. Connect-time and switch-time are one code path, not two
byte-identical-by-hand copies.
The MESSAGES_SNAPSHOT messages field is a standard [{role, content}]
array of conversation turns only — agent → assistant, user → user — the
shape a generic client expects. reyn chrome (status / error / present /
intervention / trace) is not a conversation turn and is excluded from the standard
array; the reyn client rebuilds the full backlog (chrome included) from the
_reyn block, so its scrollback is unchanged.
The reyn extension profile¶
Beyond the interoperable core, reyn names its own vocabulary under a reyn-owned
namespace — the CUSTOM-event name for chrome with no standard analog, and the
frontend-tool toolName for interventions. This namespace is a documented,
tested extension profile: every reyn.* name reyn emits has a registry entry. A
completeness gate enumerates the authoritative producer domain — every
OutboxMessage(kind=...) literal across the source (direct constructions plus the
call sites of kind-forwarder helpers), plus the intervention frontend-tool encoder
— and asserts each producer kind is standard-mapped, profiled, or
control-filtered, so the profile cannot silently drift from what the codec puts
on the wire.
Three namespaces:
reyn.display.<kind>¶
A reyn display frame with no standard AG-UI analog. value is {"text": <string>}
— the display line text.
Custom name |
Meaning |
|---|---|
reyn.display.intervention |
an intervention prompt is displayed |
reyn.display.presentation |
a present op's text; the render-node model rides the _reyn block's meta.nodes (inert on the wire — see present-on-wire) |
reyn.display.user |
a user-authored line — a submitted turn OR a resolved intervention answer, RENDERED locally by a surface off reyn.event.user_submitted / reyn.event.intervention_answer_submitted (below), never PUT onto session.outbox by any producer as of #3300 (the last such write — the intervention-answer echo — was event-ified; kept as a valid OutboxMessage kind for surfaces' own local construction — e.g. a persisted-transcript restore — and as a fail-safe profile entry, not a live outbox-fanout wire kind); meta optionally carries auth_user_id / auth_connection_id attribution for a multi-client render (backlog user turns ride the standard messages array instead) |
reyn.display.system |
a reyn chrome line — a persisted lifecycle/status marker (compaction / budget / cost-warn) |
reyn.display.__copy_last_reply__ |
the /copy sentinel — forwarded (client-side clipboard copy); see control sentinels |
reyn.display.__rewind_list__ |
the /rewind sentinel — forwarded (client-side rewind picker); see control sentinels |
reyn.display.tool_call_started |
a tool-call start trace line |
reyn.display.tool_call_completed |
a tool-call completion trace line |
reyn.display.tool_call_failed |
a tool-call failure trace line |
reyn.event.<etype>¶
A reyn audit-event with no standard AG-UI analog. value is the event's data
object. Most members are the working-indicator axis (turn-lifecycle /
tool-call / user-submitted / cancel); agent_delta (#3288 ③b) is a SEPARATE
streaming-notification axis — see its row below and the _STREAMING_EVENTS
comment in frames.py for why it was forwarded ahead of any consumer. The
plain/repl renderer still has no agent_delta branch (and may never); the
Textual TUI (interfaces/inline/textual_chat) is the one surface that
consumes it, as of #3288 ③c — see Textual TUI streamed-reply rendering
below.
★This CUSTOM mapping is what encode_frame/encode_frame_wire (the plain,
non-streaming codec path) produce for an agent_delta EventFrame — the AG-UI
emitter's actual production call site (emitter.py) instead runs every frame
through encode_frame_wire_streaming, which maps agent_delta onto the
STANDARD TEXT_MESSAGE_CONTENT surface (#3288 ③d — see Text lifecycle above),
never this CUSTOM name, on the wire a real client receives. This row documents
what the plain codec functions do in isolation (still exercised directly by
tests/interfaces/test_agent_delta_audit_event_3288.py), not what ships on the connected
wire.
Custom name |
Meaning |
|---|---|
reyn.event.user_answered_intervention |
the user answered an intervention (working-indicator axis only — carries NO display text; see reyn.event.intervention_answer_submitted below for the echo) |
reyn.event.session_attached |
a session/agent switch just happened (#3310 N1) — carries {agent, session_id}, the identity a client keys its display/reset cache on. Locally, emitted at the registry attach seam (AgentRegistry.attach/attach_session), put directly on repl_outbox as a stream BARRIER — see the session-switch barrier above; consumed by TextualChatApp (N2), which resets every per-session client state and rehydrates from history.jsonl on receipt. Remotely (#3310 N3), _SessionFrameSource synthesizes an independent per-connection equivalent when it observes a /session switch on the session backing THIS connection, and AgUiEmitter re-fires MESSAGES_SNAPSHOT/STATE_SNAPSHOT for the new session right after forwarding it — see Remote parity above |
reyn.event.user_submitted |
a user turn was submitted (#3300 P1 C) — RAW text + chain_id + msg_id + seq + meta; each surface neutralizes at its render boundary. msg_id/seq are the #3300 P2a sent-queue correlation id + order-race-gate token |
reyn.event.intervention_answer_submitted |
an intervention answer was resolved (#3300, event-ifying the LAST outbox kind="user" broadcast site — InterventionHandler.deliver_answer_to) — RAW text (the raw answer, or the matched choice's label) + intervention_id + meta; each surface neutralizes at its render boundary, following the user_submitted precedent exactly. Unlike user_submitted, this has no sent-queue staging step — an intervention answer was never a queued inbox item, so it renders straight to the flow |
reyn.event.inbox_cancel |
an UNDISPATCHED queued user message was cancelled by id (#3300 P3, via the cancel_queued client message) — carries msg_id + seq; the server-authoritative sent-queue removal signal (never a client-local "cancel succeeded" response), exclusive with turn_started for the same msg_id |
reyn.event.agent_delta |
one streamed LLM content-delta chunk (#3288 ③b) — carries text (the raw per-chunk delta), chain_id, and round_index (which LLM round of the turn produced it, #3656). A turn that calls a tool emits more than one assistant message, and chain_id alone cannot tell them apart; the producer runs inside the round, so the index is a fact it holds rather than one a consumer reconstructs from frame order. The plain codec's CUSTOM mapping (see the note above); on the actual AG-UI wire (encode_frame_wire_streaming, #3288 ③d) this rides TEXT_MESSAGE_CONTENT instead — see Text lifecycle above for the full streamed-message contract (END-only completion, reconstruction authority, late-joiner closure) |
Textual TUI streamed-reply rendering (#3288 ③c, #3283 ③)¶
The Textual TUI (interfaces/inline/textual_chat/app.py) is the L7 consumer
agent_delta was forwarded ahead of (see the note above): TextualChatApp.
_handle_agent_delta_event coalesces N deltas into ONE FlowView entry per
LLM ROUND, keyed by (chain_id, round_index) (#3656). chain_id is the
turn; round_index is which round within it, and a turn that calls a tool has
more than one — 140 deltas, three tool calls, then 300 deltas was the measured
case, and its two texts are two separate assistant messages in history. Keyed
by chain_id alone, the second round's deltas flowed into the entry created
BEFORE the tool row, so what the model wrote after reading a tool result
appeared above the call that produced it.
The first delta for a (chain_id, round_index) appends a new entry; every
later delta for the same pair updates that SAME entry in place
(Entry.set_item) rather than appending a second row. A delta from a LATER
round closes the previous round's record — the entry keeps its text and simply
stops being a target, since the terminal frame arrives once per TURN, not once
per round. A delta with no round_index (an older producer, a replayed frame)
reads 0 and therefore coalesces exactly as before.
The terminal completion (a DISPLAY frame, not an event) then FINALIZES the
LAST round's entry with the completion's authoritative full text (never the
deltas — L9 whole-persist's source of truth) and releases any earlier round,
rather than appending a second entry of its own — this is
what keeps a mid-stream-joining client (one that only ever received the TAIL
of a reply's deltas, see Text lifecycle's late-joiner closure above) to
exactly one final entry instead of a duplicate. The plain/repl renderer has
no equivalent branch and is unaffected — agent_delta there is still
consumed-but-dropped (opt-in draw, no visible-garbage window), exactly as
before ③c.
Visibility-gated live updates (#3283 ③). The in-place update above is
gated on whether the row is on screen, so a long conversation whose streaming
reply has been scrolled away costs O(1) model→view updates instead of
O(deltas). The append registers a FlowView.track_visibility tracker for the
entry; while the row is visible each delta issues its Entry.set_item as
before, and while it is NOT visible the delta still accumulates onto the
tracked reply text but issues no set_item. The tracker's on_show replays
the whole accumulated text in ONE update when the row scrolls back — so
scrolling away and back shows the COMPLETE reply, never a truncated one.
Two properties are load-bearing here:
- The gate is an optimisation, not a correctness mechanism. Text
accumulation is unconditional; only the render is deferred. Removing the
deferral leaves every reply byte-identical, just updated once per delta.
Removing the
on_showreplay does NOT — that is what puts deferred text on screen. - It is a distinct gate from flowview's own. flowview already skips the
present + reflow for an off-screen update (
FlowView.on_flow_update), but theset_itemitself — a new item object, a revision bump, a strip-cache eviction, a model→view notification — happens regardless. #3283 ③ gates that update feed; flowview gates the render. Neither replaces the other.
The tracker is released when the terminal completion settles the row — the
load-bearing release, since nothing else would ever unregister a settled row's
observer and they would otherwise accumulate for the whole session — and, belt
and braces, for every still-in-flight reply on a session switch
(session_attached, #3310 N2), where clearing the model already drops every
observer via FlowView.on_flow_clear. The completion's own final write is NOT
visibility-gated: the authoritative full text lands whether or not the row is
on screen.
Textual TUI gutters — state (left) + elapsed time/turn tokens (right) (#3283 ①②④)¶
The Textual TUI's FlowView (interfaces/inline/textual_chat) paints TWO
fixed-width columns per row, both driven by flowview's FlowDecorator
protocol (decorate(entry, width, height) -> RenderableType), never a
second hand-rolled column:
- LEFT gutter —
ReynGutter(gutter.py): the #3273 state contract. A kind-driven glyph (❯user,●assistant/tool-header,⎿tool-result) whose COLOUR is driven by the entry'sEntryState— RUNNING amber, SUCCESS green, ERROR coral, CANCELLED dim, DEFAULT the kind's own colour. A RUNNING entry's glyph BLINKS (●/○), picked from a monotonic clock (int(clock() / frame_period)) that flowview's ownFlowView(animation_fps=N)re-invokes on each animation tick — no app-side timer (#3283 ①, native-blink equivalence). The ADDRESSED-ROW RAIL (#3490) is drawn in the RIGHT gutter, described below — this column carried it until #3526 moved it on the owner's instruction. There is exactly ONE addressed position — the keyboard cursor (#3476 ⑥), which is also whatctrl+nsearch moves (#3493) rather than keeping a second selection of its own, so two different rows can never both be marked by construction instead of by a gating rule that has to stay correct. flowview 0.11.0 (#3624) merged keyboard highlight and mouse selection into that oneselectable=cursor — previouslyhighlight=(keyboard-only) andselectable=(mouse-only) were independent flags, and reyn leftselectable=off specifically to keep a click from moving the addressed position; that separation no longer exists upstream, soFlowView(selectable=True)now enables the cursor for BOTH inputs and a click both moves and commits it, same as Enter/Space. reyn keeps the addressed-row rail's single-position invariant regardless (a click just becomes a second way to move it, same as an arrow key) — see "Textual TUI keyboard cursor" below for the copy-on-commit hazard this merge introduced and how it is contained. The state glyph keeps its ownEntryStatecolour — being addressed is a POSITION, not an outcome, so the mark must not repaint the state vocabulary. The rail's colour is_CC_TEXT("default"), so it forces no colour of its own and follows the theme's foreground. A named ANSI colour was tried first, to have the TERMINAL's own palette resolve it: rich does keep such a colour palette-relative (the strip carriesColorType.STANDARD), but Textual downconverts it to truecolor at output (measured in a real terminal —"blue"arrived as\x1b[38;2;157;101;255], its theme's purple). The only true passthrough is the app-wideApp.ansi_color, which would drop the whole_CC_*palette to 16 colours, so it is deliberately not set. Since #3526 the bar is a thin▏(U+258F) in the RIGHT gutter's LEADING cell — the edge facing the body, so it stays as near the text as that side allows — spanning the body's whole post-wrapheightso one entry reads as one marked block. Both the old and new positions cost no body column (each gutter is a fixed-width band) and both double as a divider; what differs is DISTANCE, since the right margin is a place most lines stop short of, unlike the line start the left rail met. The app suppliesReynRightGutter(is_marked=…), which readsFlowView.currentlive on every gutter repaint, and re-derives the affected rows' gutters viaFlowView.refresh_gutteron eachHighlightedand on focus changes (the gutter cache is keyed on a decor revision that neither a cursor move nor a focus change bumps, so without that invalidation the rail would strand on the row it was first painted on). The rail shows only while the pane is actually being addressed — FlowView focused, or the search bar open; the position persists either way. Why the rail is gutter CONTENT and not aflowview--highlightcomponent style: flowview applies a component style asSegment.apply_style(segments, style)==style + segment.style— a BASE beneath each segment's own attributes, with nopost_style— so a background there is swallowed on exactly the rows carrying the ROW TINT described next.text-style: reversedoes survive that merge (it is what #3476 ⑤/⑥ originally shipped) but inverts fg/bg into a near-white block over the palette, so surviving the merge is necessary and not sufficient. That class (flowview--highlight—flowview--cursorbefore 0.7.0,flowview--selecteda synonym from 0.11.0 until #3624 / flowview 0.12.0 dropped the alias) is therefore left undeclared, and flowview 0.6.1 onward honours that: an undeclared component class paints nothing, because the row overlay uses the partial component style (only the rules an app actually declared). Under 0.6.0 it did not — Textual resolves an undeclared component class to a concrete style synthesised from inherited values (get_component_rich_style("flowview--highlight")returnedStyle(color=#e0e0e0, bgcolor=#121212)), flowview painted it, and the addressed row came out near-black; because the cursor auto-arms on the newest entry, the BOTTOM row wore it permanently (#3496, reported upstream as textual-flowview#5 and fixed in 0.6.1, which let reyn delete the subclass that had been suppressing the accessor). 0.6.1 also made a declared component background win over a row's ownPresentation.background(textual-flowview#6) — so a component style is now a viable way to mark a row, and reyn still does not use one: the gutter rail leaves the conversation's own colours completely alone, which is the owner-directed design, not a workaround. What keeps this honest istest_the_addressed_row_keeps_its_own_background, which compares each row's painted background before and after it becomes addressed — it fails whichever side disturbs the row (it is RED on 0.6.0 without the subclass, verified). - ROW TINT —
Presentation.background(presenter.py): a user row and a FAILURE row (atool_call_failed/errorframe, or atool_call_completedwhose summary is a✗) carry a whole-row background that flowview paints edge to edge across gutter + body + padding (_view._compose_line). Every tint is a_CC_*_BGconstant — a faint DARK block (_CC_USER_BG,_CC_ERR_BG) that the row's normal foreground stays legible against; a saturated_CC_*foreground colour is never reused as a background. The two vocabularies are kept disjoint deliberately: foreground and background are chosen on independent code paths, so overlapping them is how they collide. #3367 was exactly that collision — every failure leg pairedstyle=_CC_ERRwithbackground=_CC_ERR, painting the row's text (and, because the tint spans the gutter column, the gutter's coral⎿/✗glyph) in its own background colour, so a failed tool call rendered as an unreadable solid band.tests/interfaces/test_textual_chat_row_contrast_3367.pygates the invariant over the (kind, state) cross-product enumerated fromDISPLAY_KINDS+EntryState. - RIGHT gutter —
ReynRightGutter(gutter.py, #3283 ④): one column, two label families, wired via flowview's additiveright_decorator/right_gutter_widthparams. flowview takes a single right decorator, so this class composes two single-purpose halves and joins whatever is non-empty: ReynTimingGutter— per-entry ELAPSED time (Ns/Nm/Nh). Only a tool-call entry that actually has timing data shows a label — LIVE while RUNNING (read off the same start marker the ② live-spinner body uses), or the FINAL captured duration once SETTLED (stashed at settle time, before the live marker is stripped).-
ReynTurnUsageGutter— TWO different figures, two different anchor rows (↑12k ↓1.8k—↑prompt,↓completion). Earlier revisions anchored both a per-call figure AND the turn total to the samekind="agent"row, falling back to the turn total whenever a specific agent row didn't carry its ownprompt_tokens— ambiguous, since the same visual slot answered two different questions depending on a hidden fact about that one frame. Split into two anchors instead (#4691 arc item ④, owner ruling):kind="agent"rows (TURN_ANCHOR_KIND) show ONLY their own per-call absolute figure, straight fromentry.item.meta— never a turn-total fallback. A row with no per-call figure (a restored/legacy frame, or an agent-kind emit site that never threaded one through) renders an EMPTY cell, never a silently-substituted number.kind="user"rows (TURN_TOTAL_ANCHOR_KIND) — the line that OPENS a turn — show the turn TOTAL via a keyed lookup overBudgetTracker's bounded per-turn buckets (BudgetTracker.turn_usageviaSession.turn_usage, reached from the status snapshot'sturn_usage_fn) — the per-turn attribution #3339 captured at the source, with the prompt/completion split accumulated alongside the total at the call. Never derived by differencing cumulative counters. Anchoring to the opening line rather than a settled reply also means the figure never repeats across a turn's multipleagentrows the way the shared-anchor design risked. A row that NAMES a turn (meta["chain_id"]) whose figure the runtime does not hold renders—, never0: a turn that made no LLM call, a turn EVICTED from the bounded buckets, an unknown chain_id, and a REMOTE client (per-turn buckets are session-local and not projected onto the wire —turn_usage_fnisNonethere, the same frame-sufficiency boundary as the past-turn log). A turn that recorded 0 tokens renders↑0 ↓0— a measured fact, kept distinct from—. A row naming NO turn at all (every RESTORED row —chain_idis not carried onto a re-projected persisted frame, and the per-turn buckets are in-memory live-session state a restart does not rehydrate) renders an EMPTY cell — nothing unknown to report on a row with no turn to report.
The turn's USD cost is deliberately not drawn here even though the lookup returns it: tokens answer the question this column exists for, and the narrower column leaves the conversation body room (a real-TTY read at 80 columns found a wider gutter left long tables and code cramped).
/costand the status line remain the spend surfaces.
A dedicated state chip, the umbrella issue's third candidate, stays dropped:
it would duplicate the left gutter's existing EntryState encoding.
A row with no data in either family renders an empty cell — no placeholder,
no "0s", no 0 tokens. Both families are live-session-only by
decision: a persisted ChatMessage carries no timing field,
project_restored_frames does not carry chain_id onto a restored frame,
and the per-turn buckets are in-memory state a restart does not rehydrate —
so a restored row's right gutter is blank, never a reconstructed value.
Both labels are painted with _CC_AMBIENT ("dim", i.e. SGR 2) rather than
a colour, so the TERMINAL's theme decides their shade (#3536). They had used
the fixed mid-grey _CC_DIM (#6b7280), which on a transparent terminal
background left them unreadable — its contrast is whatever shows through.
This applies HERE and not to _CC_DIM generally: a terminal-chosen
foreground is only safe over a terminal-chosen background, and these labels
ride solely on rows the presenter does not tint (agent,
tool_call_started). On a tinted row (_CC_USER_BG / _CC_ERR_BG, fixed
dark hex) the same substitution would be dark-on-dark for a light-terminal
user, and the row-contrast gate would stop measuring the pairing entirely —
it only inspects segments whose foreground is concrete.
The column is a fixed width derived in terminal cells
(rich.cells.cell_len, the measure Textual's own compositor applies) from
the widest label each family can emit — not from a character count. The two
direction markers are East Asian Ambiguous width; rich resolves them to one
cell, so the derivation and the renderer agree by construction.
Hiding a gutter (#3352)¶
Both columns cost their width on every row, so either can be switched off and its whole column handed back to the conversation body:
| Key | Effect |
|---|---|
ctrl+g |
Show/hide the LEFT (state-marker) gutter — 2 columns |
ctrl+t |
Show/hide the RIGHT (elapsed/tokens) gutter — 12 columns |
Both keys appear in the TUI's Help pane (sourced from the app's own
binding table). Note that ctrl+r is not available for new bindings: it is
reserved for voice input (see RESERVED_KEYS in textual_chat/chrome.py), and
it is reverse-history-search in most shells. The two sides are
independent — flowview exposes
left_gutter_visible / right_gutter_visible as two flags and reyn follows
that granularity rather than offering a single combined switch.
Hiding is a real width recovery, not a blank column: flowview counts a hidden
gutter as width 0 (left_gutter_effective_width / right_gutter_effective_width),
grows FlowView.body_width by exactly that amount and re-presents the body at
the new width. Measured on an 80×24 terminal, the body goes 66 → 78 columns
with the right gutter hidden and → 80 with both hidden. (FlowView.region.width
stays the full terminal width in all cases — it does not respond to gutter
configuration and is not the plane to read.)
The start state is config-backed (chat.gutters.left / chat.gutters.right,
both true by default); a keypress is session-scoped and never writes back
to reyn.yaml.
Textual TUI empty state — the fresh-session hint (#3476)¶
A session with no history used to open onto a blank void above the composer (owner design review). The conversation pane now paints a centred hint while the model holds no entries:
This is flowview's empty= / empty_align="middle" — an EMPTY STATE the
library itself clears the instant the first entry lands, not an app-managed
banner. That distinction is the whole point of using it: a reyn-side
show/hide would be a second piece of state to keep in step with the model,
and it would drift the first time an entry arrived through a path that
forgot to hide it. The hint is a rich.Text built by empty_state_hint()
(textual_chat/app.py) in the palette's dim tone, so it reads as ambient
guidance rather than content.
The same change made restore hydration a single FlowModel.extend call
instead of one append per frame: extend reflows the view once for the
whole page (flowview 0.6.0), where the per-entry loop reflowed once per
entry. The per-entry set_state calls that follow only repaint gutters, so
they add no reflow.
Textual TUI lazy history paging (#3476)¶
Restore materialises only the newest _HYDRATE_PAGE_FRAMES (200) frames
of the projected history. The older prefix is held aside
(_older_frames, oldest-first) and paged in a slice at a time as the user
scrolls toward it:
FlowView.ReachedTop(edge-triggered, armedreach_threshold=3rows early so the page is in place before the user arrives, and re-armed when the view retreats from the edge) →FlowModel.insert_many(0, page).insert_manyreflows once for the whole prepended slice and flowview preserves the scroll position across it, so the row being read does not move.- With nothing left to page in, the handler is a no-op. Live frames are unaffected — they append at the bottom through the frame pump and never reach this path.
_older_framesis reset by every hydrate call (initial mount AND session switch), so a switch can never page in the previous session's leftovers.
A restored tool frame's terminal EntryState is applied by ONE shared
transition (_apply_restored_state) that runs on both the hydrate and
the page-in path — a row that pages in lazily settles exactly as it would
have on first paint.
This is not a performance fix, and the doc should not imply it is. The
view-side cost of hydrating everything at once was measured small (heights
are lazily estimated, so a 40 000-frame extend costs ≈ 41 ms); paging was
adopted as deliberate forward infrastructure for histories far beyond that,
with the measurement recorded on the issue. What the tests pin is therefore
the correctness of the paging, never a timing claim.
/copy's reply ring is seeded from the FULL restored history, not from the
materialised page — what /copy N addresses is the history, and a reply in
the not-yet-paged-in prefix stays reachable. The seeding walks the frames
oldest-first with appendleft, the same direction the live pump uses: the
ring is a deque(maxlen=COPY_BUFFER_MAX) whose index 0 must be the newest
reply, and a reversed() + append seeding expressed that correctly only
while the reply count stayed under the cap — past it, append evicts from
the NEWEST side and silently inverts the "1 = newest" contract (#3486).
Textual TUI in-conversation search (#3476, #3692 PR-B ③)¶
ctrl+n opens a one-line search bar docked directly above the composer (the
last chrome region before the input row; collapsed by default). Originally
ctrl+f — moved by #3692 PR-B once flowview 0.13 gave that key its own
meaning (cursor_scroll_page_down, one member of a ctrl+b/d/e/f/u/y
vim-scroll set): reyn's search is entry-granular over the FULL conversation
model (forcing lazily-paged-in older history to materialise first) while
flowview's own */n/N is a row/character-granular cursor jump limited to
whatever is already materialised — measurably different features, so per the
issue's own decision rule the search moved rather than displacing one
vim-scroll key out of its set. ctrl+p was the first candidate and a real
trap: free by every enumeration below, yet still claimed — by Textual's own
App.COMMAND_PALETTE_BINDING, a class attribute outside the declarative
BINDINGS list this enumeration otherwise walks (measured: pressing it in a
real pilot opened the command palette, not the search bar). ctrl+n was
re-verified free against the full enumeration: TextArea's own
ctrl-bindings, flowview's owned set, RESERVED_KEYS (ctrl+r/F2), the
(separate, unbuilt) plain-CLI redesign proposal's own key table, and
Textual's App/Screen class attributes beyond BINDINGS — and pressed,
not just declared, in test_search_bar_3476.py.
| Key | Effect |
|---|---|
ctrl+n |
Open (or refocus) the bar |
Enter / ↑ |
Step to an OLDER match |
Shift+Enter / ↓ |
Step to a NEWER match |
Esc |
Close, clear the mark, return focus to the composer |
Matching is incremental: every keystroke recomputes the match set, selects the
newest hit and centres it (scroll_to_entry(align="center", animate=True)),
and the bar shows the hit's model-order position as n/M. Stepping uses
FlowView.find_previous / find_next (model order, wrapping). The arrows map
spatially — ↑ walks toward older entries, the direction the viewport
moves — so the key pressed and the way the conversation scrolls always agree;
Enter = older follows the same reasoning, since a bottom-anchored
conversation is searched backward from now.
Two decisions worth stating because the obvious alternative is wrong:
- The predicate reads the model text (
entry.item.text), neverFlowView.entry_text(). The latter returns the rendered body and is""for an entry that has not been presented yet, which would silently exclude every never-scrolled-to row from the search domain. (#4171: flowview 0.17.0 fixed this same gap in its OWN*/n/Nsearch — see "Textual TUI text cursor" below — via an optionalsearch_text=reyn now supplies; the two searches stay independent features, not a shared implementation, but neither is limited to the rendered band any more.) - Opening the bar first materialises the entire lazily-held older prefix
(one
insert_many). A hit that exists in the restored history but not in the materialised page would otherwise read as "no results" — a lie, and the measured full-hydrate cost makes paying it all at once cheaper than teaching search a second, virtual domain.
Search moves the keyboard cursor (#3493) rather than holding a selection
of its own, so the hit is marked by the one addressed-row rail described under
Textual TUI gutters above — and closing the bar keeps the cursor on the hit,
so Shift+Tab/Ctrl+O back into the pane resumes navigating from what you found. Its
keys are registered in SEARCHBAR_KEYS
(textual_chat/chrome.py) so the Help pane sources them from where they are
defined.
Textual TUI keyboard cursor (#3476, #3624)¶
The conversation pane carries an entry-level cursor
(FlowView(selectable=True)) — flowview 0.11.0 unified what used to be two
independent flags, keyboard-only highlight= and mouse-only selectable=
(0.7.0's name for what 0.6.x called cursor=), into ONE current entry driven
by both inputs; 0.12.0 (#3624) then removed the highlight= alias entirely,
so selectable=True is now the only spelling and it enables the mouse
alongside the keyboard whether reyn wants the mouse leg or not (see the hazard
below). The pane is reached via Textual's own Shift+Tab focus cycling, and
(#3692 PR-B ①) Ctrl+O — a direct jump reyn adds because flowview cannot bind
a key for "I don't have focus yet"; only the app that currently holds focus
can move it in. Esc returns to the composer (machine-verified by the
Esc-sufficiency gate, including the #3692 case where an active text-cursor
selection intercepts Esc one layer in instead — see "Textual TUI text
cursor" below). While FlowView does not hold focus these keys are unaffected;
the composer's own PageUp/PageDown scroll delegation calls actions on the
view directly and does not depend on the cursor at all.
| Key / input | Effect |
|---|---|
Ctrl+O / Shift+Tab |
Focus the pane, landing on the remembered current entry (or the newest, on first entry) |
↑ ↓ PgUp PgDn Home End |
Move the cursor (flowview's own bindings) |
Enter |
Copy the cursor entry's text to the clipboard |
Space |
Fold/unfold the highlighted entry's tool detail (#4697); inside the text cursor below, falls through to the same copy as Enter instead |
| a click | Move the cursor to the clicked entry (flowview 0.11.0+; does not copy — see below) |
r |
Open /rewind |
Esc |
Back to the composer (or, with an active text-cursor selection, cancels the selection first) |
Arriving at the pane arms the cursor on the newest entry rather than
leaving it invisible until the first arrow press: flowview's move_current
starts from current=None and only lands on an entry once a direction key (or
click) moves it (Textual's own, unrelated TextArea.move_cursor is a
same-named different API and not this one), which is a real gap for a feature
whose whole point is a visible position indicator. A remembered position is
kept across visits — leaving and re-entering resumes where you were.
Copy (Enter/Space only) — a click must not trigger it (#3624). flowview
0.11.0 made Selected fire on every commit: Enter, Space, and a click,
with nothing in the event that says which one it was (Selected.__init__
takes only flow_view/entry). reyn's pre-0.11.0 intent — Enter/Space on the
cursor entry copies it to the clipboard — cannot be read off Selected
directly any more: doing so would let one stray click silently overwrite
whatever the user had copied in a different application, possibly
credentials. reyn does not register on_flow_view_selected at all. Instead
textual_chat/app.py defines _CursorFlowView, a thin FlowView subclass
that overrides action_activate — the method flowview's own BINDINGS bind
Enter/Space to (Binding("enter", "activate", …)) — to additionally post a
private _FlowViewKeyCommitted message alongside the super() call. A click
never runs action_activate: FlowView.on_click calls self.activate()
directly, bypassing the action/binding system entirely, so the override sees
only the keyboard path. TextualChatApp.on_flow_view_key_committed is what
performs the clipboard write, keyed off that message rather than Selected.
This is a direct, ring-free path: /copy N addresses one of the last
COPY_BUFFER_MAX agent replies by ordinal, whereas the cursor points at
one exact, arbitrary entry of any kind (a user line, a tool result), so there
is no ordinal to resolve and no reason to go through the ring.
#4697 further split Space itself, on top of the Enter/Space-vs-click split
above: _CursorFlowView overrides Space's own BINDINGS entry to
action_toggle_fold instead of upstream's action_activate. Outside the text
cursor below, Space no longer reaches action_activate/the clipboard at all —
it posts ToggleFoldRequested to fold/unfold the highlighted entry's tool
detail (#4691 §6 owner ruling: highlight movement stopped auto-expanding/
folding tool detail, so a dedicated open/close key was needed). Inside the
text cursor (cursor_visible), action_toggle_fold falls through to
action_activate() — the same clipboard-copy path Enter always takes — so an
in-progress text selection is never disrupted by a stray fold. Enter's own
binding and everything above about the Enter/Space-vs-click split is
unaffected; only Space's outside-text-cursor behavior moved.
r submits a bare /rewind through the ordinary submit seam — the same
path a composer-typed /rewind takes, so the checkpoint picker and rewind's
destructive-action path are untouched. It is a fast keyboard entry point,
not a jump to the checkpoint belonging to the cursor's entry: the
conversation log's ChatMessage.seq and the WAL seq that
AgentRegistry.list_rewind_points addresses are different sequence spaces
with no correlation wired anywhere, so a targeted jump would need new
plumbing rather than a new binding.
The cursor's position is marked by the addressed-row rail described under
Textual TUI gutters above. Its keys live in CONVERSATION_CURSOR_KEYS
(textual_chat/chrome.py) for the Help pane.
Textual TUI conversation tree — Group nesting (#4691)¶
Three levels of FlowView.Entry nesting, one mechanism per level, built on
flowview's own Entry.append_child/.children/.collapsed/.toggle_collapsed()
primitives — reyn adds no tree data structure of its own:
- Turn Group — a
kind="user"row (the line that OPENS a turn) is every completion Group of that turn's PARENT. - Completion Group — a
kind="agent"row that dispatched tool calls is its own tool rows' PARENT. - Tool rows — leaves, nested under whichever completion Group's
call_idthey carry.
_resolve_append_parent (textual_chat/app.py) makes this recursive without
per-level code: it checks (1) a _call_parents[call_id] match first, (2) the
CURRENT turn's own open parent (_current_turn_parent) if no call-level match
and the frame isn't itself kind="user", (3) flat top-level otherwise (a
legacy/restored row, an op-loop caller with no call_id, or no turn open).
A turn's first kind="agent" row hits (2) — it has no parent of its OWN
yet — then registers itself into _call_parents (_register_call_parent),
so every later row sharing that call_id finds it via (1). One call_id
lookup, checked at two different moments, produces both tree levels.
Registration is provider-independent (#4777, owner-reported): gated on
dispatched_tool_calls, a REYN-OBSERVED fact router_loop.py stamps from
the LLM result's own tool_calls list — never on the provider's own
self-reported finish_reason string, which some providers never emit as
"tool_calls" even when they did dispatch one, silently disabling Group
construction end-to-end on those providers if gated on it (found live, not
in a test suite written against a provider that reports it correctly). A
terminal reply that dispatches no tools still registers (harmless — nothing
ever looks up an unused call_id) but never spins, since there is nothing
for it to wait on. Both entry-creation call sites — the ordinary
non-streaming append AND _handle_agent_delta_event's first-streamed-delta
creation — route through the same _append_frame/_resolve_append_parent
pair, closing an earlier streaming-bypass class where a streamed reply's own
entry-creation path never registered as a Group parent or nested under its
turn at all, no matter what the non-streaming path did.
Defaults, opposite by design (owner ruling):
- The turn Group defaults OPEN. Its
RUNNINGstate is SET, not derived, at promotion (_handle_turn_started_event) — deriving it from zero children (there are none yet) would show no state at all, and deriving it incrementally as completion Groups settle mid-turn would flicker the row toSUCCESSbetween calls while the turn itself is still in flight. Settling happens exactly once, at the turn's own end (_settle_turn_parent, called from the_TURN_END_EVENT_TYPESleg of frame pumping). - A completion Group defaults COLLAPSED, called at registration time
(
entry.collapse()inside_register_call_parent, guarded ondispatched_tool_calls) — even though the entry may still be a leaf with no child arrived yet. Before textual-flowview 0.22.0 this was a documented no-op on a leaf, worked around by re-asserting.collapse()at the entry's ownappend_childcall site; 0.22.0's own fix (release notes: a child appended later "walks its ancestors and is born folded") made collapse state stick on any live entry, leaf included, so that workaround is gone.
Open/close: Space, only on a row that HAS children.
on_flow_view_toggle_fold_requested checks entry.children truthy FIRST —
an exclusive signal for a Group parent, since append_child is called from
exactly one place — and calls entry.toggle_collapsed() (flowview's own
fold/unfold primitive; flowview's own za/zR/zM z-prefix key family
already reaches the same primitive through a separate path — Space just
wires the SAME primitive to a second key, not a new one) before falling
through to the settled-tool-row detail-expand check described under
Textual TUI keyboard cursor above; a Group parent's own meta carries no
_RESULT_KIND_KEY, so without this check-order it would never reach a fold
path at all. A collapsed Group parent shows its child COUNT next to the
state glyph ("(2 folded)", dim) rather than a bare, uninformative row —
deliberately minimal, no summary wording, no icon vocabulary.
Parent state is DERIVED from children, recomputed on every child settle
(_recompute_parent_state, called from each child's own settle path):
RUNNING wins if any child still is; among terminal states ERROR wins
over SUCCESS (one failed child taints the whole call); CANCELLED counts
as neither — an orphan is not a failure. The turn parent reuses this same
function one layer up at its own settle time, passing any one of its
children so the recompute walks across every completion Group of that turn.
A turn that ends with no completion Group having landed under it (cancelled
before the first call) settles CANCELLED — there is nothing to recompute
FROM, and nothing was observed to call a success.
A Group parent's own line recedes while EXPANDED (children visible) —
palette.TOKENS["@recede@"] (an SGR dim attribute, not a colour — CLAUDE.md's
TUI colour policy token, distinct from the plain REPL renderer's own hex
_CC_DIM), applied to the parent's body only; children are unchanged. Owner
ruling: the parent weakens, never the children strengthen — the reverse
would make a Group's children stand out MORE than an ordinary row, changing
how the whole conversation reads. Excluded when collapsed (that state
already recedes for a different reason — naming a hidden count, not sitting
as a structural peer beside visible children).
Textual TUI text cursor (#3507, #3692)¶
flowview 0.13 replaced the earlier entry-gated cursor-entry step with an
always-on per-character text cursor: c (flowview's own key, toggle_cursor)
just shows or hides the cursor block, and visual mode (v/V…y) is the
one real mode — there is no separate mode to enter or leave any more. This is
what the entry-level highlight cannot do: the finest keyboard position used to
be a whole entry, so selecting part of a long reply had no keyboard route at
all.
Every one of these keys is flowview's own keymap (hjkl w b e 0 $ ^ gg
G v V y zz zt zb Ctrl-E Ctrl-Y Ctrl-D Ctrl-U Ctrl-F Ctrl-B Esc, always live,
plus * / n / N to search the selection). reyn declares no key binding
of its own for any of them, including c — deliberately, so the keymap
cannot drift from upstream's; a test asserts that absence. One consequence:
the half/full-page scroll bindings (Ctrl-D/Ctrl-U/Ctrl-F/Ctrl-B,
flowview 0.10.0) were adopted automatically by the same no-own-bindings
rule — a version bump that extends upstream's keymap needs no reyn-side
change (#3624: looked at, adopted implicitly rather than as a separate
feature decision). c reaching flowview reached the same way — the 0.13 pin
bump removed reyn's own c binding along with the whole entry/exit wiring
it drove, rather than rebinding c to something new.
The interaction that matters for this surface: set_current (flowview 0.13.1)
moves the text cursor onto the currently addressed entry without moving
the addressed-row rail itself, so c (show/hide the cursor) and cursor
motion do not drag the rail along with them — 0.13.0 regressed this (c
moved the addressed row), fixed in 0.13.1. FlowView.row_count /
row_text(y) / entry_at_row(y) are the row-level primitives the text
cursor is built on, available to any consumer that needs to map content rows
back to entries.
A selection — whether from y (yank) or a mouse drag — covers the body
columns only (flowview 0.9.0): the gutters are decoration, like a scrollbar,
so a yank carries the message text and never a state glyph, an elapsed label,
or a token figure. row_text(y) is body-only for the same reason. Reading a
gutter off get_selection therefore reports an empty gutter for a perfectly
painted one — the surface that answers "is the gutter on screen?" is
render_line(y), Textual's own paint surface.
reyn.intervention.<kind>¶
An open namespace carried differently from the two above: it is the toolName
of the HITL frontend-tool TOOL_CALL_START (a standard event, not a CUSTOM
one — see Intervention frontend-tool), so a generic client can render and answer
an intervention as an ordinary tool call. <kind> is the intervention kind
(ask_user, permission.*, …) — caller-supplied, so this is profiled at the
namespace level (fixed value schema), not as a closed member set.
toolCallId— the intervention id (the answer-correlation anchor a client echoes back verbatim in aTOOL_CALL_RESULT).args—{prompt, detail, choices, suggestions}, what a generic client renders to pose the question.
The reyn.display.* and reyn.event.* namespaces above are CUSTOM-event names a
generic client ignores (skipped, not fatal); the reyn client reconstructs the exact
frame from the _reyn block. An unknown reyn.* name a client predates is likewise
skipped, not fatal.
Local ≡ remote¶
The server serializes the SAME unified frame stream the local in-process transport produces (display outbox + the renderer-relevant audit-event subset). The AG-UI transport adds only wire framing, never new render semantics — so the remote renderer's display bytes and working-indicator transitions are identical to the local ones.
Local ≡ remote holds at the renderer/loop layer, not just the transport. The
interactive-surface choice (Claude Code-style TUI on an interactive TTY, plain
console for --cui / non-TTY / piped) is one shared seam
(renderer.uses_app_input() and is_tty, the same predicate make_renderer uses
behind _inline_interactive), and both reyn chat and reyn chat --connect hand
a ClientTransport + a ChatReadModel to the SAME driver
(client_driver.run_chat_client). On an interactive TTY that driver routes to the
Textual conversation-pane app (reyn.interfaces.inline.textual_chat, the #3273
TUI rebuild), which owns both input and output and drains the SAME
transport.frames() stream — so an interactive remote attach renders the TUI, not
a plain fallback, from the identical frame stream a local attach consumes. The
client reads its status bar / intervention region / task poll through the
read-model: a RegistryReadModel off the local session, or a RemoteReadModel
off the STATE_* view above.
Local ≡ remote holds for INPUT too, symmetric with output. A resolved
intervention answer (InterventionHandler.deliver_answer_to — the one funnel
every answer path shares: TUI free-text, the Textual TUI's grouped
intervention panel (reyn.interfaces.inline.textual_chat.intervention_panel,
3299 P1/P2, tab-ified #3308 P5 — one tab per PENDING intervention, each a¶
closed-set RadioSet or free-text Input, between the conversation and the
input row, replacing the earlier in-flow chip surface; answering a tab
delivers targeted at THAT intervention's id — R1 by-id delivery — and marks
it ✓/inert without removing it, so several simultaneously-outstanding
interventions are each independently answerable, in any order, without one
displacing another), an A2A peer, and the AG-UI HITL round-trip above) emits
an intervention_answer_submitted audit-event (#3300 — event-ifying the LAST
outbox kind="user" broadcast site, following the user_submitted precedent
below exactly). A submitted turn (Session.submit_user_text) emits the
sibling user_submitted audit-event (#3300 P1 C — replacing an earlier
outbox-echo write, a category error: an INPUT written into the display/OUTPUT
channel). Both ride the SAME unified frame stream as an EventFrame
(_TURN_AND_ANSWER_EVENTS, transport/frames.py) — the encode/decode is
generic (transport/agui/protocol.py), so no wire changes were needed for
either event type. Every attached surface's event→display handler
(ConsoleChatRenderer.on_audit_event / InlineChatRenderer.on_audit_event /
TextualChatApp._pump_frames) renders the line, neutralizing at that render
boundary (renderer.user_submitted_display_message /
renderer.intervention_answer_display_message —
TextualChatApp._handle_intervention_answer_event for the Textual surface) —
except the one client whose own terminal already showed it, for
user_submitted only (an intervention answer has no client-local echo to
de-duplicate against — the panel/composer never prints the answer itself, so
every attached surface, including the answering one, renders off THIS event
with no suppression logic). On the plain PromptSession
loop (--cui / chat.render_mode: plain / non-TTY, stream_client.py), an
interactive TTY's prompt_session.prompt_async leaves the typed line on
screen the instant Enter is pressed — that already IS the echo. Re-rendering
it again from the broadcast user_submitted event printed every LLM-round-
trip turn's own line twice (#3287; a local /quit never reaches
submit_user_text, so it never doubled — the asymmetry the bug report
noticed). The fix is ownership, not suppression-by-default, and uses TWO
DIFFERENT correlation mechanisms — one per transport shape, neither by text:
- Local (
InProcessTransport):route_input_linerecords themsg_iditstransport.submit_user_textcall RETURNS (the SAME correlation iduser_submitted'smsg_idfield carries, #3300 P2a) in a small set (own_submissions, owned per client-loop-pair, never shared across clients);run_output_loopskips re-rendering auser_submittedevent only when itsmsg_idmatches an entry in THIS client's own set.ClientTransport.submit_user_textreturns the assignedmsg_id(previouslyNone) —InProcessTransportreturnsSession.submit_user_text's own return value directly, same-task and race-free (nothing yields between the audit-event emit and the id reaching the caller). - Remote (
AgUiTransport): matches the broadcast event'smeta.auth_connection_idagainst the client's OWNconnection_idinstead (remote_client.pymints it client-side withuuid.uuid4()BEFORE any submit and stamps it on every POST; the AG-UI endpoint'suser_messagehandler already attributes every submit with it — #3300's existing multi-client display plumbing,endpoint.py→session.py'smeta→ the broadcast event, unchanged wire shape). Known up-front, with no dependency on any other channel — see "closing the race" below.
Either mechanism: every other attached client's turns (and this client's own turns when non-interactive, where nothing else echoes the line) still render normally. With 2+ clients attached, everyone still sees every OTHER client's turn and every answer, not only the agent's replies to them; each client just stops duplicating its own.
Correlation is by identity, never by text — an earlier revision matched
by text and was caught in review (co-vet finding F1 on #3309): two attached
clients submitting the identical short line (e.g. both answering "yes")
would cross-match, simultaneously swallowing the OTHER client's turn and
leaving THIS client's own turn to double-print later, reintroducing the bug
through a different door. msg_id (#3300 P2a) and auth_connection_id
(#3300, multi-client attribution) were both added SPECIFICALLY as identity
fields — never form-sniffed from content — so two different submissions
never collide even with identical text.
Closing the race, not just narrowing it (co-vet finding F2 on #3309): an
earlier revision used msg_id for the remote path too, reading it from the
POST response body — but that id only becomes visible once the POST
returns, and the server may already have pushed the SSE broadcast for the
same submission over the INDEPENDENT events connection in the interim, a
network-ordering race between the two channels. The reviewer pointed out
this is unnecessary: meta.auth_connection_id is the client's own identity,
known BEFORE the submit even happens — matching on it needs no second
channel to resolve at all, closing the race structurally rather than
documenting it as an accepted residual. msg_id remains load-bearing for a
different reason on the remote path: #3300 Y-client (cancel-by-id) needs the
client to learn its own message id regardless of transport, so
AgUiTransport.submit_user_text still returns it — it is simply no longer
what remote echo-suppression correlates on.
AG-UI event coverage — reading the numbers honestly¶
Frame loss is zero and reyn-client fidelity is 100%, regardless of the
numbers below. Every event carries the reyn-private _reyn reconstruction
block (see Standard envelope, reyn-private richness above); the reyn client
always recovers the exact original frame from it. The coverage figures in this
section describe something different: how much of the AG-UI standard
event vocabulary — the signal a generic, non-reyn AG-UI client can render
without any reyn-specific knowledge — reyn currently emits natively, as
opposed to folding into a CUSTOM event a generic client has to skip. A low
number here is a statement about generic-client richness, not about data
loss.
| Category | Standard events | reyn-mapped | Disposition |
|---|---|---|---|
| State | 3 | 3 | complete |
| Lifecycle | 5 | 3 | intentional-scope — the 2 Step events fold into the STATE_* read-model's waiting_on field instead of a separate standard event (see STATE_* — the status read-model above) |
| Tool | 5 | 3 | complete for the HITL round-trip — TOOL_CALL_START + TOOL_CALL_END (with a standard status field) + TOOL_CALL_RESULT (the intervention frontend-tool answer round-trip); the TOOL_CALL_ARGS/_CHUNK pair is intentional-scope (a tool call is already complete by the time reyn emits it — there is no in-flight args stream to chunk) |
| Text | 4 | 3 | conforming triplet, plain and streamed — a whole message rides TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT → TEXT_MESSAGE_END, correlated by messageId; a message that streamed (#3288 ③a/③b/③d) rides the SAME triplet with a REAL per-delta TEXT_MESSAGE_CONTENT for each chunk (see Text lifecycle above) — only the condensed single-event TEXT_MESSAGE_CHUNK variant is unmapped (intentional-scope — the triplet form already covers streaming; reyn has no use for the alternate condensed encoding) |
| Special | 2 | 1 | intentional-scope — reyn-private payloads are always structured (CUSTOM); the standard RAW passthrough event has no reyn use case |
| Activity | 2 | 0 | intentional-scope — reyn has no direct analog; the same information is already carried by the frame stream + STATE_* |
| Reasoning | 7 | 3 | standard-mapped — a whole reasoning message rides REASONING_MESSAGE_START → REASONING_MESSAGE_CONTENT → REASONING_MESSAGE_END, correlated by messageId; the outer REASONING_START/REASONING_END context wrapper and the streaming REASONING_MESSAGE_CHUNK/REASONING_ENCRYPTED_VALUE variants are intentional-scope (reyn is whole-message; no encrypted CoT) |
Totals: reyn natively emits 15 of the 28 active-roster standard events
(16/28 counting the CUSTOM catch-all itself as one). The 28-event roster is
Lifecycle (5) + Text (4) + Tool (5) + State (3) + Activity (2) + Reasoning (7)
+ Special (2), tallied from the canonical AG-UI event reference
(https://docs.ag-ui.com/concepts/events). That reference self-reports up to
~34 event names in total when meta/deprecated/draft entries outside the
active roster are counted — the exact figure is spec-version dependent, so
this page tracks the 28-event active roster, not the larger number.
Why the gaps are dispositioned the way they are¶
- Reasoning (standard-mapped). reyn treats reasoning as a first-class
concept, and a reasoning display frame now maps to the standard reasoning
message triplet (
REASONING_MESSAGE_START→REASONING_MESSAGE_CONTENT→REASONING_MESSAGE_END), so a generic AG-UI client renders it directly instead of skipping aCUSTOMpayload. Two boundaries are respected (see reasoning lifecycle): the reasoning-display toggle is honored by construction — a reasoning frame only exists when display is on, so display off ⇒ zeroREASONING_*events, and the mapping adds no new gate — and the reasoning chain-of-thought stays a display signal only, never routed to the observability export (the OTLP content-off default is unaffected). The outerREASONING_START/REASONING_ENDwrapper and the streaming chunk/encrypted variants are intentional-scope (reyn is whole-message). - Everything marked intentional-scope reflects a real architectural difference (reyn's whole-message outbox, structured-only private payloads, no in-flight tool-args phase, no direct "activity" concept) rather than an oversight — closing these gaps would mean inventing streaming/chunking machinery reyn's design deliberately does not have, not fixing a bug.