From 65dced1758a2cb9667dead31bd08575e742d12dc Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 11:24:20 -0400 Subject: [PATCH 1/3] feat(ask-user): timed waits with explicit timeout result and late-reply follow-ups - ask_user_question accepts optional timeoutSeconds (30-3600). The main-owned coordinator stamps expiresAt and settles on the deadline with a main-only timedOut response. The agent is told to proceed on best judgement or its stated default. - Unattended (Remote-owned) runs always expire within the existing 5-minute Remote question lifetime. Remote streams now project the real agent deadline as expiresAt. There is no wire change or revision bump. - chat:answerQuestionnaire reports answered/expired. The desktop marks expired cards and offers to send or queue a late answer as a follow-up message. - iOS and Android explain an expired question instead of dropping it silently. - Docs: plan, plans index, Remote API expiresAt semantics, memory note, papercuts. Co-Authored-By: Claude Opus 5.5 --- .papercuts/troubleshooting.md | 4 + .../features/chat/AidenChatViewModel.kt | 3 + docs/aiden-remote-api-v1.md | 2 +- docs/plans/README.md | 1 + docs/plans/timed-ask-user-plan.md | 31 ++++++ .../Features/Remote/AidenChatFeature.swift | 3 + main/handlers/chat.ts | 19 +++- main/services/aiden-remote-streams.test.ts | 43 ++++++++ main/services/aiden-remote-streams.ts | 13 ++- .../ask-user-question-coordinator.test.ts | 84 ++++++++++++++++ .../services/ask-user-question-coordinator.ts | 79 ++++++++++++++- .../ask-user-question-extension.test.ts | 52 ++++++++++ main/services/ask-user-question-extension.ts | 54 +++++++--- main/services/llm-client.ts | 37 ++++++- package.json | 2 +- .../ask-user-question-expiry-notice.test.tsx | 36 +++++++ .../ask-user-question-expiry-notice.tsx | 54 ++++++++++ renderer/lib/ipc.ts | 7 +- renderer/main/chat-pane.tsx | 84 ++++++++++++++-- renderer/shared/ask-user-question.test.ts | 70 +++++++++++++ renderer/shared/ask-user-question.ts | 99 +++++++++++++++++++ scripts/ci-test-registry.json | 1 + 22 files changed, 742 insertions(+), 36 deletions(-) create mode 100644 docs/plans/timed-ask-user-plan.md create mode 100644 renderer/components/ask-user-question-expiry-notice.test.tsx create mode 100644 renderer/components/ask-user-question-expiry-notice.tsx diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index ceeb89458..bae920d60 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1404,3 +1404,7 @@ because their native file-mutator test binary had not been built. Run ## 2026-09-26 PR #121 merge of #251 (Remote contract revision 14) - A PR that adds to the Remote contract has to renumber when main bumps `contractRevision`. The conflicts show up in 7 files: both fixtures, the TS/iOS/Android fixture assertions and the iOS fixture CodingKeys. After resolving, `cmp` the Android copy against the shared fixture. Plan docs that name the revision also go stale. + +## 2026-09-27 timed ask-user (feature/timed-ask-user) +- The worktree-isolation guard refuses compound shell commands (python heredoc plus a runner, `cat >> < Date() else { pendingQuestion = nil + // The Mac stopped waiting at expiresAt and the agent continued on + // its own judgement; say so instead of silently dropping the answer. + presentedError = String(localized: "This question expired, so Aiden continued with its best judgement. Send your answer as a message if it should change course.") return } guard let streamID = activeStreamID, diff --git a/main/handlers/chat.ts b/main/handlers/chat.ts index 7acd51f39..e0c94cd06 100644 --- a/main/handlers/chat.ts +++ b/main/handlers/chat.ts @@ -10,6 +10,7 @@ import { llmClient } from "../services/llm-client.js"; import { chatGenerationOwner } from "../services/chat-generation-owner.js"; import { isSafeSubagentIdentifier } from "../../renderer/shared/subagent-runs.js"; import { parseChatRunInput } from "../../renderer/shared/chat-run-input.js"; +import type { AskUserQuestionAnswerStatus } from "../../renderer/shared/ask-user-question.js"; import { parseParams } from "./chat-params.js"; import { geminiLiveService } from "../services/gemini-live/service-main.js"; import { MAX_CHAT_MESSAGE_CONTENT_BYTES } from "../../renderer/shared/chat-message-contract.js"; @@ -162,12 +163,24 @@ export function registerChatGenerationHandlers(): void { ipcMain.handle( "chat:answerQuestionnaire", - async (event, promptId: unknown, response: unknown) => { - if (typeof promptId !== "string" || !promptId) return; + async ( + event, + promptId: unknown, + response: unknown, + ): Promise<{ status: AskUserQuestionAnswerStatus } | undefined> => { + if (typeof promptId !== "string" || !promptId) return undefined; const owner = chatGenerationOwner(event); - if (!llmClient.answerQuestionnaire(promptId, response, owner.documentId)) { + const outcome = llmClient.answerQuestionnaireWithOutcome( + promptId, + response, + owner.documentId, + ); + if (outcome === "rejected") { throw new Error("This renderer document does not own that questionnaire."); } + // "expired" tells the renderer the agent already moved on, so it can + // offer the answer as a follow-up message instead of losing it. + return { status: outcome }; }, ); } diff --git a/main/services/aiden-remote-streams.test.ts b/main/services/aiden-remote-streams.test.ts index 545e544e1..11b9c3a04 100644 --- a/main/services/aiden-remote-streams.test.ts +++ b/main/services/aiden-remote-streams.test.ts @@ -1568,6 +1568,49 @@ test("remote questions expire and are dropped on terminal state", async () => { ); }); +test("remote questions expire at the agent deadline, never later than the Remote lifetime", () => { + let now = Date.parse("2026-09-27T10:00:00.000Z"); + const settled: string[] = []; + const service = new AidenRemoteStreamService({ + now: () => now, + cancel: () => true, + approve: () => true, + respondQuestion: (promptId) => { + settled.push(promptId); + return true; + }, + }); + const owner = service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:questionnaire", { + ...QUESTION_PROMPT, + expiresAt: "2026-09-27T10:01:00.000Z", + }); + assert.equal( + service.pendingQuestion("device-1", "stream-1")?.expiresAt, + "2026-09-27T10:01:00.000Z", + ); + now = Date.parse("2026-09-27T10:01:00.000Z"); + assert.throws( + () => service.questionChatId("device-1", "q-prompt-1"), + (error: unknown) => (error as { code?: string }).code === "question_expired", + ); + // The host is asked to settle it, which the coordinator records as a timeout. + assert.deepEqual(settled, ["q-prompt-1"]); + + const owner2 = service.create("device-1", "stream-2", "chat-1", "turn-2"); + owner2.owner.send("chat:questionnaire", { + ...QUESTION_PROMPT, + promptId: "q-prompt-2", + streamId: "stream-2", + expiresAt: "2026-09-27T12:00:00.000Z", + }); + assert.equal( + service.pendingQuestion("device-1", "stream-2")?.expiresAt, + new Date(now + 5 * 60 * 1_000).toISOString(), + ); + owner2.owner.send("chat:done", { chat: { messages: [{ id: "a-1", role: "assistant" }] } }); +}); + test("questionnaire publishes reject unbound prompts and duplicate stream prompts", () => { const service = new AidenRemoteStreamService({ now: () => 1_000, diff --git a/main/services/aiden-remote-streams.ts b/main/services/aiden-remote-streams.ts index 7ce6f1a35..2b257f56e 100644 --- a/main/services/aiden-remote-streams.ts +++ b/main/services/aiden-remote-streams.ts @@ -1053,17 +1053,26 @@ export class AidenRemoteStreamService { if (this.questions.has(promptId) || this.pendingQuestionForStream(stream.streamId)) { throw new Error("A questionnaire is already pending on this stream."); } - const expiresAt = this.options.now() + QUESTION_LIFETIME_MS; + // The coordinator stamps the agent-facing deadline on the prompt. Use it + // so the phone card expires at the instant the agent stops waiting, but + // never extend past the Remote question lifetime. + const now = this.options.now(); + const agentDeadline = + typeof payload.expiresAt === "string" ? Date.parse(payload.expiresAt) : Number.NaN; + const expiresAt = Number.isFinite(agentDeadline) + ? Math.min(Math.max(agentDeadline, now), now + QUESTION_LIFETIME_MS) + : now + QUESTION_LIFETIME_MS; const expiry = setTimeout(() => { const current = this.questions.get(promptId); if (!current || current.expiresAt !== expiresAt) return; + // The host settles a past-deadline prompt as timed out, not closed. this.resolveQuestion(promptId, { version: ASK_USER_QUESTION_VERSION, promptId, cancelled: true, answers: [], }); - }, QUESTION_LIFETIME_MS); + }, expiresAt - now); expiry.unref?.(); this.questions.set(promptId, { streamId: stream.streamId, diff --git a/main/services/ask-user-question-coordinator.test.ts b/main/services/ask-user-question-coordinator.test.ts index 8b9d23c48..a4fc6c450 100644 --- a/main/services/ask-user-question-coordinator.test.ts +++ b/main/services/ask-user-question-coordinator.test.ts @@ -62,3 +62,87 @@ test("abort and renderer detach settle pending prompts as cancelled", async () = assert.equal(prompts.length, 2); assert.equal(coordinator.pendingCount, 0); }); + +test("an unanswered timed prompt tells the agent no answer arrived and reports late replies as expired", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: Date.parse("2026-09-27T10:00:00.000Z") }); + let published!: AskUserQuestionPromptV1; + const coordinator = new AskUserQuestionCoordinator((prompt) => { + published = prompt; + }); + const pending = coordinator.request( + { streamId: "s-timed", toolCallId: "call-timed", questions, timeoutMs: 90_000 }, + "document-one", + ); + assert.equal(published.expiresAt, "2026-09-27T10:01:30.000Z"); + + t.mock.timers.tick(89_999); + assert.equal(coordinator.pendingCount, 1); + t.mock.timers.tick(1); + const response = await pending; + assert.equal(response.timedOut, true); + assert.deepEqual(response.answers, []); + assert.equal(coordinator.pendingCount, 0); + + const late = { + version: ASK_USER_QUESTION_VERSION, + promptId: published.promptId, + cancelled: false, + answers: [{ questionIndex: 0, kind: "option", answer: "Guided" }], + }; + assert.equal(coordinator.respondWithOutcome(published.promptId, late, "document-one"), "expired"); + // Another document cannot learn about or claim someone else's prompt. + assert.equal(coordinator.respondWithOutcome(published.promptId, late, "document-two"), "rejected"); +}); + +test("an answer that races past the deadline is settled as a timeout, never delivered", async (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1_000_000 }); + let published!: AskUserQuestionPromptV1; + const coordinator = new AskUserQuestionCoordinator((prompt) => { + published = prompt; + }); + const pending = coordinator.request( + { streamId: "s-race", toolCallId: "call-race", questions, timeoutMs: 30_000 }, + "document-one", + ); + // Wall clock passes the deadline before the timer callback gets to run. + t.mock.timers.setTime(1_030_000); + assert.equal( + coordinator.respondWithOutcome( + published.promptId, + { + version: ASK_USER_QUESTION_VERSION, + promptId: published.promptId, + cancelled: false, + answers: [{ questionIndex: 0, kind: "option", answer: "Direct" }], + }, + "document-one", + ), + "expired", + ); + const response = await pending; + assert.equal(response.timedOut, true); + assert.deepEqual(response.answers, []); +}); + +test("prompts without a timeout wait for their owner and carry no deadline", async () => { + let published!: AskUserQuestionPromptV1; + const coordinator = new AskUserQuestionCoordinator((prompt) => { + published = prompt; + }); + const pending = coordinator.request( + { streamId: "s-open", toolCallId: "call-open", questions }, + "document-one", + ); + assert.equal(published.expiresAt, undefined); + const answer = { + version: ASK_USER_QUESTION_VERSION, + promptId: published.promptId, + cancelled: false, + answers: [{ questionIndex: 0, kind: "option", answer: "Direct" }], + } as const; + assert.equal( + coordinator.respondWithOutcome(published.promptId, answer, "document-one"), + "answered", + ); + assert.equal((await pending).timedOut, undefined); +}); diff --git a/main/services/ask-user-question-coordinator.ts b/main/services/ask-user-question-coordinator.ts index 131e4c2a6..d7090fcce 100644 --- a/main/services/ask-user-question-coordinator.ts +++ b/main/services/ask-user-question-coordinator.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { ASK_USER_QUESTION_VERSION, parseAskUserQuestionResponse, + type AskUserQuestionAnswerStatus, type AskUserQuestionPromptV1, type AskUserQuestionResponseV1, type AskUserQuestionV1, @@ -10,11 +11,22 @@ import { interface PendingQuestionnaire { prompt: AskUserQuestionPromptV1; ownerDocumentId: string; + deadline?: number; settle(response: AskUserQuestionResponseV1): void; } +/** + * Prompts that stopped waiting at their deadline. Kept briefly so an owner's + * late answer is reported as expired (and can become a follow-up message) + * instead of looking like an ownership failure. + */ +const MAX_EXPIRED_PROMPTS = 64; + +export type AskUserQuestionRespondOutcome = AskUserQuestionAnswerStatus | "rejected"; + export class AskUserQuestionCoordinator { private readonly pending = new Map(); + private readonly expired = new Map(); private readonly detachedStreams = new Set(); constructor(private readonly publish: (prompt: AskUserQuestionPromptV1) => void) {} @@ -24,14 +36,22 @@ export class AskUserQuestionCoordinator { streamId: string; toolCallId: string; questions: AskUserQuestionV1[]; + /** Stop waiting after this long and tell the agent no answer arrived. */ + timeoutMs?: number; }, ownerDocumentId: string, signal?: AbortSignal, ): Promise { + const { timeoutMs, ...fields } = descriptor; + const deadline = + timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Date.now() + timeoutMs + : undefined; const prompt: AskUserQuestionPromptV1 = { version: ASK_USER_QUESTION_VERSION, promptId: `q-${randomUUID()}`, - ...descriptor, + ...fields, + ...(deadline !== undefined ? { expiresAt: new Date(deadline).toISOString() } : {}), }; const cancelled = (): AskUserQuestionResponseV1 => ({ version: ASK_USER_QUESTION_VERSION, @@ -44,17 +64,21 @@ export class AskUserQuestionCoordinator { } return new Promise((resolve) => { let settled = false; + let timer: ReturnType | undefined; const aborted = () => finish(cancelled()); const finish = (response: AskUserQuestionResponseV1) => { if (settled) return; settled = true; + if (timer) clearTimeout(timer); this.pending.delete(prompt.promptId); signal?.removeEventListener("abort", aborted); + if (response.timedOut) this.rememberExpired(prompt.promptId, ownerDocumentId); resolve(response); }; this.pending.set(prompt.promptId, { prompt, ownerDocumentId, + ...(deadline !== undefined ? { deadline } : {}), settle: finish, }); signal?.addEventListener("abort", aborted, { once: true }); @@ -62,6 +86,10 @@ export class AskUserQuestionCoordinator { aborted(); return; } + if (deadline !== undefined) { + timer = setTimeout(() => finish(timedOut(prompt.promptId)), deadline - Date.now()); + timer.unref?.(); + } try { this.publish(prompt); } catch { @@ -70,13 +98,34 @@ export class AskUserQuestionCoordinator { }); } - respond(promptId: string, value: unknown, ownerDocumentId: string): boolean { + /** + * Settle a prompt from its owner. A prompt whose deadline has passed is + * settled as timed out even if its timer has not fired yet, so the agent + * never receives an answer the owner saw as expired. + */ + respondWithOutcome( + promptId: string, + value: unknown, + ownerDocumentId: string, + ): AskUserQuestionRespondOutcome { const entry = this.pending.get(promptId); - if (!entry || entry.ownerDocumentId !== ownerDocumentId) return false; + if (!entry) { + return this.expired.get(promptId) === ownerDocumentId ? "expired" : "rejected"; + } + if (entry.ownerDocumentId !== ownerDocumentId) return "rejected"; + if (entry.deadline !== undefined && Date.now() >= entry.deadline) { + entry.settle(timedOut(promptId)); + return "expired"; + } const response = parseAskUserQuestionResponse(value, entry.prompt); - if (!response) return false; + if (!response) return "rejected"; entry.settle(response); - return true; + return "answered"; + } + + /** True when the prompt is no longer waiting because of this owner's call. */ + respond(promptId: string, value: unknown, ownerDocumentId: string): boolean { + return this.respondWithOutcome(promptId, value, ownerDocumentId) !== "rejected"; } cancelStream(streamId: string): void { @@ -104,9 +153,29 @@ export class AskUserQuestionCoordinator { shutdown(): void { for (const entry of [...this.pending.values()]) this.cancelStream(entry.prompt.streamId); this.detachedStreams.clear(); + this.expired.clear(); } get pendingCount(): number { return this.pending.size; } + + private rememberExpired(promptId: string, ownerDocumentId: string): void { + this.expired.set(promptId, ownerDocumentId); + while (this.expired.size > MAX_EXPIRED_PROMPTS) { + const oldest = this.expired.keys().next().value; + if (oldest === undefined) break; + this.expired.delete(oldest); + } + } +} + +function timedOut(promptId: string): AskUserQuestionResponseV1 { + return { + version: ASK_USER_QUESTION_VERSION, + promptId, + cancelled: true, + answers: [], + timedOut: true, + }; } diff --git a/main/services/ask-user-question-extension.test.ts b/main/services/ask-user-question-extension.test.ts index eb5dccf43..8d02e19d4 100644 --- a/main/services/ask-user-question-extension.test.ts +++ b/main/services/ask-user-question-extension.test.ts @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { + ASK_USER_MAX_TIMEOUT_SECONDS, + ASK_USER_MIN_TIMEOUT_SECONDS, + ASK_USER_UNATTENDED_TIMEOUT_SECONDS, +} from "../../renderer/shared/ask-user-question.js"; import { createAskUserQuestionExtension, + resolveAskUserQuestionTimeoutMs, shouldEnableAskUserQuestionExtension, } from "./ask-user-question-extension.js"; import { piRuntimeReplayPolicy } from "./pi-runtime-tool.js"; @@ -70,3 +76,49 @@ test("tool returns selected and skipped answers without replaying an interruptio assert.match(text, /Answer: Direct/u); assert.match(text, /Answer: Skipped/u); }); + +const oneQuestion = [ + { + question: "Which approach?", + header: "Approach", + options: [ + { label: "Direct", description: "Implement it now." }, + { label: "Guided", description: "Explain each step." }, + ], + }, +]; + +test("a timed-out questionnaire tells the agent to proceed on its own judgement", async () => { + const requestedTimeouts: Array = []; + const extension = createAskUserQuestionExtension({ + request: async (_toolCallId, _questions, _signal, timeoutSeconds) => { + requestedTimeouts.push(timeoutSeconds); + return { version: 1, promptId: "q-one", cancelled: true, answers: [], timedOut: true }; + }, + }); + const tool = extension.tools?.[0]; + assert.ok(tool); + const result = await tool.execute("call-timed", { questions: oneQuestion, timeoutSeconds: 5 }); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + assert.match(text, /No answer was received/u); + assert.match(text, /best judgement/u); + assert.doesNotMatch(text, /closed the questionnaire/u); + // Out-of-range requests are clamped into the supported window. + assert.deepEqual(requestedTimeouts, [ASK_USER_MIN_TIMEOUT_SECONDS]); + + await tool.execute("call-untimed", { questions: oneQuestion }); + assert.deepEqual(requestedTimeouts, [ASK_USER_MIN_TIMEOUT_SECONDS, undefined]); +}); + +test("attended owners wait unless asked; unattended owners always get a capped deadline", () => { + const cap = ASK_USER_UNATTENDED_TIMEOUT_SECONDS * 1_000; + assert.equal(resolveAskUserQuestionTimeoutMs(undefined, { unattended: false }), undefined); + assert.equal(resolveAskUserQuestionTimeoutMs(120, { unattended: false }), 120_000); + assert.equal( + resolveAskUserQuestionTimeoutMs(ASK_USER_MAX_TIMEOUT_SECONDS * 10, { unattended: false }), + ASK_USER_MAX_TIMEOUT_SECONDS * 1_000, + ); + assert.equal(resolveAskUserQuestionTimeoutMs(undefined, { unattended: true }), cap); + assert.equal(resolveAskUserQuestionTimeoutMs(60, { unattended: true }), 60_000); + assert.equal(resolveAskUserQuestionTimeoutMs(3_600, { unattended: true }), cap); +}); diff --git a/main/services/ask-user-question-extension.ts b/main/services/ask-user-question-extension.ts index d5c21e01d..1b8dad213 100644 --- a/main/services/ask-user-question-extension.ts +++ b/main/services/ask-user-question-extension.ts @@ -6,8 +6,13 @@ import { ASK_USER_MAX_LABEL_LENGTH, ASK_USER_MAX_OPTIONS, ASK_USER_MAX_QUESTIONS, + ASK_USER_MAX_TIMEOUT_SECONDS, ASK_USER_MIN_OPTIONS, + ASK_USER_MIN_TIMEOUT_SECONDS, ASK_USER_QUESTION_TOOL_NAME, + ASK_USER_UNATTENDED_TIMEOUT_SECONDS, + formatAskUserQuestionAnswers, + normalizeAskUserTimeoutSeconds, parseAskUserQuestions, type AskUserQuestionResponseV1, type AskUserQuestionV1, @@ -28,6 +33,24 @@ export interface AskUserQuestionExtensionScope { excluded: boolean; } +/** + * Resolve how long a questionnaire waits before the agent continues without + * an answer. Attended desktop owners wait until answered unless the agent + * asked for a deadline. Unattended owners always get one, capped at the + * Remote question lifetime so the phone card and the agent expire together. + */ +export function resolveAskUserQuestionTimeoutMs( + requestedSeconds: number | undefined, + owner: { unattended: boolean }, +): number | undefined { + const requested = normalizeAskUserTimeoutSeconds(requestedSeconds); + if (!owner.unattended) return requested === undefined ? undefined : requested * 1_000; + return ( + Math.min(requested ?? ASK_USER_UNATTENDED_TIMEOUT_SECONDS, ASK_USER_UNATTENDED_TIMEOUT_SECONDS) * + 1_000 + ); +} + export function shouldEnableAskUserQuestionExtension( scope: AskUserQuestionExtensionScope, ): boolean { @@ -45,20 +68,13 @@ function formatResult( questions: readonly AskUserQuestionV1[], response: AskUserQuestionResponseV1, ): string { + if (response.timedOut) { + return "No answer was received before the question expired. Proceed with your best judgement, using any default you stated in the question. Say which assumption you made so the user can correct it. Do not ask the same question again; the user may still send their answer as a follow-up message."; + } if (response.cancelled) { return "The user closed the questionnaire without answering. Do not repeat it immediately; continue only if the task can proceed safely, otherwise ask in chat."; } - const byIndex = new Map(response.answers.map((answer) => [answer.questionIndex, answer])); - return questions - .map((question, index) => { - const answer = byIndex.get(index); - if (!answer) return `${index + 1}. ${question.question}\nAnswer: Skipped`; - if (answer.kind === "multi") { - return `${index + 1}. ${question.question}\nAnswer: ${answer.selected.join(", ")}`; - } - return `${index + 1}. ${question.question}\nAnswer: ${answer.answer}`; - }) - .join("\n\n"); + return formatAskUserQuestionAnswers(questions, response.answers); } export function createAskUserQuestionExtension(options: { @@ -66,6 +82,7 @@ export function createAskUserQuestionExtension(options: { toolCallId: string, questions: AskUserQuestionV1[], signal?: AbortSignal, + timeoutSeconds?: number, ): Promise; }): PiAgentRuntimeExtension { const tool: AgentTool = declarePiRuntimeReplay( @@ -73,7 +90,7 @@ export function createAskUserQuestionExtension(options: { name: ASK_USER_QUESTION_TOOL_NAME, label: "Ask User Question", description: - "Ask the user 1-4 concise structured questions when a material choice cannot be inferred safely. Each question needs 2-4 distinct options with short labels and useful descriptions. The UI automatically offers a custom answer and Skip, so do not add Other or a skip option.", + "Ask the user 1-4 concise structured questions when a material choice cannot be inferred safely. Each question needs 2-4 distinct options with short labels and useful descriptions. The UI automatically offers a custom answer and Skip, so do not add Other or a skip option. Set timeoutSeconds when the task can reasonably continue without an answer (for example, state a default in the question): after that long you receive a no-answer result and should proceed with your best judgement. Some unattended surfaces always apply a short deadline.", // A second questionnaire cannot replace the first composer surface while // it is awaiting its owner. Serialize calls so every prompt is answered // or cancelled before another questionnaire can be published. @@ -97,11 +114,22 @@ export function createAskUserQuestionExtension(options: { }), { minItems: 1, maxItems: ASK_USER_MAX_QUESTIONS }, ), + timeoutSeconds: Type.Optional( + Type.Integer({ + minimum: ASK_USER_MIN_TIMEOUT_SECONDS, + maximum: ASK_USER_MAX_TIMEOUT_SECONDS, + description: + "Optional. Seconds to wait for an answer before continuing without one.", + }), + ), }), execute: async (toolCallId, parameters, signal): Promise> => { const questions = parseAskUserQuestions((parameters as { questions?: unknown }).questions); if (!questions) throw new Error("The questionnaire is invalid."); - const response = await options.request(toolCallId, questions, signal); + const timeoutSeconds = normalizeAskUserTimeoutSeconds( + (parameters as { timeoutSeconds?: unknown }).timeoutSeconds, + ); + const response = await options.request(toolCallId, questions, signal, timeoutSeconds); return { content: [{ type: "text", text: formatResult(questions, response) }], details: null, diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index b05e858b5..f8ec05183 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -349,9 +349,13 @@ import { generativeUiArtifactStore } from "./generative-ui-artifact-store.js"; import { generationHasVisibleOutput } from "./generation-visible-output.js"; import { createAskUserQuestionExtension, + resolveAskUserQuestionTimeoutMs, shouldEnableAskUserQuestionExtension, } from "./ask-user-question-extension.js"; -import { AskUserQuestionCoordinator } from "./ask-user-question-coordinator.js"; +import { + AskUserQuestionCoordinator, + type AskUserQuestionRespondOutcome, +} from "./ask-user-question-coordinator.js"; import { ASK_USER_QUESTION_TOOL_NAME } from "../../renderer/shared/ask-user-question.js"; import { createTodoExtension, shouldEnableTodoExtension } from "./rpiv-todo/extension.js"; import { TODO_TOOL_NAME } from "./rpiv-todo/contract.js"; @@ -720,9 +724,16 @@ async function prepareGeneration( ) { generationExtensions.push( createAskUserQuestionExtension({ - request: (toolCallId, questions, requestSignal) => + request: (toolCallId, questions, requestSignal, timeoutSeconds) => questionnaires.request( - { streamId, toolCallId, questions }, + { + streamId, + toolCallId, + questions, + timeoutMs: resolveAskUserQuestionTimeoutMs(timeoutSeconds, { + unattended: browserOwner.kind === "remote", + }), + }, ownerDocumentId, requestSignal, ), @@ -2201,7 +2212,16 @@ export const llmClient = { requestSignal?: AbortSignal, ) => questionnaires.request( - { streamId, toolCallId, questions }, + { + streamId, + toolCallId, + questions, + // The advisor picker falls back to its default model on + // timeout; only unattended owners get a deadline here. + timeoutMs: resolveAskUserQuestionTimeoutMs(undefined, { + unattended: owner.kind === "remote", + }), + }, owner.documentId, requestSignal, ), @@ -3700,6 +3720,15 @@ export const llmClient = { return questionnaires.respond(promptId, response, ownerDocumentId); }, + /** Desktop answer path: distinguishes an expired prompt from a foreign one. */ + answerQuestionnaireWithOutcome( + promptId: string, + response: unknown, + ownerDocumentId: string, + ): AskUserQuestionRespondOutcome { + return questionnaires.respondWithOutcome(promptId, response, ownerDocumentId); + }, + steer(streamId: string, text: string, ownerDocumentId: string): boolean { const generation = active.get(streamId); if (!generation || generation.owner.documentId !== ownerDocumentId || generation.cancelRequested) { diff --git a/package.json b/package.json index fe9c8a2ff..0a4320fa2 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/empty-chat-migration.test.ts main/services/chat-first-message-commit.test.ts main/services/chat-run-input-admission.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/provider-artwork.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/lib/chat-message-queue.test.ts renderer/lib/chat-draft.test.ts renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts renderer/components/settings/skills-settings.test.tsx renderer/lib/composer-type-focus.test.ts", "test:rich-links": "tsx --test renderer/lib/rich-link.test.ts renderer/components/message-bubble.test.tsx renderer/lib/streaming-motion-contract.test.ts", "test:display-image": "tsx --test main/services/display-image-artifact-store.test.ts main/services/display-image-extension.test.ts main/services/generation-timeline.test.ts renderer/components/message-bubble.test.tsx renderer/lib/ipc-stream.test.ts", - "test:ask-user-question": "tsx --test renderer/shared/ask-user-question.test.ts main/services/ask-user-question-coordinator.test.ts main/services/ask-user-question-extension.test.ts renderer/components/ask-user-question-composer.test.ts", + "test:ask-user-question": "tsx --test renderer/shared/ask-user-question.test.ts main/services/ask-user-question-coordinator.test.ts main/services/ask-user-question-extension.test.ts renderer/components/ask-user-question-composer.test.ts renderer/components/ask-user-question-expiry-notice.test.tsx", "test:todo": "tsx --test main/services/rpiv-todo/*.test.ts renderer/shared/todo.test.ts renderer/components/todo-panel.test.tsx renderer/lib/scroll-follow.test.ts main/services/generation-timeline.test.ts main/handlers/ipc-contract.test.ts main/handlers/chats.test.ts renderer/lib/ipc-stream.test.ts", "test:btw": "tsx --test main/services/rpiv-btw/*.test.ts renderer/shared/btw.test.ts renderer/components/btw-card.test.tsx", "test:advisor": "tsx --test renderer/shared/advisor.test.ts main/services/advisor-context.test.ts main/services/advisor-attempt-store.test.ts main/services/advisor-runtime.test.ts main/services/advisor-integration.test.ts", diff --git a/renderer/components/ask-user-question-expiry-notice.test.tsx b/renderer/components/ask-user-question-expiry-notice.test.tsx new file mode 100644 index 000000000..51db8d165 --- /dev/null +++ b/renderer/components/ask-user-question-expiry-notice.test.tsx @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AskUserQuestionExpiryNotice } from "./ask-user-question-expiry-notice.js"; + +const noop = () => undefined; + +function buttonLabels(markup: string): string[] { + return [...markup.matchAll(/]*>([^<]*)<\/button>/gu)].map((match) => match[1]!); +} + +test("an expired questionnaire explains that Aiden moved on without offering actions", () => { + const markup = renderToStaticMarkup(); + assert.match(markup, /role="status"/u); + assert.match(markup, /continued with its best judgement/u); + assert.deepEqual(buttonLabels(markup), []); +}); + +test("a late answer offers to send or discard, and queues while a response streams", () => { + const idle = renderToStaticMarkup( + , + ); + assert.deepEqual(buttonLabels(idle), ["Discard", "Send as follow-up"]); + + const streaming = renderToStaticMarkup( + , + ); + assert.deepEqual(buttonLabels(streaming), ["Discard", "Queue follow-up"]); + assert.match(streaming, /once the current response finishes/u); + + const sending = renderToStaticMarkup( + , + ); + assert.deepEqual(buttonLabels(sending), ["Discard", "Sending…"]); + assert.equal([...sending.matchAll(/]*disabled=""/gu)].length, 2); +}); diff --git a/renderer/components/ask-user-question-expiry-notice.tsx b/renderer/components/ask-user-question-expiry-notice.tsx new file mode 100644 index 000000000..6ab09c663 --- /dev/null +++ b/renderer/components/ask-user-question-expiry-notice.tsx @@ -0,0 +1,54 @@ +import { Clock } from "lucide-react"; +import { Button, Callout } from "./ui"; + +/** + * Shown when an ask_user_question deadline passed. Kept separate from the + * questionnaire card so the card's own layout stays independent of timing. + * + * - `expired`: the card is still open, but the agent already moved on; an + * answer submitted now becomes a follow-up offer instead. + * - `late-answer`: the user answered after the deadline; offer to send the + * answer as a normal follow-up message, or discard it. + */ +export type AskUserQuestionExpiryNoticeProps = + | { state: "expired" } + | { + state: "late-answer"; + sending?: boolean; + /** True while a response is streaming, so the follow-up is queued. */ + queued?: boolean; + onSend(): void; + onDiscard(): void; + }; + +export function AskUserQuestionExpiryNotice(props: AskUserQuestionExpiryNoticeProps) { + return ( +
+ +

