diff --git a/docs/qa-evidence/question-response-retry/after.png b/docs/qa-evidence/question-response-retry/after.png new file mode 100644 index 0000000000..27dddb4bf6 Binary files /dev/null and b/docs/qa-evidence/question-response-retry/after.png differ diff --git a/docs/qa-evidence/question-response-retry/before.png b/docs/qa-evidence/question-response-retry/before.png new file mode 100644 index 0000000000..651e5f54da Binary files /dev/null and b/docs/qa-evidence/question-response-retry/before.png differ diff --git a/packages/app/e2e/browser/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts index 1d75ae2839..b444a8cddf 100644 --- a/packages/app/e2e/browser/question-prompt-pagination.spec.ts +++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts @@ -1,5 +1,16 @@ +import { fork } from "node:child_process"; +import path from "node:path"; +import { expect } from "@playwright/test"; +import { z } from "zod"; import { test } from "../support/fixtures"; -import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream"; +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { killProcessTree } from "../support/helpers/spawn-node"; +import { + buildAgentRoute, + openAgentRoute, + seedMockAgentWorkspace, +} from "../support/helpers/mock-agent"; import { chooseQuestionOption, continueToNextQuestion, @@ -130,3 +141,204 @@ test.describe("Question prompt pagination", () => { } }); }); + +const questionDaemonMessage = z.discriminatedUnion("type", [ + z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }), + z.object({ + type: z.literal("attempt"), + requestId: z.string(), + response: z.unknown(), + sessionId: z.string(), + }), + z.object({ + type: z.literal("resolved"), + requestId: z.string(), + response: z.unknown(), + sessionId: z.string(), + pendingRequestIds: z.array(z.string()), + }), + z.object({ type: z.literal("error"), error: z.string() }), +]); + +type QuestionResponseAttempt = Extract, { type: "attempt" }>; + +type QuestionResolution = Extract, { type: "resolved" }>; + +interface QuestionRetryControl { + attempts: QuestionResponseAttempt[]; + resolutions: QuestionResolution[]; + releaseFirstResponse(): void; +} + +const retryTest = test.extend<{ + questionClient: QuestionRetryControl; +}>({ + questionClient: async ({ page }, provide, testInfo) => { + const serverDir = path.resolve(__dirname, "../../../server"); + const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), { + cwd: serverDir, + execArgv: ["--import", "tsx"], + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + const output: string[] = []; + child.stdout?.on("data", (data: Buffer) => output.push(data.toString())); + child.stderr?.on("data", (data: Buffer) => output.push(data.toString())); + const ready = Promise.withResolvers<{ port: number; serverId: string }>(); + const attempts: QuestionResponseAttempt[] = []; + const resolutions: QuestionResolution[] = []; + child.on("message", (message: unknown) => { + const parsed = questionDaemonMessage.parse(message); + switch (parsed.type) { + case "ready": + ready.resolve(parsed); + break; + case "attempt": + attempts.push(parsed); + break; + case "resolved": + resolutions.push(parsed); + break; + case "error": + ready.reject(new Error(parsed.error)); + break; + } + }); + child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`))); + const timeout = setTimeout( + () => ready.reject(new Error("Question daemon startup timed out")), + 20_000, + ); + try { + const daemon = await ready.promise; + clearTimeout(timeout); + const session = await seedMockAgentWorkspace({ + port: daemon.port, + repoPrefix: "question-retry-", + title: "Question response retry", + initialPrompt: "Emit synthetic questions.", + }); + try { + await page.goto("/", { waitUntil: "commit" }); + const serverId = daemon.serverId; + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + label: "Question retry host", + nowIso: new Date().toISOString(), + }); + await page.evaluate((extraHost) => { + localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost])); + }, host); + await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), { + waitUntil: "commit", + }); + await waitForQuestionPrompt(page, 120_000); + await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") }); + } finally { + await session.cleanup(); + } + } finally { + clearTimeout(timeout); + await killProcessTree(child); + if (testInfo.status !== testInfo.expectedStatus) { + await testInfo.attach("question-daemon", { + body: output.join(""), + contentType: "text/plain", + }); + } + } + }, +}); + +retryTest( + "failed question submission preserves answers and allows retry", + async ({ page, questionClient }, testInfo) => { + retryTest.setTimeout(180_000); + const card = page.getByTestId("question-form-card"); + await chooseQuestionOption(page, "App"); + await chooseQuestionOption(page, "Behind feature flag"); + await fillQuestionAnswer(page, { + question: SUCCESS_QUESTION, + answer: "Preserve my answers after a failed submission.", + }); + await card.getByRole("button", { name: "Submit" }).click(); + await expect(card.getByRole("progressbar")).toBeVisible(); + await expectQuestionPrimaryActionDisabled(page, "Submit"); + await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled(); + await expect(card.getByRole("tab").first()).toBeDisabled(); + await expect(card.getByRole("textbox")).not.toBeEditable(); + questionClient.releaseFirstResponse(); + await expectQuestionPrimaryActionEnabled(page, "Submit"); + await card.screenshot({ path: testInfo.outputPath("submission-failed.png") }); + await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again."); + await expect(card.getByRole("progressbar")).toHaveCount(0); + await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit"); + await expectQuestionDismissEnabled(page); + await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS }); + await expectQuestionOptionSelected(page, "App"); + await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS }); + await expectQuestionOptionSelected(page, "Behind feature flag"); + await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS }); + await expect(card.getByRole("textbox")).toHaveValue( + "Preserve my answers after a failed submission.", + ); + await submitQuestionAnswers(page); + await expectVisibleAgentSurfacesIdle(page); + await expect.poll(() => questionClient.attempts.length).toBe(2); + expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]); + await expect + .poll(() => questionClient.resolutions) + .toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]); + expect(questionClient.attempts[1].response).toMatchObject({ + behavior: "allow", + updatedInput: { + answers: { + surface: "App", + rollout: "Behind feature flag", + success: "Preserve my answers after a failed submission.", + }, + }, + }); + await page.screenshot({ path: testInfo.outputPath("submission-retried.png") }); + }, +); + +retryTest( + "failed question dismissal allows an immediate retry", + async ({ page, questionClient }, testInfo) => { + retryTest.setTimeout(180_000); + const card = page.getByTestId("question-form-card"); + questionClient.releaseFirstResponse(); + await chooseQuestionOption(page, "App"); + await chooseQuestionOption(page, "Behind feature flag"); + await fillQuestionAnswer(page, { + question: SUCCESS_QUESTION, + answer: "Start with the internal team.", + }); + await card.getByRole("button", { name: "Dismiss" }).click(); + await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again."); + await expect(card.getByRole("progressbar")).toHaveCount(0); + await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss"); + await expectQuestionDismissEnabled(page); + await expectQuestionPrimaryActionEnabled(page, "Submit"); + await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") }); + await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS }); + await expectQuestionOptionSelected(page, "App"); + await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS }); + await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team."); + await expect(card.getByRole("alert")).toBeVisible(); + await card.getByRole("button", { name: "Dismiss" }).click(); + await expect(card).toHaveCount(0); + await expectVisibleAgentSurfacesIdle(page); + await expect.poll(() => questionClient.attempts.length).toBe(2); + expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]); + await expect + .poll(() => questionClient.resolutions) + .toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]); + expect(questionClient.attempts[1].response).toEqual({ + behavior: "deny", + message: "Dismissed by user", + }); + await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") }); + }, +); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index cd7aa42b70..09b02cda18 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1531,11 +1531,17 @@ function PermissionRequestCard({ ); if (request.kind === "question") { + const responseFailed = + permissionMutation.isError && + permissionMutation.variables?.agentId === permission.agentId && + permissionMutation.variables?.requestId === permission.request.id; + const responseError = responseFailed ? t("message.question.responseFailed") : null; return ( ); } diff --git a/packages/app/src/components/question-form-card.browser.test.tsx b/packages/app/src/components/question-form-card.browser.test.tsx index 630b001aa6..83120931d1 100644 --- a/packages/app/src/components/question-form-card.browser.test.tsx +++ b/packages/app/src/components/question-form-card.browser.test.tsx @@ -58,6 +58,7 @@ function mountCard(question: Record) { permission={buildPermission(question)} onRespond={onRespond} isResponding={false} + responseError={null} />, ), ); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 655e01d210..7014de89d1 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,3 +1,4 @@ +import { Alert } from "@/components/ui/alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo, useRef, type RefObject } from "react"; import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; @@ -27,6 +28,7 @@ interface QuestionFormCardProps { permission: PendingPermission; onRespond: (response: AgentPermissionResponse) => void; isResponding: boolean; + responseError: string | null; } const IS_WEB = isWeb; @@ -339,7 +341,12 @@ function QuestionOtherInput({ ); } -export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { +export function QuestionFormCard({ + permission, + onRespond, + isResponding, + responseError, +}: QuestionFormCardProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); @@ -438,7 +445,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ]); const handleDeny = useCallback(() => { - if (!questions) return; + if (!questions || isResponding) return; setRespondingAction("dismiss"); if (shouldSubmitEmptyOnDismiss(questions)) { onRespond({ @@ -454,7 +461,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi behavior: "deny", message: "Dismissed by user", }); - }, [questions, onRespond, otherTexts, permission.request.input, selections]); + }, [questions, isResponding, onRespond, otherTexts, permission.request.input, selections]); const handleSelectQuestion = useCallback((index: number) => { setActiveQuestionIndex(index); @@ -487,6 +494,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi [theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent], ); + const pendingAction = isResponding ? respondingAction : null; const primaryDisabled = isResponding || (isLastQuestion ? !allAnswered : !activeQuestionAnswered); const primaryActionLabel = isLastQuestion ? t("message.question.submit") @@ -550,7 +558,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi const dismissLabel = resolveDismissLabel(questions, t("common.actions.dismiss")); const selected = selections[resolvedActiveQuestionIndex] ?? new Set(); const otherText = otherTexts[resolvedActiveQuestionIndex] ?? ""; - const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false; + const showTextInput = questionShowsTextInput(questions[resolvedActiveQuestionIndex]); return ( @@ -604,6 +612,8 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ) : null} + {responseError ? : null} + - {respondingAction === "dismiss" ? ( + {pendingAction === "dismiss" ? ( ) : ( @@ -631,7 +641,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi accessibilityLabel={primaryActionLabel} testID="question-form-primary-action" > - {respondingAction === "submit" ? ( + {pendingAction === "submit" ? ( ) : ( diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5e09ac5957..c5b9cf42d6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -369,6 +369,7 @@ export const ar: TranslationResources = { failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.", }, question: { + responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.", submit: "يُقدِّم", next: "التالي", answerPlaceholder: "اكتب إجابتك...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 8c6a870580..76135fb6c9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -366,6 +366,7 @@ export const en = { failedRetry: "Dictation failed. Tap retry.", }, question: { + responseFailed: "Could not send your response. Try again.", submit: "Submit", next: "Next", answerPlaceholder: "Type your answer...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 62667ba9f9..d2572a4bab 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -374,6 +374,7 @@ export const es: TranslationResources = { failedRetry: "El dictado falló. Toca reintentar.", }, question: { + responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.", submit: "Entregar", next: "Siguiente", answerPlaceholder: "Escribe tu respuesta...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8e70ece6da..48fd08c9ed 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -374,6 +374,7 @@ export const fr: TranslationResources = { failedRetry: "La dictée a échoué. Appuyez sur Réessayer.", }, question: { + responseFailed: "Impossible d’envoyer votre réponse. Réessayez.", submit: "Envoyer", next: "Suivant", answerPlaceholder: "Saisissez votre réponse…", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index d7b08a9c84..b3482f5c15 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -374,6 +374,7 @@ export const ja: TranslationResources = { failedRetry: "音声入力に失敗しました。再試行をタップしてください。", }, question: { + responseFailed: "回答を送信できませんでした。もう一度お試しください。", submit: "送信", next: "次へ", answerPlaceholder: "回答を入力...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index c82240f0c4..e39d625253 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -370,6 +370,7 @@ export const ko: TranslationResources = { failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.", }, question: { + responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.", submit: "제출", next: "다음", answerPlaceholder: "답변을 입력하세요...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 622c13e798..8c3d93499a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -373,6 +373,7 @@ export const ptBR: TranslationResources = { failedRetry: "Falha no ditado. Toque para tentar novamente.", }, question: { + responseFailed: "Não foi possível enviar sua resposta. Tente novamente.", submit: "Enviar", next: "Próximo", answerPlaceholder: "Digite sua resposta...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 733596de39..df57855cb6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -373,6 +373,7 @@ export const ru: TranslationResources = { failedRetry: "Диктовка не удалась. Нажмите «Повторить».", }, question: { + responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.", submit: "Отправить", next: "Далее", answerPlaceholder: "Введите ответ...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index c8627ca769..984cc0d71a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -369,6 +369,7 @@ export const zhCN: TranslationResources = { failedRetry: "听写失败。点按重试。", }, question: { + responseFailed: "未能发送你的回复,请重试。", submit: "提交", next: "下一步", answerPlaceholder: "输入你的回答...", diff --git a/packages/server/src/server/test-utils/question-response-daemon.ts b/packages/server/src/server/test-utils/question-response-daemon.ts new file mode 100644 index 0000000000..2899dc8ba3 --- /dev/null +++ b/packages/server/src/server/test-utils/question-response-daemon.ts @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; +import { + MockLoadTestAgentClient, + MockLoadTestAgentSession, +} from "../agent/providers/mock-load-test-agent.js"; +import { createTestPaseoDaemon } from "./paseo-daemon.js"; + +const firstResponse = Promise.withResolvers(); + +class RetryQuestionSession extends MockLoadTestAgentSession { + private attempts = 0; + + override async respondToPermission( + requestId: string, + response: Parameters[1], + ) { + this.attempts += 1; + process.send?.({ type: "attempt", requestId, response, sessionId: this.id }); + if (this.attempts === 1) { + await firstResponse.promise; + throw new Error("Synthetic question response failure"); + } + return super.respondToPermission(requestId, response); + } +} + +class RetryQuestionClient extends MockLoadTestAgentClient { + override async createSession(config: Parameters[0]) { + const session = new RetryQuestionSession({ config, sessionId: randomUUID() }); + session.subscribe((event) => { + if (event.type === "permission_resolved") { + process.send?.({ + type: "resolved", + requestId: event.requestId, + response: event.resolution, + sessionId: session.id, + pendingRequestIds: session.getPendingPermissions().map((request) => request.id), + }); + } + }); + return session; + } +} + +async function main(): Promise { + const daemon = await createTestPaseoDaemon({ + agentClients: { mock: new RetryQuestionClient() }, + isDev: true, + mcpEnabled: false, + corsAllowedOrigins: [`http://localhost:${process.env.E2E_METRO_PORT}`], + }); + process.on("message", (message: unknown) => { + if (message === "release") firstResponse.resolve(); + }); + const shutdown = async () => { + firstResponse.resolve(); + await daemon.close(); + process.exit(0); + }; + process.once("SIGINT", () => void shutdown()); + process.once("SIGTERM", () => void shutdown()); + process.once("disconnect", () => void shutdown()); + process.send?.({ type: "ready", port: daemon.port, serverId: daemon.daemon.getServerId() }); +} + +void main().catch((error: unknown) => { + process.send?.({ type: "error", error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +});