diff --git a/nerve/agent/interactive.py b/nerve/agent/interactive.py index 01946f7..40c71aa 100644 --- a/nerve/agent/interactive.py +++ b/nerve/agent/interactive.py @@ -219,6 +219,21 @@ async def _handle_interactive( # cancelled). has_pending then reflects any remaining interaction. self._pending.pop(interaction_id, None) await self._broadcast_awaiting() + # Tell every client this interaction is settled so parallel clients + # clear their pending poll/plan prompt (the answering client cleared + # it locally). Buffered for reconnect replay on the session channel. + # Best-effort: a broadcast failure must not break the interaction flow. + try: + await self._broadcast(self.session_id, { + "type": "interaction_resolved", + "session_id": self.session_id, + "interaction_id": interaction_id, + }) + except Exception as e: # pragma: no cover - defensive + logger.debug( + "Failed to broadcast interaction_resolved for %s: %s", + self.session_id, e, + ) async def _broadcast_awaiting(self) -> None: """Broadcast this session's waiting-for-input state to all clients. diff --git a/tests/test_interactive.py b/tests/test_interactive.py index 45050aa..152cbcd 100644 --- a/tests/test_interactive.py +++ b/tests/test_interactive.py @@ -88,6 +88,37 @@ async def broadcast(channel: str, msg: dict) -> None: unregister_handler("sess-await") +@pytest.mark.asyncio +async def test_resolve_broadcasts_interaction_resolved(): + """Resolving emits a session-scoped interaction_resolved event so parallel + clients clear their pending poll/plan prompt instead of re-prompting.""" + messages: list[tuple[str, dict]] = [] + + async def broadcast(channel: str, msg: dict) -> None: + messages.append((channel, msg)) + + handler = InteractiveToolHandler("sess-resolved", broadcast, interactive_capable=True) + register_handler("sess-resolved", handler) + try: + task = asyncio.create_task( + handler._handle_interactive("AskUserQuestion", {"questions": []}) + ) + await _wait_until_pending(handler) + interaction = next(m for (_c, m) in messages if m["type"] == "interaction") + + assert handler.resolve(interaction["interaction_id"], {"q": "a"}) + await task + + resolved = [(c, m) for (c, m) in messages if m["type"] == "interaction_resolved"] + assert resolved, "expected an interaction_resolved broadcast" + channel, msg = resolved[-1] + assert channel == "sess-resolved" + assert msg["session_id"] == "sess-resolved" + assert msg["interaction_id"] == interaction["interaction_id"] + finally: + unregister_handler("sess-resolved") + + @pytest.mark.asyncio async def test_cancel_all_clears_awaiting(): """Stopping a session mid-wait denies the interaction and clears the flag.""" diff --git a/web/src/api/websocket.ts b/web/src/api/websocket.ts index 3413028..34f9f2e 100644 --- a/web/src/api/websocket.ts +++ b/web/src/api/websocket.ts @@ -17,6 +17,7 @@ export type WSMessage = | { type: 'session_archived'; session_id: string } | { type: 'plan_update'; session_id: string; content: string } | { type: 'interaction'; session_id: string; interaction_id: string; interaction_type: 'question' | 'plan_exit' | 'plan_enter'; tool_name: string; tool_input: Record } + | { type: 'interaction_resolved'; session_id: string; interaction_id: string } | { type: 'subagent_start'; session_id: string; tool_use_id: string; subagent_type: string; description: string; model?: string } | { type: 'subagent_complete'; session_id: string; tool_use_id: string; duration_ms: number; is_error?: boolean } | { type: 'file_changed'; session_id: string; path: string; operation: string; tool_use_id: string } diff --git a/web/src/components/Chat/tools/PlanApprovalBlock.tsx b/web/src/components/Chat/tools/PlanApprovalBlock.tsx index 6ddce44..4bd65aa 100644 --- a/web/src/components/Chat/tools/PlanApprovalBlock.tsx +++ b/web/src/components/Chat/tools/PlanApprovalBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { FileCheck, Play, Ban, Check } from 'lucide-react'; import type { ToolCallBlockData } from '../../../types/chat'; import { useChatStore } from '../../../stores/chatStore'; @@ -16,6 +16,11 @@ export function PlanApprovalBlock({ block }: { block: ToolCallBlockData }) { (isExitPlan && pendingInteraction.interactionType === 'plan_exit') || (isEnterPlan && pendingInteraction.interactionType === 'plan_enter') ); + // Latch that the prompt was once live. A later disappearance then means it was + // resolved (here or by a parallel client) — distinguishes the post-resolution + // settling window from the pre-prompt one so we don't show a stale "waiting". + const seenInteractive = useRef(false); + if (isInteractive) seenInteractive.current = true; // Already responded or tool completed if (responded || block.status === 'complete') { @@ -35,14 +40,18 @@ export function PlanApprovalBlock({ block }: { block: ToolCallBlockData }) { ); } - // Waiting for user input + // Waiting for user input (pre-prompt), or settling after another client + // resolved it while this client's tool_result hasn't landed yet. if (!isInteractive) { + const settling = seenInteractive.current; return (
- {isExitPlan ? 'Waiting to approve plan...' : 'Waiting to enter plan mode...'} + {settling + ? 'Resolving…' + : (isExitPlan ? 'Waiting to approve plan...' : 'Waiting to enter plan mode...')}
diff --git a/web/src/components/Chat/tools/QuestionBlock.tsx b/web/src/components/Chat/tools/QuestionBlock.tsx index 4711f4f..0d8ce8a 100644 --- a/web/src/components/Chat/tools/QuestionBlock.tsx +++ b/web/src/components/Chat/tools/QuestionBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { MessageCircleQuestion, Check, Send } from 'lucide-react'; import type { ToolCallBlockData } from '../../../types/chat'; import { MarkdownContent } from '../MarkdownContent'; @@ -17,19 +17,55 @@ interface Question { multiSelect: boolean; } +// Recover the chosen option labels from the persisted tool_result, whose shape is +// `... "question"="label, label" ...`, so a reloaded poll re-highlights the answer. +function parseChosenSelections(result: string, questions: Question[]): Map> { + const chosen = new Map>(); + questions.forEach((q, qIdx) => { + const needle = `"${q.question}"="`; + const start = result.indexOf(needle); + if (start === -1) return; + const valStart = start + needle.length; + const valEnd = result.indexOf('"', valStart); + if (valEnd === -1) return; + // Recover the chosen labels as exact ", "-joined tokens. (A label that + // itself contains ", " won't re-highlight — cosmetic, reload-only.) + const chosenLabels = new Set(result.slice(valStart, valEnd).split(', ')); + const set = new Set(); + q.options.forEach((opt, oIdx) => { + if (chosenLabels.has(opt.label)) set.add(oIdx); + }); + if (set.size > 0) chosen.set(qIdx, set); + }); + return chosen; +} + export function QuestionBlock({ block }: { block: ToolCallBlockData }) { const questions = (block.input.questions as Question[]) || []; // Per-question selections: Map> const [selections, setSelections] = useState>>(new Map()); const [submitted, setSubmitted] = useState(false); const [hoveredOption, setHoveredOption] = useState<{ q: number; o: number } | null>(null); + const questionPending = useChatStore(s => s.pendingInteraction?.interactionType === 'question'); + // Latch that a live question prompt was seen; once it clears (answered here, + // by a parallel client, or before reconnect replay) the form must lock. + const seenPending = useRef(false); + if (questionPending) seenPending.current = true; if (questions.length === 0) return null; const isSingleSimple = questions.length === 1 && !questions[0].multiSelect; + // A persisted tool_result means the interaction is already resolved — render + // read-only so a reloaded session shows the answer instead of re-prompting. + const parsedSelections = block.result !== undefined ? parseChosenSelections(block.result, questions) : null; + const isResolved = submitted || parsedSelections !== null || (seenPending.current && !questionPending); + // Prefer parsed answers, but fall back to live selections when the result + // string didn't parse (e.g. a quote in a label) so the highlight isn't lost. + const effSelections = parsedSelections && parsedSelections.size > 0 ? parsedSelections : selections; + const handleSelect = (qIdx: number, oIdx: number) => { - if (submitted) return; + if (isResolved) return; setSelections(prev => { const next = new Map(prev); const q = questions[qIdx]; @@ -50,41 +86,23 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) { const submitAnswers = (sel?: Map>) => { const s = sel || selections; - setSubmitted(true); - // Check store at call time (not closure) — the interaction event - // may arrive after the component rendered but before the user clicks. + // Answer only when a live question interaction is pending. Without one the + // poll is already resolved (reload, or answered by a parallel client), so + // never fall back to posting the answer as a fresh chat message. const state = useChatStore.getState(); - const pending = state.pendingInteraction; - const hasInteraction = pending?.interactionType === 'question'; - - if (hasInteraction) { - // Build answers dict for the SDK: { questionText: selectedLabel } - const answers: Record = {}; - for (let i = 0; i < questions.length; i++) { - const chosen = s.get(i); - if (!chosen || chosen.size === 0) continue; - const labels = Array.from(chosen).map(o => questions[i].options[o].label); - answers[questions[i].question] = labels.join(', '); - } - state.answerInteraction(answers); - } else { - // Fallback: send as a regular message (tool already completed / non-interactive) - const parts: string[] = []; - for (let i = 0; i < questions.length; i++) { - const chosen = s.get(i); - if (!chosen || chosen.size === 0) continue; - const labels = Array.from(chosen).map(o => questions[i].options[o].label); - if (questions.length > 1) { - parts.push(`**${questions[i].header}**: ${labels.join(', ')}`); - } else { - parts.push(labels.join(', ')); - } - } - if (parts.length > 0) { - state.sendMessage(parts.join('\n')); - } + if (state.pendingInteraction?.interactionType !== 'question') return; + + setSubmitted(true); + // Build answers dict for the SDK: { questionText: selectedLabel } + const answers: Record = {}; + for (let i = 0; i < questions.length; i++) { + const chosen = s.get(i); + if (!chosen || chosen.size === 0) continue; + const labels = Array.from(chosen).map(o => questions[i].options[o].label); + answers[questions[i].question] = labels.join(', '); } + state.answerInteraction(answers); }; const allAnswered = questions.every((_q, i) => { @@ -114,7 +132,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) { {/* Options */}
{q.options.map((opt, oIdx) => { - const isSelected = selections.get(qIdx)?.has(oIdx) || false; + const isSelected = effSelections.get(qIdx)?.has(oIdx) || false; const isHovered = hoveredOption?.q === qIdx && hoveredOption?.o === oIdx; return ( @@ -123,9 +141,9 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) { onClick={() => handleSelect(qIdx, oIdx)} onMouseEnter={() => setHoveredOption({ q: qIdx, o: oIdx })} onMouseLeave={() => setHoveredOption(null)} - disabled={submitted} + disabled={isResolved} className={`question-option w-full text-left px-3.5 py-2.5 rounded-md border transition-all duration-150 ${ - submitted + isResolved ? isSelected ? 'border-accent/40 bg-accent/10 cursor-default' : 'border-surface-raised bg-bg-sunken opacity-40 cursor-default' @@ -151,7 +169,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
- {opt.markdown && (isHovered || (isSelected && !submitted)) && ( + {opt.markdown && (isHovered || (isSelected && !isResolved)) && (
@@ -164,7 +182,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) { ))} {/* Submit button — shown for multi-question or multiSelect, hidden for single simple question */} - {!isSingleSimple && !submitted && ( + {!isSingleSimple && !isResolved && (
)} - {/* Answered confirmation */} - {submitted && ( + {/* Resolution confirmation — "Answered", or "Closed" when the + interaction ended without an answer (timeout / cancel / deny). */} + {isResolved && (
- - Answered + + + {block.isError ? 'Closed' : 'Answered'} +
)} diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index 15bfbf1..e28065a 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -12,7 +12,7 @@ import { extractTodosFromMessages, extractCCTasksFromMessages } from './helpers/ import { handleThinking, handleToken, handleToolUse, handleToolResult, handleDone, handleStopped, handleError, handleWakeup, handleAutoTurn, handleModelChanged } from './handlers/streamingHandlers'; import { handleSessionUpdated, handleSessionStatus, handleSessionSwitched, handleSessionForked, handleSessionResumed, handleSessionArchived, handleSessionRunning, handleSessionAwaitingInput, handleAnswerInjected } from './handlers/sessionHandlers'; import { handlePlanUpdate, handleSubagentStart, handleSubagentComplete, handleHoaProgress, handleWorkflowProgress } from './handlers/panelHandlers'; -import { handleInteraction, handleFileChanged, handleNotification, handleNotificationAnswered, handleNotificationExpired, handleBackgroundTasksUpdate } from './handlers/auxiliaryHandlers'; +import { handleInteraction, handleInteractionResolved, handleFileChanged, handleNotification, handleNotificationAnswered, handleNotificationExpired, handleBackgroundTasksUpdate } from './handlers/auxiliaryHandlers'; export interface TodoItem { content: string; @@ -66,7 +66,7 @@ const VIEW_SCOPED_EVENTS = new Set([ 'thinking', 'token', 'tool_use', 'tool_result', 'done', 'stopped', 'error', 'wakeup', 'auto_turn', 'model_changed', 'session_status', 'plan_update', 'subagent_start', 'subagent_complete', 'hoa_progress', 'interaction', - 'file_changed', + 'interaction_resolved', 'file_changed', ]); interface ChatState { @@ -741,6 +741,7 @@ export const useChatStore = create((set, get) => ({ case 'workflow_progress': return handleWorkflowProgress(msg, get, set); // Auxiliary case 'interaction': return handleInteraction(msg, get, set); + case 'interaction_resolved': return handleInteractionResolved(msg, get, set); case 'file_changed': return handleFileChanged(msg, get, set); case 'notification': return handleNotification(msg, get, set); case 'notification_answered': return handleNotificationAnswered(msg, get, set); diff --git a/web/src/stores/handlers/auxiliaryHandlers.ts b/web/src/stores/handlers/auxiliaryHandlers.ts index a435cf9..c5e8289 100644 --- a/web/src/stores/handlers/auxiliaryHandlers.ts +++ b/web/src/stores/handlers/auxiliaryHandlers.ts @@ -29,6 +29,18 @@ export function handleInteraction( } } +export function handleInteractionResolved( + msg: Extract, + get: Get, + set: Set, +): void { + // The interaction was answered/denied (possibly by a parallel client) — drop + // the pending prompt so this client's poll/plan UI stops soliciting an answer. + if (get().pendingInteraction?.interactionId === msg.interaction_id) { + set({ pendingInteraction: null }); + } +} + export function handleFileChanged( msg: Extract, _get: Get, diff --git a/web/src/stores/handlers/sessionHandlers.ts b/web/src/stores/handlers/sessionHandlers.ts index a27cc8f..7c2ebda 100644 --- a/web/src/stores/handlers/sessionHandlers.ts +++ b/web/src/stores/handlers/sessionHandlers.ts @@ -42,12 +42,20 @@ export function handleSessionStatus( // Rebuild panel tabs from buffered events const restored = rebuildPanelTabsFromBuffer(bufferedEvents, blocks); - // Restore pending interaction from buffer (last interaction event wins) + // Restore pending interaction from buffer (last interaction event wins), + // but treat any interaction the buffer later marks resolved as settled so a + // mid-turn reconnect doesn't re-prompt for an already-answered poll/plan. + const resolvedInteractionIds = new Set(); + for (const event of bufferedEvents) { + if (event.type === 'interaction_resolved') { + resolvedInteractionIds.add((event as Extract).interaction_id); + } + } let restoredInteraction: ReturnType['pendingInteraction'] = null; for (const event of bufferedEvents) { if (event.type === 'interaction') { const ie = event as Extract; - restoredInteraction = { + restoredInteraction = resolvedInteractionIds.has(ie.interaction_id) ? null : { interactionId: ie.interaction_id, interactionType: ie.interaction_type, toolName: ie.tool_name,