Skip to content

fix(bridge): surface between-turn AskUserQuestion as its own card#274

Merged
floodsung merged 1 commit into
mainfrom
fix/between-turn-question-spontaneous
May 14, 2026
Merged

fix(bridge): surface between-turn AskUserQuestion as its own card#274
floodsung merged 1 commit into
mainfrom
fix/between-turn-question-spontaneous

Conversation

@floodsung

Copy link
Copy Markdown
Contributor

Summary

  • AskUserQuestion works inside a user-initiated turn (the bridge sees state.pendingQuestion and renders a dedicated question card with a typed-reply prompt). Between turns — teammate / /goal / continuation-burst follow-up — the PreToolUse hook in PersistentClaudeExecutor fires with activeTurn === null, so there is no streaming CardState to piggy-back on.
  • The question text only landed inside the coalesced "Agent activity" body, and the user's typed reply was treated as a fresh user turn — which then blocked for 6 minutes on the still-hanging hook (user-visible: "Thinking…" forever).
  • This PR adds a between-turn-question event side-channel so the bridge can render a dedicated question card and route the user's next reply back through the existing executor.resolveQuestion() path.

What changed

src/engines/claude/persistent-executor.ts

  • Extracted parseAskUserQuestionInput() (mirrors the parse logic in stream-processor.extractPendingQuestion) and exported a BetweenTurnQuestionEvent payload shape.
  • In askUserQuestionHook, when this.activeTurn is null, emit between-turn-question after the resolver has been registered in pendingQuestionResolvers (so a fast bridge reply can't race past it). The hook still awaits as before — emission is purely a side-channel.

src/bridge/message-bridge.ts

  • New pendingBetweenTurnQuestions: Map<chatId, { toolUseId, questions, cardMessageId }>.
  • attachSpontaneousHandler subscribes to between-turn-question alongside the existing spontaneous / continuation-turn channels.
  • handleBetweenTurnQuestion() sends a fresh question card via sendQuestionCard (fallback sendCard); single in-flight slot per chatId (older one is marked superseded). Multi-sub-question between-turn calls only surface the first question (rare; logged warning).
  • tryHandleBetweenTurnQuestionReply() runs at the top of handleMessage (right after the /command early-return) — parses the typed reply, calls executor.resolveQuestion(), and updates the question card to a "complete" answered state. Falls back to error state if the executor is gone (e.g. evicted between question and reply).
  • On executor-removed (LRU eviction / /reset), any pending between-turn question card is flushed to an error state so the user isn't left hanging.

tests/between-turn-question.test.ts

  • 7 new tests covering parseAskUserQuestionInput (well-formed / malformed / non-object input) and the executor hook (emits when no activeTurn; does NOT emit when one exists; resolver registered before event; abort cleans up).

Test plan

Automated:

  • npm run build — clean.
  • npx vitest run — 298/298 pass (was 291, +7 new).
  • npm run lint — 0 errors (pre-existing warnings unrelated to this change).

Manual:

  • Start a chat with a teammate / /goal task that triggers AskUserQuestion between user turns.
  • Confirm a dedicated question card appears (separate from the coalesced "Agent activity" card).
  • Reply with the option number or free text — card flips to "✅ Reply: …" state; the agent's main work resumes within seconds (not 6 minutes).
  • Trigger a second between-turn question without answering the first — the older card is marked "Superseded by a newer question." and the new card supersedes it.
  • During a between-turn question, run /reset — question card flips to "Question canceled — agent session ended." and a subsequent user message starts a new turn cleanly.

🤖 Generated with Claude Code

When AskUserQuestion fires between user turns (teammate / /goal /
continuation-burst follow-up), the PreToolUse hook in
PersistentClaudeExecutor has no `activeTurn` to piggy-back on. The
question text only landed inside the coalesced "Agent activity" body, and
the user's typed reply was treated as a fresh user turn — which then
blocked for 6 minutes on the still-hanging hook ("Thinking…" forever).

Fix: when the hook trips with no activeTurn, additionally emit a
`between-turn-question` event with the parsed questions payload. The
bridge subscribes alongside the existing `spontaneous` /
`continuation-turn` channels and:
  - sends a dedicated question card via sendQuestionCard
  - intercepts the user's next typed reply for that chatId in
    handleMessage (before runningTasks / queue branches)
  - routes the parsed answer back via executor.resolveQuestion(),
    unblocking the hook and letting the SDK proceed
  - tears the card down to error state if the executor is evicted

Multi-sub-question between-turn calls only surface the first question
(rare; logged) — same single-question-at-a-time pattern as runOneTurn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@floodsung floodsung merged commit fff0e55 into main May 14, 2026
3 checks passed
@floodsung floodsung deleted the fix/between-turn-question-spontaneous branch May 14, 2026 08:28
floodsung added a commit that referenced this pull request May 14, 2026
`handleContinuationTurn` renders the agent's response to an SDK-injected
`<task-notification>` (background bash return etc.) as a fresh streaming
card. Before this fix, if the agent invoked AskUserQuestion *inside* that
continuation turn, the stream processor would set
`state.pendingQuestion` but the loop body just kept updating the main
card with the question text inline — no dedicated question card, no
reply routing. The user's typed reply was treated as a fresh user turn
that immediately blocked for 6 minutes on the still-hanging hook.

Fix: reuse the standalone between-turn question pipeline from #274. When
the continuation stream yields `waiting_for_input` + `pendingQuestion`:
  - update the main card with a "_Waiting for your answer to the
    question card below…_" hint (drops `pendingQuestion`, mirrors the
    pattern in runOneTurn)
  - call `handleBetweenTurnQuestion` to surface a dedicated v1 question
    card and register the toolUseId for reply interception in
    handleMessage
  - track surfaced toolUseIds in a per-stream Set so we don't re-send
    the card on every subsequent delta while the same question is still
    waiting