+

+ {props.state === "late-answer" ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 94023a0d8..fcb9bfd38 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -85,6 +85,7 @@ import type { OnboardingOutcome, OnboardingSnapshot } from "../shared/onboarding import type { SkillInvocationV1 } from "../shared/slash-commands"; import { parseAskUserQuestionPrompt, + type AskUserQuestionAnswerStatus, type AskUserQuestionPromptV1, type AskUserQuestionResponseV1, } from "../shared/ask-user-question"; @@ -1107,7 +1108,11 @@ export const chatsApi = { options?: { formFillExcludedOrders?: number[] }, ) => invoke("chat:approve", approvalId, decision, options), answerQuestionnaire: (promptId: string, response: AskUserQuestionResponseV1) => - invoke("chat:answerQuestionnaire", promptId, response), + invoke<{ status: AskUserQuestionAnswerStatus } | undefined>( + "chat:answerQuestionnaire", + promptId, + response, + ), }; export const botsApi = { diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index b572833ca..ed74ba607 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -27,6 +27,7 @@ import { useReadAloud } from "../lib/tts-client"; import type { ReadAloudActionProps } from "../components/read-aloud-button"; import { Composer } from "../components/composer"; import { AskUserQuestionComposer } from "../components/ask-user-question-composer"; +import { AskUserQuestionExpiryNotice } from "../components/ask-user-question-expiry-notice"; import { TodoPanel, todoPanelHasVisibleChrome } from "../components/todo-panel"; import { BtwCard, reduceBtwView, type BtwLiveView } from "../components/btw-card"; import { ModelPicker } from "../components/model-picker"; @@ -160,9 +161,11 @@ import { useAppendReconciliationRequired } from "../lib/append-reconciliation"; import { isLocalProviderDeployment } from "../shared/provider-deployment"; import type { ChatArtifactV1 } from "../shared/chat-artifacts"; import { useAppCapabilities } from "../lib/app-capabilities"; -import type { - AskUserQuestionPromptV1, - AskUserQuestionResponseV1, +import { + askUserQuestionMsUntilExpiry, + formatLateAskUserQuestionFollowUp, + type AskUserQuestionPromptV1, + type AskUserQuestionResponseV1, } from "../shared/ask-user-question"; import { TodoSnapshotReadFence, type TodoSnapshotViewV1 } from "../shared/todo"; import type { BtwEventV1 } from "../shared/btw"; @@ -507,6 +510,11 @@ export function ChatPane({ chatId }: { chatId: string }) { const [approvals, setApprovals] = React.useState([]); const [questionnaire, setQuestionnaire] = React.useState(null); const [questionnaireSubmitting, setQuestionnaireSubmitting] = React.useState(false); + // The prompt whose agent deadline passed while its card was still open. + const [expiredQuestionnaireId, setExpiredQuestionnaireId] = React.useState(null); + // An answer submitted after the deadline, offered as a follow-up message. + const [lateQuestionnaireAnswer, setLateQuestionnaireAnswer] = React.useState(null); + const [lateAnswerSending, setLateAnswerSending] = React.useState(false); const [btwView, setBtwView] = React.useState(null); const [todoSnapshot, setTodoSnapshot] = React.useState(null); const [computerUseSaving, setComputerUseSaving] = React.useState(false); @@ -669,6 +677,9 @@ export function ChatPane({ chatId }: { chatId: string }) { setApprovals([]); setQuestionnaire(null); setQuestionnaireSubmitting(false); + setExpiredQuestionnaireId(null); + setLateQuestionnaireAnswer(null); + setLateAnswerSending(false); setBtwView(null); setTodoSnapshot(null); decidingApprovalRef.current = null; @@ -1683,13 +1694,38 @@ export function ChatPane({ chatId }: { chatId: string }) { [chatId], ); + React.useEffect(() => { + if (!questionnaire) return; + const remaining = askUserQuestionMsUntilExpiry(questionnaire, Date.now()); + if (remaining === undefined) return; + const promptId = questionnaire.promptId; + const timer = window.setTimeout(() => setExpiredQuestionnaireId(promptId), remaining); + return () => window.clearTimeout(timer); + }, [questionnaire]); + const questionnaireExpired = + questionnaire !== null && expiredQuestionnaireId === questionnaire.promptId; + const answerQuestionnaire = React.useCallback( async (response: AskUserQuestionResponseV1) => { if (!questionnaire || questionnaireSubmitting) return; + const prompt = questionnaire; setQuestionnaireSubmitting(true); try { - await chatsApi.answerQuestionnaire(questionnaire.promptId, response); - if (chatIdRef.current === chatId) setQuestionnaire(null); + // Once the deadline passed the agent has moved on; skip the host round + // trip and turn a real answer into a follow-up offer. + const status = + expiredQuestionnaireId === prompt.promptId + ? "expired" + : (await chatsApi.answerQuestionnaire(prompt.promptId, response))?.status; + if (chatIdRef.current !== chatId) return; + setQuestionnaire(null); + if (status === "expired") { + setLateQuestionnaireAnswer( + response.cancelled + ? null + : (formatLateAskUserQuestionFollowUp(prompt.questions, response.answers) ?? null), + ); + } } catch (questionError) { if (chatIdRef.current !== chatId) return; toast.error( @@ -1699,9 +1735,35 @@ export function ChatPane({ chatId }: { chatId: string }) { if (chatIdRef.current === chatId) setQuestionnaireSubmitting(false); } }, - [chatId, questionnaire, questionnaireSubmitting], + [chatId, expiredQuestionnaireId, questionnaire, questionnaireSubmitting], ); + const sendLateQuestionnaireAnswer = React.useCallback(async () => { + const text = lateQuestionnaireAnswer; + if (!text || lateAnswerSending) return; + setLateAnswerSending(true); + try { + // A response still streaming gets the answer as the next queued turn, + // exactly as if the user had typed it into the composer. + if (isGenerating || isStartingGeneration) await queueMessage(text, []); + else await handleSend(text, []); + if (chatIdRef.current === chatId) setLateQuestionnaireAnswer(null); + } catch (sendError) { + if (chatIdRef.current !== chatId) return; + toast.error(sendError instanceof Error ? sendError.message : "Couldn't send that answer."); + } finally { + if (chatIdRef.current === chatId) setLateAnswerSending(false); + } + }, [ + chatId, + handleSend, + isGenerating, + isStartingGeneration, + lateAnswerSending, + lateQuestionnaireAnswer, + queueMessage, + ]); + const openFolder = React.useCallback(() => { if (effectiveWorkspace?.folderPath) void workspacesApi.openFolder(effectiveWorkspace.id); }, [effectiveWorkspace?.folderPath, effectiveWorkspace?.id]); @@ -2484,6 +2546,16 @@ export function ChatPane({ chatId }: { chatId: string }) { }} /> ) : null} + {lateQuestionnaireAnswer && !questionnaire ? ( + setLateQuestionnaireAnswer(null)} + onSend={() => void sendLateQuestionnaireAnswer()} + /> + ) : null} + {questionnaireExpired ? : null} {questionnaire ? ( { + const envelope = { + version: ASK_USER_QUESTION_VERSION, + promptId: "q-timed", + streamId: "s-timed", + toolCallId: "call-timed", + questions, + }; + const timed = parseAskUserQuestionPrompt({ ...envelope, expiresAt: "2026-09-27T10:05:00.000Z" }); + assert.equal(timed?.expiresAt, "2026-09-27T10:05:00.000Z"); + assert.equal(parseAskUserQuestionPrompt({ ...envelope, expiresAt: "in five minutes" }), undefined); + assert.equal( + parseAskUserQuestionPrompt({ ...envelope, expiresAt: "September 27, 2026 10:05" }), + undefined, + ); + assert.ok(timed); + const now = Date.parse("2026-09-27T10:04:00.000Z"); + assert.equal(askUserQuestionMsUntilExpiry(timed, now), 60_000); + assert.equal(askUserQuestionMsUntilExpiry(timed, now + 120_000), 0); + assert.equal(askUserQuestionMsUntilExpiry({}, now), undefined); + + const response = parseAskUserQuestionResponse( + { + version: ASK_USER_QUESTION_VERSION, + promptId: "q-timed", + cancelled: true, + answers: [], + timedOut: true, + }, + timed, + ); + assert.equal(response?.timedOut, undefined); +}); + +test("timeout requests clamp into the supported window", () => { + assert.equal(normalizeAskUserTimeoutSeconds(undefined), undefined); + assert.equal(normalizeAskUserTimeoutSeconds("300"), undefined); + assert.equal(normalizeAskUserTimeoutSeconds(Number.NaN), undefined); + assert.equal(normalizeAskUserTimeoutSeconds(1), 30); + assert.equal(normalizeAskUserTimeoutSeconds(299.6), 300); + assert.equal(normalizeAskUserTimeoutSeconds(86_400), 3_600); +}); + +test("a late answer becomes a readable follow-up and an empty one offers nothing", () => { + const parsed = parseAskUserQuestions([ + ...questions, + { + question: "Which extras?", + header: "Extras", + multiSelect: true, + options: [ + { label: "Tests", description: "Add tests." }, + { label: "Docs", description: "Update docs." }, + ], + }, + ]); + assert.ok(parsed); + const followUp = formatLateAskUserQuestionFollowUp(parsed, [ + { questionIndex: 1, kind: "multi", selected: ["Tests", "Docs"] }, + ]); + assert.ok(followUp); + assert.match(followUp, /expired before I replied/u); + assert.match(followUp, /1\. How should I help\?\nAnswer: Skipped/u); + assert.match(followUp, /2\. Which extras\?\nAnswer: Tests, Docs/u); + assert.equal(formatLateAskUserQuestionFollowUp(parsed, []), undefined); +}); diff --git a/renderer/shared/ask-user-question.ts b/renderer/shared/ask-user-question.ts index cc218c7da..b3cbb008f 100644 --- a/renderer/shared/ask-user-question.ts +++ b/renderer/shared/ask-user-question.ts @@ -8,6 +8,16 @@ export const ASK_USER_MAX_LABEL_LENGTH = 60; export const ASK_USER_MAX_QUESTION_LENGTH = 1_000; export const ASK_USER_MAX_DESCRIPTION_LENGTH = 2_000; export const ASK_USER_MAX_CUSTOM_ANSWER_LENGTH = 4_000; +/** Shortest wait the agent may request before continuing without an answer. */ +export const ASK_USER_MIN_TIMEOUT_SECONDS = 30; +/** Longest wait the agent may request; attended desktop chats default to no deadline. */ +export const ASK_USER_MAX_TIMEOUT_SECONDS = 60 * 60; +/** + * Unattended owners (a paired phone that may have gone away) always carry a + * deadline, and never a longer one. It matches the Remote v1 question + * lifetime so the wire `expiresAt` and the agent-facing deadline coincide. + */ +export const ASK_USER_UNATTENDED_TIMEOUT_SECONDS = 5 * 60; const RESERVED_OPTION_LABELS = new Set(["Other", "Type something.", "Next"]); export interface AskUserQuestionOptionV1 { @@ -28,6 +38,11 @@ export interface AskUserQuestionPromptV1 { streamId: string; toolCallId: string; questions: AskUserQuestionV1[]; + /** + * RFC 3339 instant after which the agent stops waiting and continues with + * its best judgement. Absent when the prompt waits for its owner. + */ + expiresAt?: string; } export type AskUserQuestionAnswerV1 = @@ -40,6 +55,84 @@ export interface AskUserQuestionResponseV1 { promptId: string; cancelled: boolean; answers: AskUserQuestionAnswerV1[]; + /** + * Main-owned only: the deadline passed without an answer. The response + * parser never copies it, so a renderer or Remote client cannot forge one. + * Timed-out responses are also `cancelled` so consumers that only know the + * cancel path (the advisor picker) fall back to their default. + */ + timedOut?: true; +} + +/** Outcome of an owner's answer attempt, reported back to the answering surface. */ +export type AskUserQuestionAnswerStatus = "answered" | "expired"; + +/** + * Clamp an agent-requested wait into the supported window. A missing or + * non-numeric request returns undefined so the owner default applies. + */ +export function normalizeAskUserTimeoutSeconds(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.min( + ASK_USER_MAX_TIMEOUT_SECONDS, + Math.max(ASK_USER_MIN_TIMEOUT_SECONDS, Math.round(value)), + ); +} + +/** + * Milliseconds until the agent stops waiting (0 once expired), or undefined + * for a prompt that waits for its owner indefinitely. + */ +export function askUserQuestionMsUntilExpiry( + prompt: Pick, + now: number, +): number | undefined { + if (!prompt.expiresAt) return undefined; + const deadline = Date.parse(prompt.expiresAt); + if (!Number.isFinite(deadline)) return undefined; + return Math.max(0, deadline - now); +} + +function parseInstant(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 64) return undefined; + const time = Date.parse(value); + if (!Number.isFinite(time)) return undefined; + const normalized = new Date(time).toISOString(); + return normalized === value ? normalized : undefined; +} + +/** + * Plain-text rendering of an owner's answers, shared by the agent tool result + * and the late-reply follow-up message. + */ +export function formatAskUserQuestionAnswers( + questions: readonly AskUserQuestionV1[], + answers: readonly AskUserQuestionAnswerV1[], +): string { + const byIndex = new Map(answers.map((answer) => [answer.questionIndex, answer])); + return questions + .map((question, index) => { + const answer = byIndex.get(index); + if (!answer) return `${index + 1}. ${question.question}\nAnswer: Skipped`; + if (answer.kind === "multi") { + return `${index + 1}. ${question.question}\nAnswer: ${answer.selected.join(", ")}`; + } + return `${index + 1}. ${question.question}\nAnswer: ${answer.answer}`; + }) + .join("\n\n"); +} + +/** + * Follow-up chat message for answers that arrived after the agent stopped + * waiting. Returns undefined when nothing was answered, so there is nothing + * worth sending. + */ +export function formatLateAskUserQuestionFollowUp( + questions: readonly AskUserQuestionV1[], + answers: readonly AskUserQuestionAnswerV1[], +): string | undefined { + if (answers.length === 0) return undefined; + return `Answering your earlier question, which expired before I replied:\n\n${formatAskUserQuestionAnswers(questions, answers)}`; } function record(value: unknown): Record | undefined { @@ -124,12 +217,18 @@ export function parseAskUserQuestionPrompt(value: unknown): AskUserQuestionPromp } const questions = parseAskUserQuestions(input.questions); if (!questions) return undefined; + let expiresAt: string | undefined; + if (input.expiresAt !== undefined) { + expiresAt = parseInstant(input.expiresAt); + if (!expiresAt) return undefined; + } return { version: ASK_USER_QUESTION_VERSION, promptId: input.promptId, streamId: input.streamId, toolCallId: input.toolCallId, questions, + ...(expiresAt ? { expiresAt } : {}), }; } diff --git a/scripts/ci-test-registry.json b/scripts/ci-test-registry.json index 1d92f63c7..61579df9c 100644 --- a/scripts/ci-test-registry.json +++ b/scripts/ci-test-registry.json @@ -453,6 +453,7 @@ "main/services/tts/speech-text.test.ts", "renderer/components/activity-feed.test.tsx", "renderer/components/ask-user-question-composer.test.ts", + "renderer/components/ask-user-question-expiry-notice.test.tsx", "renderer/components/assistant/assistant-ui.test.tsx", "renderer/components/assistant/use-assistant-chat.test.ts", "renderer/components/assistant/use-assistant-live.test.tsx", From aa05d93f0de6038f93066f536e204d7028f83287 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 13:19:35 -0400 Subject: [PATCH 2/3] fix: preserve timed ask-user answers and CLI deadlines --- .memory/timed-ask-user.md | 9 ++ .papercuts/troubleshooting.md | 1 + docs/plans/timed-ask-user-plan.md | 3 +- .../src/extensions/ask-user-question-core.ts | 109 ++++++++++++++++++ .../cli/src/extensions/ask-user-question.ts | 67 +---------- packages/cli/tests/extensions.test.mjs | 61 ++++++++++ renderer/main/chat-pane.tsx | 10 +- renderer/shared/ask-user-question.test.ts | 4 + renderer/shared/ask-user-question.ts | 8 ++ 9 files changed, 207 insertions(+), 65 deletions(-) create mode 100644 .memory/timed-ask-user.md create mode 100644 packages/cli/src/extensions/ask-user-question-core.ts diff --git a/.memory/timed-ask-user.md b/.memory/timed-ask-user.md new file mode 100644 index 000000000..8a7ff4949 --- /dev/null +++ b/.memory/timed-ask-user.md @@ -0,0 +1,9 @@ +# Timed ask-user waits — 2026-09-27 + +Branch: `feature/timed-ask-user`. Plan: `docs/plans/timed-ask-user-plan.md`. + +The desktop coordinator owns optional deadlines, `expiresAt`, explicit expired responses, and a bounded recent-prompt set. Attended desktop requests without a timeout continue waiting; unattended requests use the Remote-aligned cap. Timed-out prompts now remain in the desktop composer after generation settles, letting a late answer become a follow-up or queued follow-up. + +The CLI adapter must forward the tool's `timeoutSeconds` to the terminal selection UI. The CLI uses one absolute deadline across the complete questionnaire and multi-select steps, passing each `select` only the remaining milliseconds plus the tool-call abort signal. Pi `ExtensionUIContext.select` supports both options. + +Relevant validation: `npm run test:ask-user-question`; `npm run test:cli`; `npm --prefix packages/cli run type-check`. diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index bae920d60..d46930b74 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1408,3 +1408,4 @@ because their native file-mutator test binary had not been built. Run ## 2026-09-27 timed ask-user (feature/timed-ask-user) - The worktree-isolation guard refuses compound shell commands (python heredoc plus a runner, `cat >> <; +} + +type Selection = + | { kind: "selected"; choice: string } + | { kind: "cancelled" } + | { kind: "timedOut" }; + +function emptyResponse( + answers: AskUserQuestionAnswerV1[], + options: { cancelled: boolean; timedOut?: boolean }, +): AskUserQuestionResponseV1 { + return { + version: ASK_USER_QUESTION_VERSION, + promptId: randomUUID(), + cancelled: options.cancelled, + answers, + ...(options.timedOut ? { timedOut: true } : {}), + }; +} + +/** Runs every terminal dialog under one deadline for the entire questionnaire. */ +export async function requestQuestionnaire( + ui: SelectUi | undefined, + questions: AskUserQuestionV1[], + signal?: AbortSignal, + timeoutSeconds?: number, +): Promise { + const answers: AskUserQuestionAnswerV1[] = []; + if (!ui) return emptyResponse(answers, { cancelled: true, timedOut: timeoutSeconds !== undefined }); + const deadline = timeoutSeconds === undefined ? undefined : Date.now() + timeoutSeconds * 1_000; + const select = async (title: string, options: string[]): Promise => { + if (signal?.aborted) return { kind: "cancelled" }; + const remaining = deadline === undefined ? undefined : deadline - Date.now(); + if (remaining !== undefined && remaining <= 0) return { kind: "timedOut" }; + const choice = await ui.select(title, options, { + ...(signal ? { signal } : {}), + ...(remaining !== undefined ? { timeout: remaining } : {}), + }); + if (signal?.aborted) return { kind: "cancelled" }; + if (deadline !== undefined && Date.now() >= deadline) return { kind: "timedOut" }; + return choice === undefined ? { kind: "cancelled" } : { kind: "selected", choice }; + }; + const interrupted = (selection: Exclude) => + emptyResponse(answers, { + cancelled: true, + ...(selection.kind === "timedOut" ? { timedOut: true } : {}), + }); + + for (const [index, question] of questions.entries()) { + if (signal?.aborted) return interrupted({ kind: "cancelled" }); + const title = `(${index + 1}/${questions.length}) ${question.question}`; + if (question.multiSelect === true) { + const selected: string[] = []; + for (;;) { + const remaining = question.options + .map((option) => option.label) + .filter((label) => !selected.includes(label)); + if (remaining.length === 0) break; + const selection = await select( + `${title} — picked: ${selected.join(", ") || "none"}`, + ["Done", "Skip question", ...remaining], + ); + if (selection.kind !== "selected") return interrupted(selection); + if (selection.choice === "Skip question") break; + if (selection.choice === "Done") { + if (selected.length > 0) answers.push({ questionIndex: index, kind: "multi", selected }); + break; + } + selected.push(selection.choice); + } + } else { + const selection = await select(title, [ + ...question.options.map((option) => option.label), + "Skip question", + ]); + if (selection.kind !== "selected") return interrupted(selection); + if (selection.choice !== "Skip question") { + answers.push({ questionIndex: index, kind: "option", answer: selection.choice }); + } + } + } + return emptyResponse(answers, { cancelled: false }); +} + +export function createTerminalAskUserQuestionRequest( + resolveUi: () => SelectUi | undefined, +): ( + toolCallId: string, + questions: AskUserQuestionV1[], + signal?: AbortSignal, + timeoutSeconds?: number, +) => Promise { + return (_toolCallId, questions, signal, timeoutSeconds) => + requestQuestionnaire(resolveUi(), questions, signal, timeoutSeconds); +} diff --git a/packages/cli/src/extensions/ask-user-question.ts b/packages/cli/src/extensions/ask-user-question.ts index c28475ab2..9d2027a9f 100644 --- a/packages/cli/src/extensions/ask-user-question.ts +++ b/packages/cli/src/extensions/ask-user-question.ts @@ -5,80 +5,23 @@ * questions accumulate choices until Done. Escape cancels the questionnaire. */ -import { randomUUID } from "node:crypto"; import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; -import { - ASK_USER_QUESTION_VERSION, - type AskUserQuestionAnswerV1, - type AskUserQuestionResponseV1, - type AskUserQuestionV1, -} from "../../../../renderer/shared/ask-user-question.js"; import { createAskUserQuestionExtension } from "../../../../main/services/ask-user-question-extension.js"; import { aidenExtensionToInlineFactory, type AidenExtensionShape } from "../pi-bridge/adapt-aiden-extension.ts"; import type { CapturedContext } from "../pi-bridge/adapt-aiden-extension.ts"; - -export interface SelectUi { - select(title: string, options: string[], opts?: { placeholder?: string }): Promise; -} - -/** Shared with the advisor extension, whose reviewer picker uses the same flow. */ -export async function requestQuestionnaire( - ui: SelectUi | undefined, - questions: AskUserQuestionV1[], - signal?: AbortSignal, -): Promise { - if (!ui) { - return { version: ASK_USER_QUESTION_VERSION, promptId: randomUUID(), cancelled: true, answers: [] }; - } - const answers: AskUserQuestionAnswerV1[] = []; - for (const [index, question] of questions.entries()) { - if (signal?.aborted) { - return { version: ASK_USER_QUESTION_VERSION, promptId: randomUUID(), cancelled: true, answers }; - } - const title = `(${index + 1}/${questions.length}) ${question.question}`; - if (question.multiSelect === true) { - const selected: string[] = []; - for (;;) { - const remaining = question.options.map((option) => option.label).filter((label) => !selected.includes(label)); - if (remaining.length === 0) break; - const choice = await ui.select(`${title} — picked: ${selected.join(", ") || "none"}`, [ - "Done", - "Skip question", - ...remaining, - ]); - if (choice === undefined) { - return { version: ASK_USER_QUESTION_VERSION, promptId: randomUUID(), cancelled: true, answers }; - } - if (choice === "Skip question") break; - if (choice === "Done") { - if (selected.length > 0) answers.push({ questionIndex: index, kind: "multi", selected }); - break; - } - selected.push(choice); - } - } else { - const choice = await ui.select(title, [...question.options.map((option) => option.label), "Skip question"]); - if (choice === undefined) { - return { version: ASK_USER_QUESTION_VERSION, promptId: randomUUID(), cancelled: true, answers }; - } - if (choice !== "Skip question") { - answers.push({ questionIndex: index, kind: "option", answer: choice }); - } - } - } - return { version: ASK_USER_QUESTION_VERSION, promptId: randomUUID(), cancelled: false, answers }; -} +import { createTerminalAskUserQuestionRequest, type SelectUi } from "./ask-user-question-core.ts"; +export { requestQuestionnaire, type SelectUi } from "./ask-user-question-core.ts"; export function createAskUserQuestionInlineExtension(context: { latestContext(): CapturedContext | undefined; }): { name: string; factory: ExtensionFactory } { const extension = createAskUserQuestionExtension({ - request: async (_toolCallId, questions, signal) => { + request: createTerminalAskUserQuestionRequest(() => { const ctx = context.latestContext(); const ui = ctx?.hasUI ? (ctx.ui as { select?: SelectUi["select"] }) : undefined; const select = ui?.select?.bind(ui); - return requestQuestionnaire(select ? { select } : undefined, questions, signal); - }, + return select ? { select } : undefined; + }), }); return aidenExtensionToInlineFactory("aiden-ask-user-question", extension as AidenExtensionShape); } diff --git a/packages/cli/tests/extensions.test.mjs b/packages/cli/tests/extensions.test.mjs index 4bf7a5371..1a09a4c09 100644 --- a/packages/cli/tests/extensions.test.mjs +++ b/packages/cli/tests/extensions.test.mjs @@ -87,6 +87,7 @@ function evalModule(expression) { const bridgeUrl = JSON.stringify(pathToFileURL(path.join(pkgDir, "src", "pi-bridge", "adapt-aiden-extension.ts")).href); const widgetUrl = JSON.stringify(pathToFileURL(path.join(pkgDir, "src", "extensions", "todo-widget.ts")).href); const settingsUrl = JSON.stringify(pathToFileURL(path.join(pkgDir, "src", "extensions", "aiden-settings.ts")).href); +const askUserCoreUrl = JSON.stringify(pathToFileURL(path.join(pkgDir, "src", "extensions", "ask-user-question-core.ts")).href); test("bridge registers tools and chains system prompts onto before_agent_start", () => { const out = evalModule(` @@ -164,6 +165,66 @@ try { assert.match(out, /normalize-ok/); }); +test("CLI ask-user applies one absolute timeout across every terminal selection", () => { + const out = evalModule(` +import { createTerminalAskUserQuestionRequest } from ${askUserCoreUrl}; +const originalNow = Date.now; +let now = 1_000; +Date.now = () => now; +try { + const controller = new AbortController(); + const timeouts = []; + let selectionCount = 0; + const request = createTerminalAskUserQuestionRequest(() => ({ + select: async (_title, _options, options) => { + selectionCount++; + if (options?.signal !== controller.signal) throw new Error("AbortSignal was not forwarded"); + timeouts.push(options?.timeout); + if (selectionCount <= 2) { + now += 10_000; + return selectionCount === 1 ? "Direct" : "Guided"; + } + if (selectionCount === 3) { + now += 5_000; + return "Done"; + } + now += options.timeout; + return undefined; + }, + })); + const question = { + question: "Which approach?", + header: "Approach", + multiSelect: true, + options: [ + { label: "Direct", description: "Take action." }, + { label: "Guided", description: "Explain each step." }, + { label: "Balanced", description: "Mix both approaches." }, + ], + }; + const result = await request("call-one", [question, { ...question, multiSelect: false }], controller.signal, 30); + if (JSON.stringify(timeouts) !== JSON.stringify([30_000, 20_000, 10_000, 5_000])) { + throw new Error("per-question timeouts reset the deadline: " + JSON.stringify(timeouts)); + } + if ( + !result.cancelled || + !result.timedOut || + JSON.stringify(result.answers[0]) !== JSON.stringify({ + questionIndex: 0, + kind: "multi", + selected: ["Direct", "Guided"], + }) + ) { + throw new Error("timeout response lost its result or prior answer: " + JSON.stringify(result)); + } + console.log("ask-user-timeout-ok"); +} finally { + Date.now = originalNow; +} +`); + assert.match(out, /ask-user-timeout-ok/); +}); + test("memory scope id matches the desktop SAFE_ID charset and is stable per folder", () => { const out = evalModule(` import { workspaceScopeId, memoryEnabledFor } from ${JSON.stringify( diff --git a/renderer/main/chat-pane.tsx b/renderer/main/chat-pane.tsx index ed74ba607..e382214d5 100644 --- a/renderer/main/chat-pane.tsx +++ b/renderer/main/chat-pane.tsx @@ -164,6 +164,7 @@ import { useAppCapabilities } from "../lib/app-capabilities"; import { askUserQuestionMsUntilExpiry, formatLateAskUserQuestionFollowUp, + retainExpiredAskUserQuestionForLateAnswer, type AskUserQuestionPromptV1, type AskUserQuestionResponseV1, } from "../shared/ask-user-question"; @@ -1219,7 +1220,10 @@ export function ChatPane({ chatId }: { chatId: string }) { setGenerationTimeline(null); generationTimelineRef.current = null; setApprovals([]); - setQuestionnaire(null); + // Timed-out prompts remain answerable after the agent continues with its default. + setQuestionnaire((current) => + retainExpiredAskUserQuestionForLateAnswer(current), + ); setQuestionnaireSubmitting(false); } }, @@ -1296,7 +1300,9 @@ export function ChatPane({ chatId }: { chatId: string }) { generationTimelineRef.current = null; } setApprovals([]); - setQuestionnaire(null); + setQuestionnaire((current) => + retainExpiredAskUserQuestionForLateAnswer(current), + ); setQuestionnaireSubmitting(false); const persistedFailure = updatedChat?.messages[updatedChat.messages.length - 1]?.role === "assistant" && diff --git a/renderer/shared/ask-user-question.test.ts b/renderer/shared/ask-user-question.test.ts index 700697f87..e51745b77 100644 --- a/renderer/shared/ask-user-question.test.ts +++ b/renderer/shared/ask-user-question.test.ts @@ -8,6 +8,7 @@ import { parseAskUserQuestionPrompt, parseAskUserQuestionResponse, parseAskUserQuestions, + retainExpiredAskUserQuestionForLateAnswer, } from "./ask-user-question.js"; const questions = [ @@ -104,6 +105,9 @@ test("prompt deadlines are strict instants and owners cannot forge a timeout", ( assert.equal(askUserQuestionMsUntilExpiry(timed, now), 60_000); assert.equal(askUserQuestionMsUntilExpiry(timed, now + 120_000), 0); assert.equal(askUserQuestionMsUntilExpiry({}, now), undefined); + assert.equal(retainExpiredAskUserQuestionForLateAnswer(timed, now), null); + assert.equal(retainExpiredAskUserQuestionForLateAnswer(timed, now + 120_000), timed); + assert.equal(retainExpiredAskUserQuestionForLateAnswer({ ...timed, expiresAt: undefined }, now), null); const response = parseAskUserQuestionResponse( { diff --git a/renderer/shared/ask-user-question.ts b/renderer/shared/ask-user-question.ts index b3cbb008f..5f8ac1159 100644 --- a/renderer/shared/ask-user-question.ts +++ b/renderer/shared/ask-user-question.ts @@ -93,6 +93,14 @@ export function askUserQuestionMsUntilExpiry( return Math.max(0, deadline - now); } +/** Keep an already-expired prompt available so its owner can turn a late answer into a follow-up. */ +export function retainExpiredAskUserQuestionForLateAnswer( + prompt: AskUserQuestionPromptV1 | null | undefined, + now = Date.now(), +): AskUserQuestionPromptV1 | null { + return prompt && askUserQuestionMsUntilExpiry(prompt, now) === 0 ? prompt : null; +} + function parseInstant(value: unknown): string | undefined { if (typeof value !== "string" || value.length > 64) return undefined; const time = Date.parse(value); From 2972a4ffeba47cf4831cb5d600cbc84e58d29706 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 13:24:23 -0400 Subject: [PATCH 3/3] Preserve completed ask-user multi-selections --- .memory/timed-ask-user.md | 2 +- .papercuts/troubleshooting.md | 1 + packages/cli/src/extensions/ask-user-question-core.ts | 5 ++++- packages/cli/tests/extensions.test.mjs | 7 +------ 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.memory/timed-ask-user.md b/.memory/timed-ask-user.md index 8a7ff4949..f81efadac 100644 --- a/.memory/timed-ask-user.md +++ b/.memory/timed-ask-user.md @@ -4,6 +4,6 @@ Branch: `feature/timed-ask-user`. Plan: `docs/plans/timed-ask-user-plan.md`. The desktop coordinator owns optional deadlines, `expiresAt`, explicit expired responses, and a bounded recent-prompt set. Attended desktop requests without a timeout continue waiting; unattended requests use the Remote-aligned cap. Timed-out prompts now remain in the desktop composer after generation settles, letting a late answer become a follow-up or queued follow-up. -The CLI adapter must forward the tool's `timeoutSeconds` to the terminal selection UI. The CLI uses one absolute deadline across the complete questionnaire and multi-select steps, passing each `select` only the remaining milliseconds plus the tool-call abort signal. Pi `ExtensionUIContext.select` supports both options. +The CLI adapter must forward the tool's `timeoutSeconds` to the terminal selection UI. The CLI uses one absolute deadline across the complete questionnaire and multi-select steps, passing each `select` only the remaining milliseconds plus the tool-call abort signal. Pi `ExtensionUIContext.select` supports both options. If multi-select exhausts every option, preserve the accumulated choices before leaving the loop. Relevant validation: `npm run test:ask-user-question`; `npm run test:cli`; `npm --prefix packages/cli run type-check`. diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index d46930b74..7e1f6d93f 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1409,3 +1409,4 @@ because their native file-mutator test binary had not been built. Run - The worktree-isolation guard refuses compound shell commands (python heredoc plus a runner, `cat >> < option.label) .filter((label) => !selected.includes(label)); - if (remaining.length === 0) break; + if (remaining.length === 0) { + if (selected.length > 0) answers.push({ questionIndex: index, kind: "multi", selected }); + break; + } const selection = await select( `${title} — picked: ${selected.join(", ") || "none"}`, ["Done", "Skip question", ...remaining], diff --git a/packages/cli/tests/extensions.test.mjs b/packages/cli/tests/extensions.test.mjs index 1a09a4c09..4dc88d4fd 100644 --- a/packages/cli/tests/extensions.test.mjs +++ b/packages/cli/tests/extensions.test.mjs @@ -184,10 +184,6 @@ try { now += 10_000; return selectionCount === 1 ? "Direct" : "Guided"; } - if (selectionCount === 3) { - now += 5_000; - return "Done"; - } now += options.timeout; return undefined; }, @@ -199,11 +195,10 @@ try { options: [ { label: "Direct", description: "Take action." }, { label: "Guided", description: "Explain each step." }, - { label: "Balanced", description: "Mix both approaches." }, ], }; const result = await request("call-one", [question, { ...question, multiSelect: false }], controller.signal, 30); - if (JSON.stringify(timeouts) !== JSON.stringify([30_000, 20_000, 10_000, 5_000])) { + if (JSON.stringify(timeouts) !== JSON.stringify([30_000, 20_000, 10_000])) { throw new Error("per-question timeouts reset the deadline: " + JSON.stringify(timeouts)); } if (