Skip to content

Commit 87a8422

Browse files
GrigoryPervakovclaudepufit
authored
Persist and broadcast interactive poll/plan resolution (#156)
Reloading a session re-rendered an already-answered AskUserQuestion poll as a fresh interactive form, and clicking it posted a stray chat message. The resolved state was never broadcast either, so parallel clients and mid-turn reconnects kept soliciting an already-given answer. Backend: - broadcast an interaction_resolved event when an interaction settles (answer/deny/timeout/cancel), buffered for reconnect replay Frontend: - QuestionBlock renders read-only from the persisted tool_result, re-highlights the chosen options, and drops the sendMessage fallback that posted answers as new chat messages - both poll and plan blocks lock once a seen prompt clears (parallel client / pre-reconnect-replay window) - clear pendingInteraction on interaction_resolved and skip re-restoring a resolved interaction during reconnect buffer replay - show "Closed" instead of "Answered" when an interaction ends without an answer Adds a test for the resolution broadcast. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: pufit <pufit.dev@gmail.com>
1 parent 8624604 commit 87a8422

8 files changed

Lines changed: 148 additions & 50 deletions

File tree

nerve/agent/interactive.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,21 @@ async def _handle_interactive(
219219
# cancelled). has_pending then reflects any remaining interaction.
220220
self._pending.pop(interaction_id, None)
221221
await self._broadcast_awaiting()
222+
# Tell every client this interaction is settled so parallel clients
223+
# clear their pending poll/plan prompt (the answering client cleared
224+
# it locally). Buffered for reconnect replay on the session channel.
225+
# Best-effort: a broadcast failure must not break the interaction flow.
226+
try:
227+
await self._broadcast(self.session_id, {
228+
"type": "interaction_resolved",
229+
"session_id": self.session_id,
230+
"interaction_id": interaction_id,
231+
})
232+
except Exception as e: # pragma: no cover - defensive
233+
logger.debug(
234+
"Failed to broadcast interaction_resolved for %s: %s",
235+
self.session_id, e,
236+
)
222237

223238
async def _broadcast_awaiting(self) -> None:
224239
"""Broadcast this session's waiting-for-input state to all clients.

tests/test_interactive.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,37 @@ async def broadcast(channel: str, msg: dict) -> None:
8888
unregister_handler("sess-await")
8989

9090

91+
@pytest.mark.asyncio
92+
async def test_resolve_broadcasts_interaction_resolved():
93+
"""Resolving emits a session-scoped interaction_resolved event so parallel
94+
clients clear their pending poll/plan prompt instead of re-prompting."""
95+
messages: list[tuple[str, dict]] = []
96+
97+
async def broadcast(channel: str, msg: dict) -> None:
98+
messages.append((channel, msg))
99+
100+
handler = InteractiveToolHandler("sess-resolved", broadcast, interactive_capable=True)
101+
register_handler("sess-resolved", handler)
102+
try:
103+
task = asyncio.create_task(
104+
handler._handle_interactive("AskUserQuestion", {"questions": []})
105+
)
106+
await _wait_until_pending(handler)
107+
interaction = next(m for (_c, m) in messages if m["type"] == "interaction")
108+
109+
assert handler.resolve(interaction["interaction_id"], {"q": "a"})
110+
await task
111+
112+
resolved = [(c, m) for (c, m) in messages if m["type"] == "interaction_resolved"]
113+
assert resolved, "expected an interaction_resolved broadcast"
114+
channel, msg = resolved[-1]
115+
assert channel == "sess-resolved"
116+
assert msg["session_id"] == "sess-resolved"
117+
assert msg["interaction_id"] == interaction["interaction_id"]
118+
finally:
119+
unregister_handler("sess-resolved")
120+
121+
91122
@pytest.mark.asyncio
92123
async def test_cancel_all_clears_awaiting():
93124
"""Stopping a session mid-wait denies the interaction and clears the flag."""

web/src/api/websocket.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type WSMessage =
1717
| { type: 'session_archived'; session_id: string }
1818
| { type: 'plan_update'; session_id: string; content: string }
1919
| { type: 'interaction'; session_id: string; interaction_id: string; interaction_type: 'question' | 'plan_exit' | 'plan_enter'; tool_name: string; tool_input: Record<string, unknown> }
20+
| { type: 'interaction_resolved'; session_id: string; interaction_id: string }
2021
| { type: 'subagent_start'; session_id: string; tool_use_id: string; subagent_type: string; description: string; model?: string }
2122
| { type: 'subagent_complete'; session_id: string; tool_use_id: string; duration_ms: number; is_error?: boolean }
2223
| { type: 'file_changed'; session_id: string; path: string; operation: string; tool_use_id: string }

web/src/components/Chat/tools/PlanApprovalBlock.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useState, useRef } from 'react';
22
import { FileCheck, Play, Ban, Check } from 'lucide-react';
33
import type { ToolCallBlockData } from '../../../types/chat';
44
import { useChatStore } from '../../../stores/chatStore';
@@ -16,6 +16,11 @@ export function PlanApprovalBlock({ block }: { block: ToolCallBlockData }) {
1616
(isExitPlan && pendingInteraction.interactionType === 'plan_exit') ||
1717
(isEnterPlan && pendingInteraction.interactionType === 'plan_enter')
1818
);
19+
// Latch that the prompt was once live. A later disappearance then means it was
20+
// resolved (here or by a parallel client) — distinguishes the post-resolution
21+
// settling window from the pre-prompt one so we don't show a stale "waiting".
22+
const seenInteractive = useRef(false);
23+
if (isInteractive) seenInteractive.current = true;
1924

2025
// Already responded or tool completed
2126
if (responded || block.status === 'complete') {
@@ -35,14 +40,18 @@ export function PlanApprovalBlock({ block }: { block: ToolCallBlockData }) {
3540
);
3641
}
3742

38-
// Waiting for user input
43+
// Waiting for user input (pre-prompt), or settling after another client
44+
// resolved it while this client's tool_result hasn't landed yet.
3945
if (!isInteractive) {
46+
const settling = seenInteractive.current;
4047
return (
4148
<div className="my-1.5 border border-border rounded-lg bg-surface overflow-hidden">
4249
<div className="px-3 py-2.5 flex items-center gap-2">
4350
<FileCheck size={14} className="text-text-muted animate-pulse" />
4451
<span className="text-[13px] text-text-muted">
45-
{isExitPlan ? 'Waiting to approve plan...' : 'Waiting to enter plan mode...'}
52+
{settling
53+
? 'Resolving…'
54+
: (isExitPlan ? 'Waiting to approve plan...' : 'Waiting to enter plan mode...')}
4655
</span>
4756
</div>
4857
</div>

web/src/components/Chat/tools/QuestionBlock.tsx

Lines changed: 64 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useState, useRef } from 'react';
22
import { MessageCircleQuestion, Check, Send } from 'lucide-react';
33
import type { ToolCallBlockData } from '../../../types/chat';
44
import { MarkdownContent } from '../MarkdownContent';
@@ -17,19 +17,55 @@ interface Question {
1717
multiSelect: boolean;
1818
}
1919

20+
// Recover the chosen option labels from the persisted tool_result, whose shape is
21+
// `... "question"="label, label" ...`, so a reloaded poll re-highlights the answer.
22+
function parseChosenSelections(result: string, questions: Question[]): Map<number, Set<number>> {
23+
const chosen = new Map<number, Set<number>>();
24+
questions.forEach((q, qIdx) => {
25+
const needle = `"${q.question}"="`;
26+
const start = result.indexOf(needle);
27+
if (start === -1) return;
28+
const valStart = start + needle.length;
29+
const valEnd = result.indexOf('"', valStart);
30+
if (valEnd === -1) return;
31+
// Recover the chosen labels as exact ", "-joined tokens. (A label that
32+
// itself contains ", " won't re-highlight — cosmetic, reload-only.)
33+
const chosenLabels = new Set(result.slice(valStart, valEnd).split(', '));
34+
const set = new Set<number>();
35+
q.options.forEach((opt, oIdx) => {
36+
if (chosenLabels.has(opt.label)) set.add(oIdx);
37+
});
38+
if (set.size > 0) chosen.set(qIdx, set);
39+
});
40+
return chosen;
41+
}
42+
2043
export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
2144
const questions = (block.input.questions as Question[]) || [];
2245
// Per-question selections: Map<questionIndex, Set<optionIndex>>
2346
const [selections, setSelections] = useState<Map<number, Set<number>>>(new Map());
2447
const [submitted, setSubmitted] = useState(false);
2548
const [hoveredOption, setHoveredOption] = useState<{ q: number; o: number } | null>(null);
49+
const questionPending = useChatStore(s => s.pendingInteraction?.interactionType === 'question');
50+
// Latch that a live question prompt was seen; once it clears (answered here,
51+
// by a parallel client, or before reconnect replay) the form must lock.
52+
const seenPending = useRef(false);
53+
if (questionPending) seenPending.current = true;
2654

2755
if (questions.length === 0) return null;
2856

2957
const isSingleSimple = questions.length === 1 && !questions[0].multiSelect;
3058

59+
// A persisted tool_result means the interaction is already resolved — render
60+
// read-only so a reloaded session shows the answer instead of re-prompting.
61+
const parsedSelections = block.result !== undefined ? parseChosenSelections(block.result, questions) : null;
62+
const isResolved = submitted || parsedSelections !== null || (seenPending.current && !questionPending);
63+
// Prefer parsed answers, but fall back to live selections when the result
64+
// string didn't parse (e.g. a quote in a label) so the highlight isn't lost.
65+
const effSelections = parsedSelections && parsedSelections.size > 0 ? parsedSelections : selections;
66+
3167
const handleSelect = (qIdx: number, oIdx: number) => {
32-
if (submitted) return;
68+
if (isResolved) return;
3369
setSelections(prev => {
3470
const next = new Map(prev);
3571
const q = questions[qIdx];
@@ -50,41 +86,23 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
5086

5187
const submitAnswers = (sel?: Map<number, Set<number>>) => {
5288
const s = sel || selections;
53-
setSubmitted(true);
5489

55-
// Check store at call time (not closure) — the interaction event
56-
// may arrive after the component rendered but before the user clicks.
90+
// Answer only when a live question interaction is pending. Without one the
91+
// poll is already resolved (reload, or answered by a parallel client), so
92+
// never fall back to posting the answer as a fresh chat message.
5793
const state = useChatStore.getState();
58-
const pending = state.pendingInteraction;
59-
const hasInteraction = pending?.interactionType === 'question';
60-
61-
if (hasInteraction) {
62-
// Build answers dict for the SDK: { questionText: selectedLabel }
63-
const answers: Record<string, string> = {};
64-
for (let i = 0; i < questions.length; i++) {
65-
const chosen = s.get(i);
66-
if (!chosen || chosen.size === 0) continue;
67-
const labels = Array.from(chosen).map(o => questions[i].options[o].label);
68-
answers[questions[i].question] = labels.join(', ');
69-
}
70-
state.answerInteraction(answers);
71-
} else {
72-
// Fallback: send as a regular message (tool already completed / non-interactive)
73-
const parts: string[] = [];
74-
for (let i = 0; i < questions.length; i++) {
75-
const chosen = s.get(i);
76-
if (!chosen || chosen.size === 0) continue;
77-
const labels = Array.from(chosen).map(o => questions[i].options[o].label);
78-
if (questions.length > 1) {
79-
parts.push(`**${questions[i].header}**: ${labels.join(', ')}`);
80-
} else {
81-
parts.push(labels.join(', '));
82-
}
83-
}
84-
if (parts.length > 0) {
85-
state.sendMessage(parts.join('\n'));
86-
}
94+
if (state.pendingInteraction?.interactionType !== 'question') return;
95+
96+
setSubmitted(true);
97+
// Build answers dict for the SDK: { questionText: selectedLabel }
98+
const answers: Record<string, string> = {};
99+
for (let i = 0; i < questions.length; i++) {
100+
const chosen = s.get(i);
101+
if (!chosen || chosen.size === 0) continue;
102+
const labels = Array.from(chosen).map(o => questions[i].options[o].label);
103+
answers[questions[i].question] = labels.join(', ');
87104
}
105+
state.answerInteraction(answers);
88106
};
89107

90108
const allAnswered = questions.every((_q, i) => {
@@ -114,7 +132,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
114132
{/* Options */}
115133
<div className="px-3 pb-3 space-y-1.5">
116134
{q.options.map((opt, oIdx) => {
117-
const isSelected = selections.get(qIdx)?.has(oIdx) || false;
135+
const isSelected = effSelections.get(qIdx)?.has(oIdx) || false;
118136
const isHovered = hoveredOption?.q === qIdx && hoveredOption?.o === oIdx;
119137

120138
return (
@@ -123,9 +141,9 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
123141
onClick={() => handleSelect(qIdx, oIdx)}
124142
onMouseEnter={() => setHoveredOption({ q: qIdx, o: oIdx })}
125143
onMouseLeave={() => setHoveredOption(null)}
126-
disabled={submitted}
144+
disabled={isResolved}
127145
className={`question-option w-full text-left px-3.5 py-2.5 rounded-md border transition-all duration-150 ${
128-
submitted
146+
isResolved
129147
? isSelected
130148
? 'border-accent/40 bg-accent/10 cursor-default'
131149
: 'border-surface-raised bg-bg-sunken opacity-40 cursor-default'
@@ -151,7 +169,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
151169
</div>
152170
</button>
153171

154-
{opt.markdown && (isHovered || (isSelected && !submitted)) && (
172+
{opt.markdown && (isHovered || (isSelected && !isResolved)) && (
155173
<div className="mx-2 mt-1 mb-0.5 px-3 py-2 bg-bg border border-border-subtle rounded text-[12px] max-h-48 overflow-y-auto">
156174
<MarkdownContent content={opt.markdown} />
157175
</div>
@@ -164,7 +182,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
164182
))}
165183

166184
{/* Submit button — shown for multi-question or multiSelect, hidden for single simple question */}
167-
{!isSingleSimple && !submitted && (
185+
{!isSingleSimple && !isResolved && (
168186
<div className="px-3 pb-3">
169187
<button
170188
onClick={() => submitAnswers()}
@@ -181,11 +199,14 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
181199
</div>
182200
)}
183201

184-
{/* Answered confirmation */}
185-
{submitted && (
202+
{/* Resolution confirmation — "Answered", or "Closed" when the
203+
interaction ended without an answer (timeout / cancel / deny). */}
204+
{isResolved && (
186205
<div className="px-4 py-2 border-t border-accent/10 flex items-center gap-2">
187-
<Check size={12} className="text-hue-green" />
188-
<span className="text-[11px] text-hue-green/70">Answered</span>
206+
<Check size={12} className={block.isError ? 'text-text-faint' : 'text-hue-green'} />
207+
<span className={`text-[11px] ${block.isError ? 'text-text-faint' : 'text-hue-green/70'}`}>
208+
{block.isError ? 'Closed' : 'Answered'}
209+
</span>
189210
</div>
190211
)}
191212
</div>

web/src/stores/chatStore.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { extractTodosFromMessages, extractCCTasksFromMessages } from './helpers/
1212
import { handleThinking, handleToken, handleToolUse, handleToolResult, handleDone, handleStopped, handleError, handleWakeup, handleAutoTurn, handleModelChanged } from './handlers/streamingHandlers';
1313
import { handleSessionUpdated, handleSessionStatus, handleSessionSwitched, handleSessionForked, handleSessionResumed, handleSessionArchived, handleSessionRunning, handleSessionAwaitingInput, handleAnswerInjected, handleUserMessage } from './handlers/sessionHandlers';
1414
import { handlePlanUpdate, handleSubagentStart, handleSubagentComplete, handleHoaProgress, handleWorkflowProgress } from './handlers/panelHandlers';
15-
import { handleInteraction, handleFileChanged, handleNotification, handleNotificationAnswered, handleNotificationExpired, handleBackgroundTasksUpdate } from './handlers/auxiliaryHandlers';
15+
import { handleInteraction, handleInteractionResolved, handleFileChanged, handleNotification, handleNotificationAnswered, handleNotificationExpired, handleBackgroundTasksUpdate } from './handlers/auxiliaryHandlers';
1616

1717
export interface TodoItem {
1818
content: string;
@@ -66,7 +66,7 @@ const VIEW_SCOPED_EVENTS = new Set<WSMessage['type']>([
6666
'thinking', 'token', 'tool_use', 'tool_result', 'done', 'stopped', 'error',
6767
'wakeup', 'auto_turn', 'model_changed', 'session_status', 'plan_update',
6868
'subagent_start', 'subagent_complete', 'hoa_progress', 'interaction',
69-
'file_changed',
69+
'interaction_resolved', 'file_changed',
7070
]);
7171

7272
interface ChatState {
@@ -742,6 +742,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
742742
case 'workflow_progress': return handleWorkflowProgress(msg, get, set);
743743
// Auxiliary
744744
case 'interaction': return handleInteraction(msg, get, set);
745+
case 'interaction_resolved': return handleInteractionResolved(msg, get, set);
745746
case 'file_changed': return handleFileChanged(msg, get, set);
746747
case 'notification': return handleNotification(msg, get, set);
747748
case 'notification_answered': return handleNotificationAnswered(msg, get, set);

web/src/stores/handlers/auxiliaryHandlers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ export function handleInteraction(
2929
}
3030
}
3131

32+
export function handleInteractionResolved(
33+
msg: Extract<WSMessage, { type: 'interaction_resolved' }>,
34+
get: Get,
35+
set: Set,
36+
): void {
37+
// The interaction was answered/denied (possibly by a parallel client) — drop
38+
// the pending prompt so this client's poll/plan UI stops soliciting an answer.
39+
if (get().pendingInteraction?.interactionId === msg.interaction_id) {
40+
set({ pendingInteraction: null });
41+
}
42+
}
43+
3244
export function handleFileChanged(
3345
msg: Extract<WSMessage, { type: 'file_changed' }>,
3446
_get: Get,

web/src/stores/handlers/sessionHandlers.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,20 @@ export function handleSessionStatus(
4343
// Rebuild panel tabs from buffered events
4444
const restored = rebuildPanelTabsFromBuffer(bufferedEvents, blocks);
4545

46-
// Restore pending interaction from buffer (last interaction event wins)
46+
// Restore pending interaction from buffer (last interaction event wins),
47+
// but treat any interaction the buffer later marks resolved as settled so a
48+
// mid-turn reconnect doesn't re-prompt for an already-answered poll/plan.
49+
const resolvedInteractionIds = new Set<string>();
50+
for (const event of bufferedEvents) {
51+
if (event.type === 'interaction_resolved') {
52+
resolvedInteractionIds.add((event as Extract<WSMessage, { type: 'interaction_resolved' }>).interaction_id);
53+
}
54+
}
4755
let restoredInteraction: ReturnType<Get>['pendingInteraction'] = null;
4856
for (const event of bufferedEvents) {
4957
if (event.type === 'interaction') {
5058
const ie = event as Extract<WSMessage, { type: 'interaction' }>;
51-
restoredInteraction = {
59+
restoredInteraction = resolvedInteractionIds.has(ie.interaction_id) ? null : {
5260
interactionId: ie.interaction_id,
5361
interactionType: ie.interaction_type,
5462
toolName: ie.tool_name,

0 commit comments

Comments
 (0)