* feat(channels): add durable progressive replies and Slack agents * fix(extensions): wire the host channel e2e suite to the reply publication seam The extension-host crate's own test modules were never updated for two contract changes that landed with progressive reply publication, so `cargo clippy --all --all-features` was red on this crate alone: - `RunDeliveryServices` / `ChannelWorkflowDeliveryServices` traded `project_filesystem` for `reply_publication` (the run's answer is published, not sent by the observer). Four literals now build the service from the same kernel-backed ports production wires — `KernelTerminalReplyFacts` over the scripted turn coordinator and thread service, `TurnCoordinatorStopRequester` — rather than a double. - `ResolvedChannelDelivery` gained `generation`; the three deployment-bound test resolvers report 0. The suite's fake Slack server also only spoke `chat.postMessage`. Slack declares `[channel.reply] transport = "stream"`, so the answer now rides the native Agent stream: added `agents.sessions.setStatus`, `chat.startStream` / `appendStream` / `stopStream`, and `conversations.replies` handlers with the documented response shapes, plus stream recorders beside the postMessage ones. KNOWN RED, handed off: ten `channel_host::e2e_tests` Slack DM scenarios still assert the run's answer, the working indicator, and gate prompts as `chat.postMessage`. Under stream transport those live inside the published reply document, so the assertions need the same port already applied to `tests/integration/extension_delivery.rs`. Verified that the gate route survives the cutover: `record_gate_route_if_needed` still writes the source-conversation fingerprint when no prompt message is delivered, so a bare "approve" in the thread resolves the gate on a stream channel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jjE5ciGqsWBKpiJDBeHCY * chore(contracts): note the char-boundary walk on the three bounded-text slices The pre-commit UTF-8 check flags every `&text[..n]`. All three sites in the reply vocabulary walk `end` back to a char boundary immediately above the slice, so they are correct as written and only needed the `// safety:` note the check asks for. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jjE5ciGqsWBKpiJDBeHCY * fix(extensions): port the Slack DM channel-host e2e journeys to the Agent stream wire The ten channel_host::e2e_tests scenarios handed off KNOWN RED still asserted the run's answer, the working indicator, and gate/auth prompts as chat.postMessage. Slack declares [channel.reply] transport = "stream", so those now live inside the published reply document on the native Agent surface, and the suite asserts that wire: - gate/auth prompts are the streamed attention block, with the message path's copy (approval reply instruction; auth headline plus the private-DM setup link, stripped for a Shared channel audience), published exactly once even when a gate-resolution ack races the live delivery loop; - working state is the agent session status (processing -> suspended -> processing) instead of a posted-then-deleted indicator message, renamed slack_dm_streams_working_state_and_closes_the_stream_after_final_reply; - final replies ride the single chat.stopStream close, never a plain post, and nothing is ever retracted because no notice message existed. To reach that wire, the scripted RecordingTurnCoordinator now publishes the loop milestones (IterationStarted / ModelStarted / Blocked / Completed) into ONE ReplyProjection shared with the harness's ReplyPublicationService - the stand-in for the loop host's ReplyProjectionMilestoneSink, exactly as append_final_assistant_message already stands in for the loop's transcript finalization - and test_reply_publication wires the real GateAttentionEnricher over the same blocked-auth prompt source the observer consults, mirroring composition. TurnMode::Complete deliberately stays milestone-free: it is the recovery shape whose terminal reply exercises the sink's conventional post (the still-green fast-path tests pin it). FakeAuthChallengeProvider::assert_calls(n) replaces assert_single_call: a stream channel fetches the challenge twice per gate by design (the observer's serviceability decision plus the publication's attention enrichment), never once per poll; the triggered-prompt path stays at one. The bare-approve journeys keep pinning that record_gate_route_if_needed records the source-conversation fingerprint when no prompt message is delivered (bare_approve_in_dm_resolves_gate_recorded_by_observer), so a bare threaded "approve" still resolves the gate on a stream channel. cargo test -p ironclaw_extension_host --lib: 491 passed, 0 failed (was 481/10); crate clippy --all-targets --all-features clean; the four race-sensitive scenarios re-run 8x without flakes. Design doc S11 gains the evidence row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GMyAZpZcDabapDiwhjXF6U * chore: point the five arch-exempt waivers at tracking issue #8007 The pre-commit ARCH-SPRAWL checker requires a literal `plan #NNNN` in every arch-exempt annotation; the five waivers introduced with progressive reply publication cited the design-doc path instead. All five now cite #8007 (progressive reply publication: decomposition and aggregation follow-ups), which lists each site and the refactor that discharges it: - reply_publication/worker.rs too_many_args (PublicationWrite bundle) - extension_contracts channel_adapter.rs optional_arc (ChannelSurfaces.reply) - extension_contracts channel.rs large_file - extension_host channel_host.rs large_file - assistant projection/tests/runtime_stream.rs large_file Comment-only; no code change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GMyAZpZcDabapDiwhjXF6U * chore: inline the pre-commit sweep suppressions on their flagged lines The UTF8/PANIC checks in scripts/pre-commit-safety.sh match line-by-line, so a // safety: note on the adjacent line does not suppress. Move the notes onto the flagged lines (all boundary-walked or char_indices-derived slices, plus one test-only fake assertion) and reword a doc-comment JSON example that pattern-matched as byte slicing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GMyAZpZcDabapDiwhjXF6U * refactor(replies): consolidate progressive-reply publication onto existing owners Course-correction of the reply architecture on this branch (PR #8006): one projection owner, one publication owner, one seam - no parallel subsystems, no host-owned reply mode. - contracts: ReplyDocument now evolves only through bounded semantic mutators; ReplyChange/ReplyChangeClass/ReplyId and the apply reducer are deleted. ReplySinkReport keeps the opaque checkpoint plus neutral provider evidence (refs, read-back verification, outcome). - outbound: renew_reply_publication_lease deleted - a same-owner claim re-entry extends the lease and doubles as the heartbeat. Claim/fence, monotonic revisions, evidence, and one-way settlement are unchanged. - assistant: reply_projection.rs -> projection::reply (a submodule of the existing projection owner); reply_publication -> delivery_coordinator::publication (service + private worker + kernel_ports free functions). The coordinator remains the attempt aggregate's sole writer via its guarded store-op methods. - composition/binary: host_owned_reply and bind_host_owned_reply_sinks deleted; the binary names the deployment's session-reply channel (with_session_reply_channel) and composition attaches ProjectionReplySink through the ordinary surfaces.reply slot - no marker flag, nothing downstream distinguishes host-supplied sinks. - architecture: contracts size ceiling re-pinned 12_928 -> 12_896 (a tightening; measured post-consolidation). - tests: suites ported to the mutator interface; extension_delivery regains a citable assert_slack_thread_delivery_evidence so the journey-evidence contract asserts the Agent-stream wire. - docs/guidance: design doc, outbound README, root AGENTS.md, and extension/slack guidance updated to the consolidated owners. Behavior preserved: Slack Agent stream lifecycle, Telegram terminal replies, WebUI live progress, claim/fence takeover, evidence-gated settlement (ambiguous -> Unknown, never blind-retried). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GMyAZpZcDabapDiwhjXF6U * refactor(replies): fold reply publication into DeliveryCoordinator and fix its durability order One publication owner: the separately constructed ReplyPublicationService (and its Deps bundle, crate-root re-export, runtime field/getter, parallel shutdown, and the dual-handle service structs) is gone. Composition calls DeliveryCoordinator::start_reply_publication once; registration, terminal resume, settlement waits, the boot sweep, and shutdown are coordinator methods over the private publication module, and the coordinator itself is the process-journal observer (same durable cursor id). Correctness fixes the fold carries: - Worker order: provider-independent preparation before the claim, the claim immediately before egress, the desired revision persisted under the fence before the sink call, and the sink timeout clamped to the lease TTL so expiry cannot double provider calls. - Restart recovery: the terminal journal commit is acknowledged only after recovery ran (errors redeliver), plus a boot sweep over a new list_open_reply_publications tenant-index store read for the crash-after-acknowledgement window. - Slack ambiguity fail-closed per current Slack docs: an ambiguous chat.startStream marks the checkpoint and never opens a second stream nor posts the terminal text beside a possible ghost; an unreadable read-back for a text-carrying pending stays Ambiguous and is never re-sent. - Unauthorized settles Failed(AuthorizationRevoked) fail-closed per lifecycle.md, and the contract doc says so instead of claiming re-auth. Surface reduction: ReplyProjectionObserver folded to pub(crate); dead reply vocabulary deleted (activity_progress, ReplyActivityState:: {Running,Killed}, ReplySinkOutcome::retry_after, ReplyPhase::as_str) with the contracts ceiling re-pinned down 12_896 -> 12_867; the importable Slack app manifest is now the canonical package file app_manifest.json with the docs copy test-pinned identical; stale reply-journal comments corrected. Integration harnesses defer the build's publication start (test-support flag) so the one start carries the group's kernel handles. New coverage: publication ordering-and-recovery suite (desired-before- egress sabotage-verified, prep-before-claim, lease-clamped timeout, boot sweep with no journal signal, awaited acknowledgement, stable observer id), the tenant-wide open-publication listing conformance case, and the two Slack ambiguity journeys (sabotage-verified). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2Wk1ZcTFjSEEDMvj6Hsvb * fix(replies): close the review-pass defects and restore composed live streaming Review-driven correctness pass (all eleven IronLoop threads, each behind a sabotage-verified or red-first test): - Slack app manifest: drop the legacy assistant_thread_started / assistant_thread_context_changed subscriptions (Slack's live Agent View validator rejects them); the lockstep suite pins their absence and the payload parser keeps compatibility arms for older installs. - Native Agent Stop for a top-level DM resolves the run's own topic-less conversation binding (the session thread rides only the reply context), so Stop cancels the active run instead of fingerprinting a conversation that does not exist; covered by a payload test and a wire-level journey. - WebUI reasoning: the projection checkpoint tracks the open tail segment's fingerprint plus stable per-segment thinking ids, so in-place-grown reasoning republishes under one item instead of duplicating. - Reply context is snapshotted per run at target registration and persisted on the durable descriptor; the worker and every resume publish with the snapshot, so a newer DM overwriting the latest-wins per-conversation store can never re-thread an older run's reply. - Slack attachments track per-file confirmed progress and latch ambiguous files.completeUploadExternal outcomes: nothing is ever re-uploaded and the publication settles Unknown instead of possibly doubling files. - A failed run produces exactly one terminal user-visible reply: the observer posts the conventional failure notice only when no publication actually delivered. - A checkpoint-less Ambiguous outcome settles Unknown after a single attempt; RecoveryRequired terminal commits resume publications; an ambiguous no-text stopStream is verified by its own re-send; gate attention enrichment resolves the gate ref from durable run state (the loop announces only GateBlocked { kind }), and the e2e gate milestones were made production-faithful so the existing journeys pin it. Composed-runtime pass (the ironclaw_composition suite caught three branch regressions its earlier runs had not reached): - The session-channel test fixture had been flipped to bind a fake reply sink; composed runtimes therefore had no live reply publication at all. It now mirrors the binary's exact shape (delivery-only surfaces plus with_session_reply_channel), so composition attaches the real ProjectionReplySink and the composed webui SSE journeys exercise the production wiring end to end. - The answer's first visible text is a ControlCritical reconcile point: a fast run reaches its terminal commit inside the 250 ms progress-pacing window, and pacing the first text away jumped the stream straight to the finalized answer. Message transports are unaffected (Terminal only). - The pacing sleep is wake-responsive: a revision arriving mid-window re-evaluates its reconcile point immediately instead of deafly waiting the window out. Both cadence rules are pinned by a red-first worker test. - The composition manifest-parity expectation gained the five native Agent egress endpoints the manifest already declares. Gates: the struct test-support ratchet baselines shrink (runtime.rs field 10->9, method 44->43; WS0 members 269->267) because the coordinator handle became ungated production state; no ceiling was raised anywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2Wk1ZcTFjSEEDMvj6Hsvb * fix(ci): close the panic-scanner hit and the lifecycle-profile activation gap - The reply-publication publisher-id fallback no longer carries a panic-style unreachable arm: an (impossible) rejected id now logs at error level and refuses the start fail-closed, satisfying the Reborn production panic baseline. - The integration extension-lifecycle profile binds the real Slack and Telegram channel adapters, exactly as the shipping binary does: the branch's activation rule fails closed on a declared [channel.reply] with no bound reply sink (a stub could swallow a run's answer), and the lifecycle scenarios install those real manifests. The acme runtime profile had already received this fix; the plain lifecycle profile had not, which is what broke tool_call's current_tool_surface_overrides_stale_assistant_unavailable_claim in the selected integration lane. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2Wk1ZcTFjSEEDMvj6Hsvb * fix(replies): never open an empty Slack stream and converge the terminal answer once Three live-verified defects, each behind a red-first regression: - Slack opened an empty Agent stream: the sink called chat.startStream even when the chunk plan was empty — exactly what the run's first revision (`Preparing`, no facets) produces. The sink now asserts agents.sessions.setStatus(processing) and persists its checkpoint without opening a stream; the one stream opens at the first renderable chunk and carries it in the initial request. - Slack duplicated the terminal answer: the progressive answer joins every model call's streamed text while the transcript finalizes only the final call's message, so the terminal replacement broke the chunk planner's prefix-extension check (AnswerRewritten) and the close path posted the canonical answer as a conventional chat.postMessage beside the stream. fold_terminal_facts now finalizes IN PLACE when the shown text already ends with the canonical text (the checkpoint itself was never lost: persisted and reloaded correctly, generation stable, one registration site, terminal-applied latch); a terminal with no live stream — nothing renderable ever, or a genuine rewrite that closed the stale stream — creates and closes ONE native stream carrying the terminal content. post_terminal_conventionally and its checkpoint field are deleted; the two stream-open sites share one start_stream helper with the unaddressable-ghost ambiguity latch. - WebUI showed the answer twice: the live item (text:{run}, finalized:false) and the durable transcript item (message uuid, finalized:true) are two identities, and the reducer converged them only by content equality — defeated by the same progressive-vs-canonical divergence. Convergence now keys on identity (run + the live text item family), never content. Test corrections (fixture fidelity): the Slack "happy path" no longer manufactures a status the production first revision does not carry — it drives the real Preparing shape and pins setStatus-without-startStream; the explicit-status case is kept as a narrowly named synthetic chunk-planning test. New production-shaped coverage: projection-tier in-place/genuine- rewrite/empty-canonical terminal folds; sink-tier first-content (answer+ activity, attention, status), terminal-before-content, and terminal-rewrite regressions; an integration journey driving a streamed pre-tool preamble + real capability call + answer through the real projection, coordinator, Slack sink, and wire (one startStream with non-empty chunks, one stopStream, zero conventional posts); the existing Slack journey now pins non-empty startStream chunks and exactly one stream; the composed SSE test pins the run-identity contract the reducer's convergence rests on; a vitest regression composes live and durable items through the production reducer. Eleven channel-host e2e journeys and one live-progress stream test had pinned the buggy wire: instant-complete DM/mention replies arriving via chat.postMessage (the deleted conventional terminal arm), an empty stream standing in for working state, and the terminal replace of the shown text. They now assert the corrected model — working state is the `processing` session assertion with NO stream until renderable content; every reply rides exactly one Agent stream opened with content and closed once; the one remaining chat.postMessage in those journeys is the genuine binding-hint notice. Message-transport note: a multi-phase run's terminal document now carries the streamed preamble ahead of the canonical answer, so a message channel (Telegram) sends the full streamed reply rather than only the final phase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2Wk1ZcTFjSEEDMvj6Hsvb * fix(replies): close the review-verified defects from the CodeRabbit triage Seven production fixes, each behind a red-first regression: - The session-channel registration guard leaked on the actor-less first revision: the single-flight key was never returned, locking the run out of the session channel for its whole life. The guard is released so the next revision retries. - reply_delivered accepted ANY target's Delivered settlement, so the session channel delivering a run suppressed the originating channel's failure notice. It now matches the observer's own extension against the persisted target descriptor (a descriptor-less record proves nothing). - A read-back that FOUND the streaming message but got no `text` field (Slack's blocks-only rendering) was collapsed into "empty" and read as "did not land", re-sending a text-carrying pending — a visible duplicate. read_back now reports Found/FoundWithoutText/NotFound, and a textless find stays Ambiguous for text-carrying pendings. - The read-back compared the tail of the FULL answer prefix, which an earlier attention/status chunk interleaves in the message; a landed append could read as lost and be re-sent. The comparison now uses the pending append's own delta, which is contiguous by construction. - The boot-time reply-publication recovery sweep is owned: the runtime retains its JoinHandle and aborts/awaits it in shutdown before releasing publication leases, so a detached sweep can no longer re-acquire leases after shutdown. - clear_attention burned an ordinal before checking there was anything to clear, minting a spurious revision (one wasted provider round trip per no-op clear). It now checks first like every sibling mutator. - Work-summary live items were keyed on the process-global sequence, so a republish from a restored checkpoint appended a twin status bubble. The id now keys on the checkpoint's own status_publications counter. Hardening and corrections: - A channel-shaped agent stop without thread_ts is Ignored instead of normalizing to a topic-less conversation that fingerprints the wrong binding. - The conformance suite honors the report contract's "None keeps the previous checkpoint" on the terminal step (it wiped carried state and failed conforming sinks). - The Slack journey's exactly-once and no-empty-open pins moved onto the settled wire (they read a pre-settlement snapshot). - channel_egress gained the execute-level Retry-After test (the hint was helper-tested only). - The Python fake honors agent_feature_disabled on appendStream/stopStream as its comment claims; attachment_transfer keeps the URL-parse cause; the contracts README and the extension-runtime overview no longer describe the retired stream-replies-bind-no-adapter model; the reply module doc no longer overclaims a truncation flag for reasoning drops; Waker::noop() replaces the hand-rolled vtable; runtime_kind_from_display is exhaustiveness-pinned. Declined with reasons (recorded on the PR threads): the AGENTS.md no-fallback rule (the conventional path it cited is already deleted), the test-code clippy-allow annotation (tests are exempt by the architecture rule), the panicking test fixture (test misuse should fail loudly), the Telegram unknown-checkpoint fail-open (documented cross-sink version convention), the poll-helper dedupe (matches the file's dominant pattern), and the permit-across-settle scope (throughput follow-up, not a defect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P2Wk1ZcTFjSEEDMvj6Hsvb * fix(replies): surface thinking immediately across Slack and WebUI * fix(slack): finalize progressive replies and auth resume * fix(ci): align reply changes with repository gates * test(slack): assert deduplication through agent streams * fix(replies): rebuild crash-recovery scopes with the run's owner Publication rows live under the per-user outbound mount and a run's key includes its owner, but both recovery paths rebuilt the scope with TurnScope:🆕 the process-journal observer read the __system__ subtree and keyed a run nobody registered, and the boot sweep did the same. The observer now rebuilds the scope through the kernel's own turn_scope_from_process_scope (owner slot included) and the boot sweep carries the deployment actor. The publication harness scope now carries its owner like production run scopes do, which turns the two journal-recovery pins red without the fix. Two composition debug branches were folded into first-bind-wins one-liners to keep the crate under its mass ceiling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(replies): fail closed on lost channels, stale finishes, and failure detail - A capability finish that lands after the terminal outcome is ignored like every other post-terminal mutation; without the guard it minted a revision above the store's fixed terminal revision and the worker could never advance past it. - apply_terminal_facts is idempotent for a terminal document: a second application (another resume path, a redelivered commit) no longer mints a duplicate terminal revision. - A failed model call's partial streamed text is discarded when the loop retries the call instead of being frozen as finished text; the reply read "Wor\n\nWorld" and the terminal fold kept it. - A shared audience never sees activity input/output previews (shell commands, paths, hosts); the design doc always said so. - A failed run's terminal reply carries the neutral per-category copy the WebUI already shows, read through the kernel type's accessors; the model-visible failure detail no longer reaches Slack or Telegram. - A channel that disappears mid-publication counts its unresolved wait toward the terminal attempt budget and settles Failed(Rejected) instead of polling forever and being resumed on every boot. Each change carries a red-first regression test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(replies): plan wakes from a local mirror and bound the sink by the remaining lease Every streamed token wakes the publication worker, and every wake read the publication row before checking the transport cadence or the pacing window — tens of store reads per second per streaming run, discarded unread. The worker now plans each wake from a local mirror of the published revision and reads the row only once a reconcile is due; a mismatch with the fresh row re-plans. The settlement wait watches the local worker instead of scanning the thread's rows every 25 ms, reading the store only when no local worker remains. The sink call is bounded by what is left of the claim rather than the full lease TTL, so the desired-revision write can no longer let a provider call outlive the lease that covers it. Pinned by a delegating probe store: forty paced wakes cost at most two row reads (forty without the mirror, sabotage-verified), a 400 ms wait on a stalling local worker issues at most two scans, and a stalled sink is cut at the remaining lease. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(replies): persist the bounded reply context and tidy the publication lane The durable target descriptor stored the raw ingress context and every resume re-validated it; it now stores the seam-bounded value the worker publishes with (an over-bound context is dropped once, at registration), pinned by a red-first test. Shutdown aborts a worker and waits for it to stop before handing its lease back. The session-channel registration releases its single-flight guard on the invalid-ref arm. One trace target replaces two constants and thirteen inline literals, the canonical TurnStatus::is_terminal replaces a local copy, and the kernel-port attention enrichment reuses the projection's bounded-text constructors instead of a third copy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(webui): retract the local stop notice when the cancel loses the race A run the user stopped locally kept its client-only "Stopped" notice and stayed fenced even when the backend completed it normally, so the transcript showed the notice beside the answer and the fence swallowed the finalized text. A non-cancelled terminal status now lifts the fence and retracts the notice in the projection batch pre-scan, so the answer converges through the ordinary identity path. The fence itself moves from eleven inline checks to one gate per ingress (frame and projection item), each deriving the run id the way its handler does, and the stopped-notice id family becomes a shared constant like the failure prefixes. New pins: two runs streaming in one thread converge independently, a reasoning item republished under its id is replaced in place, a run started after a local stop streams normally, and every typed live frame of a stopped run is dropped until its cancellation settles it once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(slack): close the reply sink's ambiguity and duplication gaps - One checkpoint reset (forget_presentation) owns the stream-presentation fields; the mid-run rewrite path had kept the old plan title key, so a re-presented stream opened without its plan header. - A 2xx answer this sink cannot read (not JSON, or no boolean ok) is the lost-answer shape and now arms the same pending/ghost-stream latches a transport loss arms; before, the retry re-appended applied text or opened a second stream. A read-back without a messages array proves nothing instead of reading as "message gone" and re-sending. - The terminal outcome note is checkpointed, so a retried stopStream after the note rode startStream no longer appends it twice. - Transport faults on the upload ticket and private byte upload are retryable; only files.completeUploadExternal latches the fail-closed attachment ambiguity. - An unauthorized read-back is reported as Unauthorized rather than retried as Ambiguous until the terminal budget lapses. - Read-back proves a pending delta only past the already-applied text, so a delta repeating earlier text is never proven by the old occurrence; a punctuation-only delta stays Ambiguous. - The unused agents.sessions.rename egress grant is removed and the lockstep test now also checks declared agent paths ⊆ inventory. Each behavior change carries a red-first test against the fake Agent API. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(replies): one advance builder and one step handler in the worker The advance request was assembled three times with the same derived fields (a desired revision that never moves backwards, a terminal revision set once) and the step context was built twice for the two arms of one match. AdvanceScope owns the derived fields, record_outcome takes the scope instead of a parallel carrier struct, and the reconcile arms converge on one handle_step call. No behavior change; the publication suite is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(outbound): pin the publication substate's concurrency, isolation, and durability guards The publication contract now proves what its docs claimed: two store instances racing the lease claim yield exactly one Acquired and one Held (and a CAS version miss converges); a backend without versioned CAS fails the claim closed without mutating the row; the tenant-wide open listing is empty before any write and never crosses tenants that share agent/project/thread ids; attempt-level rewrites keep the publication substate intact; the target descriptor (reply context included) round trips through open, replay, and a libSQL reopen; and the advance pre-checks reject a terminal revision below the published one. list_open_reply_publications drops settled and plain rows page by page instead of retaining every tenant row until the sort. The shared reply sink conformance drive derives the terminal document's attachment refs from the files the request materializes, and adds one negative step: an unreadable checkpoint is never evidence the terminal was applied. Docs note that the outbound mount is per-user, so a sweep sees one owner subtree per scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(replies): align guidance, comments, and test docs with the reply seam Guidance and docs now describe what ships: every declared [channel.reply] binds one ReplySink (stream or message is cadence only), the session channel's sink is the projection sink composition attaches, final-reply attachments materialize inside reply publication, and the two 2026-08 channel design records carry superseded-in-part banners pointing at the progressive-reply design. The Slack rows say stream replies; the unused agents.sessions.rename link is gone from the setup docs; tests/AGENTS.md maps the SSE first-text journeys. Code-adjacent fixes riding along: the RebornRuntime delivery-coordinator accessor is test-support again (production wiring takes the coordinator from the factory) with the struct ratchet re-pinned 267 -> 268 / 43 -> 44 against main's 269 / 44; the binary's session-reply-channel naming is pinned by a CLI test; the DCR recovery diagnostics are debug-level and the malformed-record renewal has a caller-level test (sabotage-verified); the stale notice-route comment, the split doc block in composition test-support, the misplaced doc on the failure-notice test, the fake channel adapter counters' docs, and the ExtensionBindings doc are corrected; the Slack exactly-once integration journey waits for the attempt to settle before sampling the wire. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(replies): keep the checkpoint a sink hands back with a terminal failure A Permanent or Unauthorized report's checkpoint was dropped: the worker's Permanent step carried no checkpoint, so the settled row kept only the last checkpoint from an earlier step, while the comment beside it said the sink's checkpoint stays on the settled row. The step now carries the report's checkpoint through record_outcome, and the shared fake sink hands one back for those outcomes so the settlement test proves it lands (red without the carry-through). The loop-host prompt test's second assertion pinned the same sentence twice; it now pins the startup-context sentence it was named for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(replies): never call the sink on a lapsed lease A claim whose lease had already lapsed by the time the provider call would start produced a zero timeout budget; tokio still polls the sink once under a zero timeout, then the elapsed timeout read as an ambiguity that, with no checkpoint, settled the publication Unknown although no provider call had been made. The worker now skips the call on a zero remainder and returns a retry naming the lease; the next pass re-claims, and a terminal revision that keeps lapsing fails closed under the retry budget. Pinned red-first by a_lapsed_lease_skips_the_provider_call_instead_of_reading_a_zero_budget_as_ambiguity. The WebUI run-id helper no longer spells the reducer's text-branch anchor, so the static source pin that splits on it reads the reducer again (the optional-chaining form is the simpler helper anyway). The extension surfaces skill lists the three channel trait homes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(webui): let a locally stopped run's final reply through the fence The local-stop fence dropped every typed frame scoped to a run the user stopped, including its final_reply. When the cancel lost the race and the finalized reply outran the projection status that lifts the stop, the authoritative answer was discarded and the Stopped notice stayed. The final reply is the backend's own evidence that the run completed, so it now lifts the stop (retracting the notice) and renders. Red-first vitest case delivers the final reply before any projection status; the fence test keeps every other typed frame fenced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(webui): render a standalone numeric sentence instead of an empty list A model answering "19." was invisible: to CommonMark a line holding only an ordered-list marker is a list with one empty item, and the renderer produced <ol start="19"><li></li></ol> while the transcript held the answer. The renderer now escapes a bare marker outside code fences, so the sentence renders as text; real lists and fenced code are untouched. Rendered-output regression test for the bare, trailing-line, and trailing-space forms plus the list and fence controls. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(webui): show a tool's inputs, never its output, on the activity card The expanded activity card rendered a Result tab with the tool's bounded output preview, up to 16 KiB of raw JSON. The card now offers Details, Parameters, and Error only; the output stays on the durable record for diagnostics and channel flows. The dead result renderer, its two helpers, and its two locale strings are removed. Rendered-card test: a completed tool with a preview offers no Result tab, a failed one still surfaces its error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(slack): render meaningful blocks — no sentinel task, headers with real tasks, text by paragraph Live validation found two presentation defects on the native Agent stream. The plan opened every stream with a hidden-title sentinel task so a lone plan_update would render; Slack hides the title and keeps drawing its bullet. And every text delta went out as its own markdown_text chunk, which Slack renders as its own block, so token boundaries became paragraph breaks. The sentinel is gone. The plan_update lifecycle label rides only with real task cards, a stream opens only once the document holds renderable content (a whole paragraph, a task card, a driver status line, or an attention block), and the session status alone carries the thinking state of a tool-less run. The stream stays in chunks mode for its whole life — Slack forbids mixing markdown_text with chunks and a stream keeps its opening mode — so progress text is published by whole paragraph: a delta goes out through its last blank line outside a code fence, a paragraph past SLACK_TEXT_HOLD_MAX_CHARS flushes at its last sentence end, and the terminal, a finalized canonical answer, or a new attention block flushes everything still held, so what the run said precedes the block. The published-prefix hash covers exactly what Slack shows, so rewrite detection is unchanged. Plan-level pins (empty document plans nothing; header with the first real task; paragraph, fence, hold-bound, terminal, and finalized cases) and the forty agent-API journeys re-derived on the new exact stream sequences, including a new paragraph-streaming journey; the channel-host working- state journey asserts the session status with no stream before content. Design doc updated; old checkpoints carrying the sentinel entry stay readable and never re-send it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(webui,slack): close a fence only on its own delimiter; hold text rather than slice a char The bare-number renderer toggled its fence state on any fence-like line, so a ~~~ line inside a ``` block closed the state early and a bare number after it was escaped inside the code. It now tracks the opening delimiter and width and closes only on the same character at that width or wider with nothing after it, as CommonMark specifies. Regression proven against the previous version. The Slack plan's publish boundary is a line end or the position past a whitespace char, both char boundaries; the slice now goes through a checked get that holds the text if that ever stops being true, with a multibyte paragraph pinned. The final-reply fixture in the chat-events race test carries the wire's required generated_at and asserts it is the rendered timestamp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
IronClaw E2E Tests
Browser-level end-to-end tests for the IronClaw web gateway using Python + Playwright.
Prerequisites
- Python 3.11+
- Rust toolchain (for building ironclaw)
- Chromium (installed via Playwright)
- Node.js with
npx(for Emulate-backed provider fixtures)
Setup
cd tests/e2e
pip install -e .
playwright install chromium
Build ironclaw
The tests need the ironclaw binary built with libsql support:
cargo build -p ironclaw
Run tests
# From repo root
pytest tests/e2e/ -v
# Run a single scenario
pytest tests/e2e/scenarios/test_chat.py -v
# With visible browser (not headless)
HEADED=1 pytest tests/e2e/scenarios/test_connection.py -v
Architecture
Tests start two subprocesses:
- Mock LLM (
mock_llm.py) -- fake OpenAI-compat server with canned responses - IronClaw -- the real binary with gateway enabled, pointing to the mock LLM
Then Playwright drives a headless Chromium browser against the gateway, making DOM assertions.
Scenarios
| File | What it tests |
|---|---|
test_connection.py |
Auth, tab navigation, connection status |
test_chat.py |
Send message, SSE streaming, response rendering |
test_skills.py |
ClawHub search, skill install/remove |
test_tool_approval.py |
Tool approval overlay (approve, deny, always, params toggle) |
test_reborn_webui_v2_tool_gates.py |
Reborn served tool turn, cancellation, approval approve/decline paths, and manual-token auth gate with SSE/artifact redaction |
test_sse_reconnect.py |
SSE reconnection handling, keepalive comments, restart recovery, stale reconnect IDs, and connection-limit coverage |
test_html_injection.py |
HTML injection security |
test_extensions.py |
Extensions tab: install, remove, configure, OAuth, auth card, activate |
test_oauth_refresh.py |
Hosted Gmail/MCP OAuth refresh; the Gmail path refreshes through the proxy and reads seeded Gmail data from Emulate |
test_reborn_webui_v2_sso.py |
Standalone Reborn SSO login, ticket exchange, logout revocation, and two-user thread/timeline isolation through a local mock OIDC provider |
test_emulate_reborn_provider_contracts.py |
Emulate provider contracts for Reborn-backed Google Gmail/Calendar/Drive reads, writes, missing resources, and account isolation; Slack QA 9/10 channel/thread/DM routing, strict-scope failures, profiles, mentions, and identity shapes; and GitHub identity, negative-result, repo/issue/PR/search/branch/git-object/release/fork/action-route surfaces |
test_provider_fault_proxy.py |
Self-tests the transparent provider fault proxy, reusable status/transport/response profiles, safe request ledger, reset behavior, and commit-then-disconnect semantics |
test_reborn_emulate_full_path.py |
Full-path IronClaw + Emulate coverage: install/auth extensions, drive scripted Gmail/Calendar/Drive/GitHub/Slack calls, assert provider-side state, and exercise GitHub→Slack, Calendar+Drive→Slack, Gmail→Slack, and Slack→Drive→Slack dispatch |
Reborn coverage gate
The GitHub Actions Code Coverage workflow uses
tests/e2e/reborn_coverage_tests.txt instead of running every scenario in this
directory. That manifest is intentionally limited to tests that boot
ironclaw serve and cover the Reborn WebChat v2 or OpenAI-compatible
API surface. Legacy gateway tests and ENGINE_V2=true compatibility tests stay
in the E2E suite, but they are not part of the Reborn coverage gate. Manifest
entries may be pytest node IDs when only part of a broader scenario file belongs
in this gate.
Adding new scenarios
- Create
tests/e2e/scenarios/test_<name>.py - Use the
pagefixture for a fresh browser page - Use selectors from
helpers.py(updateSELdict if new elements are needed) - Keep tests deterministic -- use the mock LLM, not real providers
Emulate-backed provider fixtures
Emulate coverage is intentionally limited to provider APIs that match Reborn features already present in the codebase:
- Google: Gmail, Calendar, Drive, Docs, Sheets, and Slides seeded reads plus stateful message, event, file, document, spreadsheet, presentation, slide, text, shape, and image mutations with isolated accounts where applicable.
- Slack: auth, conversations, channel/thread/DM delivery, reactions, user lookup, membership, self-authored/last-sent identity, missing email/scope, mention encoding, two isolated DM targets, and exact-count readback.
- GitHub: authenticated user, repo create/list/metadata, fork list/create, release create/latest/list, issue create/read/comment/list/search, PR create/read/list/files/review/comment/merge, review-thread resolution, contents create/read/delete, search, branch/ref mutation, Git blob/tree/commit read/write, Actions workflow dispatch/readback/reruns, two repositories with distinct latest releases, and private-account isolation.
These expanded APIs come from the immutable serrrfirat/emulate fork pinned by
the Reborn E2E workflow; the unpinned local emulate@0.7.0 fallback does not
provide all of them.
The direct provider-contract tests prove the emulator fixture layer. Full-path
Reborn + Emulate tests should use hosted_google_emulate_server or a matching
provider fixture, install/auth the extension through IronClaw, drive
/api/chat/send with the scripted mock LLM, and assert provider state through
Emulate readback.
The full-path QA runtime routes provider traffic through the transparent fault
proxy while keeping baseline and readback traffic direct to Emulate. Add common
failure shapes to PROVIDER_FAULT_PROFILES, then apply them to a representative
operation class in PROVIDER_FAULT_CASES; do not create the full Cartesian
product. For ambiguous writes, assert the proxy's forwarded/responded evidence
and direct provider mutation count so a lost acknowledgement cannot become a
false success or a duplicate mutation.
Do not duplicate account binding, refresh/reconnect, duplicate-inbound, or
repeated-delivery contracts as provider-only fixtures. Those are
caller/runtime properties and remain covered at their existing hermetic seams
(runtime_credentials, gsuite_core, github_wasm_runtime_contract,
idempotent_replay, trigger/outbound integration tests, and
test_v2_auth_oauth_matrix.py). Malformed and transport failures cross the
real caller path only for the representative operation-class matrix. Emulate
supplies provider state for full-path flows; it is neither an OAuth authority
nor the fault injector.
Manual QA mapping
The Emulate provider contracts map to the manual QA sheet only where Emulate can represent the backing provider API. Google Docs, Sheets, and Slides operations now execute through their native extension routes instead of Drive-style substitutes. Rows that also depend on model-authored routines or live news remain only partially hermetic; 8B-8C still need a separate fake HN/search endpoint. Telegram and Twitter/X rows 1A-1C are not covered by Emulate.
Live Persona Failure Notes
For the live 20+ turn persona workflows and recurring tool-misuse patterns seen
there, see LIVE_TOOL_FAILURES.md.
Skip/xfail debt
For the current inventory of E2E skips/xfails and the policy for keeping browser
lifecycle tests deterministic, see E2E_DEBT.md.
Mocking API responses with page.route()
For tabs that depend on external data (extensions, jobs, memory, routines), use
Playwright's page.route() to intercept the browser's HTTP requests to the
ironclaw gateway and return deterministic fixture JSON. This avoids needing
real installed binaries, live external services, or complex database setup.
Basic pattern
import json
async def test_something(page):
# 1. Set up route intercepts BEFORE navigation triggers the fetch
# Always use async def handlers — route.fulfill() is a coroutine and must be awaited.
async def handle_tools(route):
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"tools": [{"name": "echo", "description": "Echo"}]}),
)
await page.route("**/api/extensions/tools", handle_tools)
# 2. Navigate / interact to trigger the fetch
await page.locator('.tab-bar button[data-tab="extensions"]').click()
# 3. Assert on the rendered DOM
rows = page.locator("#tools-tbody tr")
assert await rows.count() == 1
Matching only the exact path
**/api/extensions matches http://host/api/extensions but NOT sub-paths
like http://host/api/extensions/install. For the bare list endpoint, add
a check inside the handler:
async def handle_ext_list(route):
path = route.request.url.split("?")[0]
if path.endswith("/api/extensions"):
await route.fulfill(json={"extensions": []})
else:
await route.continue_() # Let sub-paths through to the real server
await page.route("**/api/extensions*", handle_ext_list)
Mocking method-specific behaviour (GET vs POST)
async def handle_setup(route):
if route.request.method == "GET":
await route.fulfill(json={"secrets": [...]})
else: # POST
await route.fulfill(json={"success": True})
await page.route("**/api/extensions/my-ext/setup", handle_setup)
Counting calls (for reload tests)
calls = []
async def counting_handler(route):
calls.append(1)
await route.fulfill(json={"extensions": []})
await page.route("**/api/extensions", counting_handler)
# ... interact ...
assert len(calls) == 2 # called twice (initial + after some action)
Applying the pattern to other tabs
| Tab | Key API endpoints to mock |
|---|---|
| Jobs | /api/jobs, /api/jobs/{id}, /api/jobs/{id}/events |
| Memory | /api/memory/search, /api/memory/tree, /api/memory/read |
| Routines | /api/routines, /api/routines/{id}/runs |
Injecting state directly via page.evaluate()
For purely client-side UI (components rendered entirely in JS without API calls), call the JavaScript function directly to skip the network layer entirely:
# Show an approval card without needing a real tool execution
await page.evaluate("""
showApproval({
request_id: 'test-001',
thread_id: currentThreadId,
tool_name: 'shell',
description: 'Run something',
})
""")
This is the pattern used in most of test_tool_approval.py and parts of
test_extensions.py (auth card, configure modal). The waiting-approval
regression in test_tool_approval.py uses a real tool call instead so it can
exercise backend approval state.