fix question options rendering permanently disabled

AskUserQuestion has no answer channel: it resolves itself the moment it is
called, returning "The user did not answer the questions." before the buttons
have painted. QuestionActivity gated its options on `message.output === undefined`,
so that self-resolve disabled every option on every question, always. Reported
from real use — muted text, no-entry cursor, nothing clickable.

The output means the tool call is over, which is true and irrelevant. The answer
travels back as an ordinary chat message. `sent` is the only real latch.

The test was the worse half: it asserted the greyed-out state was correct, using
the literal self-resolve string as its fixture. Code and test were wrong in the
same direction, so a green suite sat on top of a feature that had never once
worked. Inverted it, and verified it fails against the old logic rather than
just passing against the new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 01:57:16 +00:00
co-authored by Claude Opus 5
parent f8028364f1
commit 8a4b96daf8
2 changed files with 32 additions and 6 deletions
@@ -106,8 +106,14 @@ describe('the escape hatch', () => {
});
});
describe('a question the harness already resolved', () => {
test('is read-only — its answer cannot be changed', () => {
// The regression that made this component dead on arrival, pinned from both sides.
//
// AskUserQuestion has no answer channel: it resolves ITSELF, immediately, with the string below — so a
// real question always carries an output by the time it renders. The component gated on that output, and
// the test here asserted the resulting greyed-out state was correct. Both were wrong in the same
// direction, so the suite passed and every question in the product was unclickable.
describe('a question the SDK has already self-resolved', () => {
test('is still answerable — the output ends the tool call, not the conversation', () => {
const answers: string[] = [];
const resolved = {
...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]),
@@ -115,6 +121,19 @@ describe('a question the harness already resolved', () => {
} as never;
render(<QuestionActivity message={resolved} onAnswer={(t) => answers.push(t)} />);
fireEvent.click(screen.getByText('Postgres'));
expect(answers).toEqual([]);
expect(answers).toEqual(['Postgres']);
});
test('but answering twice is refused — the latch is what we sent, not what the tool returned', () => {
const answers: string[] = [];
render(
<QuestionActivity
message={{ ...ask([{ question: 'Which?', header: 'Store', options: OPTIONS }]), output: 'x' } as never}
onAnswer={(t) => answers.push(t)}
/>,
);
fireEvent.click(screen.getByText('Postgres'));
fireEvent.click(screen.getByText('SQLite'));
expect(answers).toEqual(['Postgres']);
});
});
@@ -67,9 +67,16 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
const questions = input.questions;
if (!questions || questions.length === 0) return null;
// A tool call with an output has already been resolved by the harness; nothing here can change it.
const pending = message.output === undefined;
const isDisabled = sent || !pending;
// Deliberately NOT gated on `message.output`.
//
// AskUserQuestion resolves itself the instant it is called — the SDK has no answer channel, so it
// returns "The user did not answer the questions." before these buttons have painted. Gating on the
// output therefore disabled every option on every question, always: the tool was rendered, greyed out,
// and unanswerable. A test asserted that as correct, so the suite stayed green over a dead feature.
//
// The output says the TOOL CALL is over, which is true and irrelevant. The answer travels back as an
// ordinary chat message, and a message can always be sent. `sent` is the only real latch.
const isDisabled = sent;
/**
* Record one answer, and send once EVERY question has one.