Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/agent/__tests__/wizard-ask-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,79 @@ describe('createWizardAskBridge', () => {
}
});

it('re-arms on each answer, so one clock is not shared by every question', async () => {
vi.useFakeTimers();
try {
// The overlay walks a multi-question request one question at a time
// and resolves once, at the end — so without the re-arm a user still
// filling in a connection form is cut off mid-form and loses every
// field they already typed.
let noteAnswer: (() => void) | undefined;
let settled = false;
const bridge = createWizardAskBridge({
getSource: () => 'postgres',
showQuestion: (_question, { onAnswer }) => {
noteAnswer = onAnswer;
return new Promise<AskAnswers>(() => undefined);
},
timeoutMs: 1000,
});

const promise = bridge.request({
questions: [
{ id: 'host', prompt: 'Host?', kind: 'text' },
{ id: 'port', prompt: 'Port?', kind: 'text' },
],
});
void promise.then(() => {
settled = true;
});

await vi.advanceTimersByTimeAsync(800);
noteAnswer?.();
// Past the request's age limit, but only 800ms of silence.
await vi.advanceTimersByTimeAsync(800);
expect(settled).toBe(false);

await vi.advanceTimersByTimeAsync(200);
await expect(promise).resolves.toMatchObject({ timedOut: true });
} finally {
vi.useRealTimers();
}
});

it('records how many questions the user answered before it expired', async () => {
vi.useFakeTimers();
try {
const bridge = createWizardAskBridge({
getSource: () => 'postgres',
showQuestion: (_question, { onAnswer }) => {
onAnswer();
return new Promise<AskAnswers>(() => undefined);
},
timeoutMs: 1000,
});
const promise = bridge.request({
questions: [
{ id: 'host', prompt: 'Host?', kind: 'text' },
{ id: 'port', prompt: 'Port?', kind: 'text' },
],
});
await vi.advanceTimersByTimeAsync(1000);
await promise;

const cancelledCall = wizardCaptureMock.mock.calls.find(
([name]) => name === 'wizard_ask cancelled',
);
expect(cancelledCall?.[1]).toMatchObject({
questions_answered: 1,
question_count: 2,
});
} finally {
vi.useRealTimers();
}
});

