feat(chat): host events and wait_for_user_action - #60
Merged
Merged
Conversation
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-agents0.5.0 —emitAgentEvent(name, detail, { key })for transitions,useAgentState(key, source)forstate. 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 actionso 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_TURNS6 → 25 inlib-sim. The new guided-workflow simulation caseis 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:
duration of a declared wait. Backstopped by the wait's own timer, the abort signal, and
cancelWait()in the turn'sfinally— but the general stall net is off while it waits.withHostConsequences, so they run the settle barrierafter each call; previously they were passed through unwrapped.
serializeDetail(publisher side).sanitizeEvent, thechannel boundary, still takes
detailverbatim — a host posting raw on the channelbypasses the guard.