diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 49f5b5e7..1bf2126c 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -1091,7 +1091,31 @@ function settleAllQuestions(session: PersistentSession, reason: string): void { export function answerClaudeQuestion(sessionKey: string, userId: number, toolCallId: string, answer: string): boolean { const session = ownedSession(sessionKey, userId); const pending = session?.pendingQuestions.get(toolCallId); - if (!session || !pending) return false; + + // ── A dropped answer has to SAY it was dropped ── + // + // Every failure on this path used to be silent: the caller discards the boolean, `sendFire` does + // nothing when no sidecar is connected, and this returns undefined for both "no such session" and + // "not yours". So from the person's side a lost answer and a slow agent are the same thing — a card + // that does not change — and the only move left is to send another message. Reported exactly that way. + // + // The receipt for a GOOD answer is deliberately the tool:result in the durable log, so that a second + // pane sees it too. A failure gets the same treatment for the same reason: an error into the session + // log reaches every socket watching, rather than confirming to the one browser that clicked. + if (!session) { + console.warn(`[claude:question:${sessionKey}] answer for a session this process does not hold`); + return false; + } + if (!pending) { + console.warn(`[claude:question:${sessionKey}] answer for ${toolCallId}, which is no longer waiting`); + session.emit({ + type: 'error', + message: + 'That answer arrived too late — the question had already been given up on, and the agent has ' + + 'moved on. Send it as a message instead.', + }); + return false; + } clearTimeout(pending.timer); session.pendingQuestions.delete(toolCallId); // The turn resumes now, so silence stops being legitimate and the watchdog goes back on. `armStall` diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 8fe97967..96881020 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -321,6 +321,8 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { // the plan commands above, one layer deeper: what the user needs to see is the question's own card // filling in, and that arrives as the `tool:result` this unblocks. case 'claude:question-answer': + // The boolean was discarded here, which is how a dropped answer became invisible. It reports its + // own reason now — this only has to stop throwing it away. claudeManager.answerClaudeQuestion(cmd.sessionKey, cmd.userId, cmd.toolCallId, cmd.answer); break; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/QuestionActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/QuestionActivity.tsx index fdb9777c..19423b4b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/QuestionActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/QuestionActivity.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { MessageCircleQuestion, Check } from 'lucide-react'; +import { MessageCircleQuestion, Check, Loader2 } from 'lucide-react'; import type { ChatMessage } from '../types'; type ToolMessage = Extract; @@ -113,8 +113,28 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) = }); }; + /* + Answered, and the agent has not spoken yet. + + `sent` is local and optimistic; `message.output` is the tool result, which only arrives once the + harness has taken the answer and produced something. Between the two there is a real gap — the agent + may be thinking for a while — and until now nothing filled it: the card simply went read-only, which + looks exactly like a card that TIMED OUT. Reported as "I answer and it does not react, so I send + another message". + + Saying so costs one line and removes the ambiguity: an answered card now looks different from an + abandoned one, without pretending to know when the agent will speak. + */ + const awaitingAgent = sent && message.output === undefined; + return (
+ {awaitingAgent && ( +
+ + Answer sent — waiting for the agent +
+ )} {questions.map((q, qi) => { const multi = isMulti(q); const answer = answers[qi];