Raxol. Harness. Projection. BlockBuilder
(Raxol v2.6.1)
View Source
Folds one turn's family: :loop events into %Block{}s.
Two-pass per turn:
fold_items/1-- a single streaming pass over the turn's events (already id-recovered byRaxol.Harness.Projection.Recovery) that groups events byitem_id(an "item-group"), detects orphanitem_completedrecords (no matchingitem_started, §4.1), drops lateitem_deltas arriving after their item sealed (N-SEAL), and accumulates the live tail for items that never completed.build_blocks/2-- walks the completed item-groups in order with 1-item lookahead: a well-formedtool_useimmediately followed by a well-formedtool_resultmerges into ONE:tool_callblock (Block has no separate:tool_resultkind -- the merge is how a tool round-trip becomes a single renderable unit). Everything else becomes its own block.
approval_requested events carry their own complete payload (no
item lifecycle) and are folded as singleton groups, always
well-formed, never eligible for the tool-call merge.
The duration fix (STATE note)
Block.from_events/3's own duration_from_timestamps/1 finds the
FIRST item_started and FIRST item_completed in whatever event
list it's given -- correct for a single item, wrong for a merged
tool_use+tool_result pair (it reports the tool_use's own start/complete
span, not the full call-to-result span). This module owns the fix:
every :tool_call block's outcome.duration_ms is recomputed here
from the min/max timestamp across ALL of its source events, overriding
Block's narrower calculation post-construction.
Recovered-block provenance
Block has no provenance field (frozen struct, T4's write-set).
Orphan/opaque-kind recovered blocks are flagged via two extra keys
merged into block.content -- :recovered (boolean) and
:recovered_reasons (a list, since orphan-ness and unknown-kind are
independent and a block can be both at once) -- which is safe: every
Block.render/2 content pattern match binds only the specific keys
it needs, so extra keys are inert. Both keys come from
Raxol.Harness.Projection.Recovery.recovery_meta_keys/0, the same
source Projection.transcript_identity/1 strips back out.
Live-tail keys are per-turn, not global
build_tail/2 keys the live tail by {turn_id, item_id}, not raw
item_id. Item ids are only guaranteed unique WITHIN a turn; a
producer reusing "i1" in a later turn (while an earlier turn's
"i1" is still unsealed) would otherwise collide when
Raxol.Harness.Projection.project_turn/3 merges each turn's tail
into the session-wide accumulator, silently dropping the earlier
turn's live entry.
Live-tail delta buffer is bounded
An item that never completes (item_started with no matching
item_completed) accumulates its item_delta chunks forever unless
capped. fold_event(:item_delta, ...) keeps a sliding window of the
most recent @max_tail_delta_chunks chunks per item, diagnosing
:delta_buffer_capped once per item the first time a chunk is
dropped (not on every subsequent delta).
Completion evidence (the honesty row)
After a turn's blocks are built, build_turn/3 scans the turn's raw
events for the LAST turn_completed whose payload final is true
(string or atom key -- the wire is string-keyed JSON, live producer
events are atom-keyed, see Raxol.Agent.Contract.gated_done_payload/4,
read-only ground truth in packages/raxol_agent). A non-final turn
(no such event) is untouched -- byte-identical output, no :completion
key anywhere.
When a final turn_completed IS found and the turn produced at least
one block, content.completion is merged into the turn's LAST block
only (the one a "done" claim attaches to):
refsmissing, not a list, or an empty list ->%{evidence: :none}-- the design creed's mandatory absence row (never blank, never a checkmark).- otherwise ->
%{evidence: entries, total: n, type_counts: counts},entriescapped at@max_completion_entries(Block.render/2's completion row shows "+N more" for the rest),nthe FULL ref count, andcountsa[%{type:, count:}]list (descending count, ties broken by first-appearance order amongrefs) covering EVERY ref, not just the capped entries -- the summary line's breakdown must be accurate even when most refs are never individually rendered. Maps, not{type, count}tuples: this content rides throughJason.encode!/1at bless time, which has noEncoderfor tuples.
Refs are SESSION-scoped journal offsets, not turn-scoped -- and why
Per the frozen offset law (a ref is a journal event id, and ids are
session-wide), resolution is against the WHOLE session's id-recovered
event stream, not just the owning turn's own events. Projection. project/2 builds one id -> event index over the id-recovered
stream (before family partition or turn bucketing) and threads it
into every turn's build_turn/3 call; this module never re-derives
it and never assumes a ref lives in the events it was handed.
This is a DISPLAY-side decision, not a re-implementation of the
producer's evidence gate: the honest producer
(Raxol.Agent.Contract.gated_done_payload/4 / the evidence gate it
consults) only ever emits gate-accepted, SAME-turn refs -- the gate is
the sole acceptance authority. A direct call back into the gate is
impossible (raxol_agent depends on main raxol, never the reverse),
but that only rules out CALLING it -- WHICH of its predicates to
re-derive locally is a decision, made on predicate stability:
- Closed predicates are mirrored. Index existence (a rejected
missing_refrenders"unresolvable evidence ref"), item_type (a rejectednot_evidenceis disclosed by the type breakdown -- renders "1 message", never "1 tool result"), and turn_id equality (a rejectedforeign_turnrenders markedcross_turn: true, see below) are decidable from frozen wire facts and can never diverge from the gate's own reading of them. - Open predicates are knowingly NOT mirrored -- see "Knowingly unmarked" below.
Session-scope resolution here is a deliberate display-side SUPERSET of
what an honest producer emits -- a claim citing a ref the gate would
have rejected as foreign-turn is SHOWN, marked, rather than hidden or
silently resolved as if unremarkable. type carries the "what kind"
signal, cross_turn the orthogonal "which turn" signal.
Knowingly unmarked: stale and mutation-echo
A same-turn ref the gate would reject as stale_evidence (the cited
result predates a later mutation in the claiming turn) or as
mutation_echo (the last mutation's own result echo, verifying
nothing) renders as ordinary, UNMARKED evidence. This is a conscious
decision, pinned by tests, not an oversight. Both predicates consume
the gate's mutation predicate, which is an OPEN predicate: today it is
fail-safe ("every completed tool_use is a mutation ... and not
classified_effect_free?", where classified_effect_free?/1 is
constantly false), but that private function is an explicit, designed
refinement seam -- a structural effect classification is planned to
remove effect-free tools from the mutation set. Mirroring today's
everything-mutates reading into this renderer would freeze it here:
once the seam is filled, gate-ACCEPTED evidence (accepted precisely
because the intervening tools were classified effect-free) would
render with false stale/echo marks -- the display would accuse honest
evidence, which burns trust in the marks that ARE reliable. The
closed-predicate marks above never have that failure mode.
Bound on the residual: the current gate is fail-closed on journals
without effect classification, and the producer attaches refs ONLY
on gate-accept -- so today's honest wire never carries refs at all,
and an unmarked stale/echo render is reachable only through a
tampered or synthetic journal. Even there, the closed-predicate marks
and the projection's damaged flag still catch most tampering
shapes; what remains unmarked is exactly a same-turn, evidence-class,
index-resolvable ref whose only defect is ordering relative to
mutations -- a defect only the gate's evolving mutation predicate can
judge without false accusations.
Cross-turn disclosure
Every resolved entry, and a session-wide tally alongside type_counts,
compares the resolved event's OWN turn_id against the CLAIMING
turn's turn_id (the final turn_completed event's own turn_id --
not a turn a same-turn :tool_call block happens to belong to):
- a mismatch adds
cross_turn: trueto that entry's map (put_presentstyle -- a same-turn entry's map shape is UNCHANGED, so an all-same-turn completion churns nothing that wasn't already there before this fix). cross_turn_count(tallied over ALL refs, not just the capped entries -- the identical "every ref, not just the shown ones" disciplinetype_countsalready follows) is added to the completion map ONLY when it is greater than zero, for the same no-churn reason.
Per-ref resolution never raises (defensive, bounded, always returns
%{ref:, type:, label:}, plus cross_turn: true when applicable):
- ref missing from the session index ->
type: :unresolvable,label: "unresolvable evidence ref"(literal, rendered verbatim -- an unresolvable ref is information, never silently dropped, and it still counts toward the summary line's breakdown). Never marked cross-turn: there is no resolved event to compare a turn_id against, and cross-turn is a "which turn did this citation come from" signal, not a synonym for "unresolvable". - otherwise ->
typeis the resolved event'sitem_typewhen it is anitem_completed(:tool_result,:message, ...), else:unknown.labelprefers a same-turn:tool_callblock's owncontent.name(nicer, already-extracted display name) when the ref lands in THIS turn's own blocks -- naturally absent for a cross-turn ref, since a different turn's own built blocks were never searched -- falling back to the raw event's own"name"payload field; that name is joined with the resolved event's content's first non-blank line via" — "when both are present, either alone when only one is, and the type name itself when neither resolves to anything displayable.
Every resolved label is sanitized (control-byte strip, mirroring
Raxol.Harness.Surface.ViewText.sanitize/1's byte-wise technique
verbatim, but with NO \t exception -- a completion label is a single
inline cell, never a multi-line body) and clamped to 32 display
columns (Raxol.UI.TextLayout.truncate/3, never String.length/
String.slice -- see that module's own width-safety contract) before
it ever reaches a block's content: labels originate from the LLM/agent
side and are untrusted exactly like any other tool-call/tool-result
payload this module already folds.
Known conflation (fail-open wire)
The producer emits final: true with NO refs key for at least THREE
indistinguishable states: gate-rejected evidence, a "done" citing
nothing at all, and a trivial tool-free turn with nothing to cite. The
wire carries no rejection marker distinguishing them, so the absence
row (%{evidence: :none}, "no evidence provided") is INTENTIONALLY
unconditional -- per the ratified design creed, absence renders "no
evidence provided" explicitly regardless of WHICH of the three states
produced it. Disambiguating them needs a producer-side wire change
(tracked cross-lane, not this module's concern); the zero-tool-turn
policy question is deliberately parked in the producer's gate. Do not
weaken the absence row trying to guess which of the three states it
was.
Staleness under compaction
Refs are stable, counter-derived journal ids -- a reattach at a later
offset does not shift them. But an event compacted or dropped upstream
(before this projection ever sees it) leaves the session index without
that key, and the ref renders "unresolvable evidence ref" exactly as
if the ref had never existed at all -- this module cannot tell the two
apart BY ITSELF. A consumer CAN, though, one level up: an interior
drop produces a forward id gap, which sets the whole projection's
damaged: true flag (Raxol.Harness.Projection.Recovery.filter_ids/1).
Unresolvable-in-a-damaged-stream (a possible compaction victim) is
therefore distinguishable from unresolvable-in-an-intact-stream (the
ref never existed) by checking projection.damaged, not by anything
this module's own output carries.
A final turn_completed with ZERO blocks in the turn (nothing to
attach honesty to) synthesizes no block -- turn_completed stays
structural/never block-producing (the frozen P-TIER-01 tier law) --
and instead diagnoses :final_completion_without_blocks via
Recovery.emit/2, same as every other recovered condition this module
reports.
Summary
Functions
Folds one turn's events into {blocks, tail, diagnostics}.
fold_defaults maps a Block.kind() to its initial fold state.
session_index is the whole session's id -> event map (built by
Raxol.Harness.Projection.project/2 over the id-recovered stream,
BEFORE turn bucketing) -- completion-evidence refs are session-scoped
journal offsets, not turn-scoped, so resolution reaches beyond this
turn's own events (see the moduledoc's "Refs are SESSION-scoped").
Defaults to %{} so a ref genuinely absent from the session resolves
as unresolvable rather than raising.
Functions
@spec build_turn([map()], map(), map()) :: {[Raxol.UI.Components.Harness.Block.t()], map(), [Raxol.Harness.Projection.Recovery.diagnostic()]}
Folds one turn's events into {blocks, tail, diagnostics}.
fold_defaults maps a Block.kind() to its initial fold state.
session_index is the whole session's id -> event map (built by
Raxol.Harness.Projection.project/2 over the id-recovered stream,
BEFORE turn bucketing) -- completion-evidence refs are session-scoped
journal offsets, not turn-scoped, so resolution reaches beyond this
turn's own events (see the moduledoc's "Refs are SESSION-scoped").
Defaults to %{} so a ref genuinely absent from the session resolves
as unresolvable rather than raising.