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:
@@ -1091,7 +1091,31 @@ function settleAllQuestions(session: PersistentSession, reason: string): void {
|
|||||||
export function answerClaudeQuestion(sessionKey: string, userId: number, toolCallId: string, answer: string): boolean {
|
export function answerClaudeQuestion(sessionKey: string, userId: number, toolCallId: string, answer: string): boolean {
|
||||||
const session = ownedSession(sessionKey, userId);
|
const session = ownedSession(sessionKey, userId);
|
||||||
const pending = session?.pendingQuestions.get(toolCallId);
|
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);
|
clearTimeout(pending.timer);
|
||||||
session.pendingQuestions.delete(toolCallId);
|
session.pendingQuestions.delete(toolCallId);
|
||||||
// The turn resumes now, so silence stops being legitimate and the watchdog goes back on. `armStall`
|
// 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
|
// 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.
|
// filling in, and that arrives as the `tool:result` this unblocks.
|
||||||
case 'claude:question-answer':
|
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);
|
claudeManager.answerClaudeQuestion(cmd.sessionKey, cmd.userId, cmd.toolCallId, cmd.answer);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MessageCircleQuestion, Check } from 'lucide-react';
|
import { MessageCircleQuestion, Check, Loader2 } from 'lucide-react';
|
||||||
import type { ChatMessage } from '../types';
|
import type { ChatMessage } from '../types';
|
||||||
|
|
||||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="my-1 space-y-3">
|
<div className="my-1 space-y-3">
|
||||||
|
{awaitingAgent && (
|
||||||
|
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span>Answer sent — waiting for the agent</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{questions.map((q, qi) => {
|
{questions.map((q, qi) => {
|
||||||
const multi = isMulti(q);
|
const multi = isMulti(q);
|
||||||
const answer = answers[qi];
|
const answer = answers[qi];
|
||||||
|
|||||||
Reference in New Issue
Block a user