an answer that goes nowhere now says so, and one that lands says that too

Second half of the AskUserQuestion report: "when I answer, the agent does not react — I see the bubble
for a long time and end up sending a message anyway", intermittently and not reproducible.

Two different causes, both invisible from the outside.

A DROPPED answer was silent at every step. user-instance discarded the boolean, sendFire does nothing
when no sidecar is connected, and the lookup returns undefined for both "no such session" and "not
yours". So a lost answer and a slow agent looked identical: a card that does not change. It now warns
with the reason, and where a session exists it emits an error into the durable log — the same channel a
successful answer uses, so a second pane sees it too, rather than confirming to the browser that
clicked.

A LANDED answer had the opposite problem. The card goes read-only the moment you pick, but `sent` is
local and optimistic while `message.output` only arrives once the harness has produced something. In
between there is a real gap, and nothing filled it — so an answered card looked exactly like one that
had timed out. It now says "Answer sent — waiting for the agent", which makes the two states different
without pretending to know when the agent will speak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:31:25 +00:00
co-authored by Claude Opus 5
parent 1a215dcfee
commit 8d476129ea
3 changed files with 48 additions and 2 deletions
+25 -1
View File
@@ -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`
@@ -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;