Same `executor.resolveQuestion()` path unblocks the hook; the
continuation stream then continues normally.

Co-authored-by: Flood Sung <floodsung@xvirobotics.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
SimonYeyi pushed a commit to SimonYeyi/metabot that referenced this pull request May 26, 2026
…irobotics#274)

When AskUserQuestion fires between user turns (teammate / /goal /
continuation-burst follow-up), the PreToolUse hook in
PersistentClaudeExecutor has no `activeTurn` to piggy-back on. The
question text only landed inside the coalesced "Agent activity" body, and
the user's typed reply was treated as a fresh user turn — which then
blocked for 6 minutes on the still-hanging hook ("Thinking…" forever).

Fix: when the hook trips with no activeTurn, additionally emit a
`between-turn-question` event with the parsed questions payload. The
bridge subscribes alongside the existing `spontaneous` /
`continuation-turn` channels and:
  - sends a dedicated question card via sendQuestionCard
  - intercepts the user's next typed reply for that chatId in
    handleMessage (before runningTasks / queue branches)
  - routes the parsed answer back via executor.resolveQuestion(),
    unblocking the hook and letting the SDK proceed
  - tears the card down to error state if the executor is evicted

Multi-sub-question between-turn calls only surface the first question
(rare; logged) — same single-question-at-a-time pattern as runOneTurn.

Co-authored-by: Flood Sung <floodsung@xvirobotics.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
SimonYeyi pushed a commit to SimonYeyi/metabot that referenced this pull request May 26, 2026
…otics#275)

`handleContinuationTurn` renders the agent's response to an SDK-injected
`<task-notification>` (background bash return etc.) as a fresh streaming
card. Before this fix, if the agent invoked AskUserQuestion *inside* that
continuation turn, the stream processor would set
`state.pendingQuestion` but the loop body just kept updating the main
card with the question text inline — no dedicated question card, no
reply routing. The user's typed reply was treated as a fresh user turn
that immediately blocked for 6 minutes on the still-hanging hook.

Fix: reuse the standalone between-turn question pipeline from xvirobotics#274. When
the continuation stream yields `waiting_for_input` + `pendingQuestion`:
  - update the main card with a "_Waiting for your answer to the
    question card below…_" hint (drops `pendingQuestion`, mirrors the
    pattern in runOneTurn)
  - call `handleBetweenTurnQuestion` to surface a dedicated v1 question
    card and register the toolUseId for reply interception in
    handleMessage
  - track surfaced toolUseIds in a per-stream Set so we don't re-send
    the card on every subsequent delta while the same question is still
    waiting

Same `executor.resolveQuestion()` path unblocks the hook; the
continuation stream then continues normally.

Co-authored-by: Flood Sung <floodsung@xvirobotics.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant