Skip to content

feat(chat): host events and wait_for_user_action - #60

Merged
albanm merged 28 commits into
mainfrom
feat-follow-ups
Sep 16, 2026
Merged

albanm merged 28 commits into
mainfrom
feat-follow-ups

Conversation

@albanm

@albanm albanm commented Sep 16, 2026

Copy link
Copy Markdown
Member

A host page can now tell the chat what happened and what is true now, and the agent can
hand a step back to the user and resume when they act.

Pages publish on the tab BroadcastChannel via @data-fair/lib-vue-agents 0.5.0 —
emitAgentEvent(name, detail, { key }) for transitions, useAgentState(key, source) for
state. The chat delivers them to the model once and persisted, never starting a turn: a
retained state snapshot at activation (first turn, after reset, after compaction), events
appended to the tool result that caused them, otherwise drained into the next user turn's
hidden context. One new tool, wait_for_user_action, resolves on the next reported action
so the agent can prepare a form or a wizard and let the user press the button themselves.

Why: the chat only learned what it asked for, and only when a message arrived — so it
asked the user to describe their own screen, and had no way to pause on a step that was
the user's to take.

Also here: PERSONA_MAX_TURNS 6 → 25 in lib-sim. The new guided-workflow simulation case
is what exposed the old ceiling — a persona executing a handed-over procedure spent all six
turns on the work and had none left to reply. Separable from the rest if you'd rather it
landed on its own.

Heads-up:

  • A turn can now stay open up to 600s with the idle watchdog deliberately suspended for the
    duration of a declared wait. Backstopped by the wait's own timer, the abort signal, and
    cancelWait() in the turn's finally — but the general stall net is off while it waits.
  • Sub-agent tools are now wrapped by withHostConsequences, so they run the settle barrier
    after each call; previously they were passed through unwrapped.
  • Sentinel neutralisation lives in serializeDetail (publisher side). sanitizeEvent, the
    channel boundary, still takes detail verbatim — a host posting raw on the channel
    bypasses the guard.

albanm and others added 28 commits September 15, 2026 10:24
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Replace `Omit<HostEventMessage, 'channel'>` with `DistributiveOmit` that
correctly distributes over the union, preserving `event` and `key` fields
in their respective variants. Export `HostEventPost` type. Remove type
casts that were masking the unsoundness. Update comment to describe the
actual implementation rather than referencing task boundaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…or_user_action tool

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…r-const suppression

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…ion in the main tool set

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…ost status

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…t note, wait for me)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Also widens mock-model.ts's 'hello' seam from exact equality to
endsWithCommand (matching the adjacent host-events seams): on an
activation turn, host-state hidden context now precedes the visible
message, which broke the strict-equality check for any page (like
chat-workflow) that publishes state on mount.

3 of 8 scenarios fail on a genuine product defect, left red per the
task: wait_for_user_action's chat-activity line never renders because
agent-stream-parts.ts unconditionally resets activity on the
'tool-call' stream part, clobbering the 'waiting' state the tool's own
onWaiting callback just set. See task-9-report.md for full evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
The SDK starts wait_for_user_action's execute() (setting the 'waiting'
activity via onWaiting) before the stream-part loop drains the
buffered 'tool-call' part, so the unconditional activity=null on that
part's setActivity('tool') was clobbering it right after — the tool
chip survived because it reads the invocation, not the activity, which
is the asymmetry task-9's e2e spec caught. Guard the clear so it never
downgrades an active 'waiting' state; onDone still clears it normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…ust its final value

fill() sets a title in one DOM mutation, so the coalescing scenario
never produced an intermediate line for the old not.toContain check to
exclude, and the positive check only looked for the final value as a
substring — deleting HostEventStore.push's coalescing entirely would
still have passed it. Assert the collapse directly instead: exactly
one 'wizard:' line in the answer, and the stale
{"step":"title","type":"note","title":""} line's absence (a value the
page really produces, per the next test in this file). Also drops a
redundant .last() where exactly one tool-chip exists at that point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…r_user_action

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
… events

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…t, not same-turn wait

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
… add regression test

The doc was written before 84f4464 landed, so it didn't carry the two design facts
that fix forces: the idle watchdog is suspended (not the wait capped) for the
duration of a wait_for_user_action, and activation drops keyed events already
covered by the <host-state> snapshot to avoid telling the model the same fact
twice. Also note that the judged simulation persona only acts between turns, so
the same-turn wait resume is only observable in the e2e spec.

