fix: preserve Markdown content and readable host messages

Keep literal code and inert image references, restore the accepted reading hierarchy and wrapping table grid, and distinguish host notices from provider and reviewer words with resolvable source links.

Reconstituted from the byte-verified reviewed integration tree, with the new transport size-band rationale corrected at its first publication. Original history, reviews and both stashes remain retained on the recovery line.
This commit is contained in:
Ouroboros 2026-09-30 14:31:54 +03:00
parent dd61d6946b
commit 5f8af91023
49 changed files with 3880 additions and 290 deletions

View file

@ -58,6 +58,17 @@ Rules:
- Control chrome keeps its own dimension tokens (`--button-font-size`,
`--pill-font-size`). They are control geometry, not the reading scale; do not
replace them with type tokens or vice versa.
- **The one reading exception: a full rich chat answer.** A chat bubble whose
message is rich Markdown (an answer, the Project completion mirror, a
`markdown: true` System row) is long text at the bubble's 16px reading
size. Its headings step up from that text: `--md-heading-major` (1.25em,
20px) for `#` and `##`, `--md-heading-minor` (1.125em, 18px) for `###`,
the reading size for deeper levels; its tables read at that size too. The
steps are relative to the reading text, not UI sizes, and only the rich
answer's heading rules read the two tokens (§5). Nothing else changes
size: controls, labels, cards and page chrome keep the four sizes, and so
do the compact surfaces (a Skill Review report, a task timeline) and the
other rich-content cards (the question card, the update letter).
## 2. Hierarchy rule
@ -73,6 +84,10 @@ steps down. Concretely:
`--type-body` semibold. A bare `<h3>`/`<h4>`/`<strong>` that inherits the
browser default is a defect: it lands at bold 16px full white and ties with,
or beats, the content it introduces.
- A Markdown heading is a subsection heading too — a `--type-body` semibold
label in a Skill Review report, the question card and the update letter,
and its row's own size in a task timeline — except inside a full rich chat
answer, which is reading text and follows the §1 ladder instead.
**The 12px UPPERCASE label pattern is retired** on migrated surfaces. All-caps
at a small size costs legibility, widens every label, and when a panel repeats
@ -294,7 +309,18 @@ a blocking exit still read `Done with warnings` or `Failed`.
A host fact about a task is a row of that task's card, never a standalone
bubble beside it. A reviewer panel that settles after its task already ended
adds one System row naming the reviewed version — delivered, different, or delivery
unknown — before its verdict. That row lands inside the finished card (its Reviews group carries the note, the timeline
unknown — before its verdict. Its reviewer lines name the model that answered as an
engine reported it (else the requested one, labelled so: a direct API route's own
target is a request, not a report; two seats of one model stay two lines) and
say each outcome in words — passed it, rejected it, inconclusive, still awaited,
outcome unknown, not sent, unavailable, or answered without a readable verdict;
an unavailable reviewer's cause is quoted as the delegated engine's report, never
as the provider's. A reviewer's shortened note, like an engine cause the host's own
bound shortened, says only that it was shortened;
the row offers the exact applied record as a download once the host stored it,
and a cut of the whole row names that record only then — never a guessed link.
Slot ids, verdict
tokens and omission markers stay in that record and the model's mailbox. That row lands inside the finished card (its Reviews group carries the note, the timeline
keeps the row) without changing the card's chip, title or meta, and a standalone
row appears only when the task has no card record in the page. Saving and showing
late criticism does not replace passing it to Ouroboros for consideration: the host
@ -317,7 +343,15 @@ lives in `log_events.js`). The routing receipt under an owner
message is such a surface: a refused addressing act carries the host-composed
`cause` sentence (`project_dialogue.routing_refusal_cause` — one host table for
the receipt line, the System row and the picker toast), a landed act carries
none, and an unknown reason stays raw. Host text speaks only for the host's own
none, and an unknown reason stays raw. A provider failure quotes only the
provider's own sentence (a long one, or one its producer already cut, shortened in
words beside the Logs pointer);
without one it names the host's classification of the failure (beside the
provider's own error code and parameter when an engine relays them, as the
provider's) and points at the
Logs, which keep the host's record of it (a stream error's sentence only as a
bounded excerpt, so no row promises more) — neither an exception's text nor an
engine's composed diagnostic reaches the owner's row. Host text speaks only for the host's own
actions, its own counts and signed quotes; a source it could not read is
unknown, never zero. A terminal whose preserved output was
never reviewed shows that output labelled rather than hidden: a short labelled
@ -527,12 +561,54 @@ preference when it opens and on every reconnect; there is no file watcher.
content it contains. Where content lives inside a click-to-toggle surface
(a task card's summary), the surface ignores a pointer click whose drag
produced a non-empty selection; keyboard activation is unaffected.
- **A markdown heading inside chat is a subsection label**, never a page
title: in chat bubbles every heading level renders at `--type-body`
semibold; in a task card's timeline it renders inline, without block
margins, at its row's own size, with a copyable line break before the
following paragraph. The page-size `md-h1` belongs to non-chat
surfaces only.
- **A markdown heading inside chat is never a page title.** In a full rich
answer (§1: answers, the Project completion mirror, `markdown: true` System
rows) it is a section of the answer and never smaller than the text it
opens: the §1 reading ladder — `#` = `##`, then `###`, then deeper levels
at the reading size — semibold, in the bubble's own ink, with clearly more
space above than below. The compact renderer's surfaces keep their density
(§2): in a Skill Review report every level is a `--type-body` semibold
label, and in a task card's timeline a heading renders inline, without
block margins, at its row's own size, with a copyable line break before
the following paragraph. Other rich-content cards (the question card, the
update letter) keep `--type-body` semibold labels. Bold text in chat
Markdown and rich-content cards is semibold, so a bold lead sentence never
outweighs a heading. The page-size `md-h1` belongs to non-chat surfaces
only.
- **Rendered Markdown keeps its author's structure and text.**
- A table stays a grid and fills its column. Cells wrap between words and
align to the top; the author's GFM column alignment holds (numbers right,
centred columns centred) with tabular figures; only a link, hash or code
span may break anywhere. In a full rich answer it reads at the answer's
text size; compact surfaces and cards keep `--type-body`. A table in
either renderer scrolls sideways only when its columns cannot fit even
wrapped, and only then is it a named keyboard region (`Scrollable
table`) whose sides that still hide columns fade. No stacked row cards,
no guessing a table's purpose from its words, no smaller text to make it
fit.
- A thematic break is a `--surface-border` hairline with section spacing.
Inline code keeps the ink of the text around it on its neutral pill.
- Code is the author's literal text: a fence or code span shows and
copies exactly what was written (`&amp;`, `<div>`, `**stars**`
included), and no formatting rule reaches inside it. A rich answer
reads code as its Markdown parser does. The compact renderer keeps the
promise for its own narrower grammar only: a fence opened by three
backticks, an optional one-word language and a line break, closed by
the next three backticks; and a code span of one or two backticks
around text on one line. Other code forms (a span across lines, a
longer backtick run, a `~~~` or indented block) are outside that
grammar, and the compact renderer does not promise them literal. Rich
prose reads one stored entity layer (`&amp;` shows `&`); an entity never
creates structure, and raw HTML stays literal text.
- A Markdown image is never loaded. It stays a visible reference,
`Image: <alt>`. In a rich answer it is a link through the chat URL policy
when its address is allowed, plain text otherwise, and inside an authored
link that link's own text; the compact renderer links it like its other
links (a refused address becomes their `#` placeholder). Chat media sent
through the media path is unaffected.
- **A narrow chat column gives every bubble its full width.** A column of
620px or less — a phone or a narrow Project panel alike — drops the
wide-screen gutters; the column, not the viewport, decides.
- **A task card's summary outranks its details.** The latest-activity line is
`--type-body`; collapsed timeline rows are a dense log at `--type-meta` in
`--text-secondary`; an expanded row returns to `--type-body` in
@ -701,8 +777,9 @@ not child-task cards and never prove execution by themselves.
nor awaited (a settled failure, an expired window, lost custody, a refusal)
adds `· m unavailable` and keeps the warning tone beside the awaited slots; a
settled plan wave with no awaited slot reads its `no verdict` line in the
neutral tone (an acceptance panel with none still keeps the warning tone and
its `DEGRADED` verdict), and each unavailable plan reviewer row names the
neutral tone (an acceptance panel with none reads the same line but keeps the
warning tone; its stored `DEGRADED` stays in the attempt detail), and each
unavailable plan reviewer row names the
model and quotes the engine's reported sentence when one exists — the
failure code stays in the task detail and Logs.
- A plan wave the mind ordered weaker than the owner's effort setting says so in
@ -910,8 +987,9 @@ and minus a bare terminal outcome), never the lane that ran the turn (owner
decision 16.09: real work is a task card, a greeting is nothing). A block with
work — a review group, a child card, an evidence or narration row, a tool
error — is the task card whether a managed root or a direct
conversation turn produced it: a title (the coined name, the latest narration
headline, or the `Working…`/`Task activity` placeholder), the status chip, Stop
conversation turn produced it: a title (the coined name or the latest narration
headline; a finished card with neither reads `Task activity`, a running one
stays untitled because its status chip already says Working), the status chip, Stop
while the host attests it, and `Turn into project` in Main unless its origin is
already bound (a direct turn's later rows then route to the Project room like a
turn that called `ensure_project_scope`). A nested child card is work inside

View file

@ -167,7 +167,7 @@ scanned data-relative path to be covered by a row here (count-anchored both ways
| `state/subagent_worktrees.json` (registry; checkouts live OUTSIDE data root) | `ouroboros/subagent_worktrees.py` | none — malformed → typed refusal (absent = empty is the designed asymmetry) — accepted; file_baseline retains copied binary input identities | prune_orphans (age + missing checkout; skips delegated_exec) + custody-cross-checked snapshot prune (fail-closed on unreadable custody) | permanent leak of checkouts + pinned refs (nothing else names them) |
| `task_results/artifacts/<task>/source_handles/context_checkpoints` (`focus_source_<reader>-<sha256>.md`) | `ouroboros/tools/project_journal.py` (`_retain_focus_source`, through `artifacts.store_actor_source_bytes`) on the canonical root; read by `task_finalization.focus_source_projection` (digest glob for a historical selector) | native `task_source` handle on `focus.source_handle` (size + sha256, verified on read) | write-once, digest-named; lives with the task's artifacts (no separate prune) | a roster's `retained_source` reads `source_unavailable`; the focus text itself survives on the task result |
| `task_results/artifacts/<task>/source_handles/review_inputs` | `review_source_closure.py`, existing artifact/source promotion owners; `review_native_episode.py` mirrors continuation sources into canonical custody | scoped data-root layout containing immutable digest-bound named inputs and declared task owners; `ReviewRequest.policy.review_source_closure` schema 1 carries provenance, read root and refmap | retained with task artifacts, excluded from deliverables; no separate ledger or pruning timer | deleting it removes a paid reviewer’s input/continuation read bindings; closure reuse fails closed |
| `task_results/artifacts/<task>/source_handles/context_checkpoints` (`acceptance-<sha256>.json` and named review source snapshots) | acceptance publication; `task_finalization.review_source_projection` via `get_task_result(review_source_sha256=...)` | existing `task_source` identity plus host acceptance surface/physical-task verification; explicit Unicode ranges | immutable historical source, retained with task artifacts; root receivers use canonical task/digest binding | exact source reports unavailable or identity mismatch; no substitute task, authority corpus or filesystem fallback |
| `task_results/artifacts/<task>/source_handles/context_checkpoints` (`acceptance-<sha256>.json` and named review source snapshots) | acceptance publication; `task_finalization.host_acceptance_source` behind `get_task_result(review_source_sha256=...)` and the owner's `?source=` record download | existing `task_source` identity plus host acceptance surface/physical-task verification; explicit Unicode ranges | immutable historical source, retained with task artifacts; root receivers use canonical task/digest binding | exact source reports unavailable or identity mismatch; no substitute task, authority corpus or filesystem fallback |
| `task_results/artifacts/<task>/delegated_runs/<run>/disposition.lock` | `tools/subagent_integration_delegated.py` through `platform_layer.acquire_exclusive_file_lock` | canonical capture lock with owner-aware stale recovery; authority rechecked while held | released in the disposition writer's `finally`; a live owner is never evicted | concurrent materializers could apply the same result twice |
| `task_results/artifacts/<task>/source_handles/delegated_activity` | `delegate_activity._persist` through `artifacts.store_actor_source_bytes`; `_retained_range` reads the same canonical store | immutable JSONL ranges named by run, sequence interval and SHA256 | retained with canonical task artifacts; survives execution-drive and engine-journal cleanup, no separate pruning timer | full delegated speech and event evidence disappear; readers report missing source instead of certifying completeness |
| `task_results/artifacts/<task>/delegated_runs/<run>/engine-files-manifest.json`, `task_results/artifacts/<task>/delegated_runs/<run>/*-*` | `ouroboros/delegate_directory.py`, existing streamed task-artifact owner | exact engine manifest SHA and per-file before/after digests | canonical result artifacts retained; engine keeps unapplied copy results under its existing disposition/retention owner | loss of complete binary results, baseline references and evidence after execution copy cleanup |

View file

@ -91,7 +91,7 @@ Shared frontend primitives keep pages from acquiring competing contracts — fro
- `skill_card_renderer.js` ← installed-skill cards.
- `hub_sync.js` ← the one catalog×listing verdict (install/installed/update/adopt/none plus badges) for Hub cards and My-skills badges, joined to `/api/extensions` by canonical name; receipts are history, never gates; catalog rows prove no PR merge or ownership.
- `client_surface.js` ← the send-time sending-surface snapshot (raw observables, no device taxonomy) that `chat.js` spreads into each chat frame.
- `chat_markdown.js` ← marked+DOMPurify, chat URLs (http(s), mailto, `/api/files/download`), KaTeX (no single `$`), lazy Mermaid and bounded chart blocks. `mountChatMarkdown` binds HTML to `.ui-rich-content` on existing hosts; bubble/card owners retain enhancement/teardown and density. `chat.js` disposes on removal. Vendors: `web/vendor/VENDOR-MANIFEST.md`.
- `chat_markdown.js` ← marked+DOMPurify, chat URLs (http(s), mailto, `/api/files/download`), KaTeX (no single `$`), lazy Mermaid, bounded charts, unloaded images (DESIGN §5). `mountChatMarkdown` binds HTML to `.ui-rich-content` on existing hosts; bubble/card owners retain enhancement/teardown and density. `chat.js` binds its column's tables (`bindMarkdownTables`); it disposes both. Vendors: `web/vendor/VENDOR-MANIFEST.md`.
- `log_events.js` ← event classification, shared technical outcome reducers and the one factual task-presentation projection for Chat and Logs: task truth maps only to `Working` / `Done` / `Done with warnings` / `Failed` / `Cancelled`; it owns no actions, incidents or notifications, compact headlines never expose raw reason codes, and a task-authored message is visible in the receiving task's timeline (`task_message_injected`, sender by value) and in the sender's (`task_message_routed`, written or refused with the host reason).
- `effort_evidence.js` ← Logs: requested effort, host carrier, engine preparation, sourced provider observation; missing echoes stay unknown.
- `toast.js`, `masonry.js`, `widget_frame.js`, `widget_job.js`, CSS tokens ← notifications, layout, framed-widget bootstrap/lifecycle, bounded widget request/job policy.
@ -122,7 +122,7 @@ DESIGN's role/type and `_emit_progress.narration` survive `send_with_budget` and
`terminal_projection.py` owes the Project row and deferred Main mirror together. `write_task_result` stamps `canonical_terminal_projection_origin=terminal_transition` only on an accepted root terminal transition, never from an incoming write/projector/replica. This preserves the terminal-write/readiness crash gap without replaying historical markerless rows. Readiness precedes effects; a projection lock serializes callbacks, not result IO. Publication and retirement require `headless.terminal_task_files_ready`, preserving cancellation and split-root semantics: synthesis alone does not prove artifact completion. Tokens heal append/receipt crashes; malformed history rows are skipped, unreadable segments defer. Task-done isolates publication errors; checkpoint/startup retry, then the existing 600s sweep. Children skip root history recovery. Retirement requires durable Main registration or proven ineligibility and matching token/attempt/checkpoint/artifact facts. Checkpoint returns the re-read row, or `None` if its write failed. Early finals carry status/axes beside finalizing without ending liveness; settled speech replay refreshes canonical axes. Bounded outbox and at-least-once transport preclude exactly-once.
Main receives its own dialogue, Project quiz mirrors and `project_handoff`/`project_started`/`project_completion_summary` rows ending in the Project reference; other Project traffic stays there. Lifecycle `text` is plain: producers strip markdown before persistence/send, history normalizes legacy rows without rewriting `chat.jsonl`, and System rendering escapes text unless `markdown: true` or the dedicated `skill_review` renderer applies. `projects_registry.mirrored_answer` carries whole `completion_answer` only for nonempty `model_final` in a settled phase within `MIRRORED_ANSWER_MAX_CHARS`; otherwise absent, never cut. `chat.js::addMessage` renders that as an ordinary assistant message; `project_answer.js::decorateProjectRow` adds the fold/reference, retaining ordinary keys, history, snapshots, Copy and disposal (DESIGN "Project completion mirror"). `ORIGIN_ADDRESSED_NOTICE_TYPES` in `project_dialogue.room_membership` keeps `task_not_started`, `task_start_unconfirmed` and `steer_not_delivered` in their issuing chat on replay despite target binding. Owner steering refused without an owner-message receipt gets one factual System row.
Main receives its own dialogue, Project quiz mirrors and `project_handoff`/`project_started`/`project_completion_summary` rows ending in the Project reference; other Project traffic stays there. Lifecycle `text` is plain: producers strip markdown before persistence/send and history normalizes legacy rows without rewriting `chat.jsonl` (System rendering: DESIGN §4). `projects_registry.mirrored_answer` carries whole `completion_answer` only for nonempty `model_final` in a settled phase within `MIRRORED_ANSWER_MAX_CHARS`; otherwise absent, never cut. `chat.js::addMessage` renders that as an ordinary assistant message; `project_answer.js::decorateProjectRow` adds the fold/reference, retaining ordinary keys, history, snapshots, Copy and disposal (DESIGN "Project completion mirror"). `ORIGIN_ADDRESSED_NOTICE_TYPES` in `project_dialogue.room_membership` keeps `task_not_started`, `task_start_unconfirmed` and `steer_not_delivered` in their issuing chat on replay despite target binding. Owner steering refused without an owner-message receipt gets one factual System row.
Every unanswered Project quiz, waited on or not, is mirrored into Main from one keyed pointer, without a second durable message. `chat_decision.buildQuizCard` renders the same Project form plus the Project reference (DESIGN “Project question mirror”); its own-answer and option controls use the existing decision ingress. The first confirmed `answered` observation from any source—answer response, 409 winner, `quiz_state`, history or census—starts one five-second `MIRROR_SETTLE_MS` countdown. Later observations do not restart it. Only Main's copy retires, via `releaseMessageNode` inside the stable-viewport transaction; Project history remains. Focus inside the retiring copy moves to the next Main question or the keyboard owner's composer. `releaseViews`/`destroy` clear timers and markdown. A partial copy upgraded to a complete answered form binds its card before settlement and gets the same countdown; an initially answered pointer never mounts.

View file

@ -85,7 +85,7 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera
| GET | `/api/tasks/{task_id}` | `gateway.tasks.api_task_get` |
| GET | `/api/tasks/{task_id}/events` | `gateway.tasks.api_task_events` (legacy integer rank) |
| POST | `/api/tasks/{task_id}/events` | `gateway.tasks.api_task_events` (read-only v2 cursor) |
| GET | `/api/tasks/{task_id}/artifacts/{name}` | `gateway.tasks.api_task_artifact` via task_archive and task-owned stores: bare name = top-level file, `?relpath=` = nested file, `?archive=<dir>` = ZIP described by artifact_archives. Recorded digests bind files and ZIP members: drift is 409 artifact_identity_changed with digest, failed capture 404 artifact_unverified; x-ouroboros-artifact-identity says verified/unmeasured. Delegated JSONL `?source=` requires source_handles/delegated_activity content-addressed name, matching basename, confined descent and digest. Ordinary file/chat-media/ZIP and delegated JSONL reads return 503 where confined opens are unsupported, including Windows (issue #1297); result-published review sources retain read_task_result_source_bytes. |
| GET | `/api/tasks/{task_id}/artifacts/{name}` | `gateway.tasks.api_task_artifact` via task_archive and task-owned stores: bare name = top-level file, `?relpath=` = nested file, `?archive=<dir>` = ZIP described by artifact_archives. Recorded digests bind files and ZIP members: drift is 409 artifact_identity_changed with digest, failed capture 404 artifact_unverified; x-ouroboros-artifact-identity says verified/unmeasured. Delegated JSONL `?source=` requires source_handles/delegated_activity content-addressed name, matching basename, confined descent and digest. Ordinary file/chat-media/ZIP and delegated JSONL reads return 503 where confined opens are unsupported, including Windows (issue #1297); result-published review sources retain read_task_result_source_bytes; a replaced host `acceptance-<sha256>.json` record resolves by host_acceptance_source membership. |
| POST | `/api/tasks/{task_id}/cancel` | `gateway.tasks.api_task_cancel` |
| POST | `/api/tasks/{task_id}/hurry` | `gateway.tasks.api_task_hurry` |
| POST | `/api/tasks/{task_id}/resume` | `gateway.tasks.api_task_resume` |

View file

@ -6,23 +6,23 @@ Worker execution and cognition: lifecycle, tools, context fitting, authority, re
A queued task enters through a reviewed transport, is admitted by the supervisor queue (§5) and runs in `OuroborosAgent`; each direct-chat turn runs on its own in-process agent, tracked by the process-local `DirectActivityRegistry`, and creates no `PENDING`/`RUNNING` queue record (§3). The root pipeline (`agent_task_pipeline.py`) captures the task contract and immutable context core, runs the loop, preserves a delivery candidate, stores the result (`task_results/<id>.json`, every write stamped `_schema_version: 1`; an unstamped, future, malformed or retired-key row is quarantined by `task_result_schema.py` with its id kept occupied) and artifacts, emits lifecycle and usage evidence, performs the root-only post-task work (`post_task_synthesis.py`, checkpointed by `post_task_checkpoint.py`) and publishes the typed outcome. Queue admission proves only that asynchronous work was durably accepted; completion, objective satisfaction, artifact finality, verification and review acceptance remain separate facts. The loop's tally rides `loop_outcome.usage` while the top-level `total_rounds`/`prompt_tokens`/`completion_tokens` are the ledger's answer (`reconstruct_task_cost`); an internal exception is `failure.kind = "runtime"`, never a fabricated provider failure, and a capture the crash lost is reported unknown, never as zero counters.
The separate review and post-task executors (`review_execution.py`, the post-task pipeline) use the same evidence and context contracts. `supervisor/cognitive_operations.py` tracks in-flight LLM, review, VLM and tool work as typed `cognitive_operation` leases for the idle rail; deadlines, budgets, cancellation and absolute ceilings remain independent.
Review and post-task executors (`review_execution.py`, the post-task pipeline) use the same evidence and context contracts. `supervisor/cognitive_operations.py` tracks in-flight LLM, review, VLM and tool work as typed `cognitive_operation` leases for the idle rail; deadlines, budgets, cancellation and absolute ceilings remain independent.
Between the sends of one loop execution the transcript is append-only — each send a prefix extension of the previous — because OpenAI-family caches reuse a request only when it is a byte-prefix of the next (inside the leading system unit a byte-prefix is necessary, not sufficient: "Context fitting, retry, and compaction" below), so a replaced tail or a rewritten earlier message discards the whole conversation cache. `transcript_prefix.py` records and never blocks: `unsent_in_previous_send` allows `_append_or_merge_user_content` to merge only a tail absent from the last observed send; an absent slot or missing observation means unknown, so the producer appends a new row, compaction seams stamp `sanction_rewrite`, and a break is the `prompt_prefix_break` checkpoint fact (`kind` = `system_rewritten` | `tail_replaced` | `rewritten` | `shrunk`, plus `sanctioned_by`; a context-fit reprojection after a real overflow is an ordinary break). The observation follows a usable ordinary model response; it is not a ledger of every physical send, and existing image eviction is unchanged. A `FINAL ANSWER:` line is latched every round as the latest typed candidate, so review, nudge and forced-finalization paths never erase a structured answer; marker prompting is gated on `task_contract.answer_protocol="final_answer_line"` (`answer_protocol_active`) while the latch and extractor stay unconditional, and `outcomes.extract_final_answer` refuses the outcome-tier ledger identifiers as answers — internal enum vocabulary is never a deliverable.
Between the sends of one loop execution the transcript is append-only — each send a prefix extension of the previous — because OpenAI-family caches reuse a request only when it is a byte-prefix of the next (inside the leading system unit a byte-prefix is necessary, not sufficient: "Context fitting, retry, and compaction" below), so a replaced tail or a rewritten earlier message discards the whole conversation cache. `transcript_prefix.py` records and never blocks: `unsent_in_previous_send` allows `_append_or_merge_user_content` to merge only a tail absent from the last observed send; an absent slot or missing observation means unknown, so the producer appends a new row, compaction seams stamp `sanction_rewrite`, and a break is the `prompt_prefix_break` checkpoint fact (`kind` = `system_rewritten` | `tail_replaced` | `rewritten` | `shrunk`, plus `sanctioned_by`; a context-fit reprojection after a real overflow is an ordinary break). The observation follows a usable ordinary model response, not every physical send; existing image eviction is unchanged. A `FINAL ANSWER:` line is latched every round as the latest typed candidate, so review, nudge and forced-finalization paths never erase a structured answer; marker prompting is gated on `task_contract.answer_protocol="final_answer_line"` (`answer_protocol_active`) while the latch and extractor stay unconditional, and `outcomes.extract_final_answer` refuses the outcome-tier ledger identifiers as answers — internal enum vocabulary is never a deliverable.
`model_execution` is a compact projection of the last ordinary solve response the loop accepted (text or tool calls), selected from marked `llm_call_refs`; it keeps the initially requested model/local route, the route used and the provider's own label separate, and empty responses, forced finalization, review and post-task calls cannot replace it. Result, terminal frame, history and card share it without changing dispatch or claiming final-answer authorship; the terminal `task_summary` keeps it when the full result ages out, and a current result or live card outranks that fallback. After tool work, host-driven fallback or model-wait model changes record an authoring handover. The successor's first tool-less response gets one recovery round (one prompt per handover, under ordinary stop, deadline and budget limits) with delivery-control JSON held; a second yields `execution=degraded` and `authoring_handover_incomplete`. Ordinary no-tool turns and explicit model changes stay unchanged. Verification and skill checks still run; later tools clear only this warning, preserving incident and recovery history.
`model_execution` is a compact projection of the last ordinary solve response the loop accepted (text or tool calls), selected from marked `llm_call_refs`; it keeps the initially requested model/local route, the route used and the provider's own label separate, and empty responses, forced finalization, review and post-task calls cannot replace it. Result, terminal frame, history and card share it without changing dispatch or claiming final-answer authorship; the terminal `task_summary` keeps it when the full result ages out, and a current result or live card outranks that fallback. After tool work, host-driven fallback or model-wait model changes record an authoring handover. The successor's first tool-less response gets one recovery round (one prompt per handover, under ordinary stop, deadline and budget limits) with delivery-control JSON held; a second yields `execution=degraded` and `authoring_handover_incomplete`. Ordinary no-tool turns and explicit model changes are exempt. Verification and skill checks still run; later tools clear only this warning, preserving incident and recovery history.
`DeliveryCandidate` is retained before verification or review so a later notice, reviewer failure, deadline or provider outage cannot erase a useful answer. `outcomes.py` combines the execution, objective, review, artifact and child-absorption axes without converting one into another — the terminal custody overlay (`outcomes.custody_debt_axes`) is an instance of that rule, not an exception; verify-before-done receipts and exact artifact references are host-attested evidence, and declarations or answer prose are no substitute. Individual failed tool calls alone do not degrade a delivered answer: unresolved calls retain their typed signal/exit evidence in `execution.unresolved_tool_errors`, cosmetic errors keep their separate bucket, and either kind adds `residual_tool_errors_without_review` when the canonical objective is `not_evaluated`, including after delivery/child-state normalization. Host acceptance or qualified Advisory author completion establishes objective success; unresolved FAIL/DEGRADED verdicts, empty or failed execution, provider failure, deadlines, incomplete delivery, deferred children and failed artifact verification keep their own outcome rules. A forced exit may publish the best current candidate only with its typed rail and evidence-freshness disclosure, and lifecycle may remain `completed` while the objective or review axis records a best-effort or unaccepted result. Custody debt heals from the write side while the stored `reason_code` may not be rewritten, so the owner-facing Reason line resolves the code at RENDER time — the custody warning only while the row's own `delegated_runs_unreconciled` list is non-empty, else the execution reason, both when both are real; a healed debt is never restored, and the debt is a warning beside the rail cause, never a replacement for it — as is every standing limitation of the same answer (deferred child results; a plan review still open at delivery, worded by the outcome class recorded on `execution.plan_review` when there is one), which the normal rail types on the result exactly as the forced rails do, so one clause per fact reaches the card, the durable row and Ouroboros's own memory.
`DeliveryCandidate` is retained before verification or review so a later notice, reviewer failure, deadline or provider outage cannot erase a useful answer. `outcomes.py` combines the execution, objective, review, artifact and child-absorption axes without converting one into another, the terminal custody overlay (`outcomes.custody_debt_axes`) included; verify-before-done receipts and exact artifact references are host-attested evidence; declarations or answer prose are no substitute. Failed tool calls alone do not degrade a delivered answer: unresolved calls retain their typed signal/exit evidence in `execution.unresolved_tool_errors`, cosmetic errors keep their separate bucket, and either kind adds `residual_tool_errors_without_review` when the canonical objective is `not_evaluated`, including after delivery/child-state normalization. Host acceptance or qualified Advisory author completion establishes objective success; unresolved FAIL/DEGRADED verdicts, empty or failed execution, provider failure, deadlines, incomplete delivery, deferred children and failed artifact verification keep their own outcome rules. A forced exit may publish the best current candidate only with its typed rail and evidence-freshness disclosure, and lifecycle may remain `completed` while the objective or review axis records a best-effort or unaccepted result. Custody debt heals from the write side while the stored `reason_code` is never rewritten, so the owner-facing Reason line resolves it at RENDER time — the custody warning only while the row's own `delegated_runs_unreconciled` list is non-empty, else the execution reason, both when both are real; a healed debt is never restored, and the debt warns beside the rail cause, never replacing it — as does every standing limitation of the same answer (deferred child results; a plan review open at delivery, worded by the outcome class on `execution.plan_review` when recorded), which the normal rail types on the result exactly as the forced rails do, so one clause per fact reaches the card, the durable row and Ouroboros's own memory.
Host plan/orphan disclosures ride beside the model answer as `terminal_host_notice`; delivery sends the model answer alone and the disclosure stays a field of the result (an owed pre-upgrade notice row still replays as the untyped System row it was), and the custody audit is its own typed row (`terminal_custody_notice` on the send event → `system_type="custody_notice"`, `card_row="timeline"`, its own owed delivery id), so the open-delegation fact is a row of the task's card and the unreconciled-runs note never enters the assistant text. CLI gets one host-labelled status (`terminal_host_notice_text`); external Presence speech excludes host notices (chapter 12). Stored answer bytes do not change: the answer keeps its hash and its real PASS or FAIL when only the notice changes, and equal text cannot revive a superseded verdict. Every reader that hands a result on — synthesis, parent handoff, `get_task_result`/`wait_task`/`wait_tasks`, the `task:<id>` plan-evidence reader — carries the notice as a separate field, and the child-result and plan-evidence hashes include it, so a changed child limitation invalidates an old parent disposition or an old review. A host-salvaged terminal is labelled, not hidden: the durable row carries `Preserved intermediate output (not a final answer):` plus the bounded excerpt every terminal row uses, the untruncated copy stays with `get_task_result` and the stop receipt, and only a row written to the chat the stop receipt itself reached (`cancel_receipt.delivered_chat_id`, recorded after a successful send) reduces to its label beside the pointer; the two durable writes are not atomic, and no writer trades its only pointer for the label. One event is disclosed once, at the layer that owns it: the forced orphan note leaves a child to its own terminal row only where that row reached the reader the note addresses, and a rail that ended a routing turn is named by that turn's own row, never inferred from another layer's stamp.
Host plan/orphan disclosures ride beside the model answer as `terminal_host_notice`: delivery sends the answer alone and the disclosure stays a result field (an owed pre-upgrade notice row still replays as its untyped System row); the custody audit is its own typed row (`terminal_custody_notice` on the send event → `system_type="custody_notice"`, `card_row="timeline"`, own owed delivery id), so the open-delegation fact is a card row and the unreconciled-runs note never enters the assistant text. CLI gets one host-labelled status (`terminal_host_notice_text`); external Presence speech excludes host notices (chapter 12). Stored answer bytes do not change: the answer keeps its hash and its real PASS or FAIL when only the notice changes, and equal text cannot revive a superseded verdict. Every reader that hands a result on — synthesis, parent handoff, `get_task_result`/`wait_task`/`wait_tasks`, the `task:<id>` plan-evidence reader — carries the notice as a separate field, and the child-result and plan-evidence hashes include it, so a changed child limitation invalidates an old parent disposition or review. A host-salvaged terminal is labelled, not hidden: its durable row carries `Preserved intermediate output (not a final answer):` plus the shared bounded terminal excerpt, `get_task_result` and the stop receipt keep the untruncated copy, and only a row in the chat the stop receipt itself reached (`cancel_receipt.delivered_chat_id`, recorded after a successful send) reduces to its label beside the pointer; the two durable writes are not atomic, and no writer trades its only pointer for the label. One event is disclosed once, at the layer that owns it: the forced orphan note leaves a child to its own terminal row only where that row reached the reader the note addresses, and a rail that ended a routing turn is named by that turn's own row, never inferred from another layer's stamp.
The delivery-control protocol is resolved here and only here (DEVELOPMENT keeps the rule and points here). The candidate carries sticky loop-local provenance that its lineage has seen a host-issued delivery-control episode; without one, exact JSON is ordinary text. In a marked lineage both the ordinary and the forced resolver strip one whole-body fence and intercept recognizable whole-body envelopes and a balanced protocol object at the very END of prose (`utils.extract_trailing_json_object` + `loop_delivery._parse_delivery_control_body`): valid `keep` resolves to the retained candidate, valid `replace` to `full_answer`, and an empty or malformed body preserves the retained candidate rather than replacing it. The trailing-object rule deliberately refuses substring scanning, because quoted protocol literals mid-prose are legitimate text: a control object quoted mid-prose stays prose, as does a truncated trailing fragment. During an ordinary acceptance continuation, `finalization_control="acceptance_feedback"` makes a complete revised answer ordinary prose and keeps `keep`/`replace`/`pending_review` optional. Prose resets the pending-review choice to `wait`; it never means `finish`. An outstanding effect, owner-revision or child-action control retains its stricter rule, and unread owner source still requires acknowledgement. The ordinary resolver takes one repair round then degraded-preserve; the forced resolver resolves purely and never re-loops — malformation preserves the retained candidate with the typed `delivery_control_degraded` reason, which is this forced rail's own code, while the ordinary repair path records `invalid_delivery_control_after_repair`. Every degradation carries the cause it computed: `outcomes.derive_loop_outcome` falls back to `delivery_control_degraded` only for a degradation that reports no cause and publishes the `loop_outcome.degraded`/`degraded_reason` pair the benchmark ledgers read. A malformed attempt in a marked lineage never leaks JSON, even after the transient latch clears. Presence forced calls (turn or root, not ceiling-only child) always arm `presence_finish`; original bytes reject duplicate keys (chapter 12).
The delivery-control protocol is resolved here and only here (DEVELOPMENT keeps the rule and points here). The candidate carries sticky loop-local provenance that its lineage has seen a host-issued delivery-control episode; without one, exact JSON is ordinary text. In a marked lineage both the ordinary and the forced resolver strip one whole-body fence and intercept recognizable whole-body envelopes and a balanced protocol object at the very END of prose (`utils.extract_trailing_json_object` + `loop_delivery._parse_delivery_control_body`): valid `keep` resolves to the retained candidate, valid `replace` to `full_answer`, and an empty or malformed body preserves the retained candidate. The trailing-object rule refuses substring scanning: a control object quoted mid-prose, like a truncated trailing fragment, stays prose. During an ordinary acceptance continuation, `finalization_control="acceptance_feedback"` makes a complete revised answer ordinary prose and keeps `keep`/`replace`/`pending_review` optional. Prose resets the pending-review choice to `wait`; it never means `finish`. An outstanding effect, owner-revision or child-action control retains its stricter rule, and unread owner source still requires acknowledgement. The ordinary resolver takes one repair round then degraded-preserve; the forced resolver resolves purely and never re-loops — malformation preserves the retained candidate with the typed `delivery_control_degraded` reason, which is this forced rail's own code, while the ordinary repair path records `invalid_delivery_control_after_repair`. Every degradation carries the cause it computed: `outcomes.derive_loop_outcome` falls back to `delivery_control_degraded` only for a causeless degradation and publishes the `loop_outcome.degraded`/`degraded_reason` pair the benchmark ledgers read. A malformed attempt in a marked lineage never leaks JSON, even after the transient latch clears. Presence forced calls (turn or root, not ceiling-only child) always arm `presence_finish`; original bytes reject duplicate keys (chapter 12).
The child-absorption gate is an action gate: while undispositioned direct children remain, the loop HOLDS the candidate (`child_absorption_or_revision_required`) instead of arming the JSON-only control instruction — the hold-vs-arm split exists so the model never receives two contradictory instructions in one round. A typed keep cannot close the gate; after the one bounded reminder it forces the best-effort `children_unabsorbed` rail with a current `id [status] sha256` listing. The absorption digest's `## child` header carries the child's typed custody debt (`delegated_runs_unreconciled`, bounded, with a `get_task_result` pointer) as visibility only — the parent's authority over that patch is exactly the orphan rule, and a child's debt never relabels the root card (DESIGN §4). Finalizing over an UNDISPOSED OWN delegated patch is deliberately NOT gated: the consequence is disclosed where the decision is made (the `integrate_delegated_patch` schema, the apply receipts) and lands as the additive Done-with-warnings custody overlay rather than a hold; a pre-finalization reminder and propagation of child custody debt into the acceptance-subtree snapshot remain disclosed deferred gaps.
The child-absorption gate is an action gate: while undispositioned direct children remain, the loop HOLDS the candidate (`child_absorption_or_revision_required`) instead of arming the JSON-only control instruction, so the model never receives two contradictory instructions in one round. A typed keep cannot close the gate; after the one bounded reminder it forces the best-effort `children_unabsorbed` rail with a current `id [status] sha256` listing. The absorption digest's `## child` header carries the child's typed custody debt (`delegated_runs_unreconciled`, bounded, with a `get_task_result` pointer) as visibility only — the parent's authority over that patch is exactly the orphan rule, and a child's debt never relabels the root card (DESIGN §4). Finalizing over an UNDISPOSED OWN delegated patch is deliberately NOT gated: the consequence is disclosed where the decision is made (the `integrate_delegated_patch` schema, the apply receipts) and lands as the additive Done-with-warnings custody overlay rather than a hold; a pre-finalization reminder and propagation of child custody debt into the acceptance-subtree snapshot remain disclosed deferred gaps.
Provider death is the one forced rail that is NOT a best-effort completion: it salvages the best available text but stamps `infra_failed`, so the task terminalizes `failed` with the typed `provider_unavailable` reason and an immediate "provider outage — NOT completed" owner notification; a waited-out transport outage reaches the same rail through its deterministic no-resend branch (`transport_unavailable_no_resend`, or `provider_outcome_unknown_no_resend` when the round still holds a transport-death repeat record). The rail makes its one forced model call only while a call can still land: a transport that spent its same-model retry wall stamps `_llm_retry_wall_exhausted`, a no-call gate beside `context_overflow` and `provider_outcome_unknown`, so the forced rail never re-pays a second retry window over a proven-dead provider (disclosed residual: the marker is a last-invocation bool on the shared usage dict). Terminal delivery preserves producer authorship: `terminal_provider_notice` is host presentation beside the raw result, and receipts and secondary System incidents carry the wait duration, finalization cause and unknown-outcome warning and never recommend a blind rerun. Every forced rail stamps one closed-vocabulary producer word at the single forced-finalization sink: `model_final` for complete model text (host-authored notices stay separate), `host_notice` for host-written terminal text (an owner System row, never automatic external Presence speech), and `host_salvage` only on the provider-death rail, where managed/direct delivery replaces the text with the enriched outage receipt and the full bytes stay in task details. Missing origin stays unknown; `agent.py` resets it on host text replacement. Presence synthesis seals the recorded adapter body and internal result separately (chapter 12), never a delivery receipt.
Provider death is the one forced rail that is NOT best-effort completion: it salvages the best text but stamps `infra_failed`, so the task ends `failed` with the typed `provider_unavailable` reason and an immediate "provider outage — NOT completed" owner notification; a waited-out transport outage reaches it through the deterministic no-resend branch (`transport_unavailable_no_resend`, or `provider_outcome_unknown_no_resend` while the round holds a transport-death repeat record). Its one forced model call runs only while a call can land: a spent same-model retry wall stamps `_llm_retry_wall_exhausted`, a no-call gate beside `context_overflow` and `provider_outcome_unknown`, so no second retry window is paid over a proven-dead provider (disclosed residual: a last-invocation bool on the shared usage dict). Terminal delivery preserves producer authorship: `terminal_provider_notice` is host presentation beside the raw result, quoting only `_last_llm_provider_message`, which `loop_transport.stamp_owner_provider_message` resets per failed call to authored provider text or nothing (a Claudexor engine error authors none: its typed provider fields reset alongside in `_last_llm_provider_fields`; else words for `_last_llm_error_kind`; repr stays in `_last_llm_error`/`llm_api_error`); receipts and secondary System incidents carry wait duration, finalization cause and unknown-outcome warning and never recommend a blind rerun. Every forced rail stamps one closed-vocabulary producer word at the single forced-finalization sink: `model_final` for complete model text (host-authored notices stay separate), `host_notice` for host-written terminal text (an owner System row, never automatic external Presence speech), and `host_salvage` only on the provider-death rail, where managed/direct delivery replaces the text with the enriched outage receipt and the full bytes stay in task details. Missing origin stays unknown; `agent.py` resets it on host text replacement. Presence synthesis seals the recorded adapter body and internal result separately (chapter 12), never a delivery receipt.
Finalization controls are typed owner-mailbox entries, not injected owner prose. The supervisor may request one bounded tool-less answer, salvage the last persisted assistant text, and retain a full canonical copy when a preview would truncate it. A grace episode has one durable control and can be revoked atomically when the task itself resumes; descendant activity does not count as the task's own progress. A process that cannot be killed remains visibly running, and custody checks prevent another runtime instance from reaping work it does not own.
Finalization controls are typed owner-mailbox entries, not injected owner prose. The supervisor may request one bounded tool-less answer, salvage the last persisted assistant text, and retain a full canonical copy when a preview would truncate it. A grace episode has one durable control and can be revoked atomically when the task itself resumes; descendant activity does not count as the task's own progress. An unkillable process remains visibly running; custody checks prevent another runtime instance from reaping work it does not own.
Disclosed cancel-lifecycle residuals (deliberate): a cascade over a tree with no resolvable lineage chat whose typed handoff-row append ALSO fails still settles; an empty-intent release can add liveness noise to a foreign claim's forensic trail (bounded by the generation fences); cascade postcondition timing can flake under heavy load (the watchdog re-feeds — one retry, never a lost teardown); and the cost projection of a task whose delegated runs stayed open may read `cost_usd=0`/`cost_final=true` while a run is still live — the disclosure line names the open runs.
@ -58,7 +58,7 @@ Local decisions use `origin=local_acceptance_preparation`; reviewer decisions re
Historical plan supplements use the locked result writer and exact producer CAS, settle cost without another cycle and preserve verdict, aggregate, closure, author dispositions and current-wave pointer. Acceptance’s sole publisher, `settle_acceptance_operation`, collects at $0, reads back `late_settlement`, then enqueues one System `card_row="reviews"` through the canonical outbox. Failed publication announces nothing. Historical first-complete runs owe publication without fake pending actors; failed publication/notice custody retains `unpublished` duty across controller exit. Maintenance consumes it only after canonical read-back. Lost traces use the applied source; unreadable publications never become replacement panels. Original answer, decision, status and debt pin stay unchanged.
Late evidence retains settlement time, reviewed identity/hash/length, reviewer outputs and `emitted_answer` (§10): `reviewed_revision=delivered|different|unknown`, `reviewed_is_emitted=true|false|null`. Historical purpose requires its exact delivery triple; supersession, mutable results, disposition hashes and owed sends prove no delivery. Version precedes verdict. `late_acceptance_facts` carries `late_evidence` and its exact cross-task source into durable chat/room/Main even with consciousness off. `get_task_result(task_id=<physical author>, review_source_sha256=<digest>, source_start_char=..., source_end_char=...)` verifies canonical HOST acceptance membership or the exact schema/kind/task/attempt/debt/delivery/hash-bound debt source. Forked/empty receivers work; Child/Presence remain refused, with no retry alias, arbitrary digest search or filesystem grant. Settlement grants no continuation; consciousness admission/Stop/Panic still govern further work.
Late evidence retains settlement time, reviewed identity/hash/length, reviewer outputs and `emitted_answer` (§10): `reviewed_revision=delivered|different|unknown`, `reviewed_is_emitted=true|false|null`. Historical purpose requires its exact delivery triple; supersession, mutable results, disposition hashes and owed sends prove no delivery. Version precedes verdict; owner reviewer lines (DESIGN §4) keep the model mailbox unchanged, and `ChatOutbound.late_evidence` survives live/storage/history for the exact-record link. `late_acceptance_facts` carries that evidence and its exact cross-task source into durable chat/room/Main even with consciousness off. `get_task_result(task_id=<physical author>, review_source_sha256=<digest>, source_start_char=..., source_end_char=...)` verifies canonical HOST acceptance membership or the exact schema/kind/task/attempt/debt/delivery/hash-bound debt source. Forked/empty receivers work; Child/Presence remain refused, with no retry alias, arbitrary digest search or filesystem grant. Settlement grants no continuation; consciousness admission/Stop/Panic still govern further work.
Before review, `supervisor/queue_transitions.py` closes child admission under `_queue_lock` (§10 invariant 25); `task_status.find_child_tasks` proves subtree quiescence. An unanswered/refused fence buys no model round: disclose `admission_fence_available=false`. Early settlement releases the fence; final delivery seals through `loop_delivery._seal_admission_before_delivery`, distinguishing prior seal, owner follow-up and no answer (`admission_released=false`; Blocking approved delivery adds `admission_close_unconfirmed`). Changed subjects reopen review, preserving the old panel.
@ -250,7 +250,7 @@ Native read evidence rides the actor usage as `native_tool_receipts` (`outcome`
Late review completion is retained in the operation-addressed prompt/response CAS (`review_substrate.py`, `review_custody.py`): the response manifest stamps the complete producer outcome with the original task/root, attempt, slot/route, operation, subject, contract and roster/epoch binding, so exact reconciliation verifies that source and runs the existing surface reducer without a second paid attempt; pending, unreadable, mismatched and unknown outcomes keep custody and their full source references. Plan waves carry their dispatched set, health epoch and operation ids through the same path; free replay spends no new cycle. Skill Review reserves every chunk digest/retry key and slot operation id in `review_job.review_wave` before paid dispatch and keeps that binding in terminal history; the next authorized call may record `review_resume_of` only for the immediately preceding unsuperseded wave with identical task/root/attempt, skill/state roots, group, content, contract, rebuttal and chunks, and then reaggregates complete CAS at the original wave id without another paid stamp, even at the cycle cap. Missing or partial producers stay pending, unstarted chunks acquire no verdict authority from their reserved ids, and a new task/root, changed material/contract/rebuttal, superseding lifecycle or explicit cancellation cannot inherit the old wave. Late completion alone never revives a job or enables a skill: review-state persistence, grants/dependencies and enablement keep their lifecycle guards.
Terminal task delivery and later inspection derive a bounded host notice from the delegated-custody audit after cleanup — confirmed terminal cancellations, live runs, unresolved invocation ids and undisposed patches stay distinct — beside unchanged model text and continuation narrative, so a later cancellation never rewrites historical authorship or the answer hash. A valid acceptance PASS with partial, missing or rejected criteria remains a parseable contributing verdict; only the clean/applied host decision can authorize objective completion.
Terminal delivery and later inspection derive a bounded host notice from the post-cleanup delegated-custody audit (confirmed terminal cancellations, live runs, unresolved invocation ids and undisposed patches stay distinct) beside unchanged model text and continuation narrative, so a later cancellation never rewrites historical authorship or the answer hash. A valid acceptance PASS with partial, missing or rejected criteria remains a parseable contributing verdict; only the clean/applied host decision can authorize objective completion.
Malformed structured slot configuration is refused at save and is a typed loud review-time failure on every surface — commit triad, scope, advisory, plan, skill review, deep self-review (`deep_review_slot()` raises; `run_deep_self_review` returns the typed `deep_self_review_unavailable` result instead of a report) and task acceptance (a typed DEGRADED panel, `reviewer_slot_config_invalid`); no surface silently chooses the opposite route or a default panel. Technical failure and commit permission are separate facts: producers keep the failure origin (context, delivery, format or window authority) beside the original status, source and findings, the shared physical exception projection keeps budget/admission and authority refusals distinct, and what each enforcement mode permits after such a failure is the protocol's rule (DEVELOPMENT "Review & Commit Protocol"); neither path changes quorum, and custody, Stop/deadline, ownership and candidate binding stay independent. Contributor slot evidence retains configured-subagent identity while its stored wire uses the mutually exclusive reference-or-route form, preserving native retrieval and its API budget admission.
@ -266,7 +266,7 @@ The startup seal audit selects its bounded manifest set before reading live atte
`ClaudexorGateway` uses purpose-bound byte uploads, one idempotent operation ID, status/result reads, cancellation and explicit result acknowledgement. Claudexor performs at most one generation per operation: a lost local HTTP reply rejoins the same operation, and an unknown provider outcome never authorizes another generation. It shares its managed profile with Agent sessions — the adapter reads current access credentials while the official CLI keeps refresh ownership — and no shared external engine, relay process, copied operator auth store or public OpenAI-compatible endpoint. Metadata helpers use `read_owned_gateway` (owned-only discovery and handshake; they never install or start the engine); actual model calls and explicit lifecycle actions use `ensure_owned_gateway`.
One pre-create catalog read negotiates `captureFailureEvidence=true`/`captureEffortEvidence=true` on `POST /v2/model-operations`; invocation manifests freeze both for same-key rejoin. Unadvertised flags stay absent; runtime pinning remains independent. Supporting engines retain all failed-response bytes (incomplete/HTTP refusals too) and processing exceptions privately. CAS retains exact result bytes before ACK; GET never projects another view. Usage/events show compact problem context; a received prefix cannot prove the missing tail. Coherent provider terminals with unusable messages keep outcome/route/usage: `response_rejected` settles usage then raises non-repeatable `stream_rejected`, without unknown recovery or marking healthy accounts failed. After classification only, `ClaudexorModelError.display_message` adds sanitized typed `vendorCode`/`parameter` to error events/terminal previews; unknown outcomes expose no provider details.
One pre-create catalog read negotiates `captureFailureEvidence=true`/`captureEffortEvidence=true` on `POST /v2/model-operations`; invocation manifests freeze both for same-key rejoin. Unadvertised flags stay absent; runtime pinning remains independent. Supporting engines retain all failed-response bytes (incomplete/HTTP refusals too) and processing exceptions privately. CAS retains exact result bytes before ACK; GET never projects another view. Usage/events show compact problem context; a received prefix cannot prove the missing tail. Coherent provider terminals with unusable messages keep outcome/route/usage: `response_rejected` settles usage then raises non-repeatable `stream_rejected`, without unknown recovery or marking healthy accounts failed. After classification only, `ClaudexorModelError.display_message` adds sanitized typed `vendorCode`/`parameter` to error events; terminal previews never show that host text, only those fields as the provider's (`_last_llm_provider_fields`); unknown outcomes expose no provider details.
Money and bytes: physical attempts stay in the usage ledger, where exact zero cash, known charges, estimates and unknown cash remain distinct — a subscription is not evidence of zero cost. Claudexor removes terminal request bytes and acknowledged result bytes, an unacknowledged ready result expires after 30 days, and compact operation receipts survive body deletion, so an expired read cannot start a new generation. Model-purpose resources are not valid Agent attachments. Private continuation envelopes stay in canonical history but out of public/summarizer projections; their reuse is bound to the actual source/model/profile/account identity.
@ -603,7 +603,7 @@ Availability follows `deep_review_route`, never a window floor. An API row needs
#### Post-task reflection
Typed root post-task triggers decide whether a run warrants Experience Review. `reflection.generate_reflection` sends the Light route one open prompt with the EXACT initial text (never a prefix) and its host-recorded `task_inputs.run_origin` beside it (provenance, never by itself the accepted requirement) plus tool-use, error, review and child projections and the same frozen non-final cost snapshot the facts row carries; it runs outside the tool loop, records its own usage, and its failure never erases the delivered result or changes a review verdict. Its execution trace is the ALL-CALLS listing (`build_trace_summary(all_calls=True)`): every call in order with every argument, identical consecutive calls folded into one `×N` row with their rounds, the first line of a failed or repeated call's result, and one header count of rounds whose every call was non-ok — no positional window and no literal cut, because the consolidation seam fits the call to the Light route whenever that route's window is known (an unknown window sends the prompt unchecked — the accepted residual of enlarging it); the STORED `trace_summary` (task card, parents, children) stays the bounded two-argument preview. The trace row carries the `round_id` of the model round that issued the call (absent when unknown), when the listing really cut an argument value or a failed/repeated call's answer, the redacted per-call record is retained through `retain_memory_source` and named in the prompt as OPTIONAL reading (never a required source), and its claim states the STORED bounds on both axes, not completeness: an argument already passed `sanitize_tool_args_for_log` (an oversized value carries a marker with its length and sha) and a result is the stored actor-visible cap — more than the listing, which shows only the first line of a failed or repeated answer — a partial one naming its own `FULL_RESULT_SOURCE_JSON` or `FULL_RESULT_SOURCE_UNAVAILABLE`, with a call's recorded manifest named only when it has one. Cut detection reads the ONE shared marker list (`artifacts.SANITIZER_OMISSION_MARKERS`), because a width test over already-sanitized args measured the widest argument in the task as a small one and retained nothing at all, while a hand-rolled subset missed the `_repr` and `_error` shapes whose arguments survive only in the call blob. Unavailable source retention is disclosed, error details group by full redacted content before display clipping, and post-task synthesis — the reflection and its Pattern Register update — thinks at the owner's Task / Chat effort (`settings_scales.resolve_effort("task")`), never a literal. Admission to the Pattern Register is typed, not a word scan: it opens on a call the loop recorded as errored (its stamped `tool_result_code`, or the recorded status for a legacy row), on a producer fact naming a failure the ok status cannot carry (a preserved commit whose post-commit tests failed publishes `post_commit_tests`), on typed codes already stored with an entry, or on a genuinely FAILED child — a cancelled, soft-landed best-effort or degraded child is not a failure. Those failed-child classes reach the root through the child evidence the synthesis walk already collects and make the run error-bearing for both the trigger and the prompt's error details: children do not reflect, so a short clean root that delegated the work is the only place its child's failure can be learned from at all. Deliberately not "a reason code exists", which would open the register on every terminal.
Typed root post-task triggers decide whether a run warrants Experience Review. `reflection.generate_reflection` sends the Light route one open prompt with the EXACT initial text (never a prefix) and its host-recorded `task_inputs.run_origin` beside it (provenance, never by itself the accepted requirement) plus tool-use, error, review and child projections and the same frozen non-final cost snapshot the facts row carries; it runs outside the tool loop, records its own usage, and its failure never erases the delivered result or changes a review verdict. Its execution trace is the ALL-CALLS listing (`build_trace_summary(all_calls=True)`): every call in order with every argument, identical consecutive calls folded into one `×N` row with their rounds, the first line of a failed or repeated call's result, and one header count of rounds whose every call was non-ok — no positional window and no literal cut, because the consolidation seam fits the call to the Light route whenever that route's window is known (an unknown window sends the prompt unchecked — the accepted residual of enlarging it); the STORED `trace_summary` (task card, parents, children) stays the bounded two-argument preview. The trace row carries the `round_id` of the model round that issued the call (absent when unknown), when the listing really cut an argument value or a failed/repeated call's answer, the redacted per-call record is retained through `retain_memory_source` and named in the prompt as OPTIONAL reading, and its claim states the STORED bounds on both axes, not completeness: an argument already passed `sanitize_tool_args_for_log` (an oversized value carries a marker with its length and sha) and a result is the stored actor-visible cap, a partial one naming its own `FULL_RESULT_SOURCE_JSON` or `FULL_RESULT_SOURCE_UNAVAILABLE`, with a call's recorded manifest named only when it has one. Cut detection reads the ONE shared marker list (`artifacts.SANITIZER_OMISSION_MARKERS`), because a width test over already-sanitized args measured the widest argument in the task as a small one and retained nothing, while a hand-rolled subset missed the `_repr` and `_error` shapes whose arguments survive only in the call blob. Unavailable source retention is disclosed, error details group by full redacted content before display clipping, and post-task synthesis — the reflection and its Pattern Register update — thinks at the owner's Task / Chat effort (`settings_scales.resolve_effort("task")`), never a literal. Admission to the Pattern Register is typed, not a word scan: it opens on a call the loop recorded as errored (its stamped `tool_result_code`, or the recorded status for a legacy row), on a producer fact naming a failure the ok status cannot carry (a preserved commit whose post-commit tests failed publishes `post_commit_tests`), on typed codes already stored with an entry, or on a genuinely FAILED child — a cancelled, soft-landed best-effort or degraded child is not a failure. Those failed-child classes reach the root through the child evidence the synthesis walk already collects and make the run error-bearing for both the trigger and the prompt's error details: children do not reflect, so a short clean root that delegated the work is the only place its child's failure can be learned from. Deliberately not "a reason code exists", which would open the register on every terminal.
A reflection lands where it durably belongs: a non-project root appends the full entry to the canonical `logs/task_reflections.jsonl`, a project-scoped root to its project drive with only a bounded pointer row in the canonical log. A project-bound task's context holds a bounded labeled tail of its project's reflections; a split root's headless mirror drive is never the reflection home; the Pattern Register update stays canonical. Entries carry task identity, evidence, lessons, backlog candidates and validated memory actions. `MEMORY_ACTIONS_JSON` permits only `scratchpad_append`, `knowledge_write` and `identity_update_candidate`, bounded in count and size; `apply_memory_actions` applies them via provenance-preserving memory and knowledge APIs; an identity candidate lands in the scratchpad for review, never auto-written to `identity.md`. A project-scoped task applies knowledge actions only (project store by default, explicit global allowed), skipping scratchpad and identity candidates, which the conversation writes from any room with the full view this Light pass lacks; each skipped, empty or topic-less action is a `reflection_memory_action_skipped` event with its reason and `input_ref`: the retained exact task-input prompt, or an action's reflection-entry copy, else the canonical log pointer or `source_unavailable`. Reflection may propose a campaign or backlog item, never enqueue, review, commit or enable one.

View file

@ -72,7 +72,12 @@ ACCEPTANCE_SETTLEMENT_WAKE = (
def _reviewer_lines(wave: Dict[str, Any]) -> List[str]:
"""One line per roster slot: the reviewer's own verdict and note, or pending."""
"""One line per roster slot: the reviewer's own verdict and note, or pending.
This is the MODEL mailbox's form (``acceptance_settlement_message``): slot
ids, the verdict tokens and the bounded note with its disclosed omission
marker. The owner's row is composed separately (``_owner_reviewer_lines``).
"""
slots = wave.get("slots") or {}
verdicts = wave.get("verdicts") or {}
lines: List[str] = []
@ -84,6 +89,189 @@ def _reviewer_lines(wave: Dict[str, Any]) -> List[str]:
return lines
# ---------------------------------------------------------------------------
# The owner's reading of a settled reviewer (issue #1369). The record keeps the
# tokens: ``reviewer_outputs`` carries slot id, operation and PASS/FAIL/DEGRADED,
# the mailbox line keeps the bounded note with its omission marker, the applied
# review JSON keeps every byte. The row a person reads names the reviewer by the
# model that answered, says the verdict in words and discloses a shortening in
# words (DESIGN §4: internal reason codes belong in details and diagnostics).
# ---------------------------------------------------------------------------
_OWNER_NOTE_LIMIT = 400
# The verdict a reviewer actually settled with, in the owner's words. DEGRADED is
# a reviewer's own deliberate non-verdict; it is neither a refusal nor an absence.
_OWNER_VERDICT_WORDS = {"PASS": "passed it", "FAIL": "rejected it", "DEGRADED": "inconclusive"}
# The slot states that carry no verdict: an answer still owed, a physical outcome
# the host does not know, a request never sent, and a settled failure.
_OWNER_AWAITED = "still awaited"
_OWNER_UNKNOWN = "outcome unknown"
_OWNER_NOT_SENT = "not sent"
_OWNER_UNAVAILABLE = "unavailable"
_OWNER_UNREADABLE = "answered, but no verdict could be read"
_OWNER_SHORTENED = "… (shortened; the complete text is in the review record)"
def _slot_row(rows: Any, slot_id: str) -> Dict[str, Any]:
"""One slot's row of a run's ``actors`` or its ``slot_roster``, or {}."""
return next((row for row in rows or [] if isinstance(row, dict) and str(row.get("slot_id") or "") == slot_id), {})
def _reviewer_identity(actor: Dict[str, Any], roster_row: Dict[str, Any]) -> Dict[str, str]:
"""Which model this slot ran as: the positively observed one when the route
reported it, else the requested one (labelled as requested), else unknown.
Only an engine's final-attempt report (``usage.observed_attempt``) is an
observation. ``usage.resolved_model`` is not one: a direct API route writes
its own target there — what the host sent, not what answered."""
usage = actor.get("usage") if isinstance(actor.get("usage"), dict) else {}
attempt = usage.get("observed_attempt") if isinstance(usage.get("observed_attempt"), dict) else {}
observed = str(attempt.get("model") or "").strip()
requested = str(actor.get("model") or roster_row.get("model") or "").strip()
if observed:
label = observed
elif requested:
label = f"{requested} (requested)"
else:
label = "unknown reviewer"
return {"label": label, "model": observed, "requested_model": requested}
def _owner_outcome(actor: Dict[str, Any], status: str, verdict: str) -> str:
"""The reviewer's own settled outcome in the owner's words; tokens stay in the record.
Only an answer carries a verdict. PASS and FAIL are only ever parsed from one,
but DEGRADED is also the parser's word for NO text: ``_settled_slot_verdict``
gives it to a failed, refused or unknown slot, whose own state speaks instead.
"""
parsed = actor.get("parsed")
if verdict in {"PASS", "FAIL"} or (verdict == "DEGRADED" and parsed is not None):
return _OWNER_VERDICT_WORDS[verdict]
# Text outside the contract is an unreadable answer, whatever custody state the
# row still carries: a row that holds an answer is judged by it.
if parsed is not None or str(actor.get("raw_text") or "").strip():
return _OWNER_UNREADABLE
state = str(actor.get("operation_state") or "")
if not status or state in {"pending_dispatch", "in_flight"}:
return _OWNER_AWAITED
if state == "custody_lost" or bool(actor.get("late_result_pending")):
return _OWNER_UNKNOWN
if status == "not_dispatched" or state == "not_dispatched":
return _OWNER_NOT_SENT
if status in {"ok", "empty"}:
return _OWNER_UNREADABLE # the call returned, with no text to read
# The cause is the delegated engine's own ``failure.safeMessage`` (``run_failure_cause``),
# never the provider's or the reviewer's: the row names whose words they are. A cut made
# by the host's bound is said in words; its model-facing marker stays in the record.
from ouroboros.gateways.claudexor import reported_cause_words
words, shortened = reported_cause_words(actor.get("reported_cause"))
cause = " ".join(words.split())
quote = f'"{cause}…" (shortened)' if shortened else f'"{cause}"'
return f"{_OWNER_UNAVAILABLE} — the engine reported: {quote}" if cause else _OWNER_UNAVAILABLE
def owner_bounded_text(text: str, limit: int, *, in_record: bool) -> str:
"""The SSOT display bound (``truncate_review_artifact``: same limit, same
anti-waste floor) with its model-facing marker said in words for a person.
The marker is replaced only where the SSOT itself appended it — no reviewer
text is pattern-matched — and the words point at the review record that
holds the complete text only when ``in_record`` says it holds it."""
from ouroboros.utils import truncate_review_artifact
full = str(text or "")
bounded = truncate_review_artifact(full, limit=limit)
marker = f"\n⚠️ OMISSION NOTE: truncated at {limit} chars; original length {len(full)}"
if bounded == full or not bounded.endswith(marker):
return bounded
return bounded[: -len(marker)].rstrip() + (_OWNER_SHORTENED if in_record else "… (shortened)")
def _owner_shortened(text: str, limit: int = _OWNER_NOTE_LIMIT) -> str:
"""One reviewer's note on one line of the owner's row, bounded in words.
The note is composed before the panel's review record is stored, and that
store can fail, so the cut says only that it was shortened; the row's
record link, offered only for a stored record, is the pointer."""
return owner_bounded_text(" ".join(str(text or "").split()), limit, in_record=False)
def _owner_note(actor: Dict[str, Any], verdict_row: Dict[str, Any]) -> str:
"""The reviewer's own summary from its complete answer, shortened in words.
The mailbox note (``verdict_row['note']``) is already bounded with the
model-facing marker; a person's row re-reads the complete answer instead of
editing that marker, and falls back to the mailbox note only for a slot that
answered while the run holds no answer text, and only when that note carries
no marker of its own."""
from ouroboros.triad_review import parse_review_findings
parsed = actor.get("parsed")
findings: List[Any] = []
raw = str(actor.get("raw_text") or "")
if parsed is None and raw.strip():
try:
parsed, findings, _signal = parse_review_findings(raw)
except Exception:
log.debug("late acceptance note could not be parsed for the owner row", exc_info=True)
parsed, findings = None, []
elif isinstance(parsed, dict):
rows = parsed.get("findings")
findings = [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
elif isinstance(parsed, list):
findings = [row for row in parsed if isinstance(row, dict)]
note = str(parsed.get("summary") or "") if isinstance(parsed, dict) else ""
note = note or next((str(row.get("recommendation") or row.get("item") or "")
for row in findings if isinstance(row, dict)), "")
# A failure code or parse diagnostic stays in the task detail and Logs; the
# owner's line quotes only the reviewer's own words (or the engine's
# reported cause, already in the outcome), never the exception text. The
# mailbox note of a slot that never answered IS that text (its error, which
# ``_settled_slot_verdict`` keeps whitespace-normalised), so it never falls back.
answered = (str(actor.get("status") or "") in {"ok", "empty"}
or str(verdict_row.get("verdict") or "").upper() in {"PASS", "FAIL"})
if not note and answered and actor.get("parsed") is None and not raw.strip():
mailbox = str(verdict_row.get("note") or "")
note = "" if "⚠️ OMISSION NOTE" in mailbox else mailbox
return _owner_shortened(note)
def _owner_reviewer_lines(run: Dict[str, Any], wave: Dict[str, Any]) -> List[str]:
"""One line per roster slot for the owner: the model that answered, its
outcome in words and its own note. Two seats of the same model stay two
lines, distinguished by seat number, never merged.
Lines follow the run's recorded roster, as the review record lists it: a
released wave collects its slots from a set, so the wave's own order is
arbitrary and a seat number taken from it could name another reviewer."""
slots = wave.get("slots") or {}
verdicts = wave.get("verdicts") or {}
rank: Dict[str, int] = {}
for row in [*(run.get("slot_roster") or []), *(run.get("actors") or [])]:
if isinstance(row, dict):
rank.setdefault(str(row.get("slot_id") or ""), len(rank))
labels: List[str] = []
rows: List[tuple] = []
for slot_id, status in sorted(slots.items(), key=lambda item: rank.get(str(item[0]), len(rank))):
actor = _slot_row(run.get("actors"), str(slot_id))
verdict_row = verdicts.get(slot_id) if isinstance(verdicts.get(slot_id), dict) else {}
parsed = actor.get("parsed") if isinstance(actor.get("parsed"), dict) else {}
verdict = (str(verdict_row.get("verdict") or "")
or str(actor.get("semantic_verdict") or actor.get("signal") or "")
or str(parsed.get("verdict") or parsed.get("status") or "")).upper()
identity = _reviewer_identity(actor, _slot_row(run.get("slot_roster"), str(slot_id)))
labels.append(identity["label"])
rows.append((identity["label"], _owner_outcome(actor, str(status or ""), verdict),
_owner_note(actor, verdict_row) if actor or verdict_row else ""))
lines: List[str] = []
seen: Dict[str, int] = {}
for label, outcome, note in rows:
seen[label] = seen.get(label, 0) + 1
seat = f" (seat {seen[label]})" if labels.count(label) > 1 else ""
lines.append(f"- {label}{seat}: {outcome}" + (f" — {note}" if note else ""))
return lines
def acceptance_settlement_message(request: Any, wave: Dict[str, Any]) -> str:
"""Render the settled wave as the reviewers' own lines, bounded per slot."""
slots = wave.get("slots") or {}
@ -434,13 +622,17 @@ def late_publication_owed(task_id: str, retry_key: str) -> bool:
def _late_settlement_text(run: Dict[str, Any], wave: Dict[str, Any],
fact: Optional[Dict[str, Any]] = None) -> str:
"""The owner row: which version, then the verdict, then the reviewers' own lines."""
"""The owner row: which version, then the verdict, then the reviewers' own lines.
The reviewer lines are the owner's form (model, outcome in words, note
shortened in words); the model mailbox keeps its own ``_reviewer_lines``.
"""
signal = str(run.get("aggregate_signal") or "").upper()
verdict = ({"PASS": "reviewers later passed it.", "FAIL": "reviewers later rejected it."}.get(signal)
or _unsettled_clause(run))
version = str((fact or {}).get("reviewed_revision") or "unknown")
return "\n".join([f"{_VERSION_CLAUSES.get(version, _VERSION_CLAUSES['unknown'])}, {verdict}",
*_reviewer_lines(wave)])
*_owner_reviewer_lines(run, wave)])
def emitted_answer_fact(root: Any, task_id: str) -> Dict[str, Any]:
@ -495,6 +687,12 @@ def late_evidence_fact(run: Dict[str, Any], emitted: Dict[str, Any], *, settled_
{"slot_id": str(actor.get("slot_id") or ""), "operation_id": str(actor.get("operation_id") or ""),
"operation_state": str(actor.get("operation_state") or ""),
"verdict": str(actor.get("semantic_verdict") or actor.get("signal") or "").upper(),
# The identity the owner row names (additive): the model the route
# reported, and the one the roster asked for; "" where unreported.
"model": _reviewer_identity(
actor, _slot_row(run.get("slot_roster"), str(actor.get("slot_id") or "")))["model"],
"requested_model": _reviewer_identity(
actor, _slot_row(run.get("slot_roster"), str(actor.get("slot_id") or "")))["requested_model"],
"response_ref": dict(actor.get("response_ref") or {})}
for actor in (run.get("actors") or []) if isinstance(actor, dict)],
}

View file

@ -222,10 +222,8 @@ class ChatOutbound(TypedDict):
# v6.87.48: count of OPEN ledger rows — the disclosed cause of
# ``cost_final: false``, which can hold with every dollar bucket at zero.
non_final_rows: NotRequired[Optional[int]]
# C12: the ledger's own INTEGRITY marker. The cost authority has always
# produced it (`reconstruct_task_cost`), but no carry list named it, so an
# amount computed over a degraded ledger reached every surface looking exactly
# like one computed over a sound ledger.
# C12: the ledger's own INTEGRITY marker from the cost authority (`reconstruct_task_cost`), carried so an
# amount computed over a degraded ledger never reaches a surface looking like one over a sound ledger.
ledger_integrity_degraded: NotRequired[Optional[bool]]
# Closed shape owned by the cost producer; null means ledger unavailable.
cost_presentation: NotRequired[Optional[CostPresentation]]
@ -253,6 +251,7 @@ class ChatOutbound(TypedDict):
card_row: NotRequired[Literal["timeline", "reviews"]]
card_row_id: NotRequired[str]
card_row_revision: NotRequired[int] # canonical source order, independent of delivery timestamp
late_evidence: NotRequired[Dict[str, Any]] # late review identity/revision + exact applied source; no transcript
# Event-time human presentation; raw task/project ids remain machine keys.
target_label: NotRequired[str]
project_id: NotRequired[str]

View file

@ -914,9 +914,10 @@ def _collect_chat_rows(
if "task_terminal_status" in entry:
rec["task_terminal_status"] = str(entry.get("task_terminal_status") or "")
_copy_task_summary_metadata(rec, entry)
# Lineage, the origin label, and the host's card placement (card_row /
# card_row_id) — a stored key is replayed verbatim, an absent one is omitted.
for field in (*SUBAGENT_MESSAGE_FIELDS, "initiator", "card_row", "card_row_id", "card_row_revision", "narration"):
# Lineage, the origin label, the host's card placement (card_row / card_row_id) and a late-review
# row's evidence pointer (#1369, stored on that type only) replay verbatim; an absent key is omitted.
for field in (*SUBAGENT_MESSAGE_FIELDS, "initiator", "card_row", "card_row_id", "card_row_revision",
"late_evidence", "narration"):
if field in entry:
rec[field] = entry[field]
combined.append(rec)

View file

@ -69,6 +69,7 @@ _FILE_FLAGS = (os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEX
| getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOCTTY", 0))
_CHAT_MEDIA_DIGEST_RE = re.compile(r"chat-media-([0-9a-f]{64})\.[a-z0-9]+")
_DELEGATED_SOURCE_RE = re.compile(r"source_handles/delegated_activity/([A-Za-z0-9][A-Za-z0-9_.-]*-([0-9a-f]{64})\.jsonl)")
_ACCEPTANCE_SOURCE_RE = re.compile(r"source_handles/context_checkpoints/(acceptance-([0-9a-f]{64})\.json)")
Route = Tuple[pathlib.Path, List[str]] # (directory opened by absolute path, segments below it)
@ -319,7 +320,11 @@ def serve_task_source(drive_root: Any, stores: List[pathlib.Path], result: Dict[
like chat media: only ``source_handles/delegated_activity/<id>-<sha256>.jsonl`` whose
basename is ``name`` qualifies, read from the task's own stores (canonical first)
through the confined descent and verified against the digest its name carries. Any
other path must be a source the task result publishes, returned as its verified JSON.
other path must be a source the task result publishes, returned as its verified JSON,
or this author's own host acceptance record named by its digest: a late notice keeps
linking the record it was sent with after a newer publication replaced its panel's
ref (#1369), resolved by the same membership ``get_task_result(review_source_sha256=)``
verifies (``task_finalization.host_acceptance_source``).
"""
match = _DELEGATED_SOURCE_RE.fullmatch(str(source or ""))
if match:
@ -335,7 +340,17 @@ def serve_task_source(drive_root: Any, stores: List[pathlib.Path], result: Dict[
return Response(artifact_store.read_task_result_source_bytes(drive_root, result, name, source),
media_type="application/json")
except (OSError, ValueError, RuntimeError):
return json_error("task source is unavailable or does not match its recorded identity", 404)
pass
record = _ACCEPTANCE_SOURCE_RE.fullmatch(str(source or ""))
try:
if record and record.group(1) == name:
from ouroboros.task_finalization import host_acceptance_source
return Response(host_acceptance_source(drive_root, task_id, record.group(2))[1],
media_type="application/json")
except (OSError, ValueError, RuntimeError):
pass
return json_error("task source is unavailable or does not match its recorded identity", 404)
class _DescriptorResponse(Response):

View file

@ -163,7 +163,8 @@ def run_failure_cause(failure: Any) -> str:
"""What the engine REPORTED about a failed run (``failure.safeMessage``), whitespace-
collapsed, secret-redacted and strictly bounded; "" when it reported nothing. An OPAQUE
fact: stored and displayed, never parsed or branched on (BIBLE P5) — presence is the
only test a caller may make."""
only test a caller may make; a person's quote separates the host's own cut from the
engine's words through ``reported_cause_words``."""
from ouroboros.utils import sanitize_tool_result_for_log, truncate_within_limit
words = (failure if isinstance(failure, dict) else {}).get("safeMessage")
@ -171,6 +172,19 @@ def run_failure_cause(failure: Any) -> str:
sanitize_tool_result_for_log(" ".join(str(words or "").split())), REPORTED_CAUSE_CHARS)
def reported_cause_words(cause: Any) -> tuple[str, bool]:
"""``(words, shortened)`` of a stored ``run_failure_cause``: the engine's words
without the bound's own omission marker (``truncate_within_limit``), and whether
that bound cut them. The words were whitespace-collapsed before the bound, so a
newline can only open the host's marker; the engine's words are never read."""
text = str(cause or "")
words, marker, length = text.rpartition(
f"\n⚠️ OMISSION NOTE: truncated at {REPORTED_CAUSE_CHARS} chars; original length ")
if marker and "\n" not in words and length.isdigit() and int(length) > len(text) == REPORTED_CAUSE_CHARS:
return words, True
return text, False
def run_failure_error(run_id: str, run_state: str, failure: Any) -> ClaudexorUnavailable:
"""The typed refusal for a delegated review run that did not succeed. A null engine code
keeps the host-derived ``run_<state>`` code: a STATE fact, never a cause — the cause the

View file

@ -133,7 +133,7 @@ class ClaudexorModelError(RuntimeError):
for key, label in fields
if isinstance(value := context.get(key), str) and value.strip()
]
# Details lead so the existing terminal preview can name the refusal.
# Details lead so a shortened Logs preview still names the refusal.
return sanitize_tool_result_for_log("; ".join([", ".join(details), str(self)]) if details else str(self))

View file

@ -440,10 +440,13 @@ class _OpenAICompatibleLaneMixin:
# a blank finish_reason=null "incomplete response".
_body_err = self._provider_body_error(resp_dict)
if _body_err:
_body_message = str(_body_err.get("message") or "")
usage["provider_error"] = {
"code": _body_err.get("code"),
"type": _body_err.get("type"),
"message": str(_body_err.get("message") or "")[:300],
"message": _body_message[:300],
# A cut sentence says so: the owner's quote must not read this prefix as whole.
**({"message_truncated": True} if len(_body_message) > 300 else {}),
"kind": "rate_limit" if self._is_transient_body_error(_body_err) and str(_body_err.get("code")) == "429"
else ("provider_transient" if self._is_transient_body_error(_body_err) else "provider_error"),
}

View file

@ -281,6 +281,8 @@ def _record_and_emit_empty_response(
"_last_llm_error": _short_error_text(log_msg), "execution_status": status,
"reason_code": reason, "_last_llm_error_kind": kind,
})
from ouroboros.loop_transport import stamp_owner_provider_message
stamp_owner_provider_message(accumulated_usage, body_error) # a body error's own sentence; a blank glitch has none
accumulated_usage.get("_last_llm_call_meta", {}).update(failure_code=kind)
return event_type, is_provider_glitch, permanent_body_error
@ -678,16 +680,13 @@ def _exception_provider_message(exc: Exception, safe_error: str = "") -> str:
cloud.ru content-filter ("guardrails") block vs an ``Extra inputs are not
permitted`` reasoning_content echo. ``provider_code`` alone cannot tell them
apart, so surface the body message (sanitized + truncated) into the durable
event for the owner. Pure read of ``exc.body``/repr; never changes routing."""
body = _exception_body(exc)
if isinstance(body, dict):
nested = body.get("error")
if isinstance(nested, dict) and str(nested.get("message") or "").strip():
return sanitize_tool_result_for_log(str(nested.get("message")))[:600]
if str(body.get("message") or "").strip():
return sanitize_tool_result_for_log(str(body.get("message")))[:600]
text = str(safe_error or "").strip()
return sanitize_tool_result_for_log(text)[:600] if text else ""
event for Logs, never the owner row. Pure read of ``exc.body``/repr; never changes routing."""
body = _exception_body(exc) # always a dict
nested = body.get("error") if isinstance(body.get("error"), dict) else {}
for text in (nested.get("message"), body.get("message"), str(safe_error or "").strip()):
if str(text or "").strip():
return sanitize_tool_result_for_log(str(text))[:600]
return ""
def _provider_code_kind(provider_code: str) -> str:
@ -990,7 +989,9 @@ def _record_llm_call_error(
else:
ctx.accumulated_usage.pop("_last_llm_retry_after_sec", None)
ctx.accumulated_usage.pop("_last_llm_reset_at", None)
for key, value in (("_last_llm_provider_message", provider_message), ("_last_llm_status_code", classification.status_code),
from ouroboros.loop_transport import stamp_owner_provider_message
stamp_owner_provider_message(ctx.accumulated_usage, error) # the event above keeps the diagnostic provider_message
for key, value in (("_last_llm_status_code", classification.status_code),
("_last_llm_provider_code", classification.provider_code)):
if value:
ctx.accumulated_usage[key] = value
@ -1437,7 +1438,8 @@ def call_llm_with_retry(
context_fit_event_fields = _context_fit_event_fields(accumulated_usage) if physical_context is not None else {}
_take_custom_receipts(usage, msg, accumulated_usage)
for stale in ("_last_llm_error", "_last_llm_error_kind", "_last_llm_retry_same_request",
"_last_llm_status_code", "_last_llm_provider_code", "_last_llm_resource_refusal"):
"_last_llm_status_code", "_last_llm_provider_code", "_last_llm_provider_message",
"_last_llm_provider_fields", "_last_llm_provider_message_cut", "_last_llm_resource_refusal"):
accumulated_usage.pop(stale, None)
cost, display_model, provider, cost_estimated = _normalize_usage_cost(usage, model=model, use_local=use_local)
accumulated_usage["_observed_route"] = observed_route_stamp(usage)

View file

@ -54,7 +54,7 @@ from ouroboros.config import (
get_task_idle_timeout_sec,
)
from ouroboros.deadline_utils import parse_deadline_ts
from ouroboros.loop_llm_call import TRANSPORT_DEATHS_KEY, _TRANSIENT_BACKOFF_CAP_SEC
from ouroboros.loop_llm_call import TRANSPORT_DEATHS_KEY, _TRANSIENT_BACKOFF_CAP_SEC, _exception_body
from ouroboros.llm_probe import upstream_transport_reachable
from ouroboros.owner_mailbox import OwnerMailboxPeek
from ouroboros.utils import append_jsonl, utc_now_iso
@ -835,13 +835,127 @@ def provider_terminal_fallback_text(
def provider_failure_hint(accumulated_usage: Dict[str, Any]) -> str:
# The host's own words for a failure class when the provider left no readable
# sentence (#1369). The typed kind stays on the record and in the llm_api_error
# event; a person reads the class, never the exception repr.
_FAILURE_KIND_WORDS = {
"auth_error": "an authentication or authorization refusal",
"quota_exhausted": "a quota or billing refusal",
"subscription_window_exhausted": "a spent subscription window",
"rate_limit": "a rate limit",
"bad_request": "a rejected request",
"request_too_large": "a request the provider found too large",
"context_overflow": "a context overflow",
"transport_unavailable": "no connection to the provider",
"provider_outcome_unknown": "an attempt with no known outcome",
"provider_transient": "a temporary provider failure",
"provider_incomplete_response": "an incomplete response",
"llm_empty_response": "an empty response",
"provider_body_error": "an error in the provider's response",
"provider_error": "a provider error",
"model_substituted": "an answer from a model other than the requested one",
"deadline_exhausted": "the owner deadline running out before dispatch",
}
# Logs hold the host's record, not necessarily the provider's whole sentence: a
# stream error frame's text reaches the durable event only as a bounded excerpt.
_DIAGNOSTIC_POINTER = "The task's Logs keep the host's record of this failure."
# The owner's quote of a provider sentence passes the SSOT display bound; past it
# the quote is shortened in words beside the Logs pointer, never silently cut.
_OWNER_QUOTE_LIMIT = 600
def owner_provider_message(failure: Any) -> str:
"""The provider's OWN sentence about a failed call, whole, or "" when there is none.
``failure`` is the raised exception — a structured error body's ``message``,
a stream error's provider message — or an empty response's body-error dict
(its ``message``). A Python exception repr is never one of those: it stays in
the durable ``llm_api_error`` event and ``_last_llm_error`` for diagnostics
(#1369). Nor is a Claudexor engine error's text: its ``display_message`` joins
host-side diagnostics (stage, cause, engine code, the engine's own message)
and its provider body stays private, so only its typed provider fields speak
(``owner_provider_fields``). The owner's row bounds it (``provider_failure_hint``).
"""
from ouroboros.llm_claudexor import ClaudexorModelError
from ouroboros.utils import sanitize_tool_result_for_log
detail = " ".join(sanitize_tool_result_for_log(str(accumulated_usage.get("_last_llm_error") or "")).split()).strip()
if not detail:
if isinstance(failure, ClaudexorModelError):
return ""
return f" Last provider error: {detail}"
if isinstance(failure, dict):
authored = (failure.get("message"),)
else:
body = _exception_body(failure)
nested = body.get("error") if isinstance(body.get("error"), dict) else {}
authored = (nested.get("message"), body.get("message"), getattr(failure, "provider_message", None))
text = next((value for value in authored if isinstance(value, str) and value.strip()), "")
return sanitize_tool_result_for_log(" ".join(text.split())) if text else ""
def owner_provider_fields(failure: Any) -> Dict[str, str]:
"""The typed fields a provider itself returned through the Claudexor engine —
its error ``code`` and the refused ``parameter`` — or {}. An unknown outcome
exposes none, as in ``ClaudexorModelError.display_message``."""
from ouroboros.llm_claudexor import ClaudexorModelError
from ouroboros.utils import sanitize_tool_result_for_log
if not isinstance(failure, ClaudexorModelError) or failure.code == "model_outcome_unknown":
return {}
context = failure.problem.get("context")
context = context if isinstance(context, dict) else {}
return {label: " ".join(sanitize_tool_result_for_log(value).split())
for key, label in (("vendorCode", "code"), ("parameter", "parameter"))
if isinstance(value := context.get(key), str) and value.strip()}
def stamp_owner_provider_message(accumulated_usage: Dict[str, Any], failure: Any) -> None:
"""Replace ``_last_llm_provider_message`` with ``failure``'s own sentence and
``_last_llm_provider_fields`` with its typed provider fields, or clear them.
``_last_llm_provider_message_cut`` marks a sentence its producer already cut
(an empty response's body error keeps only a bounded ``message``).
Stamped by the call owner (``loop_llm_call``) at each failed call, so an
earlier round's sentence never speaks for this one.
"""
cut = isinstance(failure, dict) and failure.get("message_truncated") is True
for key, value in (("_last_llm_provider_message", owner_provider_message(failure)),
("_last_llm_provider_message_cut", cut),
("_last_llm_provider_fields", owner_provider_fields(failure))):
accumulated_usage.pop(key, None)
if value:
accumulated_usage[key] = value
def provider_failure_hint(accumulated_usage: Dict[str, Any]) -> str:
"""The provider's own sentence (a long one shortened in words, with the Logs
pointer), or the host's classification with that pointer.
Quotes only ``_last_llm_provider_message`` — text a provider positively
authored (``stamp_owner_provider_message``); typed provider fields without a
sentence are named as the provider's beside the host's classification. The
exception repr in ``_last_llm_error`` and an engine's composed display text
are never appended: they belong to Logs and task details.
"""
from ouroboros.utils import sanitize_tool_result_for_log, truncate_review_artifact
message = " ".join(sanitize_tool_result_for_log(
str(accumulated_usage.get("_last_llm_provider_message") or "")).split()).strip()
cut = accumulated_usage.get("_last_llm_provider_message_cut") is True
if message and not cut and truncate_review_artifact(message, limit=_OWNER_QUOTE_LIMIT) == message:
return f" The provider said: \"{message}\""
if message: # its producer or the SSOT cut it (the SSOT's anti-waste floor lets a small overflow through whole)
return f" The provider said: \"{message[:_OWNER_QUOTE_LIMIT].rstrip()}…\" (shortened). {_DIAGNOSTIC_POINTER}"
kind = str(accumulated_usage.get("_last_llm_error_kind") or "").strip()
if not kind and not str(accumulated_usage.get("_last_llm_error") or "").strip():
return ""
words = _FAILURE_KIND_WORDS.get(kind, "a provider failure")
fields = accumulated_usage.get("_last_llm_provider_fields")
fields = fields if isinstance(fields, dict) else {}
named = [f'{label} "{fields[key]}"' for key, label in (("code", "error code"), ("parameter", "parameter"))
if isinstance(fields.get(key), str) and fields[key].strip()]
if named: # the provider's own typed fields; its body, if any, stays private
return (f" The provider reported {' and '.join(named)} but no sentence the host can quote; "
f"the host classified the failure as {words}. {_DIAGNOSTIC_POINTER}")
return f" The provider gave no readable message; the host classified the failure as {words}. {_DIAGNOSTIC_POINTER}"
# What the host KNOWS it did. It asks again and names no account; which account

View file

@ -366,14 +366,17 @@ def compact_review_projection(review_runs: Any) -> Dict[str, Any]:
if key in raw_run:
panel[key] = copy.deepcopy(raw_run[key])
# A panel that settled after its task ended carries the host's own
# sentence about it; the card prints those bytes verbatim, so the
# projection copies them under the same disclosed bound every other
# owner-facing review artifact uses.
# sentence about it; the card and the owner's row print those bytes
# verbatim, so the projection copies them under the same disclosed
# bound every other owner-facing review artifact uses, a cut said in
# words. The raw run keeps every byte, so a STORED applied record is
# named as the home of the rest (many reviewers can pass the bound).
if isinstance(raw_run.get("late_settlement"), dict):
from ouroboros.utils import truncate_review_artifact
from ouroboros.acceptance_settlement import owner_bounded_text
late = copy.deepcopy(raw_run["late_settlement"])
late["note"] = truncate_review_artifact(str(late.get("note") or ""), 2000)
late["note"] = owner_bounded_text(str(late.get("note") or ""), 2000,
in_record=panel["applied_source_status"] == "available")
panel["late_settlement"] = late
for key in (
"candidate_hash", "evidence_revision", "fence_hash", "binding_hash",

View file

@ -128,6 +128,7 @@ BAND_PATHS = {
"ouroboros/loop_delivery.py": "F6 upstream sync: the delivery-protocol upstream deltas (hold-control literals, trailing-object/fence-aware protocol parsers) folded into the campaign delivery leaf (upstream leaf delivery_protocol.py retired)",
"ouroboros/loop_forced_finalization.py": "Forced-finalization rail of the v7 L-B loop split: one cohesive owner for the forced/orphan/absorption path, moved byte-preserving from loop.py (D01 lane).",
"ouroboros/loop_model_call.py": "Fallback waits retain the exact quota-refusing route across failures without inventing a second router.",
"ouroboros/loop_transport.py": "Entered the band from 993 lines with #1369 provider-message presentation: typed provider sentences, explicit shortening and engine/provider attribution share the existing transport outcome owner. No new retry or terminal authority. Shrink-only from here.",
"ouroboros/marketplace/ouroboroshub.py": "Entered the band from 373 lines: the hubflow sprint added the adopt transaction (eligibility prelude, CAS re-verification, move-aside + state-quintet snapshot, verified rollback with per-step error collection, retention finalize) beside the existing install/update flows (hubflow sprint, adopt-in-ouroboroshub owner decision D4).",
"ouroboros/mcp_client.py": "F3.1 typed-organ producer cutover (D05 entry 7b) plus E5+s2r2 (#447): the MCP transport keeps the SDK-owned error bit as a typed ToolResult, follows nextCursor pagination with injective 12-hex slugs, and discloses collision/pagination omissions; grew into the band from 984 lines, shrink-only otherwise.",
"ouroboros/memory.py": "ibl-2b09abdadd25: scratchpad content-size cap added alongside the existing block-count cap in append_scratchpad_block's eviction loop",

View file

@ -499,19 +499,51 @@ def review_source_reader(task_id: str, ref: Dict[str, Any]) -> Dict[str, Any]:
"task_id": task_id, "review_source_sha256": str(ref.get("sha256") or "")}}
def host_acceptance_source(drive_root: Any, task_id: str, digest: str) -> tuple:
"""``(ref, bytes)`` of this physical author's own HOST acceptance record, named by digest.
The digest fixes the path (no caller path, no store search); the bytes must
hash to it and decode to a host-root task-acceptance run of this very task,
or this raises. The model's ``review_source_sha256`` reader and the owner's
record download (``gateway.task_archive.serve_task_source``) share it.
"""
from ouroboros.artifacts import read_actor_source_bytes, task_artifact_dir_path
from ouroboros.source_retention import retained_task_roots
if len(digest) != 64 or digest.strip("0123456789abcdef"):
raise ValueError("review source digest is invalid")
path = f"source_handles/context_checkpoints/acceptance-{digest}.json"
# The selector addresses exact bytes, not their current placement. Only
# this author's known retained drives can supply a not-yet-canonical file.
roots = [drive_root, *retained_task_roots(drive_root, task_id)]
stored = next((candidate for root in roots
if (candidate := task_artifact_dir_path(root, task_id, create=False) / path).exists()), None)
if stored is None:
raise FileNotFoundError(path)
ref = {"kind": "task_source", "root": "artifact_store", "path": path,
"size": stored.stat().st_size, "sha256": digest}
raw = read_actor_source_bytes(drive_root, task_id, ref)
run = json.loads(raw)
request = run.get("request") if isinstance(run, dict) else None
if (not isinstance(request, dict) or run.get("authority") != "host_root"
or request.get("surface") != "task_acceptance" or request.get("task_id") != task_id):
raise ValueError("review source identity verification failed")
return ref, raw
def review_source_projection(drive_root: Any, task_id: str, digest: str,
start_char: Any = None, end_char: Any = None) -> Dict[str, Any]:
"""Read a physical author's exact acceptance source, including historical panels.
Only host acceptance panels and the canonical debt's exact pinned subject
qualify; no caller path, successor substitution or artifact-store search.
Only host acceptance panels (``host_acceptance_source``) and the canonical
debt's exact pinned subject qualify; no caller path, successor substitution
or artifact-store search.
"""
from ouroboros.artifacts import read_actor_source_bytes, task_artifact_dir_path, text_source_range_projection
from ouroboros.artifacts import read_actor_source_bytes, text_source_range_projection
unavailable = {"schema": 1, "kind": "task_review_source", "status": "unavailable"}
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
return {**unavailable, "reason": "source_ref_invalid"}
path = f"source_handles/context_checkpoints/acceptance-{digest}.json"
try:
from ouroboros.task_results import load_task_result
@ -524,22 +556,7 @@ def review_source_projection(drive_root: Any, task_id: str, digest: str,
ref = historical_ref
raw = read_actor_source_bytes(drive_root, task_id, ref)
else:
from ouroboros.source_retention import retained_task_roots
# The selector addresses exact bytes, not their current placement. Only
# this author's known retained drives can supply a not-yet-canonical file.
roots = [drive_root, *retained_task_roots(drive_root, task_id)]
stored = next((candidate for root in roots
if (candidate := task_artifact_dir_path(root, task_id, create=False) / path).exists()), None)
if stored is None:
raise FileNotFoundError(path)
ref = {"kind": "task_source", "root": "artifact_store", "path": path,
"size": stored.stat().st_size, "sha256": digest}
raw = read_actor_source_bytes(drive_root, task_id, ref)
panel = json.loads(raw)
request = panel.get("request") or {}
if panel.get("authority") != "host_root" or request.get("surface") != "task_acceptance" or request.get("task_id") != task_id:
raise ValueError("review source identity verification failed")
ref, raw = host_acceptance_source(drive_root, task_id, digest)
projection, reason = text_source_range_projection(raw.decode("utf-8"), unavailable["kind"], start_char, end_char)
return {**(projection or unavailable), "task_id": task_id, "source_ref": ref,
**({"reason": reason} if reason else {})}

View file

@ -531,7 +531,9 @@ def test_a_panel_that_settles_after_the_loop_exited_is_attached_through_the_reme
assert len(rows) == 1, [e.get("type") for e in events]
assert rows[0]["task_id"] == f.ctx.task_id and rows[0]["chat_id"] == 1
assert rows[0]["text"].startswith("On the delivered version of this answer, reviewers later passed it.")
assert "- acceptance-one: PASS" in rows[0]["text"]
# The owner row names the model, not the slot id, and says the verdict in words (#1369).
assert "- fixture/reviewer (requested): passed it — Independent review" in rows[0]["text"]
assert "acceptance-one" not in rows[0]["text"]
stored = load_task_result(f.ctx.drive_root, f.ctx.task_id)
assert stored["status"] == "completed"
panel = stored["review_projection"]["panels"][-1]

View file

@ -324,7 +324,10 @@ def test_a_panel_that_settles_after_the_task_ended_is_attached_and_announced_onc
# supersession is not delivery proof: with no send receipt the version is unknown.
assert event["text"].startswith("On the reviewed version of this answer (whether it was the delivered one "
"is unknown), reviewers later passed it.")
assert "- a: PASS — model/a says PASS" in event["text"]
# The owner row names the reviewer by model and says the verdict in words;
# the slot id and the token stay in the typed evidence (#1369).
assert "- model/a (requested): passed it — model/a says PASS" in event["text"]
assert "- a: PASS" not in event["text"]
assert event["delivery_id"] == "acceptance-late:acceptance-subject-one"
assert {key: event["progress_meta"][key] for key in ("card_row", "card_row_id")} == {
"card_row": "reviews", "card_row_id": "acceptance-late:acceptance-subject-one"}
@ -372,15 +375,20 @@ def test_the_late_row_never_reports_a_reviewer_whose_outcome_is_unknown_as_answe
wave = {"slots": {"a": "ok", "b": "ok", "c": ""},
"verdicts": {"a": {"verdict": "PASS"}, "b": {"verdict": "DEGRADED"}}}
incident = {"aggregate_signal": "DEGRADED", "actors": [
{"operation_state": "settled", "parsed": {"verdict": "PASS"}},
{"operation_state": "settled", "parsed": {"verdict": "DEGRADED"}},
{"operation_state": "custody_lost", "late_result_pending": True}]}
{"slot_id": "a", "operation_state": "settled", "parsed": {"verdict": "PASS"}},
{"slot_id": "b", "operation_state": "settled", "parsed": {"verdict": "DEGRADED"}},
{"slot_id": "c", "operation_state": "custody_lost", "late_result_pending": True}]}
text = _late_settlement_text(incident, wave)
unknown_version = "On the reviewed version of this answer (whether it was the delivered one is unknown), "
assert text.startswith(unknown_version + "reviewers later returned no settled verdict — 1 reviewer's outcome "
"is still unknown.")
assert "no quorum" not in text
assert "- a: PASS" in text and "- c: pending" in text
# Owner wording (#1369): a settled PASS, a reviewer's own DEGRADED and an
# unanswered seat read as words; with no roster identity the reviewer is unknown.
assert "- unknown reviewer (seat 1): passed it" in text
assert "- unknown reviewer (seat 2): inconclusive" in text
assert "- unknown reviewer (seat 3): still awaited" in text
assert "PASS" not in text.split("\n", 1)[1] and "DEGRADED" not in text and "pending" not in text
assert "— 2 reviewers' outcomes are still unknown." in _late_settlement_text(
{**incident, "actors": [incident["actors"][2], {"operation_state": "pending_dispatch"}]}, wave)
answered = {**incident, "actors": incident["actors"][:2]}
@ -444,8 +452,9 @@ def test_a_terminal_task_gets_one_row_at_completion_not_at_quorum(tmp_path, monk
with settled:
settled.wait_for(lambda: count["n"] >= 2, timeout=10)
rows = [e for e in events if e.get("system_type") == "acceptance_late_settlement"]
assert len(rows) == 1 and "- a: PASS" in rows[0]["text"] and "- b: PASS" in rows[0]["text"]
assert "pending" not in rows[0]["text"] and _mailbox_rows(tmp_path, "late-two") == []
assert len(rows) == 1
assert "- model/a (requested): passed it" in rows[0]["text"] and "- model/b (requested): passed it" in rows[0]["text"]
assert "still awaited" not in rows[0]["text"] and _mailbox_rows(tmp_path, "late-two") == []
def test_two_late_panels_of_one_task_each_announce_their_own_row(tmp_path, monkeypatch):

View file

@ -1,5 +1,6 @@
"""Actual terminal sender/outbox, preparation owner and registered explicit consumers."""
import copy
import hashlib
import json
import queue
import threading
@ -212,6 +213,7 @@ def test_overlapping_historical_collections_keep_the_queued_notice_room(late, tm
"""Both real collectors can observe pending before either publishes its result."""
from ouroboros import acceptance_late, acceptance_settlement as settlement, review_dispatch
from ouroboros.contracts.chat_id_policy import WEB_UI_CHAT_ID
from ouroboros.gateway.task_archive import serve_task_source
from ouroboros.projects_registry import bind_task_to_project
from supervisor.log_addressing import bound_project_chat_id
from supervisor.terminal_delivery import pending_deliveries, terminal_answer_receipts
@ -275,6 +277,11 @@ def test_overlapping_historical_collections_keep_the_queued_notice_room(late, tm
notice, = pending_deliveries(f.root)
assert notice['system_type'] == 'acceptance_late_settlement'
assert notice['progress_meta']['late_evidence']['source_ref'] != p['applied_source_ref']
# The notice's record link still opens the exact record it was sent with (#1369).
sent = notice['progress_meta']['late_evidence']['source_ref']
served = serve_task_source(f.root, [], load_task_result(f.root, f.tid), f.tid, sent['path'].rsplit('/', 1)[1],
sent['path'])
assert served.status_code == 200 and hashlib.sha256(served.body).hexdigest() == sent['sha256']
assert notice['text'] == p['late_settlement']['note']
receipt, = terminal_answer_receipts(f.root, f.tid)['delivered']
assert p['late_settlement']['historical_delivery'] == {

View file

@ -203,7 +203,12 @@ def test_delayed_quorum_callback_keeps_one_final_historical_notice(
for notice in [*notices, owed]: # The real consumer also sees an outbox replay.
chat._handle_send_message(notice, sender)
assert len(sends) == 1 and sends[0][0] == 7, sends
assert 'pending' not in sends[0][1] and f'- 2: {last_verdict}' in sends[0][1], {
# The owner row says each outcome in words (DESIGN §4), one seat per roster slot in
# roster order, so the final notice reads slot 2's own verdict at seat 3.
words = {'PASS': 'passed it', 'FAIL': 'rejected it'}[last_verdict]
seats = [line.split(' — ')[0] for line in sends[0][1].split('\n')[1:]]
assert seats == [f'- openai/gpt-4.1-nano (requested) (seat {seat}): {outcome}'
for seat, outcome in ((1, 'passed it'), (2, 'passed it'), (3, words))], {
'notices': len(notices), 'sent': sends, 'retained': panel['late_settlement']['note']}
assert len(notices) == 1, notices
assert sends[0][1] == owed['text'] == panel['late_settlement']['note'] == source['late_settlement']['note']

View file

@ -0,0 +1,670 @@
"""Owner-facing host notices (#1369): the late-review row, the provider terminal
sentence and the late-evidence carrier, each through its real producer.
The record keeps its tokens — slot ids, PASS/FAIL/DEGRADED, the ``⚠️ OMISSION
NOTE`` marker, the exception repr — in the model mailbox, the typed evidence, the
``llm_api_error`` event and Logs. The row a person reads says the same facts in
words and points at the exact record.
"""
from __future__ import annotations
import asyncio
import json
from types import SimpleNamespace
import pytest
from ouroboros.acceptance_settlement import (
_late_settlement_text, acceptance_settlement_message, late_evidence_fact,
)
from ouroboros.loop_transport import provider_failure_hint, provider_terminal_fallback_text
LONG_SUMMARY = ("The budget section is complete and every claim is backed by the attached ledger, "
"including the two contested rows. ") * 8
REPR = "PermissionDeniedError(\"Error code: 403 - {'error': {'message': 'The API key was revoked.', 'code': 403}}\")"
def _run(**overrides):
run = {
"authority": "host_root", "aggregate_signal": "PASS", "panel_id": "panel_1",
"request": {"surface": "task_acceptance", "subject": "answer A", "retry_key": "rk"},
"slot_roster": [{"slot_id": "triad_w45a8z", "model": "openai/gpt-5.5", "route": "agent_session"},
{"slot_id": "triad_bkydwq", "model": "anthropic/claude-opus-5-5", "route": "api_chat"},
{"slot_id": "triad_qq11zz", "model": "claudexor::codex=gpt-6-astra", "route": "agent_session"},
{"slot_id": "triad_late01", "model": "x/late", "route": "api_chat"}],
"actors": [
{"slot_id": "triad_w45a8z", "model": "openai/gpt-5.5", "status": "ok", "operation_state": "settled",
# A delegated engine run reports its final attempt's model: an observation.
"usage": {"resolved_model": "openai/gpt-5.5-2026-09",
"observed_attempt": {"harness_id": "codex", "model": "openai/gpt-5.5-2026-09"}},
"semantic_verdict": "PASS",
"parsed": {"verdict": "PASS", "summary": LONG_SUMMARY, "findings": []}},
{"slot_id": "triad_bkydwq", "model": "anthropic/claude-opus-5-5", "status": "ok", "operation_state": "settled",
"semantic_verdict": "DEGRADED",
"parsed": {"verdict": "DEGRADED", "summary": "The ledger could not be opened from the packet.", "findings": []}},
{"slot_id": "triad_qq11zz", "model": "claudexor::codex=gpt-6-astra", "status": "error", "operation_state": "settled",
"error": "ClaudexorModelError: engine_busy (HTTP 429)", "reported_cause": "The engine is rate limited"},
{"slot_id": "triad_late01", "model": "x/late", "operation_state": "pending_dispatch"},
],
}
run.update(overrides)
return run
def _wave():
return {"slots": {"triad_w45a8z": "ok", "triad_bkydwq": "ok", "triad_qq11zz": "error", "triad_late01": ""},
"verdicts": {"triad_w45a8z": {"verdict": "PASS", "note": LONG_SUMMARY[:400] + "\n⚠️ OMISSION NOTE: truncated at 400 chars; original length 872"},
"triad_bkydwq": {"verdict": "DEGRADED", "note": "The ledger could not be opened from the packet."},
"triad_qq11zz": {"verdict": "", "note": "ClaudexorModelError: engine_busy (HTTP 429)"}},
"total": 4}
# ---------------------------------------------------------------------------
# The owner row
# ---------------------------------------------------------------------------
def test_owner_row_names_models_and_says_each_outcome_in_words():
text = _late_settlement_text(_run(), _wave(), {"reviewed_revision": "delivered"})
head, *lines = text.split("\n")
assert head == "On the delivered version of this answer, reviewers later passed it."
# The observed model leads; a requested-only model is labelled; the cause is quoted.
assert lines[0].startswith("- openai/gpt-5.5-2026-09: passed it — The budget section is complete")
assert lines[1] == "- anthropic/claude-opus-5-5 (requested): inconclusive — The ledger could not be opened from the packet."
# The quoted cause is the engine's own ``failure.safeMessage``, attributed to the engine.
assert lines[2] == '- claudexor::codex=gpt-6-astra (requested): unavailable — the engine reported: "The engine is rate limited"'
assert lines[3] == "- x/late (requested): still awaited"
# No slot id, verdict token, marker, failure code or exception text reaches the person.
for internal in ("triad_", "PASS", "DEGRADED", "OMISSION NOTE", "HTTP 429", "ClaudexorModelError", "pending", "error"):
assert internal not in "\n".join(lines), internal
def test_owner_note_shortening_is_said_in_words_without_promising_a_record():
"""The note is composed before the panel's record is stored, and that store can
fail: a reviewer's shortened note says only that it was shortened. The row's
record link, offered only for a stored record, is the pointer."""
text = _late_settlement_text(_run(), _wave())
line = text.split("\n")[1]
assert line.endswith("… (shortened)")
assert "review record" not in text and "OMISSION NOTE" not in text
# The SSOT display bound decides the cut: the same 400-char preview, the same
# anti-waste floor (a note barely over the limit passes whole).
barely = "x" * 430
run = _run(actors=[{**_run()["actors"][0], "parsed": {"verdict": "PASS", "summary": barely}}])
whole = _late_settlement_text(run, {"slots": {"triad_w45a8z": "ok"}, "verdicts": {}}).split("\n")[1]
assert whole.endswith(barely) and "shortened" not in whole
def test_an_engine_cause_the_hosts_bound_cut_is_quoted_as_shortened_without_its_marker():
"""``run_failure_cause`` bounds the engine's words with the host's model-facing
omission marker inside the limit. The owner's quote keeps only the engine's words
and says the cut in words; the record keeps the marker. Words that merely mention
such a note are the engine's own and stay quoted as written."""
from ouroboros.gateways.claudexor import run_failure_cause
cause = run_failure_cause({"safeMessage": "The reviewer session could not start: its workspace lease expired. " * 9})
assert "\n⚠️ OMISSION NOTE: truncated at 512 chars" in cause, "the record keeps the disclosed marker"
actor = {**_run()["actors"][2], "reported_cause": cause}
wave = {"slots": {"triad_qq11zz": "error"}, "verdicts": {}}
line = _late_settlement_text(_run(actors=[actor]), wave).split("\n")[1]
kept = cause.split("\n")[0].strip()
assert line == f'- claudexor::codex=gpt-6-astra (requested): unavailable — the engine reported: "{kept}…" (shortened)'
assert actor["reported_cause"] == cause, "the canonical evidence is unchanged"
mention = run_failure_cause({"safeMessage": "Refused. ⚠️ OMISSION NOTE: truncated at 512 chars; original length 900"})
quoted = _late_settlement_text(_run(actors=[{**actor, "reported_cause": mention}]), wave).split("\n")[1]
assert quoted.endswith(f'the engine reported: "{mention}"') and "shortened" not in quoted
def test_the_model_mailbox_keeps_slot_ids_tokens_and_the_disclosed_marker():
"""The mailbox line is model-facing and stays byte-identical to its contract."""
text = acceptance_settlement_message(SimpleNamespace(retry_key="rk"), _wave())
assert text.startswith("Acceptance review rk: 3 of 4 reviewer slot(s) have answered")
assert "- triad_w45a8z: PASS — " in text and "⚠️ OMISSION NOTE: truncated at 400 chars; original length 872" in text
assert "- triad_bkydwq: DEGRADED — The ledger could not be opened from the packet." in text
assert "- triad_qq11zz: error — ClaudexorModelError: engine_busy (HTTP 429)" in text
assert "- triad_late01: pending" in text
def test_reviewer_identity_prefers_observed_then_requested_then_unknown():
run = _run(slot_roster=[{"slot_id": "s1"}], actors=[{"slot_id": "s1", "status": "ok", "operation_state": "settled",
"parsed": {"verdict": "FAIL", "summary": "Missing tests."}}])
text = _late_settlement_text({**run, "aggregate_signal": "FAIL"}, {"slots": {"s1": "ok"}, "verdicts": {}})
assert text.split("\n")[1] == "- unknown reviewer: rejected it — Missing tests."
# Two seats of one model stay two lines.
twin = {**_run()["actors"][0], "slot_id": "s2", "parsed": {"verdict": "PASS", "summary": "Fine."}}
text = _late_settlement_text(_run(actors=[_run()["actors"][0], twin]),
{"slots": {"triad_w45a8z": "ok", "s2": "ok"}, "verdicts": {}})
assert "- openai/gpt-5.5-2026-09 (seat 1): passed it" in text and "- openai/gpt-5.5-2026-09 (seat 2): passed it — Fine." in text
def test_a_route_target_is_requested_not_observed():
"""A direct API route copies its own target into ``usage.resolved_model``: what the
host sent, not what answered. Only an engine's final-attempt report is observed."""
actor = {"slot_id": "s1", "model": "anthropic/claude-opus-5-5", "status": "ok", "operation_state": "settled",
"usage": {"provider": "anthropic", "resolved_model": "anthropic/claude-opus-5-5"},
"parsed": {"verdict": "PASS", "summary": "Fine."}}
run = _run(slot_roster=[{"slot_id": "s1", "model": "anthropic/claude-opus-5-5", "route": "api_chat"}],
actors=[actor])
line = _late_settlement_text(run, {"slots": {"s1": "ok"}, "verdicts": {}}).split("\n")[1]
assert line == "- anthropic/claude-opus-5-5 (requested): passed it — Fine."
fact = late_evidence_fact(run, {"source": "terminal_delivery_registry", "state": "unknown", "delivered": []},
settled_at="2026-09-28T00:00:00+00:00")
row = fact["reviewer_outputs"][0]
assert (row["model"], row["requested_model"]) == ("", "anthropic/claude-opus-5-5")
def test_seats_follow_the_recorded_roster_not_the_released_wave_order():
"""A released wave collects its slots from a set: seat N is still the Nth roster seat."""
roster = [{"slot_id": slot, "model": "m/twin", "route": "api_chat"} for slot in ("0", "1", "2")]
actors = [{"slot_id": slot, "model": "m/twin", "status": "ok", "operation_state": "settled",
"parsed": {"verdict": verdict, "summary": f"Seat {slot}."}}
for slot, verdict in (("0", "PASS"), ("1", "PASS"), ("2", "FAIL"))]
run = _run(aggregate_signal="FAIL", slot_roster=roster, actors=actors)
# A slot the run never recorded keeps its place after the roster.
wave = {"slots": {"late": "", "2": "ok", "0": "ok", "1": "ok"}, "verdicts": {}}
assert _late_settlement_text(run, wave).split("\n")[1:] == [
"- m/twin (requested) (seat 1): passed it — Seat 0.",
"- m/twin (requested) (seat 2): passed it — Seat 1.",
"- m/twin (requested) (seat 3): rejected it — Seat 2.",
"- unknown reviewer: still awaited"]
def test_a_long_late_note_is_cut_in_words_and_the_applied_record_keeps_it_whole(tmp_path):
"""Five reviewers at the per-seat note bound compose a note past the projection's
2000-char display bound. The card and the owner's row both print the projected
note: its cut is said in words, naming the review record only when one is stored,
and the applied record (the raw run) keeps every byte."""
from ouroboros import loop, review_projection
from ouroboros.task_results import load_task_result, write_task_result
from tests.test_acceptance_publication import _context, _source
roster = [{"slot_id": f"s{i}", "model": f"provider/reviewer-{i}", "route": "api_chat"} for i in range(5)]
actors = [{"slot_id": f"s{i}", "model": f"provider/reviewer-{i}", "status": "ok", "operation_state": "settled",
"parsed": {"verdict": "PASS", "summary": LONG_SUMMARY}} for i in range(5)]
run = _run(slot_roster=roster, actors=actors)
note = _late_settlement_text(run, {"slots": {f"s{i}": "ok" for i in range(5)}, "verdicts": {}},
{"reviewed_revision": "delivered"})
marker = f"\n⚠️ OMISSION NOTE: truncated at 2000 chars; original length {len(note)}"
assert len(note) - 2000 > len(marker), "past the 2000-char bound and its anti-waste floor"
run["late_settlement"] = {"note": note}
write_task_result(tmp_path, "applied", "completed", result="The delivered answer.")
trace = {"review_runs": [run]}
loop._set_acceptance_decision(trace, {"status": "accepted", "reason": "clean_pass"})
review_projection.publish_acceptance_checkpoint(_context(tmp_path), trace)
panel = load_task_result(tmp_path, "applied")["review_projection"]["panels"][0]
assert panel["applied_source_status"] == "available"
assert panel["late_settlement"]["note"] == (
note[:2000].rstrip() + "… (shortened; the complete text is in the review record)")
assert _source(tmp_path, panel)["late_settlement"]["note"] == note
unstored = {**run, "applied_source_status": "unavailable"}
assert (review_projection.compact_review_projection([unstored])["panels"][0]["late_settlement"]["note"]
== note[:2000].rstrip() + "… (shortened)")
def test_unreadable_unknown_and_not_sent_reviewers_read_as_their_own_states():
actors = [
{"slot_id": "a", "model": "m/a", "status": "ok", "operation_state": "settled", "raw_text": "I think it is fine.", "parsed": None},
{"slot_id": "b", "model": "m/b", "status": "error", "operation_state": "custody_lost", "late_result_pending": True},
{"slot_id": "c", "model": "m/c", "status": "not_dispatched", "operation_state": "not_dispatched"},
{"slot_id": "d", "model": "m/d", "status": "error", "operation_state": "settled", "error": "Timeout after 600s"},
]
wave = {"slots": {"a": "ok", "b": "error", "c": "not_dispatched", "d": "error"},
"verdicts": {"a": {"verdict": "DEGRADED", "note": ""}, "d": {"verdict": "", "note": "Timeout after 600s"}}}
text = _late_settlement_text(_run(aggregate_signal="DEGRADED", actors=actors), wave)
lines = text.split("\n")
assert lines[0].startswith("On the reviewed version of this answer (whether it was the delivered one is unknown), "
"reviewers later returned no settled verdict — 1 reviewer's outcome is still unknown.")
assert lines[1:] == ["- m/a (requested): answered, but no verdict could be read",
"- m/b (requested): outcome unknown",
"- m/c (requested): not sent",
"- m/d (requested): unavailable"]
assert "Timeout" not in text and "DEGRADED" not in text
def test_a_slot_without_an_answer_never_reads_as_a_reviewers_inconclusive_verdict():
"""Through the real wave producer: ``_settled_slot_verdict`` parses a failed,
refused or unknown slot's EMPTY text as DEGRADED, the parser's word for no
text. Only an answer carries a reviewer's verdict; the slot state speaks for
the rest, and the host's error text stays out of the owner's line."""
from ouroboros.acceptance_settlement import _collected_wave
from ouroboros.triad_review import parse_review_findings
own = '{"verdict": "DEGRADED", "summary": "The ledger could not be opened."}'
actors = [
{"slot_id": "a", "model": "m/a", "status": "error", "operation_state": "settled",
"error": "APIConnectionError('Connection reset\n by peer')", "transport_status": "provider_transport_error"},
{"slot_id": "b", "model": "m/b", "status": "error", "operation_state": "settled",
"error": "ClaudexorModelError: engine_busy (HTTP 429)", "reported_cause": "The engine is rate limited"},
{"slot_id": "c", "model": "m/c", "status": "not_dispatched", "operation_state": "not_dispatched",
"error": "refused before dispatch"},
{"slot_id": "d", "model": "m/d", "status": "error", "operation_state": "custody_lost", "late_result_pending": True},
{"slot_id": "e", "model": "m/e", "status": "ok", "operation_state": "settled", "raw_text": own,
"parsed": parse_review_findings(own)[0], "signal": "DEGRADED", "semantic_verdict": "DEGRADED"},
{"slot_id": "f", "model": "m/f", "status": "empty", "operation_state": "settled"},
{"slot_id": "g", "model": "m/g", "operation_state": "in_flight"},
]
run = _run(aggregate_signal="DEGRADED", actors=actors, slot_roster=[])
wave = _collected_wave(run)
assert {slot: row["verdict"] for slot, row in wave["verdicts"].items()} == dict.fromkeys("abcdef", "DEGRADED")
assert wave["slots"]["g"] == ""
lines = _late_settlement_text(run, wave).split("\n")[1:]
assert lines == ["- m/a (requested): unavailable",
'- m/b (requested): unavailable — the engine reported: "The engine is rate limited"',
"- m/c (requested): not sent",
"- m/d (requested): outcome unknown",
"- m/e (requested): inconclusive — The ledger could not be opened.",
"- m/f (requested): answered, but no verdict could be read",
"- m/g (requested): still awaited"]
for internal in ("APIConnectionError", "Connection", "engine_busy", "refused before", "DEGRADED"):
assert internal not in "\n".join(lines), internal
def test_late_evidence_fact_names_the_reviewer_identity_additively():
fact = late_evidence_fact(_run(), {"source": "terminal_delivery_registry", "state": "unknown", "delivered": []},
settled_at="2026-09-28T00:00:00+00:00")
rows = {row["slot_id"]: row for row in fact["reviewer_outputs"]}
assert rows["triad_w45a8z"]["verdict"] == "PASS" # the token stays in the typed evidence
assert (rows["triad_w45a8z"]["model"], rows["triad_w45a8z"]["requested_model"]) == ("openai/gpt-5.5-2026-09", "openai/gpt-5.5")
assert (rows["triad_bkydwq"]["model"], rows["triad_bkydwq"]["requested_model"]) == ("", "anthropic/claude-opus-5-5")
assert set(rows["triad_late01"]) >= {"slot_id", "operation_id", "operation_state", "verdict", "response_ref", "model", "requested_model"}
# ---------------------------------------------------------------------------
# The provider terminal sentence
# ---------------------------------------------------------------------------
class _BodyError(Exception):
def __init__(self, message, *, status_code, body=None):
super().__init__(message)
self.status_code = status_code
if body is not None:
self.body = body
class _Raising:
def __init__(self, error):
self.error, self.calls = error, 0
def chat(self, **_kwargs):
self.calls += 1
raise self.error
class _Events:
def __init__(self):
self.events = []
def put(self, event):
self.events.append(event)
put_nowait = put
def _call(tmp_path, llm, usage):
from ouroboros.loop_llm_call import call_llm_with_retry
return call_llm_with_retry(llm, [{"role": "user", "content": "hi"}], "openai/fixture", None, "high", 3,
tmp_path, "task-403", 1, _Events(), usage, "task", False)
def test_the_exception_owner_stamps_only_the_providers_own_sentence(tmp_path, monkeypatch):
monkeypatch.setattr("ouroboros.loop_llm_call.time.sleep", lambda _s: pytest.fail("a 403 must not retry"))
body = {"error": {"message": "The API key was revoked.", "code": 403, "metadata": {"provider_name": None}}}
llm = _Raising(_BodyError(f"Error code: 403 - {body}", status_code=403, body=body))
usage = {}
msg, _cost = _call(tmp_path, llm, usage)
assert msg is None and llm.calls == 1
assert usage["_last_llm_error_kind"] == "auth_error"
assert usage["_last_llm_provider_message"] == "The API key was revoked."
assert "Error code" in usage["_last_llm_error"], "the diagnostic keeps the exception text"
hint = provider_failure_hint(usage)
assert hint == ' The provider said: "The API key was revoked."'
text = provider_terminal_fallback_text(usage, is_context_overflow=False, is_transport_wait=False,
waited_sec=0.0, is_deadline_exhausted=False)
assert text.startswith("⚠️ The model provider returned no usable response. The provider said: \"The API key was revoked.\"")
assert "Error code" not in text and "{" not in text and "PermissionDenied" not in text
# The durable event keeps the diagnostic message for Logs.
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text().splitlines()]
event, = [row for row in rows if row["type"] == "llm_api_error"]
assert event["provider_message"] == "The API key was revoked." and "Error code" in event["error"]
def test_without_a_provider_sentence_the_host_classifies_and_points_at_logs(tmp_path, monkeypatch):
monkeypatch.setattr("ouroboros.loop_llm_call.time.sleep", lambda _s: pytest.fail("a 401 must not retry"))
llm = _Raising(_BodyError("AuthenticationError('401 invalid_api_key')", status_code=401))
usage = {"_last_llm_provider_message": "stale sentence from an earlier round"}
msg, _cost = _call(tmp_path, llm, usage)
assert msg is None and "_last_llm_provider_message" not in usage
assert usage["_last_llm_error_kind"] == "auth_error"
hint = provider_failure_hint(usage)
assert hint == (" The provider gave no readable message; the host classified the failure as "
"an authentication or authorization refusal. The task's Logs keep the host's record of this failure.")
assert "AuthenticationError" not in hint and "stale sentence" not in hint
def test_a_body_error_inside_an_empty_response_carries_the_providers_sentence(tmp_path):
class _Empty:
calls = 0
def chat(self, **_kwargs):
self.calls += 1
return ({"content": "", "finish_reason": "stop"},
{"provider_error": {"code": "401", "type": "invalid_request_error",
"message": "Incorrect API key provided.", "kind": "provider_error"},
"prompt_tokens": 3, "completion_tokens": 0})
llm, usage = _Empty(), {}
msg, _cost = _call(tmp_path, llm, usage)
assert msg is None and llm.calls == 1
assert usage["_last_llm_provider_message"] == "Incorrect API key provided."
assert provider_failure_hint(usage) == ' The provider said: "Incorrect API key provided."'
def test_a_body_error_sentence_its_producer_cut_is_quoted_as_shortened(tmp_path):
"""The HTTP-200 body-error producer keeps a bounded ``message`` and says when it
cut it; the owner's row then never closes that prefix as the provider's whole
sentence. Classification and retry read code/type/kind and do not change."""
from ouroboros.llm import LLMClient
client = LLMClient()
target = client._resolve_remote_target("deepseek/deepseek-v4-flash-0731")
sentence = "Your organisation has used its whole monthly request allowance for this model family. " * 6
def produced(message):
body = {"id": "gen-1", "choices": [{"message": {"content": ""}, "finish_reason": "stop"}],
"error": {"code": 402, "message": message}}
return client._normalize_remote_response(body, target, skip_cost_fetch=True)[1]
short = produced("Insufficient credits.")
assert short["provider_error"]["message"] == "Insufficient credits." and "message_truncated" not in short["provider_error"]
cut = produced(sentence)
assert cut["provider_error"]["message"] == sentence[:300] and cut["provider_error"]["message_truncated"] is True
class _Empty:
calls = 0
def chat(self, **_kwargs):
self.calls += 1
return {"content": "", "finish_reason": "stop"}, {**cut, "prompt_tokens": 3, "completion_tokens": 0}
llm, usage = _Empty(), {}
msg, _cost = _call(tmp_path, llm, usage)
assert msg is None and llm.calls == 1 and usage["_last_llm_error_kind"] == "provider_body_error"
assert usage["_last_llm_provider_message_cut"] is True
assert provider_failure_hint(usage) == (f' The provider said: "{" ".join(sentence[:300].split())}…" (shortened). '
"The task's Logs keep the host's record of this failure.")
def test_a_usable_response_clears_the_stale_provider_sentence(tmp_path):
class _Ok:
def chat(self, **_kwargs):
return {"content": "fine"}, {"prompt_tokens": 3, "completion_tokens": 1}
usage = {"_last_llm_provider_message": "The API key was revoked.", "_last_llm_provider_message_cut": True,
"_last_llm_error": REPR, "_last_llm_error_kind": "auth_error"}
msg, _cost = _call(tmp_path, _Ok(), usage)
assert msg["content"] == "fine"
assert not any(key in usage for key in ("_last_llm_provider_message", "_last_llm_provider_message_cut",
"_last_llm_error", "_last_llm_error_kind"))
assert provider_failure_hint(usage) == ""
def test_the_hint_quotes_the_providers_sentence_and_never_a_repr():
"""A stored sentence is quoted whitespace-normalised; a repr beside a typed kind,
or a legacy record with a repr and no kind, reads as the host's classification."""
typed = {"_last_llm_error": f" {REPR} ", "_last_llm_error_kind": "auth_error"}
spoken = provider_failure_hint({**typed, "_last_llm_provider_message": " Your API key was revoked. "})
assert spoken == ' The provider said: "Your API key was revoked."'
assert provider_failure_hint(typed) == (" The provider gave no readable message; the host classified the failure as "
"an authentication or authorization refusal. The task's Logs keep the "
"host's record of this failure.")
legacy = provider_failure_hint({"_last_llm_error": "AuthenticationError('401 invalid_api_key')"})
assert legacy == (" The provider gave no readable message; the host classified the failure as a provider failure. "
"The task's Logs keep the host's record of this failure.")
for text in (spoken, provider_failure_hint(typed), legacy):
assert "PermissionDenied" not in text and "AuthenticationError" not in text and "{" not in text
def test_a_long_provider_sentence_is_shortened_in_words_beside_the_logs_pointer(tmp_path, monkeypatch):
"""No silent cut on the owner's row: the sentence is stamped whole, the SSOT
display bound decides the cut (its anti-waste floor lets a small overflow
through whole), and a cut is said in words with the pointer to Logs."""
monkeypatch.setattr("ouroboros.loop_llm_call.time.sleep", lambda _s: pytest.fail("a 403 must not retry"))
sentence = "Access to this model was denied for the configured project. " * 14 # ~840 chars
body = {"error": {"message": sentence, "code": 403}}
usage = {}
msg, _cost = _call(tmp_path, _Raising(_BodyError(f"Error code: 403 - {body}", status_code=403, body=body)), usage)
assert msg is None and usage["_last_llm_provider_message"] == sentence.strip(), "stamped whole, never sliced"
hint = provider_failure_hint(usage)
quoted = sentence.strip()[:600].rstrip()
assert hint == (f' The provider said: "{quoted}…" (shortened). '
"The task's Logs keep the host's record of this failure.")
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text().splitlines()]
event, = [row for row in rows if row["type"] == "llm_api_error"]
assert sentence.strip() in " ".join(event["error"].split()), "this body error's text carries the whole sentence"
# Barely over the bound: cheaper than a disclosure, so it passes whole.
barely = "x" * 630
assert provider_failure_hint({"_last_llm_provider_message": barely}) == f' The provider said: "{barely}"'
def test_a_shortened_stream_sentence_promises_logs_no_bytes_they_do_not_hold(tmp_path, monkeypatch):
"""An SSE error frame's sentence lives only in its body: the exception text never
carries it and the durable event keeps the bounded excerpt, so the owner's row
points at the host's record without saying the rest of the sentence is there."""
from ouroboros.llm_stream import ProviderStreamError
monkeypatch.setattr("ouroboros.loop_llm_call.time.sleep", lambda _s: pytest.fail("a 403 must not retry"))
sentence = "The upstream stream was closed for this project by the provider's access guard. " * 12
usage = {}
msg, _cost = _call(tmp_path, _Raising(ProviderStreamError({"error": {"message": sentence, "code": 403}})), usage)
assert msg is None and usage["_last_llm_provider_message"] == sentence.strip()
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text().splitlines()]
event, = [row for row in rows if row["type"] == "llm_api_error"]
kept = " ".join([event["error"], event["provider_message"]])
assert sentence.strip() not in kept and len(event["provider_message"]) <= 600, "Logs hold only an excerpt"
quoted = sentence.strip()[:600].rstrip()
assert provider_failure_hint(usage) == (f' The provider said: "{quoted}…" (shortened). '
"The task's Logs keep the host's record of this failure.")
def test_owner_provider_message_reads_body_and_stream_shapes_never_engine_display():
from ouroboros.llm_claudexor import ClaudexorModelError
from ouroboros.loop_transport import owner_provider_fields, owner_provider_message
problem = {"code": "provider_failed", "message": "Codex model request was refused (HTTP 400).",
"context": {"httpStatus": 400, "stage": "response", "errorCode": "vendor_refusal",
"vendorCode": "string_above_max_length", "parameter": "instructions",
"providerMessage": "private-body"}}
engine = ClaudexorModelError(problem)
assert "stage=response" in engine.display_message, "the fixture's display text is host-composed"
assert owner_provider_message(engine) == "", "an engine's composed display text is no provider sentence"
assert owner_provider_fields(engine) == {"code": "string_above_max_length", "parameter": "instructions"}
assert owner_provider_fields(ClaudexorModelError(problem, unknown=True)) == {}, "an unknown outcome exposes none"
assert owner_provider_fields(_BodyError("x", status_code=400, body={"message": "m"})) == {}
assert owner_provider_message(_BodyError("x", status_code=400, body={"message": " Flat body message. "})) == "Flat body message."
stream = RuntimeError("stream died")
stream.provider_message = "Upstream closed the stream."
assert owner_provider_message(stream) == "Upstream closed the stream."
assert owner_provider_message(RuntimeError(REPR)) == "", "a bare exception has no provider sentence"
# An empty response's body-error dict: its own message, or nothing.
assert owner_provider_message({"code": "401", "message": " Incorrect API key. "}) == "Incorrect API key."
assert owner_provider_message({"code": "401", "message": {"nested": "not a sentence"}}) == ""
@pytest.mark.parametrize("unknown", [False, True])
def test_a_claudexor_refusal_never_reads_as_the_providers_sentence(tmp_path, monkeypatch, unknown):
"""The engine's display text (stage, cause, engine code and message) is the
host's record: it stays in Logs. The owner's row names the provider's own typed
fields as the provider's, the host's classification as the host's, and a stale
sentence or field from an earlier round never speaks for this failure."""
from ouroboros.llm_claudexor import ClaudexorModelError
monkeypatch.setattr("ouroboros.loop_llm_call.time.sleep", lambda _s: None)
error = ClaudexorModelError({"code": "provider_failed", "message": "Codex model request was refused (HTTP 400).",
"context": {"httpStatus": 400, "stage": "response", "errorCode": "vendor_refusal",
"vendorCode": "string_above_max_length", "parameter": "instructions",
"providerMessage": "private-body"}}, unknown=unknown)
usage = {"_last_llm_provider_message": "stale sentence", "_last_llm_provider_fields": {"code": "stale_code"}}
msg, _cost = _call(tmp_path, _Raising(error), usage)
assert msg is None and "_last_llm_provider_message" not in usage
hint = provider_failure_hint(usage)
for text in ("The provider said", "stage=", "cause=", "provider_failed", "model_outcome_unknown",
"Codex model request was refused", "private-body", "stale"):
assert text not in hint, text
assert hint.endswith("The task's Logs keep the host's record of this failure.")
if unknown:
assert "_last_llm_provider_fields" not in usage
assert hint.startswith(" The provider gave no readable message; the host classified the failure as ")
else:
assert usage["_last_llm_provider_fields"] == {"code": "string_above_max_length", "parameter": "instructions"}
assert hint == (' The provider reported error code "string_above_max_length" and parameter "instructions" '
"but no sentence the host can quote; the host classified the failure as a rejected request. "
"The task's Logs keep the host's record of this failure.")
rows = [json.loads(line) for line in (tmp_path / "events.jsonl").read_text().splitlines()]
assert all(row["error"] == error.display_message for row in rows if row["type"] == "llm_api_error"), \
"Logs keep the host's composed diagnostic"
def test_a_usable_response_clears_stale_provider_fields(tmp_path):
class _Ok:
def chat(self, **_kwargs):
return {"content": "fine"}, {"prompt_tokens": 3, "completion_tokens": 1}
usage = {"_last_llm_provider_fields": {"code": "string_above_max_length"}, "_last_llm_error_kind": "bad_request"}
msg, _cost = _call(tmp_path, _Ok(), usage)
assert msg["content"] == "fine" and "_last_llm_provider_fields" not in usage
assert provider_failure_hint(usage) == ""
# ---------------------------------------------------------------------------
# The late-evidence carrier: producer → stored row → history → live frame
# ---------------------------------------------------------------------------
EVIDENCE = {"task_id": "t1", "panel_id": "panel_1", "settled_at": "2026-09-28T00:06:00+00:00",
"reviewed_revision": "delivered", "reviewed_is_emitted": True,
"source_ref": {"kind": "task_source", "root": "artifact_store", "sha256": "a" * 64, "size": 75000,
"path": f"source_handles/context_checkpoints/acceptance-{'a' * 64}.json"}}
def test_late_evidence_replays_through_the_stored_row_and_history(tmp_path):
from ouroboros.gateway.history import make_chat_history_endpoint
from supervisor import message_bus
meta = {"card_row": "reviews", "card_row_id": "acceptance-late:rk", "late_evidence": EVIDENCE}
message_bus.log_chat("system", 1, 0, "On the delivered version of this answer, reviewers later passed it.",
record_type="acceptance_late_settlement", task_id="t1", message_meta=meta, drive_root=tmp_path)
message_bus.log_chat("system", 1, 0, "Open delegated execution: run-x.", record_type="custody_notice",
task_id="t1", message_meta={**meta, "card_row": "timeline"}, drive_root=tmp_path)
stored = [json.loads(line) for line in (tmp_path / "logs" / "chat.jsonl").read_text().splitlines()]
assert stored[0]["late_evidence"] == EVIDENCE and "late_evidence" not in stored[1]
response = asyncio.run(make_chat_history_endpoint(tmp_path)(SimpleNamespace(query_params={"chat_id": "1"})))
rows = {row["text"]: row for row in json.loads(response.body)["messages"]}
late = rows["On the delivered version of this answer, reviewers later passed it."]
assert late["card_row"] == "reviews" and late["late_evidence"] == EVIDENCE
assert "late_evidence" not in rows["Open delegated execution: run-x."], "only the typed late row carries the pointer"
def test_the_live_frame_carries_the_late_evidence_pointer(tmp_path, monkeypatch):
from supervisor import message_bus
bridge = message_bus.LocalChatBridge({})
frames = []
bridge._broadcast_fn = frames.append
monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path)
monkeypatch.setattr(message_bus, "publish_event", lambda *_a, **_kw: None)
bridge.send_message(1, "On the delivered version of this answer, reviewers later passed it.", task_id="t1",
role="system", system_type="acceptance_late_settlement",
progress_meta={"card_row": "reviews", "card_row_id": "acceptance-late:rk", "late_evidence": EVIDENCE})
chat, = [row for row in frames if row.get("type") == "chat"]
assert chat["card_row"] == "reviews" and chat["late_evidence"] == EVIDENCE
def test_the_gateway_contract_names_the_pointer_in_both_mirrors():
import pathlib
from typing import get_type_hints
from ouroboros.gateway.contracts import ChatOutbound
assert "late_evidence" in get_type_hints(ChatOutbound, include_extras=True)
text = (pathlib.Path(__file__).resolve().parents[1] / "web" / "modules" / "api_types.js").read_text(encoding="utf-8")
assert "@property {Object=} late_evidence" in text
@pytest.mark.serial
def test_a_notice_links_its_own_record_after_a_newer_publication_of_its_panel(tmp_path):
"""A later collection republishes the late panel under a new applied record, while the
outbox keeps the FIRST notice and the record it was sent with. The task artifact route
resolves that record by this author's host acceptance membership (the reader the
notice's own ``read`` selector names); a forged or altered pointer resolves nothing."""
import copy
import hashlib
import pathlib
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from ouroboros import artifacts, review_projection
from ouroboros.acceptance_settlement import _stored_late_settlement, enqueue_late_acceptance_settlement
from ouroboros.gateway.tasks import api_task_artifact
from ouroboros.task_results import load_task_result, write_task_result
from supervisor.terminal_delivery import pending_deliveries
from tests.test_acceptance_publication import _context
ctx = _context(tmp_path)
write_task_result(tmp_path, "applied", "completed", result="The delivered answer.")
run = _run(actors=[actor for actor in _run()["actors"] if actor["operation_state"] == "settled"],
request={"surface": "task_acceptance", "subject": "answer A", "retry_key": "rk", "task_id": "applied"})
def collect(settled_at, revision, status):
# Each collector stamps its own settlement time on the same settled wave.
late = {"note": "On the delivered version of this answer, reviewers later passed it.",
"settled_at": settled_at, "reviewed_subject": {"retry_key": "rk", "panel_id": "panel_1"}}
trace = {"review_runs": [{**copy.deepcopy(run), "late_settlement": late}],
"_acceptance_publication_revision": revision}
panel = _stored_late_settlement(review_projection.publish_acceptance_checkpoint(ctx, trace),
trace["review_runs"][0])
row = load_task_result(tmp_path, "applied")
assert enqueue_late_acceptance_settlement(ctx, "applied", "rk", row, panel) == status
return panel
first = collect("2026-09-28T00:06:00+00:00", 0, "announced")
# The outbox still owes the first notice, so the republication queues no second one.
collect("2026-09-28T00:06:01+00:00", 1, "published")
current = load_task_result(tmp_path, "applied")["review_projection"]["panels"][0]
notice, = pending_deliveries(tmp_path)
ref = notice["progress_meta"]["late_evidence"]["source_ref"]
assert ref == first["applied_source_ref"] != current["applied_source_ref"]
assert current["late_settlement"] == first["late_settlement"]
def foreign(record):
return artifacts.store_actor_source_bytes(tmp_path, "applied", category="context_checkpoints",
source_id="acceptance", data=json.dumps(record).encode(),
extension="json")["path"]
app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", api_task_artifact)])
app.state.drive_root = tmp_path
with TestClient(app) as client:
def get(path, name=None):
return client.get(f"/api/tasks/applied/artifacts/{name or pathlib.PurePosixPath(path).name}",
params={"source": path})
for published in (ref, current["applied_source_ref"]):
response = get(published["path"])
assert response.status_code == 200, published
assert hashlib.sha256(response.content).hexdigest() == published["sha256"]
assert len(response.content) == published["size"]
forged = [
f"source_handles/context_checkpoints/acceptance-{'0' * 64}.json", # no such record
foreign({"authority": "worker", "request": {"surface": "task_acceptance", "task_id": "applied"}}),
foreign({**run, "request": {**run["request"], "task_id": "another-task"}}),
foreign({**run, "request": {**run["request"], "surface": "plan_review"}}),
f"source_handles/context_checkpoints/../context_checkpoints/{pathlib.PurePosixPath(ref['path']).name}",
]
for path in forged:
assert get(path).status_code == 404, path
assert get(ref["path"], name="other.json").status_code == 404, "the name must be the record's own"
stored = artifacts.task_artifact_dir_path(tmp_path, "applied") / ref["path"]
stored.write_bytes(stored.read_bytes().replace(b"answer A", b"answer B"))
assert get(ref["path"]).status_code == 404, "altered bytes fail their digest"

View file

@ -0,0 +1,374 @@
"""Owner-facing host notices through the real SPA and the real server (#1369).
The served page is the real ``web/`` tree behind the real ``server.py`` with the
isolated stub model; only the socket frames are authored by the test, through
the same decoder every live row uses. What a person can see is asserted: the
late-review row reads in words with no slot id, verdict token or omission
marker, its link downloads the exact applied review record the host published
(bytes that hash to the recorded digest), a forged pointer buys no bytes, a
running card's status chip says Working while its title carries no copy of the
word, a reload replays the durable row with the same link, and a settled
acceptance panel without a verdict heads its Reviews group in words while the
attempt detail keeps the stored DEGRADED and the raw provider error.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import pytest
pytest_plugins = ("tests.test_ui_smoke_playwright",)
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
LATE_TS = "2026-09-28T00:06:00+00:00"
# The record link's painted colour beside the chat link ink it should wear: a
# bare anchor paints the browser's default link blue instead of `--accent-light`.
_LINK_INK = """(link) => { const ink = document.createElement('span'); ink.style.color = 'var(--accent-light)';
link.parentElement.appendChild(ink); const want = getComputedStyle(ink).color; ink.remove();
return [getComputedStyle(link).color, want]; }"""
def _late_row(task_id, note, evidence, *, row_id="acceptance-late:rk"):
return {"type": "chat", "role": "system", "system_type": "acceptance_late_settlement", "task_id": task_id,
"chat_id": 1, "card_row": "reviews", "card_row_id": row_id, "content": note, "ts": LATE_TS,
**({"late_evidence": evidence} if evidence is not None else {})}
def _progress(task_id, text, *, narration=None):
frame = {"type": "chat", "role": "assistant" if narration else "system", "is_progress": True,
"task_id": task_id, "chat_id": 1, "content": text, "ts": "2026-09-28T00:00:00+00:00"}
if narration is not None:
frame["narration"] = narration
return frame
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_late_review_row_reads_in_words_and_downloads_its_record(direct_server_with_data, engine):
playwright_api = pytest.importorskip("playwright.sync_api")
from ouroboros import loop, review_projection
from ouroboros.acceptance_settlement import _late_settlement_text, late_evidence_fact
from ouroboros.task_results import load_task_result, write_task_result
from supervisor import message_bus
from supervisor.message_bus import append_jsonl
from tests.test_acceptance_publication import _context, _run
from tests.ui_chat_viewport_smoke import (
_CAPTURE_TEST_SOCKET, _OBSERVE_STATE_READS, _emit_ws_frame, _wait_socket_open_quiescent,
)
root = direct_server_with_data["data_dir"]
url = direct_server_with_data["url"]
evidence_dir = Path(os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(root.parent)))
evidence_dir.mkdir(parents=True, exist_ok=True)
# --- the real producers: a published acceptance panel and the owner row -----------
ctx = _context(root)
run = {**_run(), "task_attempt": ctx.task_attempt,
"slot_roster": [{"slot_id": "s1", "model": "fixture/reviewer", "route": "agent_session"}]}
# The engine's final-attempt report is what names the model that answered.
run["actors"][0].update(model="fixture/reviewer", operation_state="settled",
usage={"resolved_model": "fixture/reviewer-2026-09",
"observed_attempt": {"harness_id": "codex", "model": "fixture/reviewer-2026-09"}})
run["actors"][0]["parsed"]["summary"] = "Every claim is backed by the ledger. " * 16
trace = {"review_runs": [run]}
write_task_result(root, ctx.task_id, "completed", chat_id=1, result="The delivered answer.")
loop._set_acceptance_decision(trace, {"status": "accepted", "reason": "clean_pass"})
review_projection.publish_acceptance_checkpoint(ctx, trace)
panel = load_task_result(root, ctx.task_id)["review_projection"]["panels"][0]
ref = panel["applied_source_ref"]
wave = {"slots": {"s1": "ok"}, "verdicts": {"s1": {"verdict": "PASS", "note": "x"}}, "total": 1}
fact = late_evidence_fact(run, {"source": "terminal_delivery_registry", "state": "unknown", "delivered": []},
settled_at=LATE_TS)
note = _late_settlement_text(run, wave, fact)
assert note.startswith("On the reviewed version of this answer")
assert "fixture/reviewer-2026-09: passed it" in note and note.endswith("… (shortened)")
assert "review record" not in note, "a reviewer's cut promises no record; the row's link is the pointer"
evidence = {"task_id": ctx.task_id, "panel_id": panel["panel_id"], "settled_at": LATE_TS,
"reviewed_revision": fact["reviewed_revision"], "reviewed_is_emitted": fact["reviewed_is_emitted"],
"source_ref": ref}
# A well-formed pointer at a source this task never published: the client can
# only judge the shape, the server judges the identity and serves nothing.
forged = {**evidence, "source_ref": {**ref, "sha256": "f" * 64,
"path": f"source_handles/context_checkpoints/acceptance-{'f' * 64}.json"}}
malformed = {**evidence, "source_ref": {**ref, "root": "runtime_data"}}
# A late-review row can outlive its task card. Keep the source task outside
# Main's result-card lens while placing its test-authored notice in Main,
# so this exercises the card-less fallback rather than the card-row path.
cardless_ctx = _context(root)
cardless_ctx.task_id = "cardless-review"
cardless_ctx.current_chat_id = 0
cardless_run = _run()
cardless_run["request"]["task_id"] = cardless_ctx.task_id
cardless_run["panel_id"] = "panel_cardless"
cardless_trace = {"review_runs": [cardless_run]}
write_task_result(root, cardless_ctx.task_id, "completed", chat_id=0, result="The delivered card-less answer.")
loop._set_acceptance_decision(cardless_trace, {"status": "accepted", "reason": "clean_pass"})
review_projection.publish_acceptance_checkpoint(cardless_ctx, cardless_trace)
cardless_panel = load_task_result(root, cardless_ctx.task_id)["review_projection"]["panels"][0]
cardless_ref = cardless_panel["applied_source_ref"]
cardless_fact = late_evidence_fact(
cardless_run, {"source": "terminal_delivery_registry", "state": "unknown", "delivered": []},
settled_at=LATE_TS,
)
cardless_note = _late_settlement_text(cardless_run, wave, cardless_fact)
cardless_evidence = {
"task_id": cardless_ctx.task_id, "panel_id": cardless_panel["panel_id"], "settled_at": LATE_TS,
"reviewed_revision": cardless_fact["reviewed_revision"],
"reviewed_is_emitted": cardless_fact["reviewed_is_emitted"], "source_ref": cardless_ref,
}
def write_durable_rows():
"""The rows a reload replays: the worker's progress row and the host's late row."""
append_jsonl(root / "logs" / "progress.jsonl", {
"ts": "2026-09-28T00:00:00+00:00", "type": "send_message", "task_id": ctx.task_id, "is_progress": True,
"direction": "out", "chat_id": 1, "user_id": 0, "text": "💬 Checking the ledger",
"content": "Checking the ledger", "format": "", "role": "assistant", "system_type": "", "narration": True})
message_bus.log_chat("system", 1, 0, note, ts=LATE_TS, record_type="acceptance_late_settlement",
task_id=ctx.task_id, drive_root=root,
message_meta={"card_row": "reviews", "card_row_id": "acceptance-late:rk",
"late_evidence": evidence})
message_bus.log_chat(
"system", 1, 0, cardless_note, ts=LATE_TS, record_type="acceptance_late_settlement",
task_id=cardless_ctx.task_id, drive_root=root,
message_meta={"card_row": "reviews", "card_row_id": "acceptance-late:cardless",
"late_evidence": cardless_evidence},
)
def open_page(target):
# The initial history read must land before any test-authored frame, and
# the socket-open census must settle so a live card is not concluded by absence.
with target.expect_response(lambda response: "/api/chat/history" in response.url):
target.goto(url, wait_until="domcontentloaded")
target.wait_for_selector("#chat-input", state="attached")
_wait_socket_open_quiescent(target)
with playwright_api.sync_playwright() as playwright:
try:
browser = getattr(playwright, engine).launch()
except playwright_api.Error as exc:
if "Executable doesn't exist" in str(exc):
pytest.skip(f"{engine} is not installed: {exc}")
raise
try:
page = browser.new_page(viewport={"width": 1280, "height": 900}, accept_downloads=True)
errors: list[str] = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.add_init_script(f"({_CAPTURE_TEST_SOCKET})()")
page.add_init_script(f"({_OBSERVE_STATE_READS})()")
open_page(page)
# --- the same late-review row without a card, live -----------------------------
_emit_ws_frame(page, _late_row(cardless_ctx.task_id, cardless_note, cardless_evidence,
row_id="acceptance-late:cardless"))
cardless_bubble = page.locator(
f'.chat-bubble.system[data-task-id="{cardless_ctx.task_id}"]',
has_text="reviewers later passed it.",
)
try:
cardless_bubble.wait_for(state="visible", timeout=5000)
except playwright_api.TimeoutError:
raise AssertionError({
"bubbles": page.locator(".chat-bubble.system").all_inner_texts()[-8:],
"cards": page.locator(".chat-live-card").all_inner_texts()[-8:],
"pageerrors": errors,
}) from None
cardless_link = cardless_bubble.get_by_role("link", name="Download the review record")
cardless_link.wait_for(state="visible")
cardless_href = cardless_link.get_attribute("href")
assert cardless_href and cardless_href.startswith(
f"/api/tasks/{cardless_ctx.task_id}/artifacts/"
) and "?source=" in cardless_href
assert page.request.get(url + cardless_href).status == 200
painted, ink = cardless_link.evaluate(_LINK_INK)
assert painted == ink, (painted, ink)
cardless_bubble.screenshot(path=str(evidence_dir / f"host-notice-cardless-live-{engine}.png"))
# --- a running card: the chip says Working, the title repeats nothing -----------
_emit_ws_frame(page, _progress("turn-live", "Checkpoint saved.", narration=False))
live = page.locator('.chat-live-card[data-task-id="turn-live"]')
live.wait_for(state="visible")
assert live.locator("[data-live-phase]").inner_text().strip() == "Working"
assert live.locator("[data-live-title]").inner_text().strip() == ""
_emit_ws_frame(page, _progress("turn-live", "Working through the ledger", narration=True))
assert live.locator("[data-live-title]").inner_text().strip() == "Working through the ledger"
assert live.locator("[data-live-phase]").inner_text().strip() == "Working"
live.screenshot(path=str(evidence_dir / f"host-notice-working-card-{engine}.png"))
# --- the late-review row, live ----------------------------------------------------
_emit_ws_frame(page, _progress(ctx.task_id, "Checking the ledger", narration=True))
card = page.locator(f'.chat-live-card[data-task-id="{ctx.task_id}"]')
card.wait_for(state="visible")
_emit_ws_frame(page, _late_row(ctx.task_id, note, evidence))
card.locator("[data-live-summary-button]").click()
row = card.locator(".chat-live-line", has_text="reviewers later passed it.")
row.wait_for(state="visible")
text = row.inner_text()
assert "fixture/reviewer-2026-09: passed it" in text
for internal in ("s1:", "PASS", "DEGRADED", "OMISSION NOTE", "triad_"):
assert internal not in text, (internal, text)
assert "… (shortened)" in text
assert page.locator(f'.chat-bubble.system[data-task-id="{ctx.task_id}"]',
has_text="reviewers later passed it.").count() == 0, \
"the placed row is a card row, not a standalone System bubble"
link = row.get_by_role("link", name="Download the review record")
link.wait_for(state="visible")
painted, ink = link.evaluate(_LINK_INK)
assert painted == ink, (painted, ink)
href = link.get_attribute("href")
assert href and href.startswith(f"/api/tasks/{ctx.task_id}/artifacts/") and "?source=" in href
response = page.request.get(url + href)
assert response.status == 200
raw = response.body()
assert hashlib.sha256(raw).hexdigest() == ref["sha256"] and len(raw) == ref["size"]
record = json.loads(raw)
assert record["request"]["surface"] == "task_acceptance" and record["applied_decision"]["status"] == "accepted"
card.screenshot(path=str(evidence_dir / f"host-notice-late-review-row-{engine}.png"))
if engine == "chromium":
with page.expect_download() as downloaded:
link.click()
download = downloaded.value
assert download.failure() is None
saved = evidence_dir / f"downloaded-review-record-{engine}.json"
download.save_as(str(saved))
assert hashlib.sha256(saved.read_bytes()).hexdigest() == ref["sha256"]
# --- forged and malformed pointers buy nothing -----------------------------------
_emit_ws_frame(page, _late_row(ctx.task_id, note.replace("passed it.", "passed it. (forged)"),
forged, row_id="acceptance-late:forged"))
forged_row = card.locator(".chat-live-line", has_text="(forged)")
forged_row.wait_for(state="visible")
forged_link = forged_row.get_by_role("link", name="Download the review record")
assert forged_link.count() == 1, "a well-formed pointer renders; only the server can judge its digest"
assert page.request.get(url + forged_link.get_attribute("href")).status == 404
_emit_ws_frame(page, _late_row(ctx.task_id, note.replace("passed it.", "passed it. (malformed)"),
malformed, row_id="acceptance-late:malformed"))
malformed_row = card.locator(".chat-live-line", has_text="(malformed)")
malformed_row.wait_for(state="visible")
assert malformed_row.get_by_role("link").count() == 0, "a pointer of another shape offers no link"
# --- reload: the durable row replays on its card with the same link ---------------
write_durable_rows()
open_page(page)
replayed_card = page.locator(f'.chat-live-card[data-task-id="{ctx.task_id}"]')
replayed_card.wait_for(state="visible")
if replayed_card.get_attribute("data-expanded") != "1":
replayed_card.locator("[data-live-summary-button]").click()
replayed = replayed_card.locator(".chat-live-line", has_text="reviewers later passed it.")
replayed.wait_for(state="visible")
replayed_link = replayed.get_by_role("link", name="Download the review record")
replayed_link.wait_for(state="visible")
assert replayed_link.get_attribute("href") == href
assert hashlib.sha256(page.request.get(url + href).body()).hexdigest() == ref["sha256"]
replayed_card.screenshot(path=str(evidence_dir / f"host-notice-late-review-replayed-{engine}.png"))
cardless_replayed = page.locator(
f'.chat-bubble.system[data-task-id="{cardless_ctx.task_id}"]',
has_text="reviewers later passed it.",
)
cardless_replayed.wait_for(state="visible")
cardless_replayed_link = cardless_replayed.get_by_role(
"link", name="Download the review record",
)
cardless_replayed_link.wait_for(state="visible")
assert cardless_replayed_link.get_attribute("href") == cardless_href
assert page.request.get(url + cardless_href).status == 200
cardless_replayed.screenshot(
path=str(evidence_dir / f"host-notice-cardless-replayed-{engine}.png"),
)
assert not errors, errors
finally:
browser.close()
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_settled_no_verdict_panel_heads_in_words_and_keeps_degraded_in_detail(direct_server_with_data, engine):
playwright_api = pytest.importorskip("playwright.sync_api")
from ouroboros import review_projection
from ouroboros.task_results import load_task_result, write_task_result
from tests.test_acceptance_publication import _context, _run
from tests.ui_chat_viewport_smoke import (
_CAPTURE_TEST_SOCKET, _OBSERVE_STATE_READS, _emit_ws_frame, _wait_socket_open_quiescent,
)
root = direct_server_with_data["data_dir"]
url = direct_server_with_data["url"]
evidence_dir = Path(os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(root.parent)))
evidence_dir.mkdir(parents=True, exist_ok=True)
# The real producer settles a panel with no quorum: one reviewer's own DEGRADED
# answer, one PASS and one provider failure, on a task that has already ended.
ctx = _context(root)
run = {**_run(), "task_attempt": ctx.task_attempt, "aggregate_signal": "DEGRADED", "parsed_findings": [],
"enforcement_impact": "degrades_completion",
"slot_roster": [{"slot_id": slot, "model": f"fixture/reviewer-{slot}", "route": "api_chat"}
for slot in ("s1", "s2", "s3")],
"actors": [
{"slot_id": "s1", "model": "fixture/reviewer-s1", "status": "ok", "operation_state": "settled",
"signal": "DEGRADED", "semantic_verdict": "DEGRADED",
"parsed": {"verdict": "DEGRADED", "summary": "The ledger could not be opened.", "findings": []}},
{"slot_id": "s2", "model": "fixture/reviewer-s2", "status": "ok", "operation_state": "settled",
"signal": "PASS", "semantic_verdict": "PASS",
"parsed": {"verdict": "PASS", "summary": "Every claim is backed.", "findings": []}},
{"slot_id": "s3", "model": "fixture/reviewer-s3", "status": "error", "operation_state": "settled",
"error": "ClaudexorModelError: engine_busy (HTTP 429)"}]}
write_task_result(root, ctx.task_id, "completed", chat_id=1, result="The delivered answer.")
assert review_projection.publish_acceptance_checkpoint(ctx, {"review_runs": [run]})["status"] == "published"
stored = load_task_result(root, ctx.task_id)
panel = stored["review_projection"]["panels"][0]
assert stored["status"] == "completed" and panel["aggregate_signal"] == "DEGRADED"
with playwright_api.sync_playwright() as playwright:
try:
browser = getattr(playwright, engine).launch()
except playwright_api.Error as exc:
if "Executable doesn't exist" in str(exc):
pytest.skip(f"{engine} is not installed: {exc}")
raise
try:
page = browser.new_page(viewport={"width": 1280, "height": 900})
errors: list[str] = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.add_init_script(f"({_CAPTURE_TEST_SOCKET})()")
page.add_init_script(f"({_OBSERVE_STATE_READS})()")
with page.expect_response(lambda response: "/api/chat/history" in response.url):
page.goto(url, wait_until="domcontentloaded")
page.wait_for_selector("#chat-input", state="attached")
_wait_socket_open_quiescent(page)
# The host's own reference frame puts the Reviews section on the task card.
_emit_ws_frame(page, {"type": "chat", "role": "system", "system_type": "review_reference",
"surface": "task_acceptance", "task_id": ctx.task_id,
"chat_id": 1, "state_revision": "b" * 64})
card = page.locator(f'.chat-live-card[data-task-id="{ctx.task_id}"]')
card.wait_for(state="visible")
if card.get_attribute("data-expanded") != "1":
card.locator("[data-live-summary-button]").click()
card.locator("[data-review-section-toggle]").click()
meta = card.locator(".chat-review-group-meta")
meta.wait_for(state="visible")
# The group head: the outcome in words, then its attempt count.
assert meta.inner_text().strip() == "no verdict · 2 of 3 answered · 1 unavailable · 1"
assert "warn" in (card.locator("[data-review-group]").get_attribute("class") or "")
card.locator("[data-review-group-toggle]").click()
attempt_meta = card.locator(".chat-review-attempt-meta")
attempt_meta.wait_for(state="visible")
assert "no verdict · 2 of 3 answered · 1 unavailable" in attempt_meta.inner_text()
for header in (meta, attempt_meta):
assert "DEGRADED" not in header.inner_text()
card.locator("[data-review-attempt-toggle]").click()
detail = card.locator("[data-review-attempt-detail]")
detail.wait_for(state="visible")
detail_text = detail.inner_text()
# The stored verdict and the provider's raw error stay in the detail.
assert "verdict=DEGRADED" in detail_text
assert "ClaudexorModelError: engine_busy (HTTP 429)" in detail_text
card.screenshot(path=str(evidence_dir / f"host-notice-no-verdict-reviews-{engine}.png"))
assert not errors, errors
finally:
browser.close()

View file

@ -0,0 +1,803 @@
"""Chat Markdown reading and fidelity through the real SPA (#1370, #1367, #1368).
The served page is the real `web/` tree: `index.html`, `chat.js`, both Markdown
renderers and the production CSS. Only the HTTP API and the socket are
answered by the test, with deterministic history rows. Every check is an
invariant a reader can see — a heading never below the text it opens, a table
that scrolls only when it must and is then reachable by keyboard, code that
reads and copies as the author wrote it, an image that stays a visible
reference and never loads — measured at desktop, a narrow Project panel and a
phone, in Chromium and WebKit.
"""
from __future__ import annotations
import json
import os
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
from urllib.parse import urlparse
import pytest
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
WEB = Path(__file__).resolve().parents[1] / "web"
LEAD = (
"The migration keeps every stored answer readable while the renderer changes underneath it, "
"so the owner can compare the old and the new presentation on the same history without "
"reloading anything or losing the place they were reading."
)
HEADINGS = "\n\n".join([
"# Reading ladder", LEAD,
"## What changes", f"**Bold lead sentence.** {LEAD}",
"### Sub-question", f"A paragraph with `inline code` and a [link](https://example.com/docs). {LEAD}",
"#### Fourth level", LEAD, "---", "After the rule.",
])
PROSE_CELLS = [
("Keep the grid",
"Rows and columns stay a real table, so a reader can compare one option against another by reading "
"down a column, and assistive technology still announces the header of every cell it reads. Cells "
"wrap like any paragraph, which means a long explanation grows the row downwards instead of pushing "
"the rest of the table out of the bubble and hiding it behind a sideways scroll that nobody notices.",
"Rows become taller when a single cell carries a long explanation, so a very wordy table reads as a "
"sequence of paragraphs placed side by side."),
("Stack rows as cards",
"Every row turns into a small card with the column names repeated above each value. That reads well "
"one row at a time on a phone, but comparing two options means scrolling between cards and "
"remembering what the previous one said, and the renderer would need a rule for which tables to "
"stack, which is a guess about the author's intent that the author never made. Numeric tables "
"stacked this way lose the column alignment that made them easy to scan in the first place.",
"The transcript gets longer, and table semantics must be rebuilt by hand once display changes."),
]
PROSE_TABLE = "\n".join(
["| Option | What changes | Risk |", "| --- | --- | --- |"]
+ [f"| {a} | {b} | {c} |" for a, b, c in PROSE_CELLS]
)
NUMERIC_TABLE = "\n".join([
"| Region | Revenue | Change | Status |",
"| :--- | ---: | :---: | --- |",
"| North | 1,204,331.50 | +4.2% | on track |",
"| South | 98,014.07 | -0.8% | watch |",
])
WIDE_TABLE = "\n".join([
"| Metric | " + " | ".join(f"Month {n}" for n in range(1, 9)) + " |",
"| --- | " + " | ".join("---:" for _ in range(8)) + " |",
"| Requests | " + " | ".join(f"{1_000_000 + n * 7_123_457:,}" for n in range(8)) + " |",
])
LONG_URL = "https://example.com/" + "/".join(f"segment-{n:02d}-with-a-long-name" for n in range(8)) + "?q=1&r=2"
URL_TABLE = "\n".join([
"| Source | Address |", "| --- | --- |",
f"| Report | [{LONG_URL}]({LONG_URL}) |",
"| Commit | `3f786850e387550fdab836ed7e6dc881de23001b6f0c1a4b19d2f8e4c1a7d2e90` |",
])
FENCE_BODY = "\n".join([
"&amp; &lt; &#42; **stars** <div class=\"x\">",
"- literal dash",
"# literal hash",
"[x](https://example.com/y) | a | b |",
])
FIDELITY = "\n\n".join([
"Fidelity check.",
f"```text\n{FENCE_BODY}\n```",
"Inline: `&amp; &lt; &#42; **stars** <b>` stays literal.",
"Prose entities: Fish &amp; chips &lt;tag&gt; read once.",
"Raw HTML: <img src=x onerror=\"window.__markdownOnerror = 1\"> <script>window.__markdownScript = 1</script>",
'Before ![diagram](https://images.example/a.png "Architecture") after.',
"[![badge](https://images.example/b.png)](https://example.com/docs)",
"![unsafe](javascript:alert(1))",
"![](https://images.example/empty.png)",
"![see [the source](https://example.com/src)](https://images.example/c.png)",
"- [ ] Compute \\(x_1 + 1\\)\n- [x] Checked $$y_1 *c*$$",
])
# Compact Markdown (a Skill Review report, a task timeline) keeps its density,
# and its tables scroll exactly like a rich answer's.
COMPACT_NARROW = "| Check | Result |\n| --- | :---: |\n| Fence kept | yes |"
def compact_wide_table(runs):
return "\n".join([
"| Signal | " + " | ".join(f"Run {n}" for n in range(1, runs + 1)) + " |",
"| --- | " + " | ".join("---:" for _ in range(runs)) + " |",
"| Latency ms | " + " | ".join(f"{120_000 + n * 3_457:,}.25" for n in range(runs)) + " |",
])
COMPACT_WIDE = compact_wide_table(8)
SKILL_REVIEW = (
f"# Skill review: demo\n\nReviewers: alpha\n\n## Findings\n\n```\n{FENCE_BODY}\n```\n\n"
f"### Evidence\n\n{COMPACT_NARROW}\n\n#### Timings\n\n{COMPACT_WIDE}\n"
)
ROWS = [
{"role": "assistant", "text": text, "ts": f"2026-09-28T10:00:0{index}Z", "markdown": True}
for index, text in enumerate([HEADINGS, PROSE_TABLE + "\n\n" + NUMERIC_TABLE,
WIDE_TABLE + "\n\n" + URL_TABLE, FIDELITY])
] + [{"role": "system", "system_type": "skill_review", "text": SKILL_REVIEW, "ts": "2026-09-28T10:00:09Z"}]
HISTORY_COVERAGE = {"v": 1, "view": "fixture", "upper": {"chat": len(ROWS), "progress": 0}, "spans": {
"chat": {"from": 0, "to": len(ROWS), "chain": "fixture", "gaps": []},
"progress": {"from": 0, "to": 0, "chain": "empty", "gaps": []}}}
def capture(page, name):
root = os.environ.get("OUROBOROS_UI_EVIDENCE_DIR")
if root:
Path(root).mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(Path(root) / f"{name}.png"), animations="disabled")
@pytest.fixture(params=["chromium", "webkit"])
def reading_ui(request):
if os.environ.get("OUROBOROS_RUN_UI_SMOKE") != "1":
pytest.skip("Set OUROBOROS_RUN_UI_SMOKE=1 to run the browser flow")
playwright = pytest.importorskip("playwright.sync_api")
engine = request.param
foreign: list[str] = []
errors: list[str] = []
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
self.path = self.path.removeprefix("/static")
super().do_GET()
def respond(route):
path = urlparse(route.request.url).path
if path == "/api/onboarding":
route.fulfill(status=204)
return
body = {
# One complete first page under the history page contract: a cursor, nothing older,
# and coverage of every chat byte up to the horizon (progress known empty).
"/api/chat/history": {"messages": ROWS, "progress": [], "has_more": False, "page_cursor": "fixture-page",
"coverage": HISTORY_COVERAGE},
"/api/state": {"supervisor_ready": True, "active_chat_activities": [], "projects": []},
"/api/projects": {"projects": []},
"/api/health": {"ok": True, "version": "fixture"},
}.get(path, {})
route.fulfill(content_type="application/json", body=json.dumps(body))
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(WEB)))
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
origin = f"127.0.0.1:{server.server_port}"
def local_only(route):
# A Markdown image or any other third-party load must never leave the page.
if urlparse(route.request.url).netloc == origin:
route.fallback()
else:
foreign.append(route.request.url)
route.abort()
try:
with playwright.sync_playwright() as pw:
try:
browser = getattr(pw, engine).launch(headless=True)
except playwright.Error as exc:
if "Executable doesn't exist" in str(exc):
pytest.skip(f"{engine} is not installed: {exc}")
raise
try:
page = browser.new_page(viewport={"width": 1280, "height": 900})
page.add_init_script("""(() => {
window.__copied = null;
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {
writeText: async (text) => { window.__copied = text; },
}});
})()""")
page.on("pageerror", lambda error: errors.append(str(error)))
page.route("**/*", local_only)
page.route("**/api/**", respond)
page.route_web_socket("**/ws", lambda ws: ws.send(json.dumps({"type": "heartbeat"})))
yield {"page": page, "url": f"http://{origin}", "engine": engine, "foreign": foreign}
assert not errors
finally:
browser.close()
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def open_chat(ui, width, height=900):
page = ui["page"]
page.set_viewport_size({"width": width, "height": height})
page.goto(ui["url"] + "/")
page.wait_for_selector("#chat-input", state="attached")
page.locator("#chat-messages .chat-bubble.assistant", has_text="After the rule.").wait_for()
# The history fixture honours the page contract, so no screenshot carries a false load failure.
assert page.get_by_text("Some saved history could not be loaded.").count() == 0
return page
def mount_project_panel(page):
"""A real secondary chat instance in the Project panel (440px at this viewport)."""
page.evaluate("""async () => {
const {createChatInstance} = await import('/static/modules/chat.js');
const {createStateSnapshotSequencer} = await import('/static/modules/chat_activity.js');
const mount = document.createElement('aside');
mount.id = 'reading-panel'; mount.className = 'project-panel open';
document.body.append(mount);
const ws = {on: () => () => {}, isConnected: () => true, send: () => {}};
window.readingPanel = createChatInstance({ws, state: {activePage: 'chat', projectChatIds: new Set()},
updateUnreadBadge: () => {}, stateSnapshots: createStateSnapshotSequencer(() => {}),
chatId: 101, projectId: 'reading', idPrefix: 'reading', mountEl: mount, asPanel: true});
await window.readingPanel.refreshHistory();
}""")
page.locator("#reading-panel .chat-bubble.assistant", has_text="After the rule.").wait_for()
return "#reading-panel"
MEASURE = """(root) => {
const column = document.querySelector(root + ' .chat-messages') || document.querySelector(root);
const cs = getComputedStyle(column);
const content = column.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
const style = (el) => getComputedStyle(el);
const bubble = [...column.querySelectorAll('.chat-bubble.assistant')].find((b) => b.textContent.includes('After the rule.'));
const message = bubble.querySelector('.message');
const heading = (tag) => {
const el = message.querySelector(tag), s = style(el);
return {size: parseFloat(s.fontSize), weight: Number(s.fontWeight), color: s.color,
above: parseFloat(s.marginTop), below: parseFloat(s.marginBottom), wrap: s.textWrap || s.textWrapStyle || ''};
};
const code = message.querySelector('code.inline-code');
const hr = message.querySelector('hr');
const tables = [...column.querySelectorAll('.message.ui-rich-content .md-table-wrap')].map((wrap) => ({
head: wrap.querySelector('th').textContent,
size: parseFloat(style(wrap.querySelector('table')).fontSize),
overflow: wrap.scrollWidth - wrap.clientWidth,
region: wrap.getAttribute('role'), label: wrap.getAttribute('aria-label'),
tabindex: wrap.getAttribute('tabindex'),
edges: [wrap.hasAttribute('data-scroll-start'), wrap.hasAttribute('data-scroll-end')],
whiteSpace: style(wrap.querySelector('td')).whiteSpace,
valign: style(wrap.querySelector('td')).verticalAlign,
numeric: style(wrap.querySelector('table')).fontVariantNumeric,
aligns: [...wrap.querySelectorAll('tbody tr:first-child td')].map((td) => [td.getAttribute('align'), style(td).textAlign]),
}));
return {
engine: navigator.userAgent, content, bubble: bubble.getBoundingClientRect().width,
body: parseFloat(style(message.querySelector('p')).fontSize), ink: style(message).color,
strong: Number(style(message.querySelector('strong')).fontWeight),
h1: heading('h1'), h2: heading('h2'), h3: heading('h3'), h4: heading('h4'),
code: {color: style(code).color, parent: style(code.parentElement).color},
hr: {height: hr.getBoundingClientRect().height, top: style(hr).borderTopWidth, style: style(hr).borderTopStyle,
above: parseFloat(style(hr).marginTop), below: parseFloat(style(hr).marginBottom)},
tables,
};
}"""
def settle_tables(page, root):
"""Wait until every laid-out table's region state follows its overflow.
The marking rides a ResizeObserver, which reports after layout; a
measurement taken between a layout change and that report would see the
previous state. A state that never converges still fails the assertions.
"""
try:
page.wait_for_function("""(root) => [...document.querySelectorAll(root + ' .md-table-wrap')]
.filter((wrap) => wrap.getClientRects().length)
.every((wrap) => (wrap.scrollWidth - wrap.clientWidth > 1) === (wrap.getAttribute('role') === 'region'))""",
arg=root, timeout=5000)
except Exception:
pass
def measure(page, root):
settle_tables(page, root)
return page.evaluate(MEASURE, root)
def assert_reading(metrics, *, narrow):
body = metrics["body"]
assert body == 16, metrics
# The ladder: # = ## > ### > deeper = the reading size, never below it.
assert metrics["h1"]["size"] == metrics["h2"]["size"] == 20, metrics
assert metrics["h3"]["size"] == 18 and metrics["h4"]["size"] == body, metrics
for level in ("h1", "h2", "h3", "h4"):
heading = metrics[level]
assert heading["weight"] == 600 and heading["color"] == metrics["ink"], (level, metrics)
assert metrics["strong"] <= metrics["h2"]["weight"], metrics
for level in ("h2", "h3", "h4"):
assert metrics[level]["above"] > metrics[level]["below"], (level, metrics)
assert metrics["code"]["color"] == metrics["code"]["parent"], "inline code keeps the text ink"
hr = metrics["hr"]
assert hr["top"] == "1px" and hr["style"] == "solid" and hr["height"] <= 1 and hr["above"] == hr["below"] == 24, hr
if narrow:
# One narrow-column rule: the bubble takes the whole column.
assert metrics["bubble"] >= metrics["content"] - 2, metrics
else:
assert metrics["bubble"] <= metrics["content"] * 0.8 + 1, metrics
tables = {table["head"]: table for table in metrics["tables"]}
assert set(tables) >= {"Option", "Region", "Metric", "Source"}, tables
for table in tables.values():
# A rich answer's table reads at the answer's own size, never shrunk to fit.
assert table["size"] == body, table
assert table["whiteSpace"] == "normal" and table["valign"] == "top", table
assert "tabular-nums" in table["numeric"], table
scrolls = table["overflow"] > 1
# A table is a keyboard region exactly while it scrolls; unscrolled, only its end edge hides columns.
assert (table["region"], table["tabindex"], table["edges"]) == (
("region", "0", [False, True]) if scrolls else (None, None, [False, False])), table
if scrolls:
assert table["label"] == "Scrollable table", table
assert tables["Metric"]["overflow"] > 1, "the eight-column table must still scroll"
assert tables["Region"]["aligns"] == [["left", "left"], ["right", "right"], ["center", "center"], [None, "start"]]
if not narrow:
for head in ("Option", "Region", "Source"):
assert tables[head]["overflow"] <= 1, tables[head]
def assert_keyboard_scroll(page, root):
wrap = page.locator(f"{root} .md-table-wrap").filter(has_text="Month 8").first
wrap.focus()
assert wrap.evaluate("el => document.activeElement === el")
assert wrap.evaluate("el => [el.hasAttribute('data-scroll-start'), el.hasAttribute('data-scroll-end')]") == [False, True]
# Held, not tapped: WebKit scrolls a focused region while the key is down.
page.keyboard.press("ArrowRight", delay=300)
page.wait_for_function("el => el.scrollLeft > 0 && el.hasAttribute('data-scroll-start')", arg=wrap.element_handle())
def test_reading_ladder_and_tables_at_three_widths(reading_ui):
ui, engine = reading_ui, reading_ui["engine"]
page = open_chat(ui, 1280)
assert_reading(measure(page, "#chat-messages"), narrow=False)
assert_keyboard_scroll(page, "#chat-messages")
page.locator("#chat-messages .chat-bubble", has_text="Reading ladder").first.scroll_into_view_if_needed()
capture(page, f"reading-desktop-headings-{engine}")
page.locator("#chat-messages .md-table-wrap", has_text="Keep the grid").first.scroll_into_view_if_needed()
capture(page, f"reading-desktop-tables-{engine}")
panel = mount_project_panel(page)
assert page.locator(panel).evaluate("el => el.getBoundingClientRect().width") == 440
assert_reading(measure(page, panel), narrow=True)
assert_keyboard_scroll(page, panel)
page.locator(f"{panel} .md-table-wrap", has_text="Keep the grid").first.scroll_into_view_if_needed()
capture(page, f"reading-panel440-tables-{engine}")
page.evaluate("window.readingPanel.destroy()")
page = open_chat(ui, 390, 844)
assert_reading(measure(page, "#chat-messages"), narrow=True)
assert_keyboard_scroll(page, "#chat-messages")
page.locator("#chat-messages .chat-bubble", has_text="Reading ladder").first.scroll_into_view_if_needed()
capture(page, f"reading-phone390-headings-{engine}")
page.locator("#chat-messages .md-table-wrap", has_text="Keep the grid").first.scroll_into_view_if_needed()
capture(page, f"reading-phone390-tables-{engine}")
assert not ui["foreign"], ui["foreign"]
def test_code_images_and_raw_html_keep_the_author_text(reading_ui):
ui, engine = reading_ui, reading_ui["engine"]
page = open_chat(ui, 1280)
bubble = page.locator("#chat-messages .chat-bubble.assistant", has_text="Fidelity check.").first
bubble.scroll_into_view_if_needed()
code = bubble.locator(".md-code-block pre > code")
assert code.evaluate("el => el.textContent") == FENCE_BODY + "\n"
bubble.locator("[data-code-copy]").click()
page.wait_for_function("() => window.__copied !== null")
assert page.evaluate("() => window.__copied") == FENCE_BODY + "\n"
assert bubble.locator("code.inline-code").evaluate("el => el.textContent") == "&amp; &lt; &#42; **stars** <b>"
assert bubble.locator("p", has_text="Prose entities").inner_text() == "Prose entities: Fish & chips <tag> read once."
assert bubble.locator("script, img, [onerror]").count() == 0
assert page.evaluate("() => !window.__markdownOnerror && !window.__markdownScript")
raw = bubble.locator("p", has_text="Raw HTML").inner_text()
assert '<img src=x onerror="window.__markdownOnerror = 1">' in raw and "<script>" in raw
diagram = bubble.locator("a.md-image-ref", has_text="Image: diagram")
assert diagram.evaluate("a => [a.getAttribute('href'), a.title, a.target, a.rel, a.classList.contains('md-link')]") == [
"https://images.example/a.png", "Architecture", "_blank", "noopener noreferrer", True]
linked = bubble.locator("p", has_text="Image: badge")
assert linked.locator("a").count() == 1, "a linked image keeps its one outer link"
assert linked.locator("a").get_attribute("href") == "https://example.com/docs"
assert linked.locator("a .md-image-ref").inner_text() == "Image: badge"
unsafe = bubble.locator(".md-image-ref", has_text="Image: unsafe")
# A refused address is plain text in its paragraph's ink, never a link-styled anchor without a destination.
assert unsafe.count() == 1 and unsafe.evaluate("""el => [el.tagName, el.closest('a') === null,
getComputedStyle(el).color === getComputedStyle(el.parentElement).color,
getComputedStyle(el).textDecorationLine]""") == ["SPAN", True, True, "none"]
assert bubble.locator("a.md-image-ref[href='https://images.example/empty.png']").inner_text() == "Image"
# Words that already carry a link keep that link; anchors never nest.
worded = bubble.locator(".md-image-ref", has_text="Image: see the source")
assert worded.evaluate("el => [el.tagName, el.querySelector('a')?.getAttribute('href')]") == [
"SPAN", "https://example.com/src"]
assert bubble.locator("a a").count() == 0
capture(page, f"reading-fidelity-{engine}")
assert not [url for url in ui["foreign"] if "images.example" in url], ui["foreign"]
def test_task_list_math_renders_beside_its_checkbox(reading_ui):
page = open_chat(reading_ui, 1280)
bubble = page.locator("#chat-messages .chat-bubble.assistant", has_text="Fidelity check.").first
# Code highlighting and KaTeX run in the same enhancement write.
bubble.locator(".md-code-block code.hljs").wait_for()
# Each task keeps its box and its words, and KaTeX reads the author's TeX with no Markdown taken out of it.
assert bubble.locator("li.task-list-item").evaluate_all("""items => items.map((li) => [
li.querySelector('.md-checkbox')?.textContent,
[...li.childNodes].filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join(''),
li.querySelector('.katex annotation')?.textContent ?? null, li.querySelectorAll('em, input').length])""") == [
["", " Compute ", "x_1 + 1", 0], ["✓", " Checked ", "y_1 *c*", 0]]
AUTOLINKS = """async (sources) => {
const {renderChatMarkdown} = await import('/static/modules/chat_markdown.js');
return sources.map((source) => {
const host = document.createElement('div');
host.innerHTML = renderChatMarkdown(source);
const seen = {links: [...host.querySelectorAll('a')].map((a) => a.getAttribute('href'))};
// A code block's language label and Copy button are its chrome, not the author's text: read them apart.
const block = host.querySelector('.md-code-block');
if (block) {
const chrome = [...block.querySelectorAll('.md-code-language, [data-code-copy]')];
seen.toolbar = chrome.map((el) => el.textContent);
seen.code = block.querySelector('pre > code').textContent;
chrome.forEach((el) => el.remove());
}
seen.text = host.textContent.trim();
return seen;
});
}"""
def test_an_angle_autolink_keeps_its_destination_and_other_angles_stay_text(reading_ui):
page = open_chat(reading_ui, 1280)
seen = page.evaluate(AUTOLINKS, [
"See <https://example.com/docs> now",
"Write <mailto:team@example.com>",
"Keep <std::vector> and <b>bold</b> and <javascript:alert(1)>",
"Code `<https://example.com/x>` stays code",
# Math-like code is code: tilde and indented blocks keep the address and entity literal.
"~~~\n$$ <https://example.com/t> &amp; \\(x\\) $$\n~~~",
" $$ <https://example.com/i> &amp; $$",
"Math $$ <https://example.com/m> $$ is text",
# An escaped `<` reads as `<`; code and parked math keep the author's backslash.
"Escaped \\<b>bold\\</b>, a\\\\<i>, `\\<x>` and $$a \\< b$$",
"~~~\n\\<div> \\\\<p>\n~~~",
])
assert seen == [
{"text": "See https://example.com/docs now", "links": ["https://example.com/docs"]},
{"text": "Write mailto:team@example.com", "links": ["mailto:team@example.com"]},
{"text": "Keep <std::vector> and <b>bold</b> and <javascript:alert(1)>", "links": []},
{"text": "Code <https://example.com/x> stays code", "links": []},
{"text": "$$ <https://example.com/t> &amp; \\(x\\) $$", "links": [], "toolbar": ["text", "Copy"],
"code": "$$ <https://example.com/t> &amp; \\(x\\) $$\n"},
{"text": "$$ <https://example.com/i> &amp; $$", "links": [], "toolbar": ["text", "Copy"],
"code": "$$ <https://example.com/i> &amp; $$\n"},
{"text": "Math $$ <https://example.com/m> $$ is text", "links": []},
{"text": "Escaped <b>bold</b>, a\\<i>, \\<x> and $$a \\< b$$", "links": []},
{"text": "\\<div> \\\\<p>", "links": [], "toolbar": ["text", "Copy"], "code": "\\<div> \\\\<p>\n"},
], seen
MULTILINE_CODE = """async (sources) => {
const {mountChatMarkdown, enhanceChatMarkdown} = await import('/static/modules/chat_markdown.js');
return sources.map((source) => {
const host = document.createElement('div');
document.body.append(host);
mountChatMarkdown(host, source);
const dispose = enhanceChatMarkdown(host);
const seen = {code: [...host.querySelectorAll('code')].map((el) => el.textContent),
tex: [...host.querySelectorAll('.katex annotation')].map((el) => el.textContent),
marks: [...host.querySelectorAll('a, img, em, pre, .md-image-ref')].map((el) => el.tagName)};
dispose();
host.remove();
return seen;
});
}"""
def test_a_code_span_across_lines_keeps_its_bytes_and_the_math_after_it(reading_ui):
page = open_chat(reading_ui, 1280)
# Each span holds an unmatched opening delimiter; KaTeX reads only the prose math after it.
seen = page.evaluate(MULTILINE_CODE, [
"Run `echo $$ <https://images.example/c> &amp;\nnext` then $$x_1 *c*$$",
"> Run ```a $$ `` ![i](https://images.example/q.png)\n> next```` $$ end``` then $$y_1 *c*$$",
"- Run ``a \\[ `\n b`` then \\[z_1 *c*\\]",
# A ``` that cannot open a fence leaves the math after it to KaTeX alone.
"Mention ``` literally; compute $$x_1 *c* + \\text{`q'}$$",
])
assert seen == [
{"code": ["echo $$ <https://images.example/c> &amp; next"], "tex": ["x_1 *c*"], "marks": []},
{"code": ["a $$ `` ![i](https://images.example/q.png) next```` $$ end"], "tex": ["y_1 *c*"], "marks": []},
{"code": ["a \\[ ` b"], "tex": ["z_1 *c*"], "marks": []},
{"code": [], "tex": ["x_1 *c* + \\text{`q'}"], "marks": []},
], seen
assert not [url for url in reading_ui["foreign"] if "images.example" in url], reading_ui["foreign"]
def test_compact_consumers_show_the_same_code(reading_ui):
ui, engine = reading_ui, reading_ui["engine"]
page = open_chat(ui, 1280)
review = page.locator("#chat-messages .chat-bubble.system", has_text="Skill review: demo").first
review.locator("[data-skill-review-toggle]").click()
full = review.locator(".skill-review-full")
full.wait_for(state="visible")
assert full.locator("pre > code").evaluate("el => [el.textContent, el.children.length]") == [FENCE_BODY + "\n", 0]
assert full.locator("pre em, pre strong, pre .md-li, pre .md-h1, pre a, pre table").count() == 0
review.scroll_into_view_if_needed()
capture(page, f"reading-skill-review-{engine}")
# The task-card timeline builds its rows with the same exported builder the live card uses.
timeline = page.evaluate("""async (body) => {
const {buildTimelineItemHtml} = await import('/static/modules/chat_activity.js');
const host = document.createElement('div');
host.className = 'chat-live-card';
host.innerHTML = buildTimelineItemHtml({phase: 'working', headline: 'Checking the fence', body, lineKey: 'fence'},
{expandedLineKeys: new Set(), groupId: 'reading'});
document.querySelector('#chat-messages').append(host);
const code = host.querySelector('.chat-live-line-body pre > code');
return [code.textContent, code.children.length];
}""", f"Output:\n```\n{FENCE_BODY}\n```\nDone")
assert timeline == [FENCE_BODY + "\n", 0]
# Compact Markdown reaches the page with no sanitizer after the renderer, so code
# written inside a link destination must stay code and never become attribute text.
LINK_CODE = (
'[open](https://example.com/```\n" onclick="window.__linkCode = 1" data-x="\n```)\n'
'![pic](https://example.com/`x" onmouseover="window.__linkCode = 2`) and [docs](https://example.com/d)'
)
def test_compact_link_destination_keeps_code_out_of_attributes(reading_ui):
page = open_chat(reading_ui, 1280)
seen = page.evaluate("""async (body) => {
const {buildTimelineItemHtml} = await import('/static/modules/chat_activity.js');
const {renderMarkdown} = await import('/static/modules/utils.js');
const row = document.createElement('div');
row.className = 'chat-live-card';
row.innerHTML = buildTimelineItemHtml({phase: 'working', headline: 'Link code', body, lineKey: 'link-code'},
{expandedLineKeys: new Set(), groupId: 'link-code'});
const plain = document.createElement('div');
plain.innerHTML = renderMarkdown(body);
document.querySelector('#chat-messages').append(row, plain);
const hosts = [row.querySelector('.chat-live-line-body'), plain];
for (const el of hosts.flatMap((host) => [...host.querySelectorAll('*')])) {
el.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
}
return hosts.map((host) => ({
injected: [...host.querySelectorAll('*')].flatMap((el) => [...el.attributes].map((a) => a.name))
.filter((name) => name.startsWith('on') || name === 'data-x'),
links: [...host.querySelectorAll('a')].map((a) => [a.getAttribute('href'), a.textContent]),
code: [...host.querySelectorAll('pre > code, code.inline-code')].map((c) => c.textContent),
fired: window.__linkCode ?? null,
}));
}""", LINK_CODE)
for host in seen:
assert host == {
"injected": [],
"links": [["https://example.com/d", "docs"]],
"code": ['" onclick="window.__linkCode = 1" data-x="\n', 'x" onmouseover="window.__linkCode = 2'],
"fired": None,
}, host
# A compact image or link destination is the address alone: a title is not part
# of it, balanced parentheses are, and a refused address leaves nothing behind.
COMPACT_REFERENCES = (
'Before ![diagram](https://images.example/a.png "Architecture") after.\n'
"![chart](https://images.example/Foo_(bar).png) and [wiki](https://example.com/wiki/Foo_(bar)).\n"
"![unsafe](javascript:alert(1)) end"
)
def test_compact_image_reference_is_its_address_alone(reading_ui):
ui = reading_ui
page = open_chat(ui, 1280)
seen = page.evaluate("""async (body) => {
const {buildTimelineItemHtml} = await import('/static/modules/chat_activity.js');
const {renderMarkdown} = await import('/static/modules/utils.js');
const row = document.createElement('div');
row.className = 'chat-live-card';
row.innerHTML = buildTimelineItemHtml({phase: 'working', headline: 'References', body, lineKey: 'refs'},
{expandedLineKeys: new Set(), groupId: 'refs'});
const plain = document.createElement('div');
plain.innerHTML = renderMarkdown(body);
document.querySelector('#chat-messages').append(row, plain);
return [row.querySelector('.chat-live-line-body'), plain].map((host) => ({
text: host.textContent,
links: [...host.querySelectorAll('a')].map((a) => [a.getAttribute('href'), a.textContent, a.className]),
media: host.querySelectorAll('img, picture, video, audio, source').length,
}));
}""", COMPACT_REFERENCES)
for host in seen:
assert host == {
"text": "Before Image: diagram after.\nImage: chart and wiki.\nImage: unsafe end",
"links": [
["https://images.example/a.png", "Image: diagram", "md-link md-image-ref"],
["https://images.example/Foo_(bar).png", "Image: chart", "md-link md-image-ref"],
["https://example.com/wiki/Foo_(bar)", "wiki", "md-link"],
["#", "Image: unsafe", "md-link md-image-ref"],
],
"media": 0,
}, host
assert not [url for url in ui["foreign"] if "images.example" in url], ui["foreign"]
def assert_column_bound(page, column):
"""`createChatInstance` binds its own column's Markdown tables (chat.js); the test adds no binding."""
assert page.evaluate("(column) => document.querySelector(column).hasAttribute('data-md-tables')", column), column
COMPACT = """(column) => {
const root = document.querySelector(column);
const style = (el) => getComputedStyle(el);
const review = [...root.querySelectorAll('.chat-bubble.system')].find((b) => b.textContent.includes('Skill review: demo'));
const full = review.querySelector('.skill-review-full');
const label = (el) => ({size: parseFloat(style(el).fontSize), weight: Number(style(el).fontWeight)});
const table = (wrap) => ({
head: wrap.querySelector('th').textContent,
size: parseFloat(style(wrap.querySelector('table')).fontSize),
overflow: wrap.scrollWidth - wrap.clientWidth,
region: wrap.getAttribute('role'), label: wrap.getAttribute('aria-label'), tabindex: wrap.getAttribute('tabindex'),
edges: [wrap.hasAttribute('data-scroll-start'), wrap.hasAttribute('data-scroll-end')],
});
const row = root.querySelector('.chat-live-card[data-reading-timeline] .chat-live-line');
return {
reviewText: parseFloat(style(full).fontSize),
reviewHeadings: ['.md-h1', '.md-h2', '.md-h3'].map((cls) => [...full.querySelectorAll(cls)].map(label)).flat(),
reviewTables: [...full.querySelectorAll('.md-table-wrap')].map(table),
rowText: row && parseFloat(style(row.querySelector('.chat-live-line-body')).fontSize),
rowHeading: row && label(row.querySelector('.chat-live-line-body .md-h2')),
rowTables: row ? [...row.querySelectorAll('.md-table-wrap')].map(table) : [],
};
}"""
# A timeline row is wider than a review body: its table needs more columns to overflow.
TIMELINE_BODY = f"## Measured\nThe run finished.\n{compact_wide_table(14)}\nDone"
def add_timeline_row(page, column):
"""A task card row written after the column was bound, built by the live card's own builder."""
page.evaluate("""async ([column, body]) => {
const {buildTimelineItemHtml} = await import('/static/modules/chat_activity.js');
const host = document.createElement('div');
host.className = 'chat-live-card';
host.dataset.readingTimeline = '1';
host.innerHTML = buildTimelineItemHtml({phase: 'done', headline: 'Timings', body, lineKey: 'timings'},
{expandedLineKeys: new Set(), groupId: 'reading-timings'});
document.querySelector(column).append(host);
}""", [column, TIMELINE_BODY])
def assert_compact(metrics):
# Density is retained: every compact heading level is a body-size semibold
# label in a Skill Review report (its text is the bubble's 16px), and the
# row's own size in a timeline.
assert metrics["reviewText"] == 16, metrics
assert len(metrics["reviewHeadings"]) == 4, metrics
assert all(h == {"size": 14, "weight": 600} for h in metrics["reviewHeadings"]), metrics
assert metrics["rowHeading"] == {"size": metrics["rowText"], "weight": 600}, metrics
tables = {t["head"]: t for t in metrics["reviewTables"] + metrics["rowTables"]}
assert set(tables) == {"Check", "Signal"} and len(metrics["rowTables"]) == 1, metrics
for table in metrics["reviewTables"] + metrics["rowTables"]:
# Compact tables keep --type-body; none is shrunk to fit.
assert table["size"] == 14, table
scrolls = table["overflow"] > 1
assert (table["region"], table["tabindex"], table["edges"]) == (
("region", "0", [False, True]) if scrolls else (None, None, [False, False])), table
if scrolls:
assert table["label"] == "Scrollable table", table
assert tables["Check"]["overflow"] <= 1, tables
assert all(t["overflow"] > 1 for t in metrics["reviewTables"] + metrics["rowTables"] if t["head"] == "Signal"), metrics
PATCHED_TABLE = """(body) => new Promise((resolve) => {
const frames = (n, then) => (n ? requestAnimationFrame(() => frames(n - 1, then)) : then());
const wrap = () => document.querySelector('[data-patched-timeline] .md-table-wrap');
const state = () => ({
region: wrap().getAttribute('role'), label: wrap().getAttribute('aria-label'),
tabindex: wrap().getAttribute('tabindex'),
edges: [wrap().hasAttribute('data-scroll-start'), wrap().hasAttribute('data-scroll-end')],
focused: document.activeElement === wrap(),
});
const {record, renderer} = window.patchedTimeline;
const kept = wrap();
kept.focus();
const before = state();
// Same glyphs in another order: the cell's text changes, no size does.
record.items[0].body = body;
renderer.renderLiveCardTimeline(record);
frames(3, () => resolve({before, after: state(), same: wrap() === kept}));
})"""
def test_keyed_timeline_patch_keeps_a_scrolling_table_a_keyboard_region(reading_ui):
page = open_chat(reading_ui, 1280)
column = "#chat-messages"
assert_column_bound(page, column)
body = TIMELINE_BODY
page.evaluate("""async ([column, body]) => {
const {buildTimelineItemHtml} = await import('/static/modules/chat_activity.js');
const {createLiveCardTimelineRenderer} = await import('/static/modules/chat_render_batch.js');
const card = document.createElement('div');
card.className = 'chat-live-card';
card.dataset.patchedTimeline = '1';
card.dataset.expanded = '1';
const timelineEl = document.createElement('div');
timelineEl.className = 'chat-live-timeline';
card.append(timelineEl);
document.querySelector(column).append(card);
const record = {root: card, timelineEl, expandedLineKeys: new Set(), groupId: 'patched', items: [
{phase: 'done', headline: 'Timings', body, lineKey: 'timings'}]};
// The live card's own keyed renderer (chat.js builds the same one).
const renderer = createLiveCardTimelineRenderer({withStableViewport: (fn) => fn(), buildTimelineItemHtml});
renderer.renderLiveCardTimeline(record);
window.patchedTimeline = {record, renderer};
}""", [column, body])
page.wait_for_function("() => document.querySelector('[data-patched-timeline] .md-table-wrap')"
"?.getAttribute('role') === 'region'")
swapped = body.replace("120,000.25", "120,000.52")
assert swapped != body
seen = page.evaluate(PATCHED_TABLE, swapped)
region = {"region": "region", "label": "Scrollable table", "tabindex": "0", "edges": [False, True], "focused": True}
assert seen == {"before": region, "after": region, "same": True}, seen
wrap = page.locator(f"{column} [data-patched-timeline] .md-table-wrap")
page.keyboard.press("ArrowRight", delay=300)
page.wait_for_function("el => el.scrollLeft > 0 && el.hasAttribute('data-scroll-start')", arg=wrap.element_handle())
def open_review(page, column):
review = page.locator(f"{column} .chat-bubble.system", has_text="Skill review: demo").first
review.locator("[data-skill-review-toggle]").click()
review.locator(".skill-review-full").wait_for(state="visible")
return review
def wait_compact_regions(page, column):
# The wrapper becomes a region once it is laid out (the review body was hidden,
# the timeline row arrived after binding).
page.wait_for_function("""(column) => {
const wide = [...document.querySelectorAll(column + ' .md-table-wrap')]
.filter((w) => !w.closest('.ui-rich-content') && w.querySelector('th')?.textContent === 'Signal');
return wide.length === 2 && wide.every((w) => w.getAttribute('role') === 'region');
}""", arg=column)
def test_compact_surfaces_keep_their_density_and_scroll_tables_by_keyboard(reading_ui):
ui, engine = reading_ui, reading_ui["engine"]
page = open_chat(ui, 1280)
column = "#chat-messages"
assert_column_bound(page, column)
review = open_review(page, column)
add_timeline_row(page, column)
wait_compact_regions(page, column)
settle_tables(page, column)
assert_compact(page.evaluate(COMPACT, column))
wide = review.locator(".md-table-wrap", has_text="Latency ms")
wide.focus()
assert wide.evaluate("el => document.activeElement === el")
page.keyboard.press("ArrowRight", delay=300)
page.wait_for_function("el => el.scrollLeft > 0 && el.hasAttribute('data-scroll-start')", arg=wide.element_handle())
review.scroll_into_view_if_needed()
capture(page, f"compact-desktop-skill-review-{engine}")
page.locator(f"{column} .chat-live-card[data-reading-timeline]").scroll_into_view_if_needed()
capture(page, f"compact-desktop-timeline-{engine}")
panel = mount_project_panel(page)
panel_column = f"{panel} .chat-messages"
assert_column_bound(page, panel_column)
open_review(page, panel_column)
add_timeline_row(page, panel_column)
wait_compact_regions(page, panel_column)
settle_tables(page, panel_column)
assert_compact(page.evaluate(COMPACT, panel_column))
page.locator(f"{panel_column} .skill-review-full .md-table-wrap", has_text="Latency ms").scroll_into_view_if_needed()
capture(page, f"compact-panel440-skill-review-{engine}")
page.evaluate("(column) => { window.readingColumn = document.querySelector(column); }", panel_column)
page.evaluate("window.readingPanel.destroy()")
# destroy() releases the column's binding with the page (destroyChatMarkdown).
assert page.evaluate("() => !readingColumn.isConnected && !readingColumn.hasAttribute('data-md-tables')")
page = open_chat(ui, 390, 844)
assert_column_bound(page, column)
review = open_review(page, column)
add_timeline_row(page, column)
wait_compact_regions(page, column)
settle_tables(page, column)
assert_compact(page.evaluate(COMPACT, column))
review.locator(".md-table-wrap", has_text="Check").scroll_into_view_if_needed()
capture(page, f"compact-phone390-skill-review-{engine}")
assert not ui["foreign"], ui["foreign"]

View file

@ -801,12 +801,6 @@ def test_explicit_terminal_5xx_remains_retryable():
assert result.retry_same_request is True
def test_provider_failure_hint_formats_detail():
hint = _provider_failure_hint({"_last_llm_error": " AuthenticationError('401 invalid_api_key') "})
assert hint == " Last provider error: AuthenticationError('401 invalid_api_key')"
def test_provider_failure_hint_empty_without_error():
assert _provider_failure_hint({}) == ""
@ -846,7 +840,8 @@ def test_claudexor_display_reaches_error_and_terminal_without_retry(tmp_path, mo
assert "private-body" not in event["error"]
assert ("remote_context_overflow" in [row["type"] for row in rows]) is (kind == "context_overflow")
hint = _provider_failure_hint(usage)
assert f"provider_code={vendor}" in hint and f"parameter={parameter}" in hint
assert f'The provider reported error code "{vendor}" and parameter "{parameter}"' in hint
assert "The provider said" not in hint and "provider_code=" not in hint and "private-body" not in hint
assert provider_recovery_hint(usage) == provider_recovery_hint({**usage, "_last_llm_error": repr(error)})

View file

@ -136,12 +136,13 @@ def test_ui_browser_marked_rich_markdown_and_security(direct_server_with_data):
assert rich.locator("h1.md-h1").count() == 1, rich.inner_html()
assert rich.locator("h2.md-h2").count() == 1
assert rich.locator("h3.md-h3").count() == 1
# Every heading level is a body-size semibold label inside a bubble (DESIGN §5);
# h4+ carry the smallest label class so the clamp reaches them.
# The chat reading ladder (DESIGN §5): # = ## above ###, deeper levels at the
# 16px reading size, all semibold; h4+ carry the smallest label class.
h4 = rich.locator("h4.md-h3")
assert h4.count() == 1, rich.inner_html()
assert h4.evaluate("el => [getComputedStyle(el).fontSize, getComputedStyle(el).fontWeight]") == ["14px", "600"]
assert rich.locator("h1.md-h1").evaluate("el => getComputedStyle(el).fontSize") == "14px"
assert h4.evaluate("el => [getComputedStyle(el).fontSize, getComputedStyle(el).fontWeight]") == ["16px", "600"]
assert rich.locator("h1.md-h1").evaluate("el => getComputedStyle(el).fontSize") == "20px"
assert rich.locator("h3.md-h3").evaluate("el => getComputedStyle(el).fontSize") == "18px"
assert rich.locator("blockquote.md-quote").count() == 1
table = rich.locator(".md-table-wrap > table.md-table")
assert table.count() == 1

View file

@ -162,6 +162,27 @@ def test_type_scale_tokens_are_declared_once_in_the_root_block() -> None:
)
def test_chat_reading_ladder_is_relative_and_read_only_by_chat_headings() -> None:
"""The one exception to the closed scale (docs/DESIGN.md §1): Markdown
headings in a full rich chat answer step up from the bubble's own reading
text. The steps are relative, so they cannot become a fifth UI size, and
only the rich answer's heading rules may read them: compact Markdown in a
bubble (a Skill Review report) keeps its --type-body labels."""
root = _root_declarations("web/ui.css")
ladder = {"--md-heading-major": "1.25em", "--md-heading-minor": "1.125em"}
assert {name: root.get(name) for name in ladder} == ladder
css = _decommented(_read("web/style.css"))
readers = [
" ".join(selector.split())
for selector, body in RULE.findall(css)
if any(f"var({name})" in body for name in ladder)
]
assert readers == [
".chat-bubble .message:where(.ui-rich-content) :is(.md-h1, .md-h2)",
".chat-bubble .message:where(.ui-rich-content) .md-h3",
], readers
def _root_declarations(rel: str) -> dict[str, str]:
"""The ``:root`` block of a stylesheet as ``{token: value}``.
@ -536,6 +557,15 @@ def test_webkit_scrollbar_recipe_covers_both_axes() -> None:
)
def test_every_mask_keeps_its_webkit_companion() -> None:
"""A fade is a mask (`.scroll-fade-y`, the sideways table fade): each rule that
sets `mask-image` also sets `-webkit-mask-image`, as the file's first fade does,
so a WebKit view that reads only the prefixed property still fades and unfades."""
css = _decommented(_read("web/style.css"))
unpaired = [selector.strip() for selector, body in RULE.findall(css)
if re.search(r"(?<![-\w])mask-image\s*:", body) and "-webkit-mask-image" not in body]
assert not unpaired, f"mask-image without its -webkit- companion: {unpaired}"
def test_chat_transcript_reserves_composer_space_as_one_flex_spacer():
"""End space is a flex item; keyboard flow removes it and its extra gap."""
css = _decommented(_read("web/style.css"))

View file

@ -485,6 +485,9 @@
* ordinary row.
* @property {string=} card_row_id // the row's stable identity across live delivery, outbox replay and history
* @property {number=} card_row_revision // canonical source order, independent of delivery timestamp
* @property {Object=} late_evidence
* Late-review identity, reviewed revision and exact applied source_ref served by
* taskSourceDownloadUrl; not an original reviewer transcript or a copy of the row.
* @property {string=} target_label
* @property {string=} project_id
* @property {string=} project_name

View file

@ -1,5 +1,5 @@
import { escapeHtmlAttr, escapeHtmlText as escapeHtml } from './utils.js';
import { destroyChatMarkdown, enhanceChatMarkdown, mountChatMarkdown, renderChatMarkdown } from './chat_markdown.js';
import { bindMarkdownTables, destroyChatMarkdown, enhanceChatMarkdown, mountChatMarkdown, renderChatMarkdown } from './chat_markdown.js';
import { renderPageHeader } from './page_header.js';
import { PAGE_ICONS } from './page_icons.js';
import { showToast } from './toast.js';
@ -141,6 +141,9 @@ import {
renderCollapsedActivity,
renderLiveCardMeta as renderCardMeta,
ensureLiveActionsEl,
cardRowEvidenceRef,
cardRowSummary,
evidenceLinkHtml,
} from './chat_activity.js';
export {
@ -872,11 +875,11 @@ export function createChatInstance({
// A child is never a convertible unit (it inherits its root's Project by
// lineage) and its title is its role: both root writers skip it.
const work = !record.isSubagent && blockHasWork(record);
// The first row of work lands after the title writers ran for its frame:
// an empty title takes the placeholder here, the writers own it from then on.
// The first row of work lands after the title writers ran for its frame: an empty title
// takes a name here (Working is the chip's word, #1369); the writers own it from then on.
if (work && !record.titleEl.textContent) {
record.titleEl.textContent = record.suggestedName || record.lastHumanHeadline
|| (record.finished ? 'Task activity' : 'Working...');
|| (record.finished ? 'Task activity' : '');
}
const wanted = isMain && work && record.root.dataset.projectBound !== '1'
&& !(window.__ouroTaskBindings || {})[record.groupId];
@ -1206,10 +1209,7 @@ export function createChatInstance({
const taskId = taskKey(msg?.task_id);
const record = phase && taskId ? liveCardRecords.get(taskId) : null;
if (!record) return undefined;
const lines = String(msg.text ?? msg.content ?? '').split('\n');
const headline = lines[0].trim();
const rowId = taskKey(msg.card_row_id) || `${taskKey(msg.system_type)}|${rawTs}`;
const summary = { phase, headline, body: lines.slice(1).join('\n').trim(), dedupeKey: `cardrow|${rowId}`, cardRowRevision: msg.card_row_revision };
const summary = cardRowSummary(msg, phase, rawTs);
return withStableViewport(() => {
const before = captureLiveCardProjection(record);
let fresh;
@ -1218,7 +1218,7 @@ export function createChatInstance({
fresh = mergeHistoricalTimelineItem(record, summary, msg, normalizeLogTs(rawTs));
} else {
const { timelineUpdate } = updateLiveTimelineItem(record, summary, {
ts: normalizeLogTs(rawTs), rawTs, syntheticKey: summary.dedupeKey, headline, inPlaceByKey: true,
ts: normalizeLogTs(rawTs), rawTs, syntheticKey: summary.dedupeKey, headline: summary.headline, inPlaceByKey: true,
});
fresh = !['none', 'duplicate-skip'].includes(timelineUpdate);
}
@ -1516,7 +1516,7 @@ export function createChatInstance({
// P1: last bounded activity projection (remembered even while
// the collapsed line is suppressed on unnamed root cards) + sticky cost.
clearStickyCardState(record);
record.titleEl.textContent = record.suggestedName || (blockHasWork(record) ? 'Working...' : '');
record.titleEl.textContent = record.suggestedName || '';
setLiveCardPhase(record, 'working');
record.countEl.hidden = true;
record.countEl.textContent = '0 notes';
@ -1689,7 +1689,8 @@ export function createChatInstance({
}
record.updates += 1;
const wasFinished = record.finished;
const headline = summary.headline || record.lastHumanHeadline || 'Working...';
// No placeholder: a frame naming nothing is no narration; the chip says Working (#1369).
const headline = summary.headline || record.lastHumanHeadline || '';
const syntheticKey = summary.dedupeKey || dedupeKey || `${summary.phase || 'working'}|${headline}|${summary.body || ''}`;
const isLegacyParentSubagentKey = syntheticKey.startsWith('parent-subagent:');
// Failure/timeout update one row; success feeds the fold.
@ -1710,8 +1711,7 @@ export function createChatInstance({
const activeHeadline = shouldPromote
? headline
: (record.lastHumanHeadline
|| (record.updates > 1 ? record.titleEl.textContent : '')
|| 'Working...');
|| (record.updates > 1 ? record.titleEl.textContent : ''));
// Only task facts own the outcome chip under a hold; failed tools are diagnostics.
if (summary.observedOutcome && !record.finished) record.observedOutcome = summary.observedOutcome;
const desiredPhase = desiredLiveCardPhase(record, record.finished ? summary.phase || 'done' : '');
@ -2247,6 +2247,7 @@ export function createChatInstance({
originId: opts.originId || '',
terminalTime: opts.terminalTime || null,
skillReview: opts.skillReview || null,
evidenceRef: opts.evidenceRef || null,
});
// Mirror the sessionStorage slice(-200): the in-memory copy exists
// only to feed that snapshot, so it obeys the same cap (P3).
@ -2282,9 +2283,11 @@ export function createChatInstance({
const timeFmt = formatMsgTime(ts);
const timeHtml = timeFmt ? `<div class="msg-time" title="${escapeHtmlAttr(timeFmt.full)}">${escapeHtml(timeFmt.short)}</div>` : '';
const pendingHtml = pending ? `<div class="msg-pending">Queued until reconnect</div>` : '';
// A placed row with no card keeps the record link its card row would offer.
bubble.innerHTML = `
<div class="sender">${escapeHtml(sender)}</div>
<div class="message${richMarkdown ? ' ui-rich-content' : ''}">${rendered}</div>
${evidenceLinkHtml(opts.evidenceRef)}
${pendingHtml}
${timeHtml}
`;
@ -2484,6 +2487,14 @@ export function createChatInstance({
reorderDirtyCardIfNeeded(rec);
ensureLiveCardVisible(rec);
}
// A stored row as its bubble: its own identity and labels, plus what its kind adds.
const addStoredRow = (msg, taskId, extra) => addMessage(msg.text, msg.role, !!msg.markdown, msg.ts || null, false, {
historyId: msg.history_id, historyPosition: msg.history_position,
systemType: msg.system_type || '', taskId,
source: msg.source || '', initiator: msg.initiator || '',
senderLabel: msg.sender_label || '', senderSessionId: msg.sender_session_id || '',
...extra,
});
for (const msg of messages) {
_historyRow = msg;
const taskId = msg.task_id || '';
@ -2497,12 +2508,7 @@ export function createChatInstance({
|| cardRowsAttached.has(msg) || attachCardRow(msg, msg.ts || '') !== undefined
) continue;
if (isPlacedCardRow(msg)) {
addMessage(msg.text, msg.role, !!msg.markdown, msg.ts || null, false, {
historyId: msg.history_id, historyPosition: msg.history_position,
systemType: msg.system_type || '', taskId,
source: msg.source || '', initiator: msg.initiator || '',
senderLabel: msg.sender_label || '', senderSessionId: msg.sender_session_id || '',
});
addStoredRow(msg, taskId, { evidenceRef: cardRowEvidenceRef(msg) });
continue;
}
// Reconnect: a durably recorded submission must not stay
@ -2568,17 +2574,10 @@ export function createChatInstance({
if (msg.chat_annotation && msg.client_message_id) {
pendingSubmissions.delete(String(msg.client_message_id));
}
addMessage(msg.text, msg.role, !!msg.markdown, msg.ts || null, false, {
historyId: msg.history_id, historyPosition: msg.history_position,
systemType: msg.system_type || '',
source: msg.source || '',
initiator: msg.initiator || '',
senderLabel: msg.sender_label || '',
senderSessionId: msg.sender_session_id || '',
addStoredRow(msg, taskId, {
clientMessageId: msg.client_message_id || '',
originProjected: msg.origin_projected === true,
originId: msg.origin_id || '',
taskId,
chatAnnotation: msg.chat_annotation || null,
skillReview: msg.system_type === 'skill_review' && msg.skill && msg.job_id
? { skill: msg.skill, jobId: msg.job_id }
@ -3454,6 +3453,8 @@ export function createChatInstance({
}
}
const disposeReadingGestures = reading.bindGestures(navigateHistoryAtEdge);
// Compact tables share rich-answer keyboard/overflow affordances; destroy releases them.
bindMarkdownTables(messagesDiv);
function retryHistoricalUpserts() {
const ready = [...pendingHistoryUpserts.values()].filter(row =>
!historyNodes(row.history_id).some(node => historyNodeIsProtected(node, messagesDiv)));
@ -3681,34 +3682,26 @@ export function createChatInstance({
if (msg.role === 'assistant' || msg.role === 'system') {
return withRemoteActivity(() => {
const explicitTaskId = msg.task_id || '';
// Every path ends by syncing status; `unread` rows also count toward the badge.
const settled = (result, unread = result) => {
if (unread) incrementUnreadIfNeeded(msg);
syncChatStatus();
return result;
};
const reference = admitCardMetadata(msg);
if (reference !== undefined) {
syncChatStatus();
return reference;
}
if (reference !== undefined) return settled(reference, false);
const review = attachReviewFromRow(msg, msg.ts || '', true);
if (review !== undefined) {
syncChatStatus();
return review;
}
if (review !== undefined) return settled(review, false);
const cardRow = attachCardRow(msg, msg.ts || '');
if (cardRow !== undefined) {
if (cardRow) incrementUnreadIfNeeded(msg);
syncChatStatus();
return cardRow;
}
if (cardRow !== undefined) return settled(cardRow);
if (PROJECT_ROW_TYPES.has(msg.system_type)) {
const added = addProjectRow(msg, msg.content, { taskId: explicitTaskId });
if (added) incrementUnreadIfNeeded(msg);
syncChatStatus();
return Boolean(added);
return settled(Boolean(addProjectRow(msg, msg.content, { taskId: explicitTaskId })));
}
learnSubagentLineage(msg);
if (msg.is_progress) {
showTaskIncidentToast(msg);
const changed = updateLiveCardFromProgressMessage(msg, { grantCancelAuthority: true });
syncChatStatus();
return changed;
return settled(changed, false);
}
// An early final (post-task still running) is NOT the turn's
@ -3735,16 +3728,11 @@ export function createChatInstance({
if (msg.system_type === 'task_summary') {
const changed = appendTaskSummaryToLiveCard(msg);
if (changed) incrementUnreadIfNeeded(msg);
syncChatStatus();
return Boolean(changed);
return settled(Boolean(changed));
}
// A placed host row is never a child's answer; with no card it stays a System row.
if (explicitTaskId && !msg.card_row && subagentChildParents.has(explicitTaskId)) {
const changed = routeSubagentFinalMessageToCard(explicitTaskId, msg);
if (changed) incrementUnreadIfNeeded(msg);
syncChatStatus();
return Boolean(changed);
return settled(Boolean(routeSubagentFinalMessageToCard(explicitTaskId, msg)));
}
let changed = false;
if (finalizing) changed = markLiveCardFinalizing(explicitTaskId, msg) || changed;
@ -3757,10 +3745,10 @@ export function createChatInstance({
source: msg.source || '',
initiator: msg.initiator || '',
taskId: explicitTaskId,
// A placed row whose card is not in this chat keeps its record link.
evidenceRef: isPlacedCardRow(msg) ? cardRowEvidenceRef(msg) : null,
});
if (added || changed) incrementUnreadIfNeeded(msg);
syncChatStatus();
return Boolean(added || changed || routingCleared);
return settled(Boolean(added || changed || routingCleared), added || changed);
});
}
});

View file

@ -3,6 +3,7 @@
// in-flight direct/ephemeral turn status reducer and snapshot hydration.
import { executorIdentityMarkup, joinMetaParts } from './harness_presentation.js';
import { resultFilesItemHtml } from './result_files.js';
import { taskSourceDownloadUrl } from './api_client.js';
import { compactModel, formatLogDuration, modelExecutionLabel } from './log_events.js';
import { createSystemMessageActions } from './ui_helpers.js';
import { projectReference } from './project_reference.js';
@ -72,6 +73,37 @@ export function isLiveLineExpandable(item) {
);
}
// A late-review row links its exact applied review record through the task artifact
// route (#1369) — an absent or unsupported `late_evidence.source_ref` offers no link
// rather than a guessed one. The card's timeline and a card-less System row share it.
export function cardRowEvidenceRef(msg) {
const evidence = msg?.late_evidence && typeof msg.late_evidence === 'object'
? taskSourceDownloadUrl(String(msg.task_id || '').trim(), msg.late_evidence.source_ref) : '';
return evidence ? { href: evidence, label: 'Download the review record' } : null;
}
// The stored record link as one download anchor, or nothing. A value restored from
// the session snapshot is held to the task artifact route it was minted on. It wears
// the chat link ink (`md-link`), as the result-file downloads beside it do.
export function evidenceLinkHtml(evidenceRef) {
const href = typeof evidenceRef?.href === 'string' && evidenceRef.href.startsWith('/api/tasks/') ? evidenceRef.href : '';
return href
? `<p class="chat-live-line-evidence"><a class="md-link" href="${escapeHtmlAttr(href)}" download data-live-line-evidence>${escapeHtml(evidenceRef.label || 'Download the review record')}</a></p>`
: '';
}
// A host-placed card row (timeline or Reviews) as its timeline summary: the first line
// heads, `card_row_id` is the row's stable identity, and a late-review row carries its
// record link (`cardRowEvidenceRef`).
export function cardRowSummary(msg, phase, rawTs = '') {
const lines = String(msg.text ?? msg.content ?? '').split('\n');
const rowId = String(msg.card_row_id || '').trim() || `${String(msg.system_type || '').trim()}|${rawTs}`;
return {
phase, headline: lines[0].trim(), body: lines.slice(1).join('\n').trim(), dedupeKey: `cardrow|${rowId}`,
cardRowRevision: msg.card_row_revision, evidenceRef: cardRowEvidenceRef(msg),
};
}
export function buildTimelineItemHtml(item, record) {
if (item.resultArtifacts) return resultFilesItemHtml(item);
// A delegated observation renders its per-seq projection; one wholly shown by
@ -88,6 +120,8 @@ export function buildTimelineItemHtml(item, record) {
const displayBody = expanded ? (item.fetchedFull || item.fullBody || item.body) : item.body;
const showingFetched = expanded && Boolean(item.fetchedFull);
const loadingFull = expanded && Boolean(item.truncated && item.fullRef && !item.fetchedFull);
// A late-review row offers its exact applied review record (`cardRowSummary`).
const evidenceHtml = evidenceLinkHtml(item.evidenceRef);
const isProgressLine = item.phase === 'working' || item.phase === 'thinking';
const bodyId = `chat-live-line-body-${String(record.groupId || 'task').replace(/[^A-Za-z0-9_-]/g, '-')}-${String(item.lineKey || '').replace(/[^A-Za-z0-9_-]/g, '-')}`;
const headContent = `
@ -119,7 +153,7 @@ export function buildTimelineItemHtml(item, record) {
>
${headHtml}
${delegated ? `<div class="chat-live-line-body chat-delegated-activity" id="${escapeHtmlAttr(bodyId)}">${delegatedActivityBodyHtml(delegated, { expanded })}</div>`
: displayBody ? `<div class="chat-live-line-body${showingFetched ? ' chat-live-line-body-full' : ''}" id="${escapeHtmlAttr(bodyId)}">${renderMarkdown(displayBody, { inlineHeadingBreaks: true })}${loadingFull ? '<div class="chat-live-line-loading">Loading full output…</div>' : ''}</div>` : ''}
: displayBody || evidenceHtml ? `<div class="chat-live-line-body${showingFetched ? ' chat-live-line-body-full' : ''}" id="${escapeHtmlAttr(bodyId)}">${displayBody ? renderMarkdown(displayBody, { inlineHeadingBreaks: true }) : ''}${evidenceHtml}${loadingFull ? '<div class="chat-live-line-loading">Loading full output…</div>' : ''}</div>` : ''}
</div>
`;
}

View file

@ -66,6 +66,7 @@ export function mergeHistoricalTimelineItem(record, summary, row, ts) {
item.historyPosition = row.history_position;
item.dedupeKey = key;
item.count = 1;
if (summary.evidenceRef && !item.evidenceRef) item.evidenceRef = summary.evidenceRef;
} else {
record.items.push({
cardRowRevision: summary.cardRowRevision,
@ -73,6 +74,7 @@ export function mergeHistoricalTimelineItem(record, summary, row, ts) {
fullHeadline: summary.fullHeadline || summary.headline || 'Update',
body: summary.body || '', fullBody: summary.fullBody || summary.body || '',
fullRef: summary.fullRef || '', truncated: summary.truncated || false,
evidenceRef: summary.evidenceRef || null,
ts: ts || '', sourceTs: row.ts || '', count: 1, dedupeKey: key, ...activity,
...(evolving ? source : { historyId: identity, historyPosition: row.history_position }),
lineKey: evolving && !String(key).startsWith('cardrow|') ? `terminal-${String(key).replace(/[^A-Za-z0-9_-]/g, '-')}`

View file

@ -11,6 +11,7 @@ const MAX_CHART_POINTS = 500;
const MAX_RICH_BLOCK_SOURCE_LENGTH = 32768;
const MERMAID_SCRIPT_ID = 'chat-mermaid-library';
const ROOT_STATE = new WeakMap();
const TABLE_BINDINGS = new WeakMap();
const CHART_AUTHORED = new WeakMap();
const CHART_THEMED = new WeakMap();
const writeDirectly = (mutate) => mutate();
@ -26,38 +27,153 @@ function escapeText(value) {
.replace(/>/g, '&gt;');
}
function decodeHtmlEntities(value) {
const raw = String(value ?? '');
if (!/&[#A-Za-z]/.test(raw)) return raw;
if (typeof document !== 'undefined') {
const textarea = document.createElement('textarea');
textarea.innerHTML = raw;
return textarea.value;
}
return raw
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#0*39;/g, "'")
.replace(/&#x0*27;/gi, "'")
.replace(/&amp;/gi, '&');
/**
* Parser input whose raw HTML stays literal. `<` is the only character that
* opens HTML in marked's grammar, so it becomes `lessThan`: a decimal reference
* the message does not already contain. Prose therefore still reads `<`, and
* code maps each one back to the author's byte. `&` stays the author's: marked
* decodes one entity layer in prose (the legacy stored-entity reading) and keeps
* fenced and inline code literal. An entity never creates Markdown structure.
* The one `<` kept opens an autolink to an address a link may carry
* (`<https://…>`, `<mailto:…>`): it opens no HTML, and without it marked's bare
* URL rule would take the closing `>` into the destination.
* An escaped `\<` (backslashes paired left to right, as marked pairs them)
* becomes `escapedLessThan`, a second unused reference that takes the backslash
* too: a `\` left before a reference escapes its `&`, and prose would show the
* reference. Prose reads `<` there, as marked reads `\<`; code maps it back.
*/
export function prepareMarkdownSource(text) {
const raw = String(text ?? '');
const unused = (reference) => {
while (raw.includes(reference)) reference = reference.replace('&#', '&#0');
return reference;
};
const lessThan = unused('&#60;');
const escapedLessThan = unused(lessThan.replace('&#', '&#0'));
const source = raw.replace(/(\\?<)(?!(?:https?|mailto):[^\s<>]*>)|\\[\s\S]/gi, (match, opener) => (
opener === '<' ? lessThan : opener ? escapedLessThan : match));
return { source, lessThan, escapedLessThan };
}
/** Decode one stored entity layer, then escape it so raw HTML stays literal. */
export function prepareMarkdownSource(text) {
return escapeText(decodeHtmlEntities(String(text ?? '')));
// Attribute text in marked's own manner: an entity (including `lessThan`) stays
// an entity for the browser to read, everything else that could end the value is
// escaped.
function attributeText(value) {
return String(value ?? '').replace(/&(?!#?\w+;)/g, '&amp;').replace(/"/g, '&quot;').replace(/>/g, '&gt;');
}
// A forbidden image keeps its words and its address (#1368). Nothing loads: the
// renderer writes an inert placeholder (a link would nest inside a linked image
// and break it), and the link pass decides what it becomes. The address is
// encoded exactly as marked encodes a link's.
function renderImageReference({ href, title, tokens, text }) {
const alt = tokens ? this.parser.parseInline(tokens) : attributeText(text);
let address = '';
try { address = encodeURI(String(href ?? '')).replace(/%25/g, '%'); } catch { address = ''; }
const titleAttr = title ? ` title="${attributeText(title)}"` : '';
return `<span class="md-image-ref" data-md-image-href="${attributeText(address)}"${titleAttr}>`
+ `Image${alt ? `: ${alt}` : ''}</span>`;
}
function getMarkdownParser() {
if (markdownParser) return markdownParser;
const Marked = globalThis.marked?.Marked;
if (typeof Marked !== 'function') return null;
markdownParser = new Marked({ gfm: true, breaks: true });
markdownParser = new Marked({ gfm: true, breaks: true, renderer: { image: renderImageReference } });
return markdownParser;
}
function protectLatexDelimiters(source) {
const rawSource = String(source);
// Inside a block marked did not read as code, its one code construct: marked's own
// code span, from a whole run of backticks (its first not escaped) to the next run
// of the same length, across lines. The caller cuts the text where a span must end.
const INLINE_CODE = /(?<!(?<!\\)(?:\\\\)*[\\`])(`+)(?!`)(?:[^`]|[^`][\s\S]*?[^`])\1(?!`)/g;
// A line whose ``` could still open a fence: the run leads it, after only a quote's or
// list item's marks (marked reads a task box's text as inline), and no backtick follows.
const FENCE_LEAD = /^(?:[ \t]*(?:>|(?:[-+*]|\d{1,9}[.)])[ \t]))*[ \t]*`{3,}[^`]*$/;
// The code elements marked writes; their text holds no raw `<`.
const RENDERED_CODE = /(<code\b[^>]*>[\s\S]*?<\/code>)/;
const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
// Code marked read inside a quote or list item, as [first line, its lines] of the
// top-level block's raw; every other block in it, and each table row, as [first
// line], since a code span never leaves one. marked strips a container's marks
// line by line and lexes what is left, so the children spell that text and its
// line N is raw line N. false when a child cannot be placed so: the caller then
// keeps the whole block literal.
// A task item's text has lost its `[ ] ` box, which marked keeps as the first child
// (a loose item's first paragraph starts with it): the children spell box + text.
function nestedCode(token, line, found) {
const children = token.type === 'list' ? token.items
: token.type === 'blockquote' || token.type === 'list_item' ? token.tokens : [];
const box = token.type === 'list_item' && token.task
? [children?.[0], children?.[0]?.tokens?.[0]].find((child) => child?.type === 'checkbox') : null;
const text = String(box?.raw ?? '') + String((token.type === 'list' ? token.raw : token.text) ?? '');
if (token.type === 'table') token.raw.split('\n').forEach((_, row) => found.push([line + row]));
let offset = 0;
for (const child of children || []) {
if (typeof child?.raw !== 'string' || !text.startsWith(child.raw, offset)) return false;
if (child.type === 'code') {
found.push([line, child.raw.replace(/\n+$/, '').split('\n')]);
} else {
found.push([line]);
if (!nestedCode(child, line, found)) return false;
}
offset += child.raw.length;
line += child.raw.split('\n').length - 1;
}
return true;
}
// The author's text split into [text, isCode] runs: every code block (fenced or
// indented, top-level or nested in a quote or list item) as marked's own lexer
// reads it, then the code spans inside the rest, each within its own block. Adjacent
// prose runs join, so math may still span them, but never across a code block.
function splitAuthorCode(source, parser) {
const blocks = typeof parser?.lexer === 'function' ? parser.lexer(source) : [{ raw: source }];
const runs = [];
const push = (text, code) => {
if (!code && runs.length && !runs.at(-1)[1]) runs.at(-1)[0] += text;
else runs.push([text, code]);
};
const pushProse = (text) => {
let last = 0;
for (const match of text.matchAll(INLINE_CODE)) {
push(text.slice(last, match.index), false);
push(match[0], true);
last = match.index + match[0].length;
}
push(text.slice(last), false);
};
for (const block of blocks) {
if (block.type === 'code') { push(block.raw, true); continue; }
const found = [];
const lines = block.raw.split('\n');
// Each nested code line must end its raw line (after the container's marks).
if (!nestedCode(block, 0, found)
|| found.some(([at, code = []]) => code.some((text, k) => !lines[at + k]?.endsWith(text)))) {
push(block.raw, true);
continue;
}
const starts = [0];
for (const text of lines) starts.push(starts.at(-1) + text.length + 1);
let last = 0;
for (const [at, code = []] of found) {
if (starts[at] < last) continue; // it opens on the last line of the code before it
pushProse(block.raw.slice(last, starts[at]));
last = code.length ? starts[at + code.length] - 1 : starts[at];
if (code.length) push(block.raw.slice(starts[at], last), true);
}
pushProse(block.raw.slice(last));
}
return runs;
}
// Math is parked only outside the author's code, and each block comes back as
// marked would write that same text where it landed: code encodes every `&`,
// prose keeps an entity an entity (the one layer it reads). No `<` returns raw.
// Parked math skips marked's escapes, so `authorText` gives it back its `\<`.
function protectLatexDelimiters(source, parser, authorText = (text) => text) {
const rawSource = String(source).replace(/\r\n?/g, '\n'); // marked's own first step
let tokenStem = 'OUROBOROSLATEX';
while (rawSource.includes(tokenStem)) tokenStem += 'X';
const replacements = [
@ -67,14 +183,31 @@ function protectLatexDelimiters(source) {
['\\]', `${tokenStem}CLOSEBLOCK`],
];
const displayBlocks = [];
const protectedSource = rawSource.split(/(```[\s\S]*?```|`[^`\n]*`)/g)
.map((part, index) => {
if (index % 2 === 1) return part;
const withProtectedBlocks = part.replace(/\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]/g, (block) => {
// Without a math delimiter there is nothing to park and no second lex to pay.
const runs = /\$\$|\\[()[\]]/.test(rawSource) ? splitAuthorCode(rawSource, parser) : [[rawSource, true]];
// What marked will read of the line being written: the code before it and the
// math already parked on it count, since a code span or math may cross lines.
let line = '';
const write = (text) => {
const end = text.lastIndexOf('\n');
line = end < 0 ? line + text : text.slice(end + 1);
return text;
};
const protectedSource = runs
.map(([part, code]) => {
if (code) return write(part);
let last = 0;
const withProtectedBlocks = part.replace(/\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]/g, (block, at) => {
write(part.slice(last, at));
last = at + block.length;
// After a ``` that could open a fence, math keeps that line's backticks in view:
// marked read the line as text for one of them, and parking it would open a fence.
if (/^[^\n]*`/.test(block) && FENCE_LEAD.test(line)) return write(block);
const token = `${tokenStem}DISPLAY${displayBlocks.length}`;
displayBlocks.push([token, block]);
return token;
displayBlocks.push([token, authorText(block)]);
return write(token);
});
write(part.slice(last));
return replacements.reduce(
(value, [delimiter, token]) => value.split(delimiter).join(token),
withProtectedBlocks,
@ -83,13 +216,13 @@ function protectLatexDelimiters(source) {
.join('');
return {
protectedSource,
restore: (html) => displayBlocks.reduceRight(
(value, [token, block]) => value.split(token).join(block),
replacements.reduce(
(value, [delimiter, token]) => String(value).split(token).join(delimiter),
html,
),
),
restore: (html) => String(html).split(RENDERED_CODE).map((part, index) => {
const escape = index % 2 === 1 ? /[&<>"']/g : /[<>"']|&(?!#?\w+;)/g;
return displayBlocks.reduceRight(
(value, [token, block]) => value.split(token).join(block.replace(escape, (char) => HTML_ESCAPES[char])),
replacements.reduce((value, [delimiter, token]) => value.split(token).join(delimiter), part),
);
}).join(''),
};
}
@ -200,13 +333,27 @@ function createCodeBlock(source, language = '') {
return block;
}
function transformRenderedMarkdown(fragment) {
// Levels 4+ have no size of their own (DESIGN.md §1): they read as the
// smallest heading label, exactly as the legacy renderMarkdown demotes them.
function transformRenderedMarkdown(fragment, literal) {
// Levels 4+ carry the smallest label class, as the compact renderMarkdown
// demotes them; chat bubbles still size h4-h6 by element (DESIGN.md §5).
fragment.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((heading) => {
heading.classList.add(`md-h${Math.min(Number(heading.tagName.slice(1)), 3)}`);
});
fragment.querySelectorAll('blockquote').forEach((quote) => quote.classList.add('md-quote'));
fragment.querySelectorAll('.md-image-ref').forEach((reference) => {
const address = chatMarkdownUrl(reference.getAttribute('data-md-image-href') || '');
reference.removeAttribute('data-md-image-href');
// A refused address stays plain text, never a link-styled anchor without
// a destination. Inside an authored link, or around one in its own words,
// that link is already the destination: anchors never nest.
if (!address || reference.closest('a') || reference.querySelector('a')) return;
const link = document.createElement('a');
link.className = 'md-image-ref';
link.setAttribute('href', address);
if (reference.title) link.title = reference.title;
link.append(...reference.childNodes);
reference.replaceWith(link);
});
fragment.querySelectorAll('a').forEach((link) => {
const safe = chatMarkdownUrl(link.getAttribute('href') || '');
if (safe) link.setAttribute('href', safe); else link.removeAttribute('href');
@ -215,7 +362,7 @@ function transformRenderedMarkdown(fragment) {
link.rel = 'noopener noreferrer';
});
fragment.querySelectorAll('code:not(pre code)').forEach((code) => {
code.textContent = decodeHtmlEntities(code.textContent);
code.textContent = literal(code.textContent);
code.classList.add('inline-code');
});
fragment.querySelectorAll('input[type="checkbox"]').forEach((input) => {
@ -237,7 +384,7 @@ function transformRenderedMarkdown(fragment) {
});
fragment.querySelectorAll('pre > code').forEach((code) => {
const language = codeLanguage(code);
const source = decodeHtmlEntities(code.textContent || '');
const source = literal(code.textContent || '');
if (language === 'mermaid' || language === 'chart') {
const richBlock = document.createElement('div');
richBlock.className = `md-${language}`;
@ -251,30 +398,30 @@ function transformRenderedMarkdown(fragment) {
/** Return sanitized, presentation-ready HTML for a chat message. */
export function renderChatMarkdown(text) {
const source = prepareMarkdownSource(text).replace(
/^((?:[ \t]*&gt;)+)/gm,
(markers) => markers.replaceAll('&gt;', '>'),
);
// Without the parser the message reads as the author's exact text.
const plain = () => escapeText(text).replace(/\n/g, '<br>');
const parser = getMarkdownParser();
if (!parser || !globalThis.DOMPurify || typeof document === 'undefined') {
return source.replace(/\n/g, '<br>');
}
if (!parser || !globalThis.DOMPurify || typeof document === 'undefined') return plain();
try {
const latex = protectLatexDelimiters(source);
const { source, lessThan, escapedLessThan } = prepareMarkdownSource(text);
const authorText = (value) => String(value).split(escapedLessThan).join(`\\${lessThan}`);
const latex = protectLatexDelimiters(source, parser, authorText);
const parsed = latex.restore(parser.parse(latex.protectedSource, { async: false }));
const safe = globalThis.DOMPurify.sanitize(parsed, {
USE_PROFILES: { html: true },
// 'input' stays sanitizable so the task-list post-pass can swap checkboxes for inert glyphs; raw HTML is already escaped upstream.
// 'input' stays sanitizable so the task-list post-pass can swap checkboxes for inert glyphs; raw HTML cannot open upstream (no author `<` reaches the parser).
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'img', 'video', 'audio', 'source'],
FORBID_ATTR: ['style', 'src', 'srcset', 'srcdoc', 'onerror', 'onload'],
});
const template = document.createElement('template');
template.innerHTML = safe;
transformRenderedMarkdown(template.content);
// Code text still holds `lessThan` where the author wrote `<` and
// `escapedLessThan` where they wrote `\<`, and only there.
transformRenderedMarkdown(template.content, (value) => authorText(value).split(lessThan).join('<'));
return template.innerHTML;
} catch (error) {
console.warn('renderChatMarkdown: markdown render failed', error);
return source.replace(/\n/g, '<br>');
return plain();
}
}
@ -568,11 +715,110 @@ async function copyCode(code) {
if (!copied) throw new Error('copy command failed');
}
/* A table scrolls sideways only when its columns cannot fit even wrapped. Its
edges follow the actually hidden columns, as `scroll_fade.js` does for the
vertical scroll bodies, and only while it scrolls is its wrapper a named
region the keyboard can focus and scroll. */
function markTableOverflow(wrap) {
if (!wrap || wrap.isConnected === false) return;
const hidden = wrap.scrollWidth - wrap.clientWidth;
const scrolled = Math.abs(wrap.scrollLeft);
const scrolls = hidden > 1;
wrap.toggleAttribute('data-scroll-start', scrolls && scrolled > 1);
wrap.toggleAttribute('data-scroll-end', scrolls && hidden - scrolled > 1);
if (scrolls === wrap.hasAttribute('tabindex')) return;
if (scrolls) {
wrap.setAttribute('role', 'region');
wrap.setAttribute('aria-label', 'Scrollable table');
wrap.tabIndex = 0;
} else {
wrap.removeAttribute('role');
wrap.removeAttribute('aria-label');
wrap.removeAttribute('tabindex');
}
}
// The wrapper attributes `markTableOverflow` decides by. A keyed patch (a task
// timeline row) keeps the wrapper but copies attributes from fresh markup, which
// drops them while nothing resizes: their change re-marks the wrapper.
const TABLE_STATE = ['tabindex', 'data-scroll-start', 'data-scroll-end'];
// The table wrappers at or under a node: a mutation may add the wrapper itself.
function tableWraps(node) {
if (node?.nodeType !== 1) return [];
const nested = Array.from(node.querySelectorAll?.('.md-table-wrap') || []);
return node.matches?.('.md-table-wrap') ? [node, ...nested] : nested;
}
/**
* Keep every Markdown table under `rootEl`, from either renderer and including
* tables written into it later, a keyboard region with fading edges exactly
* while it scrolls. Table-only: nothing else in the root is enhanced, so a
* compact surface's literal code stays as rendered. Returns the disposer;
* `destroyChatMarkdown` on the root or an ancestor releases it too, and binding
* a bound root again returns its disposer.
*/
export function bindMarkdownTables(rootEl) {
if (!rootEl || typeof ResizeObserver !== 'function') return () => {};
const bound = TABLE_BINDINGS.get(rootEl);
if (bound) return bound;
// The wrapper resizes with its column; the table with its own content.
const sizes = new ResizeObserver((entries) => {
for (const entry of entries) markTableOverflow(entry.target.closest('.md-table-wrap'));
});
const observe = (wrap) => {
sizes.observe(wrap);
if (wrap.firstElementChild) sizes.observe(wrap.firstElementChild);
};
const release = (wrap) => {
sizes.unobserve(wrap);
if (wrap.firstElementChild) sizes.unobserve(wrap.firstElementChild);
};
for (const wrap of rootEl.querySelectorAll?.('.md-table-wrap') || []) observe(wrap);
// A surface that writes its Markdown later (a timeline row, a fetched review)
// is followed: a new table is observed, a removed one released. The DOM as it
// stands when the records arrive decides, so a moved table stays observed.
const writes = typeof MutationObserver === 'function' ? new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes') {
if (record.target.classList?.contains('md-table-wrap')) markTableOverflow(record.target);
continue;
}
for (const node of record.removedNodes) {
for (const wrap of tableWraps(node)) if (!rootEl.contains(wrap)) release(wrap);
}
for (const node of record.addedNodes) {
for (const wrap of tableWraps(node)) if (rootEl.contains(wrap)) observe(wrap);
}
}
}) : null;
writes?.observe(rootEl, { childList: true, subtree: true, attributeFilter: TABLE_STATE });
// Scroll events do not bubble; one capturing listener follows every wrapper.
const onScroll = (event) => {
if (event?.target?.classList?.contains('md-table-wrap')) markTableOverflow(event.target);
};
rootEl.addEventListener?.('scroll', onScroll, true);
const dispose = () => {
if (TABLE_BINDINGS.get(rootEl) !== dispose) return;
TABLE_BINDINGS.delete(rootEl);
sizes.disconnect();
writes?.disconnect();
rootEl.removeEventListener?.('scroll', onScroll, true);
rootEl.removeAttribute?.('data-md-tables');
};
TABLE_BINDINGS.set(rootEl, dispose);
// The marker lets `destroyChatMarkdown` find the binding from an ancestor.
rootEl.setAttribute?.('data-md-tables', '');
return dispose;
}
function cleanupState(root, state) {
if (!state || state.destroyed) return;
state.destroyed = true;
state.unsubscribeTheme?.();
state.unsubscribeTheme = null;
state.disposeTables?.();
state.disposeTables = null;
root.removeEventListener('click', state.clickHandler);
for (const chart of state.charts) {
try { chart.destroy(); } catch {}
@ -594,7 +840,7 @@ export function enhanceChatMarkdown(rootEl, { onDomWrite = writeDirectly, onThem
charts: new Set(), timers: new Set(), clickHandler: null, frame: null, destroyed: false,
// Bumped by every repaint so a diagram render still awaiting mermaid from
// the previous palette discards its result instead of racing this one in.
epoch: 0, unsubscribeTheme: null,
epoch: 0, unsubscribeTheme: null, disposeTables: null,
};
state.clickHandler = async (event) => {
const button = event.target?.closest?.('[data-code-copy]');
@ -641,6 +887,7 @@ export function enhanceChatMarkdown(rootEl, { onDomWrite = writeDirectly, onThem
return true;
});
void renderMermaidNodes(rootEl, state, onDomWrite);
if (rootEl.querySelector?.('.md-table-wrap')) state.disposeTables = bindMarkdownTables(rootEl);
const mountCharts = () => {
state.frame = null;
if (!state.destroyed && rootEl.isConnected !== false) {
@ -664,6 +911,10 @@ export function destroyChatMarkdown(rootEl) {
if (ROOT_STATE.has(rootEl)) roots.push(rootEl);
roots.push(...(rootEl.querySelectorAll?.('[data-chat-markdown-enhanced]') || []));
for (const root of new Set(roots)) cleanupState(root, ROOT_STATE.get(root));
// A table-only binding (a compact surface) goes with the same node.
for (const root of [rootEl, ...(rootEl.querySelectorAll?.('[data-md-tables]') || [])]) {
TABLE_BINDINGS.get(root)?.();
}
}
// Any future bubble-removal path in chat.js MUST call destroyChatMarkdown() or Chart

View file

@ -790,6 +790,7 @@ export function updateLiveTimelineItem(record, summary, { ts, rawTs, syntheticKe
fullBody: summary.fullBody || summary.body || it.fullBody || '',
fullRef: summary.fullRef || it.fullRef || '',
truncated: summary.truncated || it.truncated || false,
evidenceRef: summary.evidenceRef || it.evidenceRef || null,
// A call's failure frame replaces its receipt start: the row is
// content again once it reports an error.
receipt: Boolean(summary.receipt),
@ -837,6 +838,8 @@ export function updateLiveTimelineItem(record, summary, { ts, rawTs, syntheticKe
fullBody: summary.fullBody || summary.body || '',
fullRef: summary.fullRef || '',
truncated: summary.truncated || false,
// A late-review row's exact record link (#1369); null on every other row.
evidenceRef: summary.evidenceRef || null,
receipt: Boolean(summary.receipt),
ts: ts || '',
sourceTs: rawTs,

View file

@ -1102,7 +1102,7 @@ export function summarizeLogEvent(evt) {
function chatView({
phase = 'working',
headline = 'Working...',
headline = '', // no placeholder: a frame naming nothing has none; the chip says Working (#1369)
body = '',
fullBody = '',
fullHeadline = '',
@ -1349,7 +1349,8 @@ function summarizeChatLiveEventView(evt) {
const narration = evt.narration === true || evt.narration === undefined;
return chatView({
phase: lifecycleTerminal ? (/failed\b/i.test(progressText.full) ? 'lifecycle_error' : 'done') : 'working',
headline: progressText.preview || 'Working...',
// An empty note is no narration: a placeholder would become the card title as if said (#1369).
headline: progressText.preview || '',
fullHeadline: progressText.full || '',
activityPreview: progressText.preview || '',
visible: Boolean(progressText.preview),

View file

@ -63,7 +63,7 @@ export function setReviewAnchor(record, enabled, writePhase) {
} else {
writePhase(record, 'working');
if (!record.suggestedName && !record.lastHumanHeadline) {
record.titleEl.textContent = 'Working...';
record.titleEl.textContent = ''; // the chip says Working; the title waits for a name (#1369)
}
record.inlineTypingEl.style.display = '';
}
@ -880,11 +880,11 @@ export function taskAcceptanceGroupFromTaskDetail(detail, ownerTaskId = '') {
const verdict = text(panel?.aggregate_signal || 'UNKNOWN');
const roster = (Array.isArray(panel?.actors) ? panel.actors : []).filter((actor) => actor && typeof actor === 'object');
const awaited = roster.some(actorAwaiting);
// An awaited panel is activity only while its own task still runs to collect it;
// afterwards an unanswered slot is a recorded gap, not a degraded verdict.
// An awaited panel is activity only while its own task still runs to collect it; afterwards an unanswered
// slot is a recorded gap. A settled panel without a verdict says so in words; DEGRADED stays in its detail.
const live = awaited && text(detail?.status).toLowerCase() === 'running' && !panel?.superseded && index === panels.length - 1;
const held = { FAIL: 'FAIL', PASS: live ? 'PASS so far' : 'PASS' }[verdict.toUpperCase()] || (live ? 'in progress' : 'no verdict');
const [progress, heldTone] = awaited ? heldProgress(held, roster, (actor) => text(actor.transport_status) === 'success') : ['', ''];
const [progress, heldTone] = awaited || verdict.toUpperCase() === 'DEGRADED' ? heldProgress(held, roster, (actor) => text(actor.transport_status) === 'success') : ['', ''];
// The host composed this sentence; the card prints it verbatim and
// leads the detail with it, because the renderer shows detailText over
// summary. The panel's own verdict and identity are untouched.

View file

@ -428,11 +428,49 @@ export function accountedUpperBoundWithChildren(payload) {
// count as what the reader sees, not as their markup.
export const MARKDOWN_HEADING_MAX_CHARS = 80;
// Code is the author's literal text: a later span, heading, list, link or table
// rule must never see inside a fence or code span. Each one is parked as a token
// of letters and digits the text does not contain (so no later pattern can match
// in or across it) and `restore` puts the parked text back. Parked code is
// markup, so `holds` lets a rule that writes an attribute refuse to carry it.
const CODE_SPAN = /(``|`)(.+?)\1/g;
const CODE_STEM = 'OUROBOROSCODE';
// The token's stem: `OUROBOROSCODE` and a fixed-width tag of capital letters that
// follows no `OUROBOROSCODE` in the text. There are more tags of that width than
// occurrences, so one scan finds a free tag and the stem stays short on any input
// (lengthening it until the text lacked it cost a rescan per letter and grew every
// token with the text).
function codeStem(text) {
const after = [];
for (let at = text.indexOf(CODE_STEM); at >= 0; at = text.indexOf(CODE_STEM, at + 1)) after.push(at + CODE_STEM.length);
let width = 0;
while (26 ** width <= after.length) width += 1;
const taken = new Set(after.map((at) => text.slice(at, at + width)));
const tag = (n) => Array.from({ length: width }, (_, i) => String.fromCharCode(65 + (Math.floor(n / 26 ** i) % 26))).join('');
let free = 0;
while (taken.has(tag(free))) free += 1;
return CODE_STEM + tag(free);
}
function codeStash(text) {
const stem = codeStem(text);
const parked = [];
const pattern = new RegExp(`${stem}(\\d+)${stem}`, 'g');
return {
token: (value) => `${stem}${parked.push(value) - 1}${stem}`,
holds: (value) => String(value).includes(stem),
restore: (value) => String(value).replace(pattern, (_, index) => parked[Number(index)]),
};
}
// What the reader sees of a heading, at either stage of the pipeline. Rendered
// text has already turned matched spans into tags (not visible) and `<`/`&` into
// entities (one character each). Raw text is projected the way the renderer
// would: only MATCHED span pairs and link destinations are invisible; an unmatched
// `*`, a literal `<okay>` or a literal `&amp;` stay visible characters.
// entities (one character each); its code spans are still parked, so `restore`
// reveals them after link targets are gone. Raw text is projected the way the
// renderer would: only MATCHED span pairs and link destinations are invisible; an
// unmatched `*`, a literal `<okay>`, a literal `&amp;` and everything inside a code
// span stay visible characters.
// `[label](url)` → label with one forward cursor: a regex retried from every
// unmatched `[` is quadratic on hostile input, and this runs on live frames.
function withoutLinkTargets(text) {
@ -449,21 +487,23 @@ function withoutLinkTargets(text) {
return out + text.slice(i);
}
function visibleHeadingText(text, rendered) {
const linkless = withoutLinkTargets(text);
function visibleHeadingText(text, rendered, restore = (value) => value) {
const code = rendered ? null : codeStash(text);
const reveal = code ? code.restore : restore;
const linkless = withoutLinkTargets(code ? text.replace(CODE_SPAN, (_, _tick, body) => code.token(body)) : text);
// Past this length no span markup can bring a line under the cap; skipping the
// span regexes keeps a hostile marker run from costing quadratic time.
if (linkless.length > 8 * MARKDOWN_HEADING_MAX_CHARS) return linkless;
if (rendered) return linkless.replace(/<[^>]*>/g, '').replace(/&[#\w]+;/g, 'x');
return linkless.replace(/(``|`)(.+?)\1/g, '$2').replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/~~(.+?)~~/g, '$1');
if (reveal(linkless).length > 8 * MARKDOWN_HEADING_MAX_CHARS) return reveal(linkless);
if (rendered) return reveal(linkless).replace(/<[^>]*>/g, '').replace(/&[#\w]+;/g, 'x');
return reveal(linkless.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/~~(.+?)~~/g, '$1'));
}
function isMarkdownHeading(text, { rendered = false } = {}) {
return visibleHeadingText(text, rendered).length <= MARKDOWN_HEADING_MAX_CHARS;
function isMarkdownHeading(text, { rendered = false, restore } = {}) {
return visibleHeadingText(text, rendered, restore).length <= MARKDOWN_HEADING_MAX_CHARS;
}
function headingOrProse(cls, text, breakAfter = false) {
return isMarkdownHeading(text, { rendered: true })
function headingOrProse(cls, text, breakAfter = false, restore = undefined) {
return isMarkdownHeading(text, { rendered: true, restore })
? `<strong class="${cls}">${text}</strong>${breakAfter ? '<br>' : ''}` : text;
}
@ -544,11 +584,20 @@ export function plainCauseText(value, max = 160) {
return max > 0 && chars.length > max ? `${chars.slice(0, max - 1).join('').trimEnd()}…` : plain;
}
const TABLE_ALIGN = [[/^:-+:$/, 'center'], [/^-+:$/, 'right'], [/^:-+$/, 'left']];
// `[text](address "title")` on one line. The address has no blanks and keeps its
// parentheses balanced one level deep (`wiki/Foo_(bar)`); an optional title in
// quotes or parentheses follows a blank and is not part of it. Any other form is
// not read as a link and stays the author's text, never a guessed address.
const MARKDOWN_LINK = /(!?)\[([^\]]+)\]\([ \t]*((?:[^()\s]|\([^()\s]*\))+)(?:[ \t]+("[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?[ \t]*\)/g;
export function renderMarkdown(text, { inlineHeadingBreaks = false } = {}) {
let html = escapeHtmlText(text);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
const code = codeStash(html);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, _info, body) => code.token(`<pre><code>${body}</code></pre>`));
// One pass for both span forms: a double-backtick span may contain backticks.
html = html.replace(/(``|`)(.+?)\1/g, '<code class="inline-code">$2</code>');
html = html.replace(CODE_SPAN, (_, _tick, body) => code.token(`<code class="inline-code">${body}</code>`));
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
@ -558,15 +607,22 @@ export function renderMarkdown(text, { inlineHeadingBreaks = false } = {}) {
// Timeline labels stay inline, but their following paragraph needs a real
// copyable break when its surface collapses source whitespace.
const heading = (cls) => (match, content, offset, source) => headingOrProse(
cls, content, inlineHeadingBreaks && ['\r', '\n'].includes(source[offset + match.length]),
cls, content, inlineHeadingBreaks && ['\r', '\n'].includes(source[offset + match.length]), code.restore,
);
html = html.replace(/^#{3,6} (.+)$/gm, heading('md-h3'));
html = html.replace(/^## (.+)$/gm, heading('md-h2'));
html = html.replace(/^# (.+)$/gm, heading('md-h1'));
html = html.replace(/^- (.+)$/gm, '<span class="md-li">\u2022 $1</span>');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, text, url) {
// An image is never loaded (#1368); its reference stays a visible link. The
// title is dropped: the anchor keeps its one fixed shape.
// Code restored after the href was escaped would end the attribute with its
// own quote, and this output reaches the page unsanitized: a destination that
// holds parked code is no destination, and its source stays literal text.
html = html.replace(MARKDOWN_LINK, function(source, image, text, url, title = '') {
if (code.holds(url + title)) return source;
const safe = safeExternalUrl(decodeHtmlEntities(url));
return '<a href="' + escapeHtmlAttr(safe) + '" target="_blank" rel="noopener noreferrer" class="md-link">' + text + '</a>';
return '<a href="' + escapeHtmlAttr(safe) + '" target="_blank" rel="noopener noreferrer" class="md-link'
+ (image ? ' md-image-ref">Image: ' : '">') + text + '</a>';
});
html = html.replace(/((?:^\|.+\|$\n?)+)/gm, function(block) {
const rows = block.trim().split('\n').filter(r => r.trim());
@ -575,7 +631,10 @@ export function renderMarkdown(text, { inlineHeadingBreaks = false } = {}) {
let headIdx = -1;
for (let i = 0; i < rows.length; i++) { if (isSep(rows[i])) { headIdx = i; break; } }
if (headIdx < 1) return block;
const parseRow = (r, tag) => '<tr>' + r.trim().replace(/^\||\|$/g, '').split('|').map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>';
const cells = (r) => r.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
// The separator row's colons are the author's column alignment (GFM).
const align = cells(rows[headIdx]).map((c) => TABLE_ALIGN.find(([pattern]) => pattern.test(c))?.[1]);
const parseRow = (r, tag) => '<tr>' + cells(r).map((c, i) => `<${tag}${align[i] ? ` align="${align[i]}"` : ''}>${c}</${tag}>`).join('') + '</tr>';
let t = '<table class="md-table">';
for (let i = 0; i < headIdx; i++) t += '<thead>' + parseRow(rows[i], 'th') + '</thead>';
t += '<tbody>';
@ -583,7 +642,7 @@ export function renderMarkdown(text, { inlineHeadingBreaks = false } = {}) {
t += '</tbody></table>';
return '<div class="md-table-wrap">' + t + '</div>';
});
return html;
return code.restore(html);
}
export function extractVersions(data) {

View file

@ -2269,8 +2269,10 @@ body.resizing-panels { user-select: none; }
display: none;
}
/* The root card width is owned by the CHAT COLUMN and therefore stays late
enough to beat the base 80% rule above. Ordinary bubble gutters are separate. */
/* The one narrow-column rule: any chat column of 620px or less (a phone, a
narrow Project panel) gives bubbles and root cards its full width, without the
wide-screen gutters. It is owned by the COLUMN, not the viewport, and stays
late enough to beat the base 80% rules above. */
@container chatcol (max-width: 620px) {
.chat-bubble {
max-width: 100%;
@ -2651,13 +2653,16 @@ body.resizing-panels { user-select: none; }
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: var(--type-body);
}
/* Code keeps the ink of the text around it: in the accent-tinted assistant
bubble the link colour made a code span read as a link or an error. The
neutral pill alone marks it as code. */
.ui-rich-content .inline-code,
.chat-bubble .message .inline-code {
background: rgba(var(--neutral-rgb), 0.08);
padding: 1px 5px;
border: 1px solid var(--surface-border);
border-radius: var(--radius-xs);
color: var(--accent-light);
color: inherit;
font-size: var(--type-body);
}
.chat-bubble .message del {
@ -2715,26 +2720,43 @@ body.resizing-panels { user-select: none; }
lines, quiz cards, skill review bodies, review attempt details — but the
table styling was scoped to the chat bubble, so every other surface showed
an unseparated run of cells. The class travels with the renderer, like
.md-h1 / .md-quote / .md-link above. */
.md-h1 / .md-quote / .md-link above. A table keeps its grid and fills its
column; cells wrap like prose, so it scrolls sideways only when its columns
cannot fit even wrapped. */
.md-table {
width: max-content;
min-width: 100%;
width: 100%;
border-collapse: collapse;
color: var(--text-primary);
font-size: var(--type-body);
font-variant-numeric: tabular-nums;
}
.md-table th,
.md-table td {
min-width: 140px;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--surface-border);
text-align: left;
white-space: nowrap;
text-align: start;
vertical-align: top;
/* Words stay whole: the bubble's `anywhere` or a timeline row's
`break-word` would let the table layout squeeze a short column (a
figure, a name) to a letter per line instead of scrolling. */
overflow-wrap: normal;
word-break: normal;
}
/* A link, hash or code span has no space to wrap at, so it may break anywhere
rather than widen its column past the bubble. */
.md-table :is(a, code) { overflow-wrap: anywhere; }
/* The author's GFM alignment (marked writes it as `align`, which any rule here
would otherwise override). */
.md-table :is(th, td)[align="left"] { text-align: left; }
.md-table :is(th, td)[align="center"] { text-align: center; }
.md-table :is(th, td)[align="right"] { text-align: right; }
.md-table th {
font-weight: 600;
color: var(--text-primary);
}
/* A full rich answer's table reads at the answer's own text size (DESIGN.md
§5); compact and card tables keep --type-body. */
.chat-bubble .message:where(.ui-rich-content) .md-table { font-size: inherit; }
/* Global markdown rendering classes. */
.md-h1 {
@ -2765,20 +2787,52 @@ body.resizing-panels { user-select: none; }
font-weight: 600;
margin-block: var(--space-3) var(--space-2);
}
/* Chat-scoped heading clamp: a markdown heading inside a chat bubble is a
section label, never a page title (--type-page stays for real page titles). */
/* Compact Markdown in a bubble (a Skill Review report) keeps its dense
labels (DESIGN.md §2): every heading level at --type-body semibold, in the
transcript's established rhythm. */
.chat-bubble .message .md-h1,
.chat-bubble .message .md-h2,
.chat-bubble .message .md-h3 {
font-size: var(--type-body);
font-weight: 600;
}
/* The ordinary transcript keeps its established heading rhythm. */
.chat-bubble .message .md-h1 { margin-block: var(--space-5) var(--space-3); }
.chat-bubble .message .md-h2 { margin-block: var(--space-4) var(--space-2); }
.chat-bubble .message .md-h3 { margin-block: var(--space-3) var(--space-2); }
/* Chat reading ladder (DESIGN.md §1, §5), for a full rich answer only: the
bubble whose message chat.js marks `.ui-rich-content`. A heading there is a
section of the answer, never a page title, and never smaller than the text
it opens: `#` = `##`, `###` one step down, deeper levels at the reading
size; all in the bubble's own ink, with clearly more space above than below.
`:where` keeps the rich scope weightless, so these rules win over the
compact ones above by order and the first/last-child rules below still apply. */
.chat-bubble .message:where(.ui-rich-content) :is(.md-h1, .md-h2, .md-h3) {
color: inherit;
font-weight: 600;
line-height: var(--line-title);
text-wrap: balance;
margin-block: var(--space-5) var(--space-2);
}
.chat-bubble .message:where(.ui-rich-content) :is(.md-h1, .md-h2) { font-size: var(--md-heading-major); }
.chat-bubble .message:where(.ui-rich-content) .md-h3 { font-size: var(--md-heading-minor); }
/* h4-h6 carry .md-h3 (the compact demotion); `:where` keeps this rule no
stronger than the class rule it follows. */
.chat-bubble .message:where(.ui-rich-content) :where(h4, h5, h6).md-h3 {
font-size: inherit;
margin-block: var(--space-4) var(--space-1);
}
.chat-bubble .message > :first-child { margin-block-start: 0; }
.chat-bubble .message > :last-child { margin-block-end: 0; }
/* Bold text never outweighs the heading above it. */
.ui-rich-content :is(strong, b),
.chat-bubble .message :is(strong, b) { font-weight: 600; }
/* A thematic break is a quiet section rule, not the browser's inset double line. */
.ui-rich-content hr {
height: 0;
border: 0;
border-top: 1px solid var(--surface-border);
margin-block: var(--space-5);
}
.md-li {
display: block;
padding-left: 12px;
@ -2798,6 +2852,28 @@ body.resizing-panels { user-select: none; }
-webkit-overflow-scrolling: touch;
max-width: 100%;
scrollbar-color: var(--text-disabled) var(--surface-card-soft);
--table-fade-start: 0px;
--table-fade-end: 0px;
}
/* A table that really scrolls fades on each side that still hides columns, the
`.scroll-fade-y` idiom turned sideways: `data-scroll-start`/`-end` come from
`bindMarkdownTables` (chat_markdown.js), shared by the rich enhancement and
compact surfaces, which also makes that wrapper a focusable region. A mask
fades the content itself, so it reads on every bubble tint. */
.md-table-wrap[data-scroll-start] { --table-fade-start: var(--space-5); }
.md-table-wrap[data-scroll-end] { --table-fade-end: var(--space-5); }
.md-table-wrap:is([data-scroll-start], [data-scroll-end]) {
-webkit-mask-image: linear-gradient(to right, transparent 0, black var(--table-fade-start),
black calc(100% - var(--table-fade-end)), transparent 100%);
mask-image: linear-gradient(to right, transparent 0, black var(--table-fade-start),
black calc(100% - var(--table-fade-end)), transparent 100%);
}
/* Focused, the whole table and its ring show. */
.md-table-wrap:focus-visible {
outline: 2px solid var(--focus-accent-border);
outline-offset: 2px;
-webkit-mask-image: none;
mask-image: none;
}
.md-code-block {
@ -2958,20 +3034,8 @@ body.resizing-panels { user-select: none; }
font-size: 16px !important;
}
.chat-bubble {
max-width: 92%;
}
.chat-bubble.user {
margin-left: 8%;
}
.chat-bubble.assistant,
.chat-bubble.system {
margin-right: 8%;
}
.chat-bubble.progress {
max-width: 100%;
margin-right: 0;
}
/* Bubble width is not set here: the `chatcol` container rule is the one
narrow-column rule, for a phone and a narrow Project panel alike. */
#app {
grid-template-columns: minmax(0, 1fr);

View file

@ -134,10 +134,11 @@ test('a tool frame mints the task card before any census lists the turn: a tool
f.log({ type: 'tool_call_started', tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' }, _is_direct_chat: true });
assert.ok(f.card(), 'the first tool call mints the block');
assert.ok(f.card().querySelector('[data-turn-into-project]'), 'conversion is offered in Main from the first content row');
assert.equal(f.card().querySelector('[data-live-title]').textContent, 'Working...', 'the placeholder title of a running task card');
assert.equal(f.card().querySelector('[data-live-phase]').textContent, 'Working', 'the chip says the running state');
assert.equal(f.card().querySelector('[data-live-title]').textContent, '', 'no title repeats the chip on a running task card (#1369)');
f.log({ type: 'tool_call_finished', tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' }, duration_sec: 0.3, _is_direct_chat: true });
f.census(direct());
assert.equal(f.card().querySelector('[data-live-title]').textContent, 'Working...', 'the census lane fact does not change chrome');
assert.equal(f.card().querySelector('[data-live-title]').textContent, '', 'the census lane fact does not change chrome');
assert.ok(f.card().querySelector('[data-turn-into-project]'));
} finally { f.close(); }
});
@ -168,7 +169,7 @@ test('a direct turn with two successful tools is the task card: ONE evidence row
f.log({ type: 'tool_call_finished', tool: 'web_search', tool_call_id: 'c2', args: { query: 'ouroboros' }, duration_sec: 1.2 });
assert.ok(f.card(), 'the first tool call mints the block live');
assert.ok(f.card().querySelector('[data-turn-into-project]'), 'a working direct turn is offered conversion in Main');
assert.equal(f.card().querySelector('[data-live-title]').textContent, 'Working...', 'the running placeholder title');
assert.equal(f.card().querySelector('[data-live-title]').textContent, '', 'a running card without a name or narration has no title (#1369)');
assert.equal(f.rows().length, 1, 'four frames about two calls are the block\'s one evidence row');
// The stub cannot repaint an in-place patch; the producer states the folded row.
const c1 = { tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' } };
@ -550,7 +551,7 @@ test('a wait-only block carries no title and no conversion until its first row o
f.emit('chat', wait({ role: 'system', revision: 2, state: 'resolved', resolution: 'quota_restored' }));
assert.equal(f.card(), null, 'the block leaves with its attention');
f.log({ type: 'tool_call_started', tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' } });
assert.equal(f.card().querySelector('[data-live-title]').textContent, 'Working...');
assert.equal(f.card().querySelector('[data-live-title]').textContent, '', 'work arrived, but the chip already says Working (#1369)');
assert.ok(f.card().querySelector('[data-turn-into-project]'));
assert.equal(f.status(), 'Thinking...', 'the header keeps the census verdict for a direct turn');
} finally { f.close(); }
@ -576,7 +577,7 @@ test('a coined name titles a direct task card live and stays after its final', (
try {
f.census(direct());
f.log({ type: 'tool_call_started', tool: 'read_file', tool_call_id: 'c1', args: { path: 'README.md' } });
assert.equal(f.card().querySelector('[data-live-title]').textContent, 'Working...');
assert.equal(f.card().querySelector('[data-live-title]').textContent, '');
f.emit('task_named', { task_id: TASK, suggested_name: 'Проверка карточки' });
assert.equal(f.card().querySelector('[data-live-title]').textContent, 'Проверка карточки');
f.emit('chat', { ...final, tool_calls: 1 });

View file

@ -1114,7 +1114,8 @@ test('a direct turn renders tool work as an activity block and needs host author
const card = walkCard(messages, 'eph-1');
assert.ok(card, 'real tool work reveals the activity block');
assert.ok(convertButton(card), 'a working direct turn is offered conversion in Main');
assert.equal(card.querySelector('[data-live-title]').textContent, 'Working...', 'the running placeholder title');
assert.equal(card.querySelector('[data-live-phase]').textContent, 'Working', 'the chip states the running state');
assert.equal(card.querySelector('[data-live-title]').textContent, '', 'no title repeats the chip (#1369)');
assert.equal(card.querySelector('[data-cancel-run]'), null, 'no host cancelable marker: no Cancel');
handlers.get('chat')({
chat_id: 1, role: 'assistant', is_progress: true,

View file

@ -2,24 +2,42 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
bindMarkdownTables,
chatMarkdownUrl,
destroyChatMarkdown,
enhanceChatMarkdown,
parseChartConfig,
prepareMarkdownSource,
renderChatMarkdown,
} from '../modules/chat_markdown.js';
test('prepareMarkdownSource decodes one entity layer and escapes raw text', () => {
test('prepareMarkdownSource neutralizes only raw HTML and leaves entities to the parser', () => {
// `<` becomes a reference the text does not contain; `&` and `>` stay the
// author's, so marked reads entities in prose and keeps code literal.
const cases = [
['<tag>&', '&lt;tag&gt;&amp;'],
['<tag>&', '&#60;tag>&'],
['&lt;tag&gt;&amp;', '&lt;tag&gt;&amp;'],
['&amp;lt;tag&amp;gt;&amp;amp;', '&amp;lt;tag&amp;gt;&amp;amp;'],
['Fish & chips &amp; tea', 'Fish &amp; chips &amp; tea'],
['`<b>&lt;x&gt;&`', '`&lt;b&gt;&lt;x&gt;&amp;`'],
['> quote with a <b> tag', '> quote with a &#60;b> tag'],
['`<b>&lt;x&gt;&`', '`&#60;b>&lt;x&gt;&`'],
// An autolink to an address a link may carry opens no HTML and keeps its `<`.
['<https://e.com/a> <MAILTO:a@e.com> <https://e.com/a b> <std::x> <javascript:x>',
'<https://e.com/a> <MAILTO:a@e.com> &#60;https://e.com/a b> &#60;std::x> &#60;javascript:x>'],
// An escaped `<` takes its backslash into its own stand-in; backslashes pair as marked pairs them.
['\\<b> \\\\<i> \\\\\\<u> `\\<c>` \\a \\<https://e.com/a>',
'&#060;b> \\\\&#60;i> \\\\&#060;u> `&#060;c>` \\a \\<https://e.com/a>'],
];
for (const [source, expected] of cases) {
assert.equal(prepareMarkdownSource(source), expected, source);
assert.deepEqual(prepareMarkdownSource(source), {
source: expected, lessThan: '&#60;', escapedLessThan: '&#060;',
}, source);
}
// An author who writes a reference itself keeps it: each stand-in moves on.
assert.deepEqual(prepareMarkdownSource('&#60; and &#060; < \\<'), {
source: '&#60; and &#060; &#0060; &#00060;', lessThan: '&#0060;', escapedLessThan: '&#00060;',
});
assert.deepEqual(prepareMarkdownSource('&#060; < \\<'), {
source: '&#060; &#60; &#0060;', lessThan: '&#60;', escapedLessThan: '&#0060;',
});
});
test('renderChatMarkdown restores 11th+ display-math blocks without sentinel prefix collisions', () => {
@ -231,3 +249,209 @@ test('chart success and error writes use the supplied viewport boundary', () =>
globalThis.Chart = prior.Chart;
}
});
test('a table is a focusable region with faded edges only while it scrolls, and the disposer releases it', () => {
const prior = { ResizeObserver: globalThis.ResizeObserver, document: globalThis.document };
const observed = [];
let callback = null;
let disconnected = false;
globalThis.ResizeObserver = class {
constructor(fn) { callback = fn; }
observe(node) { observed.push(node); }
disconnect() { disconnected = true; }
};
const attributes = new Map();
const table = {};
const wrap = {
isConnected: true, scrollWidth: 900, clientWidth: 300, scrollLeft: 0, firstElementChild: table,
classList: { contains: (name) => name === 'md-table-wrap' },
hasAttribute: (name) => attributes.has(name),
setAttribute: (name, value) => attributes.set(name, String(value)),
removeAttribute: (name) => attributes.delete(name),
toggleAttribute: (name, force) => { if (force) attributes.set(name, ''); else attributes.delete(name); return force; },
set tabIndex(value) { attributes.set('tabindex', String(value)); },
};
table.closest = () => wrap;
wrap.closest = () => wrap;
const listeners = new Map();
const root = {
isConnected: true, matches: () => false,
querySelector: (selector) => selector === '.md-table-wrap' ? wrap : null,
querySelectorAll: (selector) => selector === '.md-table-wrap' ? [wrap] : [],
setAttribute() {}, removeAttribute() {},
addEventListener: (type, fn, capture) => listeners.set(`${type}:${Boolean(capture)}`, fn),
removeEventListener: (type, fn, capture) => {
if (listeners.get(`${type}:${Boolean(capture)}`) === fn) listeners.delete(`${type}:${Boolean(capture)}`);
},
};
globalThis.document = { createElement: () => ({}) };
try {
const dispose = enhanceChatMarkdown(root);
assert.deepEqual(observed, [wrap, table], 'the wrapper and its table are observed');
callback([{ target: wrap }]);
assert.deepEqual(Object.fromEntries(attributes), {
'data-scroll-end': '', role: 'region', 'aria-label': 'Scrollable table', tabindex: '0',
});
wrap.scrollLeft = 600;
listeners.get('scroll:true')({ target: wrap });
assert.equal(attributes.has('data-scroll-start'), true);
assert.equal(attributes.has('data-scroll-end'), false, 'scrolled to the end, nothing hides there');
wrap.scrollWidth = 300;
wrap.scrollLeft = 0;
callback([{ target: table }]);
assert.deepEqual(Object.fromEntries(attributes), {}, 'a table that fits is plain content again');
dispose();
assert.equal(disconnected, true);
assert.equal(listeners.has('scroll:true'), false);
} finally {
globalThis.ResizeObserver = prior.ResizeObserver;
globalThis.document = prior.document;
}
});
function fakeTableWrap(name) {
const attributes = new Map();
const table = { name: `${name} table` };
const wrap = {
name, nodeType: 1, isConnected: true, scrollWidth: 900, clientWidth: 300, scrollLeft: 0,
firstElementChild: table, attributes,
matches: (selector) => selector === '.md-table-wrap',
querySelectorAll: () => [],
classList: { contains: (className) => className === 'md-table-wrap' },
hasAttribute: (key) => attributes.has(key),
setAttribute: (key, value) => attributes.set(key, String(value)),
removeAttribute: (key) => attributes.delete(key),
toggleAttribute: (key, force) => { if (force) attributes.set(key, ''); else attributes.delete(key); return force; },
set tabIndex(value) { attributes.set('tabindex', String(value)); },
};
table.closest = () => wrap;
wrap.closest = () => wrap;
return wrap;
}
test('a compact surface binds only its tables, follows later writes, and an ancestor destroy releases it', () => {
const prior = { ResizeObserver: globalThis.ResizeObserver, MutationObserver: globalThis.MutationObserver };
const observed = new Set();
let resized = null;
let written = null;
let watching = null;
globalThis.ResizeObserver = class {
constructor(fn) { resized = fn; }
observe(node) { observed.add(node); }
unobserve(node) { observed.delete(node); }
disconnect() { observed.clear(); }
};
globalThis.MutationObserver = class {
constructor(fn) { written = fn; }
observe(node, options) { watching = { node, options }; }
disconnect() { watching = null; }
};
const first = fakeTableWrap('first');
const later = fakeTableWrap('later');
const inside = new Set([first]);
// A timeline row that arrives later carrying a table; a text node carries none.
const row = { nodeType: 1, matches: () => false, querySelectorAll: (selector) => selector === '.md-table-wrap' ? [later] : [] };
const text = { nodeType: 3 };
const markers = new Map();
const listeners = new Map();
const root = {
nodeType: 1,
querySelectorAll: (selector) => selector === '.md-table-wrap' ? [...inside] : [],
contains: (node) => inside.has(node),
setAttribute: (key, value) => markers.set(key, value),
removeAttribute: (key) => markers.delete(key),
addEventListener: (type, fn, capture) => listeners.set(`${type}:${Boolean(capture)}`, fn),
removeEventListener: (type, fn, capture) => {
if (listeners.get(`${type}:${Boolean(capture)}`) === fn) listeners.delete(`${type}:${Boolean(capture)}`);
},
};
const page = {
querySelectorAll: (selector) => selector === '[data-md-tables]' && markers.has('data-md-tables') ? [root] : [],
};
const names = () => [...observed].map((node) => node.name).sort();
try {
const dispose = bindMarkdownTables(root);
assert.equal(bindMarkdownTables(root), dispose, 'binding a bound root again returns its disposer');
assert.deepEqual(names(), ['first', 'first table']);
assert.deepEqual(watching, { node: root, options: {
childList: true, subtree: true, attributeFilter: ['tabindex', 'data-scroll-start', 'data-scroll-end'],
} });
assert.equal(markers.has('data-md-tables'), true);
assert.equal(listeners.has('scroll:true'), true);
inside.add(later);
written([{ addedNodes: [row, text], removedNodes: [] }]);
assert.deepEqual(names(), ['first', 'first table', 'later', 'later table'], 'a table written later is observed');
resized([{ target: later.firstElementChild }]);
assert.deepEqual(Object.fromEntries(later.attributes), {
'data-scroll-end': '', role: 'region', 'aria-label': 'Scrollable table', tabindex: '0',
}, 'an overflowing compact table is a keyboard region with its hidden side faded');
later.scrollLeft = 600;
// A host's own tests may call every scroll listener with no event.
assert.doesNotThrow(() => listeners.get('scroll:true')());
listeners.get('scroll:true')({ target: later });
assert.deepEqual([later.attributes.has('data-scroll-start'), later.attributes.has('data-scroll-end')], [true, false]);
// A keyed row patch keeps the wrapper but copies fresh markup's attributes:
// nothing resizes, and the removal itself re-marks the region.
later.attributes.clear();
written([{ type: 'attributes', target: later, attributeName: 'tabindex', addedNodes: [], removedNodes: [] }]);
assert.deepEqual(Object.fromEntries(later.attributes), {
'data-scroll-start': '', role: 'region', 'aria-label': 'Scrollable table', tabindex: '0',
});
// A replaced table is released; one moved within the root stays observed.
inside.delete(first);
written([{ addedNodes: [], removedNodes: [first] }, { addedNodes: [row], removedNodes: [row] }]);
assert.deepEqual(names(), ['later', 'later table']);
destroyChatMarkdown(page);
assert.equal(observed.size, 0);
assert.equal(watching, null);
assert.equal(listeners.has('scroll:true'), false);
assert.equal(markers.has('data-md-tables'), false);
dispose();
const again = bindMarkdownTables(root);
assert.notEqual(again, dispose, 'a released root binds afresh');
again();
assert.equal(markers.has('data-md-tables'), false);
} finally {
globalThis.ResizeObserver = prior.ResizeObserver;
globalThis.MutationObserver = prior.MutationObserver;
}
});
test('a chat instance binds its own column, so compact tables need no caller of their own', async () => {
const { installDom, restoreDom } = await import('./chat_dom_fixture.js');
const { createChatInstance } = await import('../modules/chat.js');
const { prior, mount } = installDom();
let instance;
try {
instance = createChatInstance({
ws: { on: () => () => {}, isConnected: () => true, send() {} },
state: { activePage: 'chat', projectChatIds: new Set(), unreadCount: 0 }, updateUnreadBadge() {},
stateSnapshots: { begin: () => ({ generation: 1, requestedAt: Date.now() }),
gate() { return Promise.resolve(this.begin()); }, isCurrent: () => true, apply() {} },
chatId: 2, idPrefix: 'chat', mountEl: mount, asPanel: true,
});
const column = globalThis.document.byId.get('chat-messages');
assert.equal(column.attributes.has('data-md-tables'), true, 'createChatInstance bound the column');
assert.equal(bindMarkdownTables(column), bindMarkdownTables(column), 'one binding per column');
// chat.js's own scroll listeners (its reading gestures last) keep their order; the table binding registers after them.
assert.deepEqual(column.listeners.get('scroll').slice(-2).map(fn => fn.name), ['noteBoxScroll', 'onScroll']);
} finally { instance?.destroy(); restoreDom(prior); }
});
test('without ResizeObserver the table binding is inert', () => {
const prior = globalThis.ResizeObserver;
delete globalThis.ResizeObserver;
try {
const touched = [];
const root = new Proxy({}, { get: (_, key) => { touched.push(key); return undefined; } });
const dispose = bindMarkdownTables(root);
assert.equal(typeof dispose, 'function');
dispose();
assert.deepEqual(touched, []);
} finally {
if (prior) globalThis.ResizeObserver = prior;
}
});

View file

@ -49,9 +49,14 @@ test('legacy renderMarkdown pins GFM-style pipe-table shape and quirks', () => {
'| one | two |',
'| three | four |',
].join('\n');
// The separator's colons are the author's alignment, written as marked writes it.
assert.equal(
renderMarkdown(source),
'<div class="md-table-wrap"><table class="md-table"><thead><tr><th>First</th><th>Second</th></tr></thead><tbody><tr><td>one</td><td>two</td></tr><tr><td>three</td><td>four</td></tr></tbody></table></div>',
'<div class="md-table-wrap"><table class="md-table"><thead><tr><th align="left">First</th><th align="right">Second</th></tr></thead><tbody><tr><td align="left">one</td><td align="right">two</td></tr><tr><td align="left">three</td><td align="right">four</td></tr></tbody></table></div>',
);
assert.equal(
renderMarkdown('| a | b |\n| --- | :-: |\n| 1 | 2 |'),
'<div class="md-table-wrap"><table class="md-table"><thead><tr><th>a</th><th align="center">b</th></tr></thead><tbody><tr><td>1</td><td align="center">2</td></tr></tbody></table></div>',
);
assert.equal(renderMarkdown('| lone | row |'), '| lone | row |');
});
@ -59,7 +64,7 @@ test('legacy renderMarkdown pins GFM-style pipe-table shape and quirks', () => {
test('legacy renderMarkdown routes links through safeExternalUrl', () => {
assert.equal(
renderMarkdown('[Web](https://example.com/docs) [Mail](mailto:owner@example.com) [Bad](javascript:alert(1))'),
'<a href="https://example.com/docs" target="_blank" rel="noopener noreferrer" class="md-link">Web</a> <a href="mailto:owner@example.com" target="_blank" rel="noopener noreferrer" class="md-link">Mail</a> <a href="#" target="_blank" rel="noopener noreferrer" class="md-link">Bad</a>)',
'<a href="https://example.com/docs" target="_blank" rel="noopener noreferrer" class="md-link">Web</a> <a href="mailto:owner@example.com" target="_blank" rel="noopener noreferrer" class="md-link">Mail</a> <a href="#" target="_blank" rel="noopener noreferrer" class="md-link">Bad</a>',
);
});

View file

@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
// The real vendored parser: whether a block is code is marked's reading, not a guess.
// The sanitizer and the DOM post-pass are the browser's (test_markdown_reading_browser).
const priorMarked = globalThis.marked;
const priorDocument = globalThis.document;
const priorPurify = globalThis.DOMPurify;
vm.runInThisContext(readFileSync(new URL('../marked.min.js', import.meta.url), 'utf8'));
globalThis.document = {
createElement: () => ({
content: { querySelectorAll: () => [] },
get innerHTML() { return this.value; },
set innerHTML(value) { this.value = String(value); },
}),
};
globalThis.DOMPurify = { sanitize: (html) => html };
const { prepareMarkdownSource, renderChatMarkdown } = await import('../modules/chat_markdown.js');
test.after(() => {
globalThis.marked = priorMarked;
globalThis.document = priorDocument;
globalThis.DOMPurify = priorPurify;
});
const plainMarked = new globalThis.marked.Marked({ gfm: true, breaks: true });
const markedAlone = (text) => plainMarked.parse(prepareMarkdownSource(text).source);
const MATHLIKE = '$$ a &amp; b <https://x.test/?q=1> \\(c\\) \\[d\\] $$';
test('code holding math-like text is exactly the code marked writes, with its URL and entity literal', () => {
const cases = [
`~~~\n${MATHLIKE}\n~~~`,
`~~~~tex\n~~~\n${MATHLIKE}\n~~~~`,
` ${MATHLIKE}`,
`\t${MATHLIKE}`,
`\`\`\`\n${MATHLIKE}\n\`\`\``,
`Span \`\`${MATHLIKE} \` tick\`\` done`,
`> quote\n>\n> ${MATHLIKE}`,
`- item\n\n ${MATHLIKE}`,
`- item\n\n ~~~\n ${MATHLIKE}\n ~~~`,
];
for (const source of cases) {
const html = renderChatMarkdown(source);
assert.equal(html, markedAlone(source), source);
assert.match(html, /&lt;https:\/\/x\.test\/\?q=1&gt;/, source);
assert.match(html, /&amp;amp;/, source);
assert.doesNotMatch(html, /<https:|<a\b|OUROBOROSLATEX/, source);
}
});
test('a $$ in one code block never pairs with one outside it', () => {
for (const fence of [['~~~sh\n', '\n~~~'], [' ', ''], ['```sh\n', '\n```']]) {
const block = `${fence[0]}echo $$${fence[1]}`;
const source = `${block}\n\nmiddle **prose** $$x_1$$\n\n${block}`;
const html = renderChatMarkdown(source);
assert.equal(html.match(/<pre><code[^>]*>echo \$\$\n<\/code><\/pre>/g)?.length, 2, source);
assert.match(html, /<p>middle <strong>prose<\/strong> \$\$x_1\$\$<\/p>/, source);
}
});
test('prose math stays the author text and never restores a raw tag', () => {
const html = renderChatMarkdown('see $$ a <https://x.test> &amp; b_1 *c* $$ and \\(x_1 * y\\)');
assert.equal(html, '<p>see $$ a &lt;https://x.test&gt; &amp; b_1 *c* $$ and \\(x_1 * y\\)</p>\n');
// Display math may still span a blank line when no code lies between.
assert.equal(renderChatMarkdown('$$a_1\n\nb_2$$'), '<p>$$a_1\n\nb_2$$</p>\n');
assert.equal(renderChatMarkdown('$$ "q" \'s\' $$'), '<p>$$ &quot;q&quot; &#39;s&#39; $$</p>\n');
});
test('an escaped < reads as < in prose and keeps its backslash in parked math', () => {
// The browser reads `&#060;` as `<` (test_markdown_reading_browser checks the page); marked keeps
// references as written, so the prose here shows the stand-in, never `&amp;` before it.
assert.equal(renderChatMarkdown('\\<tag> 5 \\< 6 a\\\\<b>'), '<p>&#060;tag&gt; 5 &#060; 6 a\\&#60;b&gt;</p>\n');
assert.equal(renderChatMarkdown('$$a \\< b$$ \\<t>'), '<p>$$a \\&#60; b$$ &#060;t&gt;</p>\n');
});
test('a $$ in code nested in a quote or list item never pairs with the prose after it', () => {
// The nested code keeps marked's literal bytes (its URL, image and delimiters inert);
// the prose after it keeps its Markdown and its math, parked so marked cannot read `*c*`.
const code = 'echo $$ <https://x.test> ![i](https://x.test/i.png) \\(';
const prose = 'after **bold** $$b_1 *c*$$';
for (const source of [
`> ${code}\n> ${prose}`,
`> quote\n> > deep\n> >\n> > ${code}\n> ${prose}`,
`- item\n\n ${code}\n\n ${prose}`,
`- [ ] task\n\n ${code}\n\n ${prose}`,
`1. [x] task\n\n ~~~\n ${code}\n ~~~\n\n ${prose}`,
]) {
const html = renderChatMarkdown(source);
assert.equal(html, markedAlone(source).replace('$$b_1 <em>c</em>$$', () => '$$b_1 *c*$$'), source);
assert.match(html, /<pre><code>echo \$\$ &lt;https:\/\/x\.test&gt; !\[i\]\(https:\/\/x\.test\/i\.png\) \\\(\n<\/code><\/pre>/, source);
assert.match(html, /<p>after <strong>bold<\/strong> \$\$b_1 \*c\*\$\$<\/p>/, source);
assert.doesNotMatch(html, /<img|<a\b|<https:|OUROBOROSLATEX/, source);
}
});
test('a task item keeps its checkbox and its prose, and its math stays the author text', () => {
// marked moves a task's `[ ] ` out of the item's text into a checkbox child.
const box = (checked) => `<input ${checked ? 'checked="" ' : ''}disabled="" type="checkbox">`;
for (const [source, html] of [
['- [ ] Compute \\(x_1 + 1\\)', `<ul>\n<li>${box(false)} Compute \\(x_1 + 1\\)</li>\n</ul>\n`],
['- [x] Compute \\(x_1 + 1\\)', `<ul>\n<li>${box(true)} Compute \\(x_1 + 1\\)</li>\n</ul>\n`],
['- [ ] Compute $$x_1 *c*$$', `<ul>\n<li>${box(false)} Compute $$x_1 *c*$$</li>\n</ul>\n`],
// A loose item carries its box inside its first paragraph.
['- [X] **one** \\[a_1\\]\n\n- [ ] two $$b *c*$$',
`<ul>\n<li><p>${box(true)} <strong>one</strong> \\[a_1\\]</p>\n</li>\n<li><p>${box(false)} two $$b *c*$$</p>\n</li>\n</ul>\n`],
['> 1. [x] quoted \\(q\\)\n> - [ ] nested `\\(c\\)` $$i *c*$$',
`<blockquote>\n<ol>\n<li>${box(true)} quoted \\(q\\)<ul>\n<li>${box(false)} nested <code>\\(c\\)</code> $$i *c*$$</li>\n</ul>\n</li>\n</ol>\n</blockquote>\n`],
]) {
assert.equal(renderChatMarkdown(source), html, source);
}
});
// Display math marked alone would read as Markdown; the renderer parks it as the author wrote it.
const parked = (html) => html.replace(/\$\$([xy])_1 <em>c<\/em>\$\$/, (_, name) => `$$${name}_1 *c*$$`);
test('a code span marked reads across lines keeps its $$ from pairing with the prose math after it', () => {
// Each span holds one $$ and runs of other lengths; only a run of its own length ends it.
for (const n of [1, 2, 3, 4]) {
const [run, shorter, longer] = [n, n - 1, n + 1].map((length) => '`'.repeat(length));
const code = `echo $$ ${shorter} <https://x.test/?q=1> &amp; ![i](https://x.test/i.png)`;
for (const source of [
`Run ${run}${code}\nnext ${longer} $$ tail${run} then $$x_1 *c*$$`,
`> Run ${run}${code}\n> next ${longer} $$ tail${run} then $$x_1 *c*$$`,
`- Run ${run}${code}\n next ${longer} $$ tail${run} then $$x_1 *c*$$`,
`Run ${run}${code}\nnext ${longer} $$ tail${run} then $$x_1 *c*$$\n---`,
]) {
const html = renderChatMarkdown(source);
assert.equal(html, parked(markedAlone(source)), source);
assert.ok(html.includes(`<code>echo $$ ${shorter} &lt;https://x.test/?q=1&gt; &amp;amp; `
+ `![i](https://x.test/i.png) next ${longer} $$ tail</code> then $$x_1 *c*$$`), source);
assert.doesNotMatch(html, /<a\b|<img|<em>|OUROBOROSLATEX/, source);
}
}
});
test('a code span ends with its paragraph, list item or table row, so math after an open run is parked', () => {
for (const source of [
'a `x $$y_1 *c*$$\n\nb` z',
'- a `x $$y_1 *c*$$\n- b` z',
'> a `x $$y_1 *c*$$\n>\n> b` z',
'| a | `x $$y_1 *c*$$ |\n| - | - |\n| b` | z |',
]) {
const html = renderChatMarkdown(source);
assert.equal(html, parked(markedAlone(source)), source);
assert.doesNotMatch(html, /<code>|<em>/, source);
}
});
test('an escaped backtick opens no span, and math that is a ``` line\'s last backtick stays in view', () => {
assert.equal(renderChatMarkdown('\\``a $$` then $$x_1 *c*$$'), '<p>`<code>a $$</code> then $$x_1 *c*$$</p>\n');
assert.equal(renderChatMarkdown('\\\\`a $$\nb` then $$x_1 *c*$$'), '<p>\\<code>a $$ b</code> then $$x_1 *c*$$</p>\n');
// marked reads the first line as text: parking its `$$`$$` away would open a fence over the rest.
assert.equal(renderChatMarkdown('``` $$`$$\n**bold** $$x_1 *c*$$'),
'<p>``` $$`$$<br><strong>bold</strong> $$x_1 *c*$$</p>\n');
});
test('only a ``` that could open a fence keeps its line\'s math in view', () => {
for (const [source, html] of [
// It leads the line marked reads, after a quote's or list's marks or a code span's end.
['> ``` $$`$$\n> **bold** $$x_1 *c*$$',
'<blockquote>\n<p>``` $$`$$<br><strong>bold</strong> $$x_1 *c*$$</p>\n</blockquote>\n'],
['1) ``` $$a$$ \\[`\\]\n **bold** $$x_1 *c*$$',
'<ol>\n<li>``` $$a$$ \\[`\\]<br><strong>bold</strong> $$x_1 *c*$$</li>\n</ol>\n'],
['x ```a\n``` $$`$$\n**bold** $$x_1 *c*$$',
'<p>x <code>a </code> $$`$$<br><strong>bold</strong> $$x_1 *c*$$</p>\n'],
// Anywhere else it is text however the math is parked: mid-line, after a code span or
// parked math, or in a task box's text, which marked reads inline.
['Mention ``` literally; compute $$x_1 *c* + \\text{`q\'}$$',
'<p>Mention ``` literally; compute $$x_1 *c* + \\text{`q&#39;}$$</p>\n'],
['`a` ``` $$x_1 *c* `$$', '<p><code>a</code> ``` $$x_1 *c* `$$</p>\n'],
['$$a\n``` b$$ $$x_1 *c* `$$', '<p>$$a\n``` b$$ $$x_1 *c* `$$</p>\n'],
['- [ ] ``` \\[x_1 *c* `\\]',
'<ul>\n<li><input disabled="" type="checkbox"> ``` \\[x_1 *c* `\\]</li>\n</ul>\n'],
]) {
assert.equal(renderChatMarkdown(source), html, source);
}
});

View file

@ -662,11 +662,14 @@ test('render arm order and enhancement guard are pinned in source', () => {
);
});
test('chat bubble heading clamp is scoped in style.css', () => {
// Inside chat bubbles every markdown heading is a subsection label at body
// size (DESIGN.md §2); the global md-h1 page-size rule stays for non-chat
// surfaces, and the live-card timeline carries its own inline clamp.
test('chat bubble heading ladder is scoped in style.css', () => {
// Only a full rich answer (`.message.ui-rich-content`) follows the reading
// ladder (DESIGN.md §1, §5); compact Markdown in a bubble (a Skill Review
// report) keeps every heading a body-size semibold label; the global md-h1
// page-size rule stays for non-chat surfaces, and the live-card timeline
// carries its own inline clamp.
assert.match(styleSource, /\.chat-bubble \.message \.md-h1,\n\.chat-bubble \.message \.md-h2,\n\.chat-bubble \.message \.md-h3 \{\n\s+font-size: var\(--type-body\);\n\s+font-weight: 600;\n\}/);
assert.match(styleSource, /\n\.chat-bubble \.message:where\(\.ui-rich-content\) :is\(\.md-h1, \.md-h2\) \{ font-size: var\(--md-heading-major\); \}\n\.chat-bubble \.message:where\(\.ui-rich-content\) \.md-h3 \{ font-size: var\(--md-heading-minor\); \}\n/);
// The timeline label follows its row's size: collapsed rows are meta size,
// an expanded row is body size (DESIGN.md §5, "summary outranks details").
// Unambiguous block scan (indent, then a non-space start): the `(\s+[^\n]+\n)*`
@ -678,7 +681,7 @@ test('chat bubble heading clamp is scoped in style.css', () => {
assert.match(styleSource, decl('\\.chat-live-line-title', 'font-size: var\\(--type-meta\\);'));
assert.match(styleSource, decl('\\.chat-live-line\\[data-expanded="1"\\] \\.chat-live-line-body', 'font-size: var\\(--type-body\\);'));
assert.match(styleSource, decl('\\.chat-live-activity', 'font-size: var\\(--type-body\\);'));
// The rich bubble renderer demotes h4-h6 to the smallest label so the clamp reaches them.
// The rich bubble renderer gives h4-h6 the smallest label class, as the compact one demotes them.
const richSource = readFileSync(new URL('../modules/chat_markdown.js', import.meta.url), 'utf8');
assert.match(richSource, /querySelectorAll\('h1, h2, h3, h4, h5, h6'\)[\s\S]{0,160}Math\.min\(Number\(heading\.tagName\.slice\(1\)\), 3\)/);
});

View file

@ -0,0 +1,219 @@
// Owner-facing host notices (#1369): a late-review row placed in a task card
// offers the exact applied review record it names through the existing artifact
// route and nothing for a pointer of another shape; a running card's status chip
// says Working while the title waits for a coined name or the turn's own
// narration, so no placeholder repeats the chip and no empty note poses as
// narration. Authored titles keep their words even when the word is Working.
import assert from 'node:assert/strict';
import test from 'node:test';
import { createChatInstance } from '../modules/chat.js';
import { summarizeChatLiveEvent } from '../modules/log_events.js';
import { taskSourceDownloadUrl } from '../modules/api_client.js';
import { evidenceLinkHtml } from '../modules/chat_activity.js';
import { installDom, restoreDom, walkCard } from './chat_dom_fixture.js';
const HASH = 'a'.repeat(64);
const SOURCE_REF = { kind: 'task_source', root: 'artifact_store', sha256: HASH, size: 75000,
path: `source_handles/context_checkpoints/acceptance-${HASH}.json` };
const NOTE = 'On the delivered version of this answer, reviewers later passed it.\n'
+ '- openai/gpt-5.5: passed it — The budget section is complete.';
const lateRow = (taskId, evidence, extra = {}) => ({
chat_id: 2, role: 'system', system_type: 'acceptance_late_settlement', task_id: taskId,
card_row: 'reviews', card_row_id: `acceptance-late:${taskId}`, content: NOTE,
ts: '2026-09-28T00:06:00Z', ...(evidence ? { late_evidence: evidence } : {}), ...extra,
});
const evidenceFor = (taskId, ref = SOURCE_REF) => ({ task_id: taskId, panel_id: 'panel_1',
settled_at: '2026-09-28T00:06:00Z', reviewed_revision: 'delivered', reviewed_is_emitted: true, source_ref: ref });
const walkNodes = (node) => [node, ...(node?.children || []).flatMap(walkNodes)];
const timelineLines = (card) => walkNodes(card).filter((node) => node.classList.contains('chat-live-line'));
// The flat fixture parses innerHTML without text; read the markup the renderer built.
function captureRenderedLines() {
const doc = globalThis.document;
const create = doc.createElement.bind(doc);
const built = [];
doc.createElement = (tag) => { const node = create(tag); built.push(node); return node; };
return () => built.map((node) => node.innerHTML).filter((html) => html.includes('class="chat-live-line '));
}
function openChat(fetchImpl) {
const { prior, mount } = installDom(fetchImpl);
const handlers = new Map();
const ws = { on(type, fn) { handlers.set(type, fn); return () => handlers.delete(type); }, isConnected: () => true, send() {} };
const instance = createChatInstance({
ws, state: { activePage: 'chat', projectChatIds: new Set(), unreadCount: 0 },
updateUnreadBadge() {}, stateSnapshots: { begin: () => ({ generation: 1, requestedAt: Date.now() }), gate() { return Promise.resolve(this.begin()); },
isCurrent: () => true, apply() {} },
chatId: 2, idPrefix: 'chat', mountEl: mount, asPanel: true,
});
const emit = (row) => handlers.get('chat')({ chat_id: 2, ts: '2026-09-28T00:00:00Z', ...row });
const hostNote = (taskId) => emit({ role: 'system', is_progress: true, task_id: taskId, content: 'Checkpoint saved.', narration: false });
return { prior, instance, handlers, emit, hostNote, messages: () => globalThis.document.byId.get('chat-messages') };
}
test('a late-review row offers its exact applied review record through the artifact route', () => {
const { prior, instance, emit, hostNote, messages } = openChat();
try {
hostNote('t1');
const card = walkCard(messages(), 't1');
assert.ok(card, 'the host note minted the task card');
const rendered = captureRenderedLines();
emit(lateRow('t1', evidenceFor('t1')));
const markup = rendered().join('');
assert.match(markup, /chat-live-line-title[^>]*>On the delivered version of this answer, reviewers later passed it\./);
assert.match(markup, /openai\/gpt-5\.5: passed it/);
const href = taskSourceDownloadUrl('t1', SOURCE_REF);
assert.equal(href, `/api/tasks/t1/artifacts/acceptance-${HASH}.json?source=${encodeURIComponent(SOURCE_REF.path)}`);
// The chat link ink (`md-link`), never the browser's default link colour.
assert.match(markup, new RegExp(`<a class="md-link" href="${href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/&/g, '&amp;')}" download data-live-line-evidence>Download the review record</a>`),
'the row links the exact applied review record, as a download');
assert.doesNotMatch(markup, /OMISSION NOTE|DEGRADED|triad_/);
assert.equal(timelineLines(card).length, 2, 'the note row and the late row');
// Redelivery keys on the host's row identity: no second row, no second link.
const again = captureRenderedLines();
emit(lateRow('t1', evidenceFor('t1')));
assert.equal(timelineLines(card).length, 2, 'the redelivered row added no timeline item');
assert.ok((again().join('').match(/data-live-line-evidence/g)?.length ?? 0) <= 1, 'at most the one link per render');
} finally { instance?.destroy(); restoreDom(prior); }
});
test('a pointer of another shape offers no link: forged, legacy and absent evidence stay text-only', () => {
const cases = {
absent: null,
legacy: { task_id: 't2', panel_id: 'p', settled_at: '', reviewed_revision: 'unknown' },
wrongRoot: evidenceFor('t2', { ...SOURCE_REF, root: 'runtime_data' }),
traversal: evidenceFor('t2', { ...SOURCE_REF, path: '../private.json' }),
badDigest: evidenceFor('t2', { ...SOURCE_REF, sha256: 'not-a-digest' }),
noSize: evidenceFor('t2', { ...SOURCE_REF, size: '75000' }),
stringRef: { ...evidenceFor('t2'), source_ref: 'source_handles/x.json' },
};
for (const [name, evidence] of Object.entries(cases)) {
const { prior, instance, emit, hostNote } = openChat();
try {
hostNote('t2');
const rendered = captureRenderedLines();
emit(lateRow('t2', evidence));
const markup = rendered().join('');
assert.match(markup, /reviewers later passed it\./, `${name}: the words still reach the card`);
assert.doesNotMatch(markup, /data-live-line-evidence|<a /, `${name}: no guessed link`);
} finally { instance?.destroy(); restoreDom(prior); }
}
});
test('history replay keeps the late row on its card with its record link', async () => {
const rows = [
{ task_id: 'r1', role: 'system', is_progress: true, narration: false, text: 'Checkpoint saved.', ts: '2026-09-28T00:00:00Z' },
{ task_id: 'r1', role: 'assistant', text: 'The report is ready.', ts: '2026-09-28T00:01:00Z' },
{ ...lateRow('r1', evidenceFor('r1')), chat_id: 1, text: NOTE, history_id: 'h-r1-late',
history_position: { source: 'chat', offset: 3 } },
];
const { prior, mount } = installDom(async (url) => ({ ok: true, json: async () =>
String(url).startsWith('/api/chat/history') ? { messages: rows } : { active_direct_turns: [] } }));
const ws = { on() { return () => {}; }, isConnected: () => true, send() {} };
let instance;
try {
instance = createChatInstance({ ws, state: { activePage: 'chat', projectChatIds: new Set(), unreadCount: 0 },
updateUnreadBadge() {}, stateSnapshots: { begin: () => ({ generation: 1, requestedAt: Date.now() }), gate() { return Promise.resolve(this.begin()); },
isCurrent: () => true, apply() {} }, chatId: 1, idPrefix: 'chat', mountEl: mount });
const rendered = captureRenderedLines();
await instance.refreshHistory({ revision: 1 });
const messages = globalThis.document.byId.get('chat-messages');
const card = walkCard(messages, 'r1');
assert.ok(card, 'the replayed task kept its card');
if (card.dataset.expanded !== '1') {
card.querySelector('[data-live-summary-button]').listeners.get('click')[0]({ detail: 0 });
}
const markup = rendered().join('');
assert.match(markup, /reviewers later passed it\./);
assert.match(markup, /data-live-line-evidence>Download the review record<\/a>/, 'the stored pointer replays as the same link');
assert.equal(timelineLines(card).filter((line) => line.dataset.liveLineKey === 'history-h-r1-late').length, 1,
'the replayed item keeps its history identity');
} finally { instance?.destroy(); restoreDom(prior); }
});
// A shortened note points at the review record in words; with no card in this chat
// the System row it becomes must still offer that record.
const SHORTENED = `${NOTE} Every claim is backed… (shortened; the complete text is in the review record)`;
const bubblesFor = (root, taskId) => walkNodes(root).filter((node) =>
node.classList.contains('chat-bubble') && node.dataset.taskId === taskId);
const escapedHref = (taskId) => taskSourceDownloadUrl(taskId, SOURCE_REF).replace(/&/g, '&amp;');
test('a late-review row with no card in this chat stays a System row that keeps its record link', () => {
for (const [name, evidence, linked] of [['stored', evidenceFor('n1'), true],
['forged', evidenceFor('n1', { ...SOURCE_REF, path: '../private.json' }), false]]) {
const { prior, instance, emit, messages } = openChat();
try {
emit(lateRow('n1', evidence, { content: SHORTENED }));
assert.equal(walkCard(messages(), 'n1'), null, `${name}: no card was minted for the row`);
const [bubble, ...extra] = bubblesFor(messages(), 'n1');
assert.ok(bubble && !extra.length, `${name}: the row is one System bubble`);
assert.match(bubble.innerHTML, /shortened; the complete text is in the review record/, name);
const link = `<a class="md-link" href="${escapedHref('n1')}" download data-live-line-evidence>Download the review record</a>`;
assert.equal(bubble.innerHTML.includes(link), linked, `${name}: ${linked ? 'the exact record link' : 'no guessed link'}`);
if (!linked) assert.doesNotMatch(bubble.innerHTML, /data-live-line-evidence|<a /, name);
} finally { instance?.destroy(); restoreDom(prior); }
}
});
test('history replay of a card-less late row keeps its record link on the System row', async () => {
const rows = [{ ...lateRow('h1', evidenceFor('h1')), chat_id: 1, text: SHORTENED, history_id: 'h-h1-late',
history_position: { source: 'chat', offset: 1 } }];
const { prior, mount } = installDom(async (url) => ({ ok: true, json: async () =>
String(url).startsWith('/api/chat/history') ? { messages: rows } : { active_direct_turns: [] } }));
const ws = { on() { return () => {}; }, isConnected: () => true, send() {} };
let instance;
try {
instance = createChatInstance({ ws, state: { activePage: 'chat', projectChatIds: new Set(), unreadCount: 0 },
updateUnreadBadge() {}, stateSnapshots: { begin: () => ({ generation: 1, requestedAt: Date.now() }), gate() { return Promise.resolve(this.begin()); },
isCurrent: () => true, apply() {} }, chatId: 1, idPrefix: 'chat', mountEl: mount });
await instance.refreshHistory({ revision: 1 });
const messages = globalThis.document.byId.get('chat-messages');
assert.equal(walkCard(messages, 'h1'), null);
const [bubble] = bubblesFor(messages, 'h1');
assert.ok(bubble, 'the replayed row is a System bubble');
assert.equal(bubble.dataset.historyId, 'h-h1-late');
assert.ok(bubble.innerHTML.includes(`<a class="md-link" href="${escapedHref('h1')}" download data-live-line-evidence>`),
'the stored pointer replays as the same link');
} finally { instance?.destroy(); restoreDom(prior); }
});
test('a record link is only ever the task artifact route it was minted on', () => {
const { prior } = installDom();
try {
const href = taskSourceDownloadUrl('t1', SOURCE_REF);
assert.match(evidenceLinkHtml({ href, label: 'Download the review record' }), /data-live-line-evidence/);
for (const forged of ['javascript:alert(1)', 'https://example.com/x', '//example.com/x', '', null]) {
assert.equal(evidenceLinkHtml({ href: forged }), '', String(forged));
}
assert.equal(evidenceLinkHtml(null), '');
} finally { restoreDom(prior); }
});
test('the status chip says Working; the title waits for a name or narration and keeps authored words', () => {
const { prior, instance, emit, hostNote, handlers, messages } = openChat();
try {
hostNote('w1');
const card = walkCard(messages(), 'w1');
assert.equal(card.querySelector('[data-live-phase]').textContent, 'Working', 'the chip states the running state');
assert.equal(card.querySelector('[data-live-title]').textContent, '', 'no placeholder repeats the chip');
// An empty narration frame names nothing and stays no narration.
emit({ role: 'assistant', is_progress: true, task_id: 'w1', content: '', narration: true });
assert.equal(card.querySelector('[data-live-title]').textContent, '', 'an empty note does not become a title');
// The model's own words are the title, even when the word is Working.
emit({ role: 'assistant', is_progress: true, task_id: 'w1', content: 'Working on the ledger', narration: true });
assert.equal(card.querySelector('[data-live-title]').textContent, 'Working on the ledger');
handlers.get('task_named')({ task_id: 'w1', suggested_name: 'Working' });
assert.equal(card.querySelector('[data-live-title]').textContent, 'Working', 'a coined name is kept verbatim');
assert.equal(card.querySelector('[data-live-phase]').textContent, 'Working');
} finally { instance?.destroy(); restoreDom(prior); }
});
test('log_events mints no placeholder headline', () => {
const empty = summarizeChatLiveEvent({ type: 'send_message', is_progress: true, task_id: 't', content: '', narration: true });
assert.deepEqual([empty.headline, empty.visible], ['', false]);
const usage = summarizeChatLiveEvent({ type: 'llm_usage', task_id: 't', model: 'm', round: 1 });
assert.equal(usage.headline, '', 'a frame that names nothing has no headline');
const spoken = summarizeChatLiveEvent({ type: 'send_message', is_progress: true, task_id: 't', content: 'Working on it', narration: true });
assert.deepEqual([spoken.headline, spoken.promote, spoken.human], ['Working on it', true, true], 'authored words are kept');
});

View file

@ -0,0 +1,175 @@
import assert from 'node:assert/strict';
import test from 'node:test';
// The compact renderer escapes through a detached element and reads link
// addresses through a textarea; this stub does both the way a browser does.
const priorDocument = globalThis.document;
globalThis.document = {
createElement: (tagName) => ({
set textContent(value) {
this.innerHTML = String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
},
set innerHTML(value) {
this._html = String(value ?? '');
if (tagName === 'textarea') {
this.value = this._html.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
},
get innerHTML() { return this._html; },
}),
};
const { joinMarkdownHeadings, renderMarkdown } = await import('../modules/utils.js');
test.after(() => { globalThis.document = priorDocument; });
// The #1367 fence: every compact substitution has something to grab inside it.
const FENCE_BODY = [
'**literal stars** and x = a * b * c',
'- literal dash',
'# comment',
'[x](https://example.com/y)',
'| a | b |',
'|---|---|',
'| 1 | 2 |',
'`tick` and ~~strike~~ and ``double``',
'a &amp; b &lt; c &#42; d <div>',
].join('\n');
const escaped = (value) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
test('a fenced block is the author text in both compact consumers', () => {
const source = `Before\n\`\`\`md\n${FENCE_BODY}\n\`\`\`\n**after**`;
const expected = `Before\n<pre><code>${escaped(FENCE_BODY)}\n</code></pre>\n<strong>after</strong>`;
assert.equal(renderMarkdown(source), expected);
assert.equal(renderMarkdown(source, { inlineHeadingBreaks: true }), expected);
});
test('an inline code span is literal and keeps formatting around it', () => {
assert.equal(
renderMarkdown('Use `**b** *i* ~~s~~ [x](https://e.com/) |a|` with **care** and `&amp;`'),
'Use <code class="inline-code">**b** *i* ~~s~~ [x](https://e.com/) |a|</code> with <strong>care</strong> and '
+ '<code class="inline-code">&amp;amp;</code>',
);
// Emphasis around a span still applies; the span itself stays literal.
assert.equal(renderMarkdown('**see `a*b*c`**'), '<strong>see <code class="inline-code">a*b*c</code></strong>');
// A pipe inside a code span does not split a table cell.
assert.match(renderMarkdown('| k | v |\n| --- | --- |\n| `a|b` | 2 |'), /<td><code class="inline-code">a\|b<\/code><\/td><td>2<\/td>/);
});
test('an author text holding the parking token reads back unchanged', () => {
const source = 'OUROBOROSCODE0OUROBOROSCODE and `code` and OUROBOROSCODEX1OUROBOROSCODEX';
assert.equal(
renderMarkdown(source),
'OUROBOROSCODE0OUROBOROSCODE and <code class="inline-code">code</code> and OUROBOROSCODEX1OUROBOROSCODEX',
);
});
test('a hostile parking-token run stays literal and costs one scan', () => {
// A stem grown one X per rescan of the text was quadratic, and past a long
// run its restore pattern no longer compiled: both consumers threw.
const run = `OUROBOROSCODE${'X'.repeat(100000)}`;
const started = Date.now();
const html = renderMarkdown(`${run} ${'`a` '.repeat(2000)}**b**`);
assert.ok(Date.now() - started < 1000, 'the parking stem must be found in one scan');
assert.equal(html, `${run} ${'<code class="inline-code">a</code> '.repeat(2000)}<strong>b</strong>`);
assert.equal(joinMarkdownHeadings(`## \`x\` ${run}\nbody`), `\`x\` ${run}\nbody`);
// Every short tag after the prefix taken: the stem still misses them all.
const tags = Array.from({ length: 26 }, (_, i) => `OUROBOROSCODE${String.fromCharCode(65 + i)}0`).join(' ');
assert.equal(renderMarkdown(`${tags} \`c\``), `${tags} <code class="inline-code">c</code>`);
});
test('the compact grammar the design names keeps its code literal', () => {
// A fence with no language, closed mid-line, and a two-backtick span holding a
// backtick: the edges of the grammar DESIGN.md promises for this renderer.
assert.equal(renderMarkdown('```\n**x** <b>\n``` after'), '<pre><code>**x** &lt;b&gt;\n</code></pre> after');
assert.equal(renderMarkdown('``a`**b**`` *c*'), '<code class="inline-code">a`**b**</code> <em>c</em>');
});
test('heading length counts code as visible text in the renderer and in previews', () => {
// 76 visible characters inside the span: a heading. Stars inside code are
// characters; the old pipeline turned them into tags and measured less.
const short = '`' + '*'.repeat(4) + 'x'.repeat(72) + '`';
assert.match(renderMarkdown(`## ${short}`), /^<strong class="md-h2"><code class="inline-code">\*{4}x{72}<\/code><\/strong>$/);
const long = '`' + '**' + 'x'.repeat(40) + '**' + 'y'.repeat(37) + '`';
assert.equal(renderMarkdown(`## ${long}`), `<code class="inline-code">**${'x'.repeat(40)}**${'y'.repeat(37)}</code>`);
// The preview projection agrees: the 81-character line is prose (no separator).
assert.equal(joinMarkdownHeadings(`## ${long}\nbody`), `${long}\nbody`);
assert.equal(joinMarkdownHeadings(`## ${short}\nbody`), `${short} —\nbody`);
// A link inside code is text, not a label with a hidden destination.
assert.equal(joinMarkdownHeadings('## `[a](b)` label\nbody'), '`[a](b)` label —\nbody');
});
// Every anchor the compact renderer writes, in its one fixed shape: an href with
// nothing that could end the attribute, then the renderer's own attributes.
const ANCHOR = /^<a href="[^"<>`]*" target="_blank" rel="noopener noreferrer" class="md-link( md-image-ref)?">$/;
const anchorTags = (html) => html.match(/<a\b[^>]*>/g) || [];
test('code inside a link destination stays literal code and never reaches the attribute', () => {
// The compact sinks (Skill Review report, task timeline, delegated rows) have
// no sanitizer after this renderer: parked code restored into an escaped href
// used to close the attribute with the code's own quote.
const fence = '[open](https://example.com/```\n" onclick="alert(1)" data-x="\n```)';
assert.equal(
renderMarkdown(fence),
'[open](https://example.com/<pre><code>" onclick="alert(1)" data-x="\n</code></pre>)',
);
const span = '![pic](https://example.com/`x" onerror="alert(1)`) then [docs](https://example.com/d)';
const rendered = renderMarkdown(span, { inlineHeadingBreaks: true });
assert.equal(
rendered,
'![pic](https://example.com/<code class="inline-code">x" onerror="alert(1)</code>) then '
+ '<a href="https://example.com/d" target="_blank" rel="noopener noreferrer" class="md-link">docs</a>',
);
for (const html of [renderMarkdown(fence), rendered]) {
for (const tag of anchorTags(html)) assert.match(tag, ANCHOR);
}
// Code in a link's words is still that link's text.
assert.equal(
renderMarkdown('[see `a" b`](https://example.com/)'),
'<a href="https://example.com/" target="_blank" rel="noopener noreferrer" class="md-link">'
+ 'see <code class="inline-code">a" b</code></a>',
);
});
test('a Markdown image stays a visible, policy-checked link in the compact renderer', () => {
assert.equal(
renderMarkdown('Before ![diagram](https://example.com/a.png) after'),
'Before <a href="https://example.com/a.png" target="_blank" rel="noopener noreferrer" class="md-link md-image-ref">'
+ 'Image: diagram</a> after',
);
assert.match(renderMarkdown('![x](javascript:alert(1))'), /^<a href="#" [^>]*class="md-link md-image-ref">Image: x<\/a>/);
// An ordinary link is unchanged.
assert.equal(
renderMarkdown('[docs](https://example.com/)'),
'<a href="https://example.com/" target="_blank" rel="noopener noreferrer" class="md-link">docs</a>',
);
});
test('a compact destination is the address alone: its title is not in it, its parentheses are', () => {
const anchor = (href, text, image = false) => `<a href="${href}" target="_blank" rel="noopener noreferrer" `
+ `class="md-link${image ? ' md-image-ref' : ''}">${text}</a>`;
const cases = [
['![diagram](https://example.com/a.png "Architecture")', anchor('https://example.com/a.png', 'Image: diagram', true)],
["![diagram](https://example.com/a.png 'Architecture')", anchor('https://example.com/a.png', 'Image: diagram', true)],
['![diagram](https://example.com/a.png (Architecture))', anchor('https://example.com/a.png', 'Image: diagram', true)],
['[docs](https://example.com/ "Docs") next', `${anchor('https://example.com/', 'docs')} next`],
['![chart](https://example.com/wiki/Foo_(bar).png) after',
`${anchor('https://example.com/wiki/Foo_(bar).png', 'Image: chart', true)} after`],
['See [wiki](https://example.com/wiki/Foo_(bar)).', `See ${anchor('https://example.com/wiki/Foo_(bar)', 'wiki')}.`],
// A refused address is refused whole: nothing of it is left behind as text.
['![x](javascript:alert(1))', anchor('#', 'Image: x', true)],
['![x](javascript:alert(document.cookie) "t") after', `${anchor('#', 'Image: x', true)} after`],
['![x](data:image/png;base64,AAAA)', anchor('#', 'Image: x', true)],
// A form this renderer does not read stays the author's text, never a guessed address.
['[a](https://example.com/a b)', '[a](https://example.com/a b)'],
['![a](https://example.com/a((b)))', '![a](https://example.com/a((b)))'],
['[a](https://example.com/a(b)', '[a](https://example.com/a(b)'],
// Code in a title is still the author's code, never a dropped tooltip.
['![d](https://example.com/a.png "`x" onerror="y`")',
'![d](https://example.com/a.png "<code class="inline-code">x" onerror="y</code>")'],
];
for (const [source, expected] of cases) {
const html = renderMarkdown(source);
assert.equal(html, expected, source);
for (const tag of anchorTags(html)) assert.match(tag, ANCHOR);
}
});

View file

@ -90,10 +90,25 @@ test('host notes are rows the card shows, never its title', () => {
f.emit({ content: 'Task acceptance review: PASS (clean acceptance).', narration: false });
assert.ok(f.card(), 'the notes are content: the block exists');
assert.equal(f.rows().length, 2, 'both notes are visible rows');
assert.equal(f.title(), 'Working...', 'the running placeholder, not the last host note');
assert.equal(f.title(), '', 'no title: neither the last host note nor a placeholder repeating the chip (#1369)');
} finally { f.close(); }
});
test('only the host placeholder left: authored words that say Working keep the title (#1369)', () => {
const f = fixture();
try {
f.census(direct());
f.emit({ content: 'Working…', narration: true });
assert.equal(f.title(), 'Working…', 'the model narrated these words; they are its own');
} finally { f.close(); }
const g = fixture();
try {
g.census(direct());
g.emit({ content: NOTE, narration: false, suggested_name: 'Working...' });
assert.equal(g.title(), 'Working...', 'a coined name is the author\'s, whatever it says');
} finally { g.close(); }
});
test('a host-notes-only turn keeps its coined name and an empty activity line', () => {
const f = fixture();
try {

View file

@ -101,7 +101,7 @@ test('the same panel of a task that is not running is a recorded gap, never a ru
assert.doesNotMatch(markup, /in progress/);
});
test('a settled failure renders exactly as it always did', () => {
test('a settled failure without a verdict reads in words and keeps its warning and its detail', () => {
const settled = panel([actor('s1', FAILED), actor('s2', FAILED), actor('s3', FAILED)], {
transport_status: 'provider_transport_error', parse_status: 'malformed', enforcement_impact: 'degrades_completion',
});
@ -112,7 +112,10 @@ test('a settled failure renders exactly as it always did', () => {
];
for (const status of ['completed', 'running']) {
const value = group([settled], status);
assert.deepEqual(facts(value), { state: 'terminal', tone: 'warn', progress: '', verdict: 'DEGRADED', activeCount: 0 });
// #1369: the owner reads words; the stored DEGRADED stays in the attempt detail below.
assert.deepEqual(facts(value), {
state: 'terminal', tone: 'warn', progress: 'no verdict · 0 of 3 answered · 3 unavailable', verdict: 'DEGRADED', activeCount: 0,
});
assert.equal(value.attempts[0].detailText, [
'Review panel panel_a72b23783ba34908: task_acceptance · authority=host_root · verdict=DEGRADED · transport=provider_transport_error · parse=malformed · quorum=0/3 (required 2) · enforcement=degrades_completion',
'Panel reason: recorded reason',
@ -120,8 +123,20 @@ test('a settled failure renders exactly as it always did', () => {
...reviewer('s1'), ...reviewer('s2'), ...reviewer('s3'),
'Cost unavailable',
].join('\n'));
assert.match(html(value), /chat-review-group-meta">DEGRADED/);
assert.match(html(value), /chat-review-group-meta">no verdict · 0 of 3 answered · 3 unavailable/);
assert.match(html(value), /chat-review-group warn/);
assert.doesNotMatch(html(value), /-meta">[^<]*DEGRADED/);
}
// A reviewer's own DEGRADED answer is an answer: the panel still has no verdict, and nothing is unavailable.
const judged = group([panel([actor('s1', SAID_DEGRADED), actor('s2', SAID_DEGRADED), actor('s3', FAILED)], {
transport_status: 'partial', parse_status: 'valid',
})], 'completed');
assert.deepEqual(facts(judged), {
state: 'terminal', tone: 'warn', progress: 'no verdict · 2 of 3 answered · 1 unavailable', verdict: 'DEGRADED', activeCount: 0,
});
// A settled verdict keeps its own word.
const passed = group([panel([actor('s1'), actor('s2'), actor('s3')], { aggregate_signal: 'PASS' })], 'completed');
assert.deepEqual([passed.progress, passed.verdict], ['', 'PASS']);
});
test('an expired window or lost custody stays a warning even while the task runs', () => {
@ -129,7 +144,9 @@ test('an expired window or lost custody stays a warning even while the task runs
const value = group([panel([actor('s1', lost), actor('s2', lost), actor('s3', lost)], {
transport_status: lost.transport_status, parse_status: 'malformed',
})], 'running');
assert.deepEqual(facts(value), { state: 'terminal', tone: 'warn', progress: '', verdict: 'DEGRADED', activeCount: 0 });
assert.deepEqual(facts(value), {
state: 'terminal', tone: 'warn', progress: 'no verdict · 0 of 3 answered · 3 unavailable', verdict: 'DEGRADED', activeCount: 0,
});
assert.match(value.attempts[0].detailText, new RegExp(`· verdict=DEGRADED · transport=${lost.transport_status} · parse=malformed ·`));
assert.doesNotMatch(value.attempts[0].detailText, /awaiting/);
}

View file

@ -455,7 +455,9 @@ test('nonterminal diagnostics stay visible facts but never promote the task', ()
assert.deepEqual({ phase: success.phase, headline: success.headline }, { phase: 'done', headline: 'Done' });
assert.match(chatSource, /const shouldPromote = Boolean\(summary\.promote\) \|\| record\.finished;/);
assert.match(chatSource, /record\.updates > 1 \? record\.titleEl\.textContent : ''/);
assert.match(chatSource, /\|\| 'Working\.\.\.'/);
// #1369: the status chip already says Working; no title or headline fallback repeats it.
assert.doesNotMatch(chatSource, /\|\| 'Working\.\.\.'/);
assert.doesNotMatch(logEventsSource, /headline = 'Working\.\.\.'|\|\| 'Working\.\.\.'/);
assert.doesNotMatch(chatSource, /record\.lastHumanHeadline \|\| headline/);
});

View file

@ -22,6 +22,10 @@
--type-body: 14px;
--type-section: 16px;
--type-page: 24px;
/* Not UI sizes: the chat reading ladder, relative to the bubble's reading
text (20px and 18px over the 16px bubble; DESIGN.md §1). */
--md-heading-major: 1.25em;
--md-heading-minor: 1.125em;
--line-meta: 1.35;
--line-body: 1.5;
--line-title: 1.3;