* fix(api): split phases at substantial text
* tune(api): split phases after 200 text chars
* fix(api): reanchor substantial text boundaries
* test(api): type multi-phase payload captures
* fix(api): preserve activity phase boundaries
* fix(api): anchor retained phase partitions
* fix(api): persist phase partition anchors
* fix(api): harden activity phase boundaries
* fix(api): preserve bounded phase partitions
* fix: preserve final and delayed phase content
* refactor(api): partition phase state at one boundary
Boundary closure split fifteen separately-maintained fields by hand, and
each fix partitioned one more while the next stayed unguarded. Fold the
overflow bookkeeping into the tracked activity list so every counted
activity carries a position, and route the split through a single
partitionAt that returns both sides.
Counts are now summed from the partition instead of reconstructed by
subtraction, so a run past the anchor budget reports every activity it
performed rather than the truncated window. Snapshots move to version 3;
the reader still accepts versions 1 and 2 and rebuilds their unpositioned
remainder as a bounded anchor, dropping it when its evidence is stale.
Adds a boundary-conservation property covering every split point.
* fix(client): drop empty phase content segments
Late-child recovery can strip every index from a segment it already
claimed, leaving a content segment with no parts. Each one still mounts a
nested ContentParts that renders nothing, and it broke the exact-segment
expectation in the late-child regression from 831a00353.
Route the four content pushes through one guard that skips index-less
segments, matching the existing splice of fully recovered segments.
* fix(api): keep the run's answer outside the collapsed phase
The substantial-text boundary replaced completion's final-text boundary
outright, so a short reply from a provider that emits no phase metadata
was folded into the collapsed parent. That is the deterministic e2e
failure at activity-phases.spec.ts:182 and codex's short-final-answer
findings; 831a00353 fixed only the path where the provider labels the
step final_answer.
Restore the completion boundary at the last materialized visible text
whatever its length. Length now decides only whether intermediate text
earns a boundary, and semantic commentary still stays inside. The
"later work" check shares one predicate with partitionAt so the two
cannot drift.
Also splits a legacy v1/v2 remainder across the positions its saved tool
anchors still materialize at, each carrying its own id so it can be
located, and merges over-cap anchors by closest pair into the earlier
position instead of folding the oldest forward.
* fix(api): clear resolved anchors and keep folded agents
Two findings from the latest review:
A resumed activity whose tool was missing at construction kept its high
fallback anchor after that tool materialized at a lower index, so the
partition rejected it at any boundary below the stale value and pushed
pre-boundary work into the following phase. Drop the anchor once every
tracked call has materialized.
Folding anchors past the cap spread only the surviving side, silently
dropping the other's agent. close() now derives both marker attribution
and the summarizer payload from the partitioned activities, so a merged
anchor carries the union instead.
Both regressions are mutation-checked against their own fix.
* fix(api): anchor live batches awaiting materialization
A batch tracked after its child-label slot is reserved but before its
tool call reaches the shared array had no materialized position, and the
partition read "nothing materialized" as "happened earlier". A boundary
between the two then counted the batch in the earlier phase while ending
before its eventual tool call, stranding the tool outside its parent.
Record the tracked start as an unresolved anchor in that window so the
existing retain branch keeps the batch on its own side. Using the plain
fallback index instead is wrong: restored evidence-less activities carry
a synthesized index, not a position.
Regression is mutation-checked against its own fix.
* perf(api): partition in one pass and reanchor filtered batches
Dropping a batch's already-covered calls leaves a different activity
behind, but the batch position was still the covered call's index. The
survivor therefore inherited a position inside an emitted phase and was
consumed by it instead of being held for its own. Re-derive the start
from the retained ids, which also restores the unresolved-anchor signal
when none of them have materialized.
The boundary partition also classified every activity twice and rescanned
retained ones to reanchor, walking the shared content array several times
per activity per boundary. Build both sides in one pass with the
materialized tool indices computed once and threaded into the predicate.
Regression is mutation-checked; an earlier version of it was vacuous
because the tool materialized before completion, converging both paths.
* perf(api): carry tool indices through boundary resolution
The previous pass cached the materialized indices only in the partition
loop, so resolution still scanned for the batch start and again for the
fully-materialized check, and an empty result triggered a third scan
inside the boundary predicate.
Walk the shared content array once per activity and carry the indices
through resolution, classification, and reanchoring. findTrackedToolStart
becomes its own first element and is dropped.
* fix: trust rendered position over registration order
Three findings from the latest review:
Context partitioning only consulted the rendered index when the activity
position tied the closing count, so a parallel lane registered before the
closing tool hooks was assigned to the earlier phase despite rendering
after the boundary. An activity position is registration order; a
materialized index is proof, and now wins whenever it has one.
Snapshot restore bound pending reasoning to the first part sharing its
80-character anchor, which could replay a still-pending lane on the
earlier side of a boundary and delete it. An ambiguous anchor is treated
as unresolved.
Recovering the only filled child label out of a phase segment left its
hasContent flag set, rendering an expandable card with an empty body.
The context regression is mutation-checked against its own fix.
* fix(api): decide context by proven position, both directions
The previous change let a rendered index override registration order only
when it proved the text was after the boundary, and trusted that index
even when it was not provably this entry's.
Both gaps were reachable. Context registered after work that already
rendered ahead of it was retained despite rendering before the boundary,
and a restored entry with no step id whose excerpt repeats after the
boundary matched the later occurrence and moved to the wrong phase.
Locating now reports whether the position is authoritative — anchored by
a step index or a unique text match — and only then decides, in both
directions. Otherwise the saved activity position stands.
Each regression is mutation-checked against its own direction.
* fix(api): carry unresolved positions through folded anchors
Folding two anchors spread only the surviving side, dropping the later
one's unresolved fallback. A boundary between them then saw just the
earlier materialized tool index and closed the whole merged count,
counting work whose tool call had not appeared and leaving that call
outside its parent.
Carry the later fallback into the merged anchor; resolution already
clears it once every retained id materializes.
Regression is mutation-checked against its own fix.
* fix(client): keep phase headers recovery did not empty
A completed phase can carry no children after compaction — its summary
header is the whole segment. Recovery spliced any segment left with no
retained indices, so a later marker deleted that header even though it
recovered nothing from it.
Only drop a segment recovery actually emptied, not one that arrived
empty. Regression is mutation-checked against its own fix.