Files
ironclaw/tests/integration
Benjamin Kurrek 51582d3930 feat(channels): add durable progressive replies and native Slack Agent UI (#8006)
* 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>
2026-09-02 17:20:01 +00:00
..