From 4f847d10ae571bfe40d1bbe427d1fe0212a490cd Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 16 Aug 2026 00:34:09 -0400 Subject: [PATCH 1/3] fix: stop reporting expired questions as user dismissals A question asked through the daemon path expired after 60 seconds. The tool then told the model "User dismissed the question without answering", so the agent claimed the user dismissed a question they were still reading. Transport failures and server shutdown produced the same message. - The question timer becomes a 30-minute lease. It guards against a leak, it is not a network timeout, and a person needs time to read and answer. - Expiry carries the new `question.expired` error code, so the tool tells the model the question was not answered instead of inventing a dismissal. Other failures become real tool errors. - Answers reach the model as the question text and the option labels the user saw, not synthesized ids such as `q_0` and `opt_0_1`. This matches the terminal and ACP surfaces, and repairs MCP elicitation, which reads answers by question text. - Escape no longer dismisses a question. The visible Dismiss button stays. - The web card warns when less than five minutes of the lease remain. --- .changeset/question-lease-and-labels.md | 5 + .../src/components/QuestionCard.vue | 41 +++++-- .../src/composables/usePythinkerWebClient.ts | 1 + .../src/i18n/locales/en/question.ts | 2 + apps/pythinker-web/src/types.ts | 1 + .../test/question-card-lifecycle.test.ts | 91 ++++++++++++++++ .../test/question-card-recommended.test.ts | 1 + packages/agent-core/src/errors/codes.ts | 7 ++ .../src/services/question/question.ts | 63 ++++++----- .../tools/builtin/collaboration/ask-user.ts | 20 +++- .../test/services/question-adapter.test.ts | 102 +++++++++++------- .../agent-core/test/tools/ask-user.test.ts | 34 ++++-- packages/protocol/src/events.ts | 1 + packages/server/src/routes/questions.ts | 15 ++- .../src/services/question/questionService.ts | 27 ++++- packages/server/test/question.e2e.test.ts | 51 +++++++-- packages/server/test/services.test.ts | 5 +- 17 files changed, 358 insertions(+), 109 deletions(-) create mode 100644 .changeset/question-lease-and-labels.md create mode 100644 apps/pythinker-web/test/question-card-lifecycle.test.ts diff --git a/.changeset/question-lease-and-labels.md b/.changeset/question-lease-and-labels.md new file mode 100644 index 000000000..a5dd49e01 --- /dev/null +++ b/.changeset/question-lease-and-labels.md @@ -0,0 +1,5 @@ +--- +'@pymodel/pythinker-code': patch +--- + +Questions no longer expire after 60 seconds, expired questions are not reported as user dismissals, answers retain question text and option labels, and Escape no longer dismisses a question. diff --git a/apps/pythinker-web/src/components/QuestionCard.vue b/apps/pythinker-web/src/components/QuestionCard.vue index 829b4aeb3..ad0bfe7a5 100644 --- a/apps/pythinker-web/src/components/QuestionCard.vue +++ b/apps/pythinker-web/src/components/QuestionCard.vue @@ -30,6 +30,22 @@ const total = computed(() => props.question.questions.length); const hasPreview = computed(() => current.value.options.some((option) => option.preview?.trim()), ); +const now = ref(Date.now()); +const remainingMinutes = computed(() => { + const expiresAt = Date.parse(props.question.expiresAt); + if (Number.isNaN(expiresAt)) return undefined; + return Math.ceil((expiresAt - now.value) / 60_000); +}); +const leaseWarning = computed(() => { + const expiresAt = Date.parse(props.question.expiresAt); + if (Number.isNaN(expiresAt)) return undefined; + const remainingMs = expiresAt - now.value; + if (remainingMs <= 0 || remainingMs > 5 * 60_000) return undefined; + if (remainingMs < 60_000) return t('question.expiresSoonSeconds'); + const minutes = remainingMinutes.value; + if (minutes === undefined) return undefined; + return t('question.expiresSoon', { minutes }); +}); function goBack(): void { if (step.value > 0) step.value--; @@ -207,17 +223,15 @@ function dismiss(): void { } // --------------------------------------------------------------------------- -// Keyboard: number keys pick options for current question, Enter submit, Esc dismiss +// Keyboard: number keys pick options for the current question and Enter submits. // --------------------------------------------------------------------------- function handleKeydown(e: KeyboardEvent): void { const tag = (document.activeElement?.tagName ?? '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return; - // While minimized the options aren't visible, so don't let number keys pick - // an unseen answer; only Escape (dismiss) stays live. - if (minimized.value && e.key !== 'Escape') return; + // While minimized the options are not visible, so keyboard selection is disabled. + if (minimized.value) return; - if (e.key === 'Escape') { e.preventDefault(); dismiss(); return; } if (e.key === 'Enter') { e.preventDefault(); submit(); return; } const num = parseInt(e.key, 10); @@ -236,8 +250,19 @@ function handleKeydown(e: KeyboardEvent): void { } } -onMounted(() => document.addEventListener('keydown', handleKeydown)); -onUnmounted(() => document.removeEventListener('keydown', handleKeydown)); +let leaseTimer: ReturnType | undefined; + +onMounted(() => { + document.addEventListener('keydown', handleKeydown); + leaseTimer = setInterval(() => { + now.value = Date.now(); + }, 30_000); +}); + +onUnmounted(() => { + document.removeEventListener('keydown', handleKeydown); + if (leaseTimer !== undefined) clearInterval(leaseTimer); +});