Add the missing regression test for the activation dedupe: the existing test only
checks the mock model's reply, which echoes <host-state> over <host-events> when
both are present and so can't detect the duplication. The new test reads the raw
gateway request body instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…, sub-agent draining

Applies the fix wave from the final whole-branch review of host events / user
actions before merge:

- gate wait_for_user_action on the store having actually heard from a host,
  not merely "a host store exists" (avoids a pointless 120s dead turn)
- cap the pending buffer's unkeyed ring and the number of distinct retention
  keys, dropping the oldest of each, so neither grows unbounded
- neutralise newlines and the hidden-context/host-events/host-state closing
  sentinels in a raw string detail, closing an early-wrapper-termination path
- stop sub-agent host tool calls from draining the shared pending buffer
  (they now peek a copy instead), so the lead still sees those events on its
  own next turn instead of losing them to the sub-agent's private history
- add HostEventStore.cancelWait() as a turn-end backstop for a waiter whose
  only other exit is the (SDK-optional) abort signal
- check waitForEvent's abort signal before its pending-buffer shortcut, so an
  already-aborted wait can't silently consume an owed event
- shape-guard raw agent-event payloads at the channel boundary
- capture the turn's own AbortController for the idle watchdog instead of the
  shared mutable reference
- re-export TRUNCATED_MARKER, guard useAgentState's onScopeDispose, and wrap
  appendHostEvents' JSON.stringify so a bad tool result can't take down a turn
- update docs/architecture/host-events.md and correct the wire-shape typo in
  the design spec

Unit and e2e coverage added throughout; see
.superpowers/sdd/2026-09-15-host-events-and-user-actions/final-fixes-report.md
for the full per-finding verification detail, including a pre-existing
(unrelated) flake found and worked around while writing the waiting-user
e2e test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
AgentEventDetail required Record<string, unknown>, and TypeScript gives
implicit index signatures to type aliases only — so the natural way to write
a payload, an interface, was rejected. Found wiring up the first real host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
The version bump left package-lock.json recording 0.4.1 and lib-vuetify
peering on ^0.4.0, so npm ci could not resolve — which breaks the docker
build and CI, though a dev tree with node_modules already in place never
notices. Hand-edited rather than regenerated to keep the diff to 4 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…number

6 was exactly the cost of look/click/look/click/look — the most ordinary
thing a person does on a multi-step page — so the reply never fit and the
run was discarded as invalid. Seen twice, both times with the persona
verifying rather than wandering, which is to say the cap was throwing away
precisely the runs worth reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
A judged run against the first real host caught the assistant saying
"tell me what appears on your screen" — the one thing this feature exists
to abolish. The blocks said who wrote them but not that they keep coming,
so the model read them as a one-off. Said in the block itself rather than
in the system prompt: it costs nothing for a host that publishes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
12 was exactly the length of one: a run had the assistant hand over the whole
dataset wizard in a single message and the person execute it in a single turn
— create, type, skip, title, option, continue, with a look between each — so
the reply had no budget left and the run was thrown away. The cost of a guided
scenario is roughly (steps x 2) + 1, and the assistant chooses how many steps
it hands over at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
A pending wait_for_user_action is the assistant standing still by its own
choice, for up to 120 seconds and as much as 600. The composer refused input
for all of it, because it refuses input whenever the chat is streaming — so
someone who wanted to say "actually, never mind" had no way to except finding
the Stop button, which is not the gesture anyone reaches for when they want to
say something.

A message now settles the wait and takes the turn back. An ordinary streaming
turn is genuinely working and still refuses input, so this only ever fires on a
pause the assistant asked for. Typing turns the Stop button into Send, keeping
both gestures reachable: Stop while there is nothing to say, Send once there is.

Two guards had to move, in the composable and in the component, and the second
was silently dropping the message after the first let it through.

Turns now also carry ownership of the shared state. A turn can be superseded
before it has finished unwinding — that is exactly what this change does — and
the aborted turn's catch/finally would otherwise report its abort over its
successor's status, clear its activity and null its abort controller, leaving
Stop inert on a turn that is really running. The same race was reachable
before, through the action buttons, which abort and send in the same tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
@albanm
albanm merged commit f1bc76f into main Sep 16, 2026
4 checks passed
@albanm
albanm deleted the feat-follow-ups branch September 16, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant