Re-pin the chat contracts on the activity-block predicate

The combined tree's full serial suite ended with 30 failures; 21 of them were
static string contracts and source pins that still described the sticky
card-presence machinery WP-G deleted (taskUiStates, forceCard/forceTaskCard,
markTaskToolCall, markAssistantReply, scheduleTaskUiCleanup, retiredTaskIds,
the cardVisible latch, the replay `needsVisibleTerminal` branch, the
applyEventTelemetry closure and the HTML-template conversion button). Every
one of them is now pinned on the mechanism that replaced it, so the contracts
say what the code does instead of guarding a deleted symbol:

- presence is ONE predicate (`blockVisible`) whose inputs are record facts,
  including that the completion note is not content and that a terminal
  outcome other than Done keeps the block;
- `ensureLiveCardVisible` both mounts and unmounts from that predicate, and
  the rebuild sweep re-runs it for every record instead of reading a latch;
- tool rows are content keyed `tool:<group>:<call id>`, so a failure or
  timeout evolves its own call's row, and replay shows the one summary row
  `noteToolMetrics` derives from the recorded per-tool counts;
- the refetch after a lost task_done is owed only by a visible block;
- a lineage-known child is minted as its parent's nested card by whichever
  path reaches it first, and the conversion button carries the design-system
  role and marker on the node the chrome sync builds.

Two fixture group ids and one test name that named the retirement/force
writers were renamed to the behaviour they now pin; the `retiredTaskIds.clear`
row is dropped because the stronger `retiredTaskIds`-absent pin owns that fact.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-15 19:38:02 +03:00
parent e40a3334da
commit 16b8895ced
4 changed files with 39 additions and 11 deletions

File diff suppressed because one or more lines are too long

View file

@ -531,7 +531,14 @@ def test_project_lifecycle_rows_render_design_system_action_static_contract():
# the shared design-system role beside their `btn btn-xs btn-danger` sibling.
assert "chat-live-project-btn" not in chat
assert "chat-live-project-btn" not in style
assert 'class="btn btn-xs btn-default" data-turn-into-project' in chat
# The conversion button is now built by the chrome sync from the record's
# facts (an HTML template could not be re-derived when a turn is direct),
# so the design-system role and the marker app.js queries are set on the
# node itself.
chrome = chat[chat.index("function syncBlockChrome(record) {"):chat.index("function syncCancelRunButton(record) {")]
assert "btn.className = 'btn btn-xs btn-default';" in chrome
assert "btn.dataset.turnIntoProject = '1';" in chrome
assert "btn.textContent = 'Turn into project';" in chrome
# The identity chip keeps its own role, now built once in ui_helpers and
# shared by the converted card (chat.js) and the bound-task footer (app.js).
assert "chat-live-project-card-btn" in helpers
@ -703,7 +710,9 @@ def test_web_frames_keep_reference_order_and_one_authored_reply():
assert reference.index("isModelWaitReference(row)") < reference.index("reviewReferenceFromRow(row)")
logs = chat[chat.index("function updateLiveCardFromLogEvent"):chat.index("function addMessage")]
assert logs.index("handleCardReference(evt)") < logs.index("const taskId = getLogTaskGroupId(evt)")
assert logs.index("handleCardReference(evt)") < logs.index("applyEventTelemetry")
# Tool accounting (the telemetry closure's one surviving job) also runs
# after the reference seam.
assert logs.index("handleCardReference(evt)") < logs.index("noteToolMetrics(taskId, evt, rawTs)")
history = chat[chat.index("function applyHistoryMessages"):chat.index("async function syncHistory")]
assert history.index("handleCardReference(msg)") < history.index("updateLiveCardFromProgressMessage(msg,")
fanout = chat[chat.index("onWs('chat'"):chat.index("onWs('message_annotation'")]

View file

@ -287,13 +287,21 @@ def test_task_done_live_summary_distinguishes_typed_failure():
assert "headline: presentation.headline" in source
def test_chat_warning_task_summaries_force_visible_cards():
def test_chat_warning_task_summaries_keep_a_visible_block():
"""A task that ended warn/error/cancelled keeps its block in the transcript.
No writer forces that any more (the sticky `forceCard` flag and the replay's
`needsVisibleTerminal` branch are gone): the ONE presence predicate admits
any terminal outcome whose phase chip is not `done`, so the same summary
row mounts the block live, after a reload and after a reconnect, while a
zero-tool Done turn leaves nothing behind.
"""
source = _read("web/modules/chat.js")
assert "summary.terminal && summary.phase === 'warn'" in source
assert (
"const needsVisibleTerminal = severity === 'error' || severity === 'warn'"
" || severity === 'cancelled';"
) in source
predicate = source[source.index("function blockVisible(record) {"):source.index("function noteDirectTurn")]
assert "|| (record.finished && record.phaseEl?.dataset?.phase !== 'done');" in predicate
# The completion note itself is not content, so Done alone admits nothing.
assert "|| record.items.some((item) => !String(item.dedupeKey || '').startsWith('task_done|'))" in predicate
assert "forceCard" not in source
def test_chat_scrolls_to_bottom_after_first_history_load():
@ -319,7 +327,13 @@ def test_chat_scrolls_to_bottom_after_first_history_load():
"The anchors factory must receive the live-card registry it reads"
assert "liveCardRecords.get(entry.taskId)" in anchor_source, \
"A rebuilt live card whose earliest timestamp changed needs canonical task lookup"
assert "reorderExisting: anchorMovedEarlier" in source, \
# One reanchor owner: a card is re-sorted only when its own anchor actually
# moved earlier (or its history position did), never on every mutation.
assert "const movedEarlier = stampNodeTimestamp(record.root, rawTs, { anchor: true });" in source, \
"The reanchor owner must read the node's own anchor move"
assert "if (!movedEarlier && !positionChanged) return false;" in source, \
"An unmoved anchor must not re-sort a mounted card"
assert "ensureLiveCardVisible(record, { reorderExisting: true });" in source, \
"A mounted task card must be re-sorted if a later event lowers its anchor"
assert "record._anchorOrderDirty = true;" in source
assert "reorderDirtyCardIfNeeded(rec);" in source, \

View file

@ -66,7 +66,12 @@ def test_replay_learns_subagent_lineage_before_merging_card_rows():
# child card; ephemeral registration now lives in the early reference seam.
assert fanout.index("learnSubagentLineage(msg);") < fanout.index("updateLiveCardFromProgressMessage(msg,")
assert fanout.index("learnSubagentLineage(msg);") < fanout.index("routeSubagentFinalMessageToCard(explicitTaskId, msg)")
assert "forceTaskCard(childId, rawTs);" in src
# A lineage-known child is minted as its parent's nested card by whichever
# path reaches it first (#636) — no sticky force writer: the parent's anchor
# follows the child's frame and the predicate always admits a child block.
assert "reanchorTaskCard(getLiveCardRecord(parentId), rawTs);" in src
assert "const record = getSubagentCardRecord(childId, parentId, role);" in src
assert "if (!record || record.isSubagent) return true;" in src
# A child is locked terminal from EITHER a terminal subagent event OR a
# genuinely-settled server task_terminal_status; interrupted stays retryable.
assert "const replayTerminal = msg.task_terminal_status" in src