fix(chat): give AskUserQuestion a real answer channel
The harness answered every question itself, instantly, with "The user did not answer the questions." — before the card had painted, let alone been read. The agent then reasoned about a refusal that never happened: on 2026-08-19 it asked the same question three times in a row while the answers sat on the screen. The web UI already worked around it by rendering the card ungated and sending the click back as an ordinary chat message. That got an answer through, but the agent read it as a new instruction arriving from nowhere rather than as the reply to what it asked, and nothing could distinguish a live question from one answered last week — every replayed transcript rendered its old questions as clickable. A PreToolUse hook is the fix, and it has to be a hook rather than canUseTool: under bypassPermissions — which is every session here — the SDK shadows the callback and never calls it. Measured against 0.2.59: a bypassing session's AskUserQuestion reached the hook and never reached canUseTool. Hooks run regardless of permission mode. The hook parks on a promise keyed by the harness's own tool_use_id, and returns `deny` with the human's answer as the reason — which the harness feeds back as the tool result verbatim. "Deny" reads wrong and is right: what it denies is the tool's ability to answer for the user, and what replaces its output is what the user actually said. Held open for 150s in testing with the turn completing normally, so a person has time to think. Keying on tool_use_id rather than an invented id means no new durable event: the questions are already in the session log as an ordinary tool:start, and the answer arrives as its tool:result. One source of truth, replay included — which is what re-enables gating the card on `output`, and with it the staleness fix. The two tests that pinned the old ungated behaviour asserted the workaround as the contract; they now pin the reversal, including a pre-fix transcript reading as settled, which it is. Unanswered questions settle like plans do: 20 minutes, every session-ending path draining them first, and wording that tells the agent the question is still on the user's screen and must not be asked again. Mobile (Butler) has the same workaround and is not fixed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,21 @@ export type ClientMessage =
|
||||
requestId: string;
|
||||
approve: boolean;
|
||||
feedback?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* The user's answer to an `AskUserQuestion` card.
|
||||
*
|
||||
* `toolCallId` is the id the client already received with `tool:start` for that very tool call, so
|
||||
* answering is addressed rather than positional — two questions in one turn, or a second browser
|
||||
* replaying a stale click, both land where they belong.
|
||||
*
|
||||
* `answer` is prose, not an index: what the agent gets back is a sentence, so a picked option, a
|
||||
* multi-select and a typed "something else" all travel the same way.
|
||||
*/
|
||||
type: 'question:answer';
|
||||
toolCallId: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
// The `Task` tool's id, when this piece of output came from a subagent rather than the agent you are
|
||||
|
||||
@@ -243,6 +243,8 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
|
||||
await handlePlanMode(ws, clientMsg);
|
||||
} else if (clientMsg.type === 'plan:decide') {
|
||||
handlePlanDecision(ws, clientMsg);
|
||||
} else if (clientMsg.type === 'question:answer') {
|
||||
handleQuestionAnswer(ws, clientMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error handling WebSocket message', { email: ws.data.email, error: String(err) });
|
||||
@@ -1270,6 +1272,25 @@ function handlePlanDecision(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay the user's answer to the question the agent is blocked on.
|
||||
*
|
||||
* Silent like its plan sibling, and for a stronger reason: the answer's own receipt is the `tool:result`
|
||||
* the sidecar emits when this unblocks it, which is durable and reaches every socket on this conversation.
|
||||
* A direct reply would confirm to the one browser that clicked, and leave a second pane showing a question
|
||||
* that is already answered.
|
||||
*/
|
||||
function handleQuestionAnswer(ws: ServerWebSocket<WSData>, msg: { toolCallId: string; answer: string }): void {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (!sessionId) return;
|
||||
sidecar.answerClaudeQuestion({
|
||||
sessionKey: sessionId,
|
||||
userId: ws.data.userId,
|
||||
toolCallId: msg.toolCallId,
|
||||
answer: msg.answer,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
|
||||
|
||||
@@ -314,6 +314,18 @@ export function decideClaudePlan(params: {
|
||||
sendFire('claude', { type: 'claude:plan-decision', id: nextId(), ...params });
|
||||
}
|
||||
|
||||
// The user's answer to an AskUserQuestion. Fire-and-forget for the same reason as the plan decision: the
|
||||
// visible result is the tool's own output arriving in the durable log, where every socket watching this
|
||||
// conversation sees it — including the one that did not click.
|
||||
export function answerClaudeQuestion(params: {
|
||||
sessionKey: string;
|
||||
userId: number;
|
||||
toolCallId: string;
|
||||
answer: string;
|
||||
}): void {
|
||||
sendFire('claude', { type: 'claude:question-answer', id: nextId(), ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the agent still running a turn for this session?
|
||||
*
|
||||
|
||||
@@ -270,6 +270,16 @@ type PersistentSession = {
|
||||
string,
|
||||
{ resolve: (decision: { approve: boolean; feedback?: string }) => void; timer: ReturnType<typeof setTimeout> }
|
||||
>;
|
||||
/**
|
||||
* AskUserQuestion calls waiting on a human, keyed by the harness's `tool_use_id`.
|
||||
*
|
||||
* Same hazard as `pendingPlans` — each entry is a promise the agent is blocked on — and settled from the
|
||||
* same places, via `settleAllQuestions`. Keyed on the tool call id rather than an id we invent because
|
||||
* the UI already has that one: it arrives with `tool:start` and comes back on the answer, so nothing has
|
||||
* to be correlated by position or by "the newest pending question", which is what breaks when two
|
||||
* questions are asked in one turn.
|
||||
*/
|
||||
pendingQuestions: Map<string, { resolve: (answer: string) => void; timer: ReturnType<typeof setTimeout> }>;
|
||||
/** The tail of the child's stderr, so its exit can carry a reason instead of just a number. */
|
||||
stderrTail: string;
|
||||
/** Whether the child ever produced a single message. False plus a non-zero exit means it never started. */
|
||||
@@ -509,6 +519,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
pendingTasks: new Set<string>(),
|
||||
planMode: params.planMode === true,
|
||||
pendingPlans: new Map(),
|
||||
pendingQuestions: new Map(),
|
||||
interrupted: false,
|
||||
stderrTail: '',
|
||||
sawOutput: false,
|
||||
@@ -579,6 +590,46 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
// returns immediately and never throws: it is a notification, and it must not be able to stall or
|
||||
// fail the compaction it is announcing.
|
||||
hooks: {
|
||||
/**
|
||||
* The answer channel for `AskUserQuestion`, and the reason it is a HOOK rather than `canUseTool`.
|
||||
*
|
||||
* Without one the harness answers the tool itself, instantly, with the literal string "The user did
|
||||
* not answer the questions." — before the card has painted, let alone been read. The agent then acts
|
||||
* on a refusal that never happened: on 2026-08-19 it asked the same question three times in a row
|
||||
* while the answers were sitting on screen.
|
||||
*
|
||||
* `canUseTool` cannot carry this. Under `bypassPermissions` — which is every session here — the SDK
|
||||
* shadows it and never calls it (measured against 0.2.59: a bypassing session's AskUserQuestion
|
||||
* reached the PreToolUse hook and never reached the callback). Hooks run regardless of permission
|
||||
* mode, which is exactly the property needed.
|
||||
*
|
||||
* The mechanism is `permissionDecision: 'deny'` with the answer as its reason, because the harness
|
||||
* feeds that reason back as the TOOL RESULT verbatim. "Deny" reads wrong and is right: the tool is
|
||||
* prevented from resolving itself, and what replaces its output is the human's answer.
|
||||
*
|
||||
* The matcher timeout is deliberately longer than `QUESTION_ANSWER_TIMEOUT_MS` so that OUR timer is
|
||||
* always the one that fires. A hook killed by the harness would fall back to the built-in lie; a
|
||||
* hook that times out here answers in words that are true.
|
||||
*/
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'AskUserQuestion',
|
||||
timeout: Math.round((QUESTION_ANSWER_TIMEOUT_MS + 5 * 60 * 1000) / 1000),
|
||||
hooks: [
|
||||
async (input) => {
|
||||
if (!('tool_use_id' in input) || typeof input.tool_use_id !== 'string') return { continue: true };
|
||||
const answer = await questionGate(session, input.tool_use_id);
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: answer,
|
||||
},
|
||||
};
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
PreCompact: [
|
||||
{
|
||||
hooks: [
|
||||
@@ -741,6 +792,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
// Before the session leaves the map, so nothing is left holding a promise whose resolver has just
|
||||
// become unreachable.
|
||||
settleAllPlans(session, 'The session ended before the plan was answered.');
|
||||
settleAllQuestions(session, 'The session ended before the question was answered.');
|
||||
sessions.delete(sessionKey);
|
||||
}
|
||||
})();
|
||||
@@ -819,6 +871,15 @@ async function applyModelChange(session: PersistentSession, model: string | unde
|
||||
*/
|
||||
const PLAN_DECISION_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long a question waits for a person before the agent is told, truthfully, that it has no answer yet.
|
||||
*
|
||||
* Matched to the plan timeout because the situation is the same one: a human who has walked away, and a turn
|
||||
* that must end rather than hang. What differs is the wording — an unanswered question is not a refusal, and
|
||||
* saying so invites the agent to re-ask, which is the behaviour this whole channel exists to stop.
|
||||
*/
|
||||
const QUESTION_ANSWER_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Hold `ExitPlanMode` open while a human decides.
|
||||
*
|
||||
@@ -874,6 +935,61 @@ async function planGate(
|
||||
return { behavior: 'allow', updatedInput: toolInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Park an `AskUserQuestion` until a person answers it, and hand back what they said.
|
||||
*
|
||||
* Returns the string that BECOMES THE TOOL RESULT, so every branch has to read as an answer to the agent
|
||||
* rather than as a status report about the plumbing.
|
||||
*
|
||||
* No `question:asked` event is emitted, and that is the point of keying on `tool_use_id`: the tool call is
|
||||
* already in the session log as an ordinary `tool:start`, durable and ordered, carrying the questions. A
|
||||
* second copy would be a second source of truth for the same card, and they would disagree the moment one
|
||||
* of them was replayed.
|
||||
*/
|
||||
async function questionGate(session: PersistentSession, toolCallId: string): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
session.pendingQuestions.delete(toolCallId);
|
||||
console.warn(`[claude:question:${session.sessionKey}] no answer in 20m`);
|
||||
resolve(
|
||||
'The user has not answered yet — they may be away from the screen. The question is still on theirs, ' +
|
||||
'so do NOT ask it again. Continue with your best judgement, or stop and wait for their next message.',
|
||||
);
|
||||
}, QUESTION_ANSWER_TIMEOUT_MS);
|
||||
|
||||
session.pendingQuestions.set(toolCallId, { resolve, timer });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every outstanding question, because the session or turn it belonged to is going away.
|
||||
*
|
||||
* Same contract as `settleAllPlans` and for the same reason: an unresolved entry is a turn that never ends.
|
||||
*/
|
||||
function settleAllQuestions(session: PersistentSession, reason: string): void {
|
||||
for (const [toolCallId, pending] of session.pendingQuestions) {
|
||||
clearTimeout(pending.timer);
|
||||
session.pendingQuestions.delete(toolCallId);
|
||||
pending.resolve(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's answer to a pending question. False when nothing is waiting under that tool call id.
|
||||
*
|
||||
* A miss is ordinary, exactly as it is for plans: a reconnected client replaying its last action, or a
|
||||
* second pane answering a question the first already answered.
|
||||
*/
|
||||
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;
|
||||
clearTimeout(pending.timer);
|
||||
session.pendingQuestions.delete(toolCallId);
|
||||
pending.resolve(answer);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every outstanding plan as refused.
|
||||
*
|
||||
@@ -957,6 +1073,7 @@ export async function interruptClaudeSession(sessionKey: string, userId: number)
|
||||
// Stop cannot reach a turn that is blocked on a plan — the agent is not running, it is waiting on us —
|
||||
// so the promise has to be released for the interrupt to have anything to interrupt.
|
||||
settleAllPlans(session, 'The turn was stopped before the plan was answered.');
|
||||
settleAllQuestions(session, 'The turn was stopped before the question was answered.');
|
||||
try {
|
||||
await session.query.interrupt();
|
||||
session.isGenerating = false;
|
||||
@@ -976,6 +1093,7 @@ export function killClaudeSession(sessionKey: string, userId: number): boolean {
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
if (session.stallTimer) clearTimeout(session.stallTimer);
|
||||
settleAllPlans(session, 'The session was closed before the plan was answered.');
|
||||
settleAllQuestions(session, 'The session was closed before the question was answered.');
|
||||
try {
|
||||
session.abort.abort();
|
||||
} catch {
|
||||
|
||||
@@ -301,6 +301,13 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
claudeManager.decideClaudePlan(cmd.sessionKey, cmd.userId, cmd.requestId, cmd.approve, cmd.feedback);
|
||||
break;
|
||||
|
||||
// Answers through the tool result the agent is blocked on, not through a reply — the same reasoning as
|
||||
// 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':
|
||||
claudeManager.answerClaudeQuestion(cmd.sessionKey, cmd.userId, cmd.toolCallId, cmd.answer);
|
||||
break;
|
||||
|
||||
case 'claude:list':
|
||||
reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions(cmd.userId) });
|
||||
break;
|
||||
|
||||
@@ -80,6 +80,22 @@ export type SidecarCommand =
|
||||
approve: boolean;
|
||||
feedback?: string;
|
||||
}
|
||||
/**
|
||||
* The user's answer to an `AskUserQuestion` the agent is blocked on.
|
||||
*
|
||||
* Sibling of `claude:plan-decision` and the second command that resolves a promise the AGENT is waiting
|
||||
* on. `toolCallId` is the harness's own `tool_use_id`, which the client already has from `tool:start` —
|
||||
* so a stale answer replayed by a reconnecting client lands on the question it was meant for, or on
|
||||
* nothing at all, and can never resolve a different one.
|
||||
*/
|
||||
| {
|
||||
type: 'claude:question-answer';
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
userId: number;
|
||||
toolCallId: string;
|
||||
answer: string;
|
||||
}
|
||||
// Which session key owns this transcript? The map lives on the agent's disk, so only it can answer —
|
||||
// see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked.
|
||||
| { type: 'claude:find-session'; id: string; claudeSessionId: string; userId: number }
|
||||
|
||||
Reference in New Issue
Block a user