Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions nerve/agent/interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions tests/test_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions web/src/api/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
| { 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 }
Expand Down
15 changes: 12 additions & 3 deletions web/src/components/Chat/tools/PlanApprovalBlock.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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') {
Expand All @@ -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 (
<div className="my-1.5 border border-border rounded-lg bg-surface overflow-hidden">
<div className="px-3 py-2.5 flex items-center gap-2">
<FileCheck size={14} className="text-text-muted animate-pulse" />
<span className="text-[13px] text-text-muted">
{isExitPlan ? 'Waiting to approve plan...' : 'Waiting to enter plan mode...'}
{settling
? 'Resolving…'
: (isExitPlan ? 'Waiting to approve plan...' : 'Waiting to enter plan mode...')}
</span>
</div>
</div>
Expand Down
107 changes: 64 additions & 43 deletions web/src/components/Chat/tools/QuestionBlock.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<number, Set<number>> {
const chosen = new Map<number, Set<number>>();
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<number>();
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<questionIndex, Set<optionIndex>>
const [selections, setSelections] = useState<Map<number, Set<number>>>(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];
Expand All @@ -50,41 +86,23 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {

const submitAnswers = (sel?: Map<number, Set<number>>) => {
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<string, 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);
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<string, 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);
answers[questions[i].question] = labels.join(', ');
}
state.answerInteraction(answers);
};

const allAnswered = questions.every((_q, i) => {
Expand Down Expand Up @@ -114,7 +132,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
{/* Options */}
<div className="px-3 pb-3 space-y-1.5">
{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 (
Expand All @@ -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'
Expand All @@ -151,7 +169,7 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
</div>
</button>

{opt.markdown && (isHovered || (isSelected && !submitted)) && (
{opt.markdown && (isHovered || (isSelected && !isResolved)) && (
<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">
<MarkdownContent content={opt.markdown} />
</div>
Expand All @@ -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 && (
<div className="px-3 pb-3">
<button
onClick={() => submitAnswers()}
Expand All @@ -181,11 +199,14 @@ export function QuestionBlock({ block }: { block: ToolCallBlockData }) {
</div>
)}

{/* Answered confirmation */}
{submitted && (
{/* Resolution confirmation — "Answered", or "Closed" when the
interaction ended without an answer (timeout / cancel / deny). */}
{isResolved && (
<div className="px-4 py-2 border-t border-accent/10 flex items-center gap-2">
<Check size={12} className="text-hue-green" />
<span className="text-[11px] text-hue-green/70">Answered</span>
<Check size={12} className={block.isError ? 'text-text-faint' : 'text-hue-green'} />
<span className={`text-[11px] ${block.isError ? 'text-text-faint' : 'text-hue-green/70'}`}>
{block.isError ? 'Closed' : 'Answered'}
</span>
</div>
)}
</div>
Expand Down
5 changes: 3 additions & 2 deletions web/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,7 +66,7 @@ const VIEW_SCOPED_EVENTS = new Set<WSMessage['type']>([
'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 {
Expand Down Expand Up @@ -741,6 +741,7 @@ export const useChatStore = create<ChatState>((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);
Expand Down
12 changes: 12 additions & 0 deletions web/src/stores/handlers/auxiliaryHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export function handleInteraction(
}
}

export function handleInteractionResolved(
msg: Extract<WSMessage, { type: 'interaction_resolved' }>,
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<WSMessage, { type: 'file_changed' }>,
_get: Get,
Expand Down
12 changes: 10 additions & 2 deletions web/src/stores/handlers/sessionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
for (const event of bufferedEvents) {
if (event.type === 'interaction_resolved') {
resolvedInteractionIds.add((event as Extract<WSMessage, { type: 'interaction_resolved' }>).interaction_id);
}
}
let restoredInteraction: ReturnType<Get>['pendingInteraction'] = null;
for (const event of bufferedEvents) {
if (event.type === 'interaction') {
const ie = event as Extract<WSMessage, { type: 'interaction' }>;
restoredInteraction = {
restoredInteraction = resolvedInteractionIds.has(ie.interaction_id) ? null : {
interactionId: ie.interaction_id,
interactionType: ie.interaction_type,
toolName: ie.tool_name,
Expand Down
Loading