it('aborts only the question whose timeout fired', async () => {
vi.useFakeTimers();
try {
Expand Down
7 changes: 6 additions & 1 deletion src/agent/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,15 @@ export interface AgentInteraction {
/**
* Open a question and resolve with the answers. The bridge that calls this
* owns the timeout, the `__cancelled__` sentinel and the analytics.
*
* `onAnswer` reports that the user answered one question of a request that
* has more to come, so the bridge can re-arm its timeout against the user's
* silence rather than against the age of the whole request. A host that
* answers a request in one shot never calls it.
*/
ask?: (
question: PendingQuestion,
context: { signal: AbortSignal },
context: { signal: AbortSignal; onAnswer?: () => void },
) => Promise<AskAnswers>;
/** Offer an optional step and resolve with whether to keep it. */
taskNotice?: (
Expand Down
47 changes: 37 additions & 10 deletions src/agent/wizard-ask-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,13 @@ export interface WizardAskBridgeOptions {
*/
showQuestion: (
question: PendingQuestion,
context: { signal: AbortSignal },
context: { signal: AbortSignal; onAnswer: () => void },
) => Promise<AskAnswers>;
/**
* Per-question timeout in milliseconds. When the user takes longer than
* this to answer, every unanswered field resolves with the
* {@link CANCELLED_SENTINEL} value. Defaults to {@link DEFAULT_ASK_TIMEOUT_MS}.
* this to answer one question, every unanswered field of the request
* resolves with the {@link CANCELLED_SENTINEL} value. Defaults to
* {@link DEFAULT_ASK_TIMEOUT_MS}.
*/
timeoutMs?: number;
/**
Expand Down Expand Up @@ -137,20 +138,34 @@ export function createWizardAskBridge(
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let settled = false;
let answeredCount = 0;
let cancelForAbort: (() => void) | undefined;
// Re-armed by `onAnswer`, so the clock measures the user's silence and
// not the age of the request. One clock over the whole request is the
// same allowance for a one-line question and for a nine-field database
// form the overlay walks one question at a time — and when it expired on
// a user who was still filling that form in, every field they had
// already typed was discarded and the agent was told nobody was there.
let armTimeout = (): void => undefined;

// Race the user against the timeout and the run. Whichever fires first
// wins. When the timeout or the run wins we also abort this question's
// signal so the host dismisses its overlay: resolving our side alone
// would leave the host's pending-question state set, and the next
// wizard_ask would be rejected as a duplicate request.
const timeoutPromise = new Promise<AskAnswers>((resolve) => {
timer = setTimeout(() => {
timedOut = true;
// Settle first: a host that rejects once dismissed must not win.
resolve(buildCancelledAnswers(questions));
controller.abort();
}, timeoutMs);
armTimeout = () => {
if (settled) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timedOut = true;
// Settle first: a host that rejects once dismissed must not win.
resolve(buildCancelledAnswers(questions));
controller.abort();
}, timeoutMs);
};
armTimeout();
});
const aborted = new Promise<AskAnswers>((resolve) => {
cancelForAbort = () => {
Expand All @@ -162,7 +177,13 @@ export function createWizardAskBridge(

try {
const answers = await Promise.race([
opts.showQuestion(pending, { signal: controller.signal }),
opts.showQuestion(pending, {
signal: controller.signal,
onAnswer: () => {
answeredCount += 1;
armTimeout();
},
}),
timeoutPromise,
aborted,
]);
Expand All @@ -173,6 +194,11 @@ export function createWizardAskBridge(
source: pending.source,
subject,
question_count: questions.length,
// How far the user got before the ask ended. A count, never a
// value: it separates a request nobody touched from one abandoned
// part-filled, which is the difference between an absent user and
// a form that asks for more than they were willing to hand over.
questions_answered: answeredCount,
duration_ms: durationMs,
timed_out: timedOut,
});
Expand All @@ -187,6 +213,7 @@ export function createWizardAskBridge(

return { answers, timedOut };
} finally {
settled = true;
if (timer) clearTimeout(timer);
if (cancelForAbort)
opts.signal?.removeEventListener('abort', cancelForAbort);
Expand Down
2 changes: 2 additions & 0 deletions src/tui/__tests__/store-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ const MUTATIONS: MutationCase[] = [
/** Read-only or notification-plumbing methods, excluded by the task brief. */
const NON_MUTATING = [
'subscribe',
// Forwards the ask bridge's timeout heartbeat; holds no session state.
'noteAskProgress',
'getSnapshot',
'getVersion',
'runInitHooks',
Expand Down
15 changes: 15 additions & 0 deletions src/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,21 @@ describe('WizardStore', () => {
expect(store.currentScreen).not.toBe(Overlay.WizardAsk);
});

it('noteAskProgress reports an answered question to the ask bridge', () => {
const store = createStore();
const onAnswer = vi.fn();
const promise = store.requestQuestion(pending, onAnswer);

store.noteAskProgress();
expect(onAnswer).toHaveBeenCalledTimes(1);

// The request is over: a later call must not re-arm its timeout.
store.resolvePendingQuestion({ goal: 'Find export', audience: 'new' });
store.noteAskProgress();
expect(onAnswer).toHaveBeenCalledTimes(1);
return promise;
});

it('throws when requestQuestion is called while another is pending', () => {
const store = createStore();
void store.requestQuestion(pending);
Expand Down
4 changes: 4 additions & 0 deletions src/tui/screens/WizardAskScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ export const WizardAskScreen = ({ store }: WizardAskScreenProps) => {
if (index + 1 < total) {
setAnswers(next);
setIndex(index + 1);
// The request's timeout is armed per question, and only this screen
// knows a question was answered — the request itself resolves once, at
// the end of the walk.
store.noteAskProgress();
return;
}
store.resolvePendingQuestion(next);
Expand Down
16 changes: 15 additions & 1 deletion src/tui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export class WizardStore {
private _resolveManualAuthCode: ((code: string) => void) | null = null;

/** Resolves the in-flight wizard_ask request. */
private _noteAskProgress: (() => void) | null = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Automated review. Not written by a human.

Nit: the doc comment "Resolves the in-flight wizard_ask request." now sits above _noteAskProgress instead of _resolvePendingQuestion. Move the new field above that comment or give it its own one-line comment.

private _resolvePendingQuestion: ((answers: AskAnswers) => void) | null =
null;

Expand Down Expand Up @@ -629,12 +630,16 @@ export class WizardStore {
* Only one request is in flight at a time — calling this while a request
* is already pending throws.
*/
requestQuestion(question: PendingQuestion): Promise<AskAnswers> {
requestQuestion(
question: PendingQuestion,
onAnswer?: () => void,
): Promise<AskAnswers> {
if (this._resolvePendingQuestion) {
throw new Error(
'requestQuestion called while another wizard_ask request is pending',
);
}
this._noteAskProgress = onAnswer ?? null;
this.$session.setKey('pendingQuestion', question);
this.pushOverlay(Overlay.WizardAsk);
analytics.wizardCapture('wizard_ask shown', {
Expand All @@ -647,13 +652,22 @@ export class WizardStore {
});
}

/**
* Report that the user answered one question of the in-flight request and
* another is coming — the ask bridge's timeout heartbeat.
*/
noteAskProgress(): void {
this._noteAskProgress?.();
}

/**
* Resolve the in-flight wizard_ask request with the user's answers and
* dismiss the overlay. Answers flow back to the agent as the tool result.
*/
resolvePendingQuestion(answers: AskAnswers): void {
const resolve = this._resolvePendingQuestion;
this._resolvePendingQuestion = null;
this._noteAskProgress = null;
this.$session.setKey('pendingQuestion', null);
this.popOverlay();
resolve?.(answers);
Expand Down
6 changes: 4 additions & 2 deletions src/ui/__tests__/agent-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,18 @@ it('forwards answers and notices, leaving the host alone once they settle', asyn
const interaction = uiInteraction(ui);
const asked = new AbortController();
const noticed = new AbortController();
const onAnswer = vi.fn();
await expect(
interaction.ask?.(question, { signal: asked.signal }),
interaction.ask?.(question, { signal: asked.signal, onAnswer }),
).resolves.toEqual({ q: 'yes' });
await expect(
interaction.taskNotice?.(notice, { signal: noticed.signal }),
).resolves.toBe(true);
// A late abort must not dismiss whatever the host shows next.
asked.abort();
noticed.abort();
expect(ask).toHaveBeenCalledWith(question);
// The host gets the bridge's timeout heartbeat alongside the question.
expect(ask).toHaveBeenCalledWith(question, onAnswer);
expect(show).toHaveBeenCalledWith(notice);
expect(cancelAsk).not.toHaveBeenCalled();
expect(cancelNotice).not.toHaveBeenCalled();
Expand Down
4 changes: 2 additions & 2 deletions src/ui/agent-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ export function createUiReducer(ui: WizardUI): (event: AgentProgress) => void {
/** The agent's questions, answered wherever `getUI()` answers them today. */
export function uiInteraction(ui: WizardUI): AgentInteraction {
return {
ask: (question, { signal }) =>
dismissOnAbort(ui.requestQuestion(question), signal, () =>
ask: (question, { signal, onAnswer }) =>
dismissOnAbort(ui.requestQuestion(question, onAnswer), signal, () =>
ui.cancelPendingQuestion(),
),
taskNotice: (notice, { signal }) =>
Expand Down
7 changes: 5 additions & 2 deletions src/ui/tui/ink-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,11 @@ export class InkUI implements WizardUI {
this.store.showSessionTimeout();
}

requestQuestion(question: PendingQuestion): Promise<AskAnswers> {
return this.store.requestQuestion(question);
requestQuestion(
question: PendingQuestion,
onAnswer?: () => void,
): Promise<AskAnswers> {
return this.store.requestQuestion(question, onAnswer);
}

cancelPendingQuestion(): void {
Expand Down
8 changes: 7 additions & 1 deletion src/ui/wizard-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,14 @@ export interface WizardUI {
* Open the wizard_ask overlay and resolve with the user's answers.
* Implementations that can't ask (CI/logging) reject so the bridge can
* surface a clear "not available" error to the agent.
*
* `onAnswer` is the bridge's timeout heartbeat: an overlay that walks a
* multi-question request calls it as each answer goes in.
*/
requestQuestion(question: PendingQuestion): Promise<AskAnswers>;
requestQuestion(
question: PendingQuestion,
onAnswer?: () => void,
): Promise<AskAnswers>;

/**
* Dismiss the in-flight wizard_ask overlay, resolving its request with
Expand Down
Loading