diff --git a/CHANGELOG.md b/CHANGELOG.md index ff161fa0cfd3..39bda7070d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Upstream: t3code 0.0.31 ### Avi Code +- Answers rejected by an expired question now return after restarting Avi Code (#123) - A stuck provider question can always be dismissed without restarting Avi Code (#122) - Restarting Avi Code keeps your provider choices while provider discovery finishes (#122) - Files in unregistered sibling repositories now open from the repository that owns them (#121) diff --git a/FUTURE_ENHANCEMENTS.md b/FUTURE_ENHANCEMENTS.md index 974a6a1ae3f8..4b1ef577b72b 100644 --- a/FUTURE_ENHANCEMENTS.md +++ b/FUTURE_ENHANCEMENTS.md @@ -17,6 +17,9 @@ ActivityWatch is authoritative for human time; sessions and GitHub only enrich a ## Deferred +- Expired questionnaire answers are restored as plain composer text so the user can confirm and + resend them. Reconstructing the original multi-step questionnaire would require a durable client + draft schema and is unnecessary while the plain-text recovery preserves every submitted value. - `/btw` silently discards attached images, terminal contexts, and preview annotations. The `/plan`/`/default` branch in `ChatView`'s send handler refuses to claim the input when any of those are present, so they survive; the `/btw` branch has no such guard and clears the composer diff --git a/TODO.md b/TODO.md index 5e2858f13187..627c47c016d3 100644 --- a/TODO.md +++ b/TODO.md @@ -36,6 +36,7 @@ Prioritized work. Structure: **shipped foundation → alpha verification → nex worktree icon. - [x] Opt-in opening of finished chats at the top of their last response instead of the live edge. - [x] Stuck provider questions remain dismissible and provider choices survive desktop restart. +- [x] Durable recovery of answers submitted to questions whose provider session already ended. ## Personal alpha verification diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0c538e2efa63..2f1218c96d60 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2814,6 +2814,7 @@ describe("ProviderCommandReactor", () => { expect(expiredActivity?.payload).toMatchObject({ requestId: "user-input-request-1", expired: true, + answers: { sandbox_mode: "workspace-write" }, }); // Nothing failed: the session outlived the question, which is not the @@ -2821,6 +2822,29 @@ describe("ProviderCommandReactor", () => { expect( thread?.activities.some((activity) => activity.kind === "provider.user-input.respond.failed"), ).toBe(false); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.user-input.respond", + commandId: CommandId.make("cmd-user-input-respond-stale-duplicate"), + threadId: ThreadId.make("thread-1"), + requestId: asApprovalRequestId("user-input-request-1"), + answers: { sandbox_mode: "workspace-write" }, + createdAt: now, + }), + ); + await harness.drain(); + const afterDuplicate = await readThread(harness); + expect( + afterDuplicate?.activities.filter( + (activity) => + activity.kind === "user-input.resolved" && + typeof activity.payload === "object" && + activity.payload !== null && + (activity.payload as Record).requestId === "user-input-request-1", + ), + ).toHaveLength(1); + expect(harness.respondToUserInput).toHaveBeenCalledTimes(1); }); it("closes a user-input request as expired when no provider session is bound", async () => { @@ -2873,6 +2897,9 @@ describe("ProviderCommandReactor", () => { const thread = await readThread(harness); expect(findUserInputResolved(thread, "user-input-request-2")?.summary).toBe("Question expired"); + expect(findUserInputResolved(thread, "user-input-request-2")?.payload).toMatchObject({ + answers: { sandbox_mode: "workspace-write" }, + }); expect(harness.respondToUserInput).not.toHaveBeenCalled(); expect( thread?.activities.some((activity) => activity.kind === "provider.user-input.respond.failed"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f3c60c5d9f0b..531709196465 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -364,6 +364,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly requestId: string; readonly createdAt: string; + readonly answers?: Readonly>; }) => Effect.all({ commandId: serverCommandId("user-input-expired-activity"), @@ -381,7 +382,7 @@ const make = Effect.gen(function* () { summary: USER_INPUT_EXPIRED_SUMMARY, payload: { requestId: input.requestId, - answers: {}, + answers: input.answers ?? {}, expired: true, detail: USER_INPUT_EXPIRED_DETAIL, }, @@ -1380,6 +1381,21 @@ const make = Effect.gen(function* () { if (!thread) { return; } + // Avi Code addition: response commands are durable and may be replayed + // after a restart or submitted repeatedly while the first is settling. + // Once the request is closed, later copies have nothing left to do. + if ( + thread.activities.some((activity) => { + if (activity.kind !== "user-input.resolved") return false; + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + return payload?.requestId === event.payload.requestId; + }) + ) { + return; + } const hasSession = thread.session && thread.session.status !== "stopped"; // Avi Code addition: no session means the question outlived the thing // that asked it. That is an expiry, not a failure the user caused. @@ -1388,6 +1404,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, requestId: event.payload.requestId, createdAt: event.payload.createdAt, + answers: event.payload.answers, }); } @@ -1407,6 +1424,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, requestId: event.payload.requestId, createdAt: event.payload.createdAt, + answers: event.payload.answers, }) : appendProviderFailureActivity({ threadId: event.payload.threadId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c4fe456542ca..cb0c3475343f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -112,7 +112,11 @@ import { import { buildPendingUserInputAnswers, derivePendingUserInputProgress, + formatExpiredUserInputAnswers, formatExpiredUserInputDraft, + hasHandledExpiredUserInputRecovery, + markExpiredUserInputRecoveryHandled, + mergeExpiredUserInputWithComposerDraft, omitPendingUserInputRequestIds, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, @@ -175,6 +179,7 @@ import { AlarmClockIcon, ChevronDownIcon, ClockIcon, + MessageSquareReplyIcon, GitBranchIcon, TriangleAlertIcon, SquarePenIcon, @@ -2257,19 +2262,54 @@ function ChatViewContent(props: ChatViewProps) { [threadActivities], ); const recoveredExpiredUserInputIdsRef = useRef>(new Set()); + const [deferredExpiredUserInputRecovery, setDeferredExpiredUserInputRecovery] = useState<{ + requestId: string; + prompt: string; + } | null>(null); + const restoreExpiredUserInput = useCallback( + (recovery: { requestId: string; prompt: string }) => { + const nextPrompt = mergeExpiredUserInputWithComposerDraft(promptRef.current, recovery.prompt); + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length), + prompt: nextPrompt, + detectTrigger: true, + }); + markExpiredUserInputRecoveryHandled(window.localStorage, recovery.requestId); + setDeferredExpiredUserInputRecovery(null); + }, + [composerDraftTarget, composerRef, setComposerDraftPrompt], + ); useEffect(() => { const unseen = expiredUserInputs.filter( - (entry) => !recoveredExpiredUserInputIdsRef.current.has(entry.requestId), + (entry) => + !recoveredExpiredUserInputIdsRef.current.has(entry.requestId) && + !hasHandledExpiredUserInputRecovery(window.localStorage, entry.requestId), ); if (unseen.length === 0) return; let recoveredPrompt: string | null = null; + let recoveredRequestId: string | null = null; for (const entry of unseen) { recoveredExpiredUserInputIdsRef.current.add(entry.requestId); if (recoveredPrompt !== null) continue; - const draft = pendingUserInputAnswersByRequestId[entry.requestId]; - if (!draft) continue; - recoveredPrompt = formatExpiredUserInputDraft(entry.questions, draft); + recoveredPrompt = entry.submittedAnswers + ? formatExpiredUserInputAnswers(entry.questions, entry.submittedAnswers) + : null; + if (recoveredPrompt === null) { + const draft = pendingUserInputAnswersByRequestId[entry.requestId]; + if (draft) { + recoveredPrompt = formatExpiredUserInputDraft(entry.questions, draft); + } + } + if (recoveredPrompt !== null) { + recoveredRequestId = entry.requestId; + setDeferredExpiredUserInputRecovery({ + requestId: entry.requestId, + prompt: recoveredPrompt, + }); + } } const expiredRequestIds = new Set(unseen.map((entry) => entry.requestId)); @@ -2280,22 +2320,13 @@ function ChatViewContent(props: ChatViewProps) { omitPendingUserInputRequestIds(existing, expiredRequestIds), ); - // Never clobber something the user is already typing. + // Restore immediately when safe. Otherwise the banner below offers an + // explicit restore that appends without overwriting the current draft. if (recoveredPrompt === null || promptRef.current.trim().length > 0) return; - promptRef.current = recoveredPrompt; - setComposerDraftPrompt(composerDraftTarget, recoveredPrompt); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(recoveredPrompt, recoveredPrompt.length), - prompt: recoveredPrompt, - detectTrigger: true, - }); - }, [ - composerDraftTarget, - composerRef, - expiredUserInputs, - pendingUserInputAnswersByRequestId, - setComposerDraftPrompt, - ]); + if (recoveredRequestId !== null) { + restoreExpiredUserInput({ requestId: recoveredRequestId, prompt: recoveredPrompt }); + } + }, [expiredUserInputs, pendingUserInputAnswersByRequestId, restoreExpiredUserInput]); const activeProposedPlan = useMemo(() => { if (!latestTurnSettled) { return null; @@ -4637,6 +4668,35 @@ function ChatViewContent(props: ChatViewProps) { ); const composerBannerItems = useMemo(() => { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + const expiredAnswerItems: ComposerBannerStackItem[] = deferredExpiredUserInputRecovery + ? [ + { + id: `expired-user-input:${deferredExpiredUserInputRecovery.requestId}`, + variant: "info", + icon: , + title: "Your answer is safe", + description: + "The provider session ended before it received your answer. Restore it as a new message to continue.", + actions: ( + + ), + dismissLabel: "Dismiss recovered answer", + onDismiss: () => { + markExpiredUserInputRecoveryHandled( + window.localStorage, + deferredExpiredUserInputRecovery.requestId, + ); + setDeferredExpiredUserInputRecovery(null); + }, + }, + ] + : []; // Avi Code addition: a send held until the running turn finishes. Says the // reload limitation out loud, because nothing persists the hold. const heldSendItems: ComposerBannerStackItem[] = isHoldingSend @@ -4720,6 +4780,7 @@ function ChatViewContent(props: ChatViewProps) { return [ ...heldSendItems, ...forkEditItems, + ...expiredAnswerItems, ...systemComposerBannerItems, ...parkedThreadItems, ]; @@ -4727,6 +4788,7 @@ function ChatViewContent(props: ChatViewProps) { return [ ...heldSendItems, ...forkEditItems, + ...expiredAnswerItems, ...systemComposerBannerItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, @@ -4774,6 +4836,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadKey, cancelForkEdit, cancelHeldSend, + deferredExpiredUserInputRecovery, forkEditState, isForkingThread, isHoldingSend, @@ -4783,6 +4846,7 @@ function ChatViewContent(props: ChatViewProps) { isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, + restoreExpiredUserInput, showBranchMismatchBanner, systemComposerBannerItems, ]); diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts index 456b72ae7132..5207c4256f9e 100644 --- a/apps/web/src/pendingUserInput.test.ts +++ b/apps/web/src/pendingUserInput.test.ts @@ -5,7 +5,11 @@ import { countAnsweredPendingUserInputQuestions, derivePendingUserInputProgress, findFirstUnansweredPendingUserInputQuestionIndex, + formatExpiredUserInputAnswers, formatExpiredUserInputDraft, + hasHandledExpiredUserInputRecovery, + markExpiredUserInputRecoveryHandled, + mergeExpiredUserInputWithComposerDraft, omitPendingUserInputRequestIds, resolvePendingUserInputAnswer, setPendingUserInputCustomAnswer, @@ -335,6 +339,47 @@ describe("formatExpiredUserInputDraft", () => { }); }); +describe("formatExpiredUserInputAnswers", () => { + it("restores persisted custom and multiple-choice answers", () => { + expect( + formatExpiredUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { + scope: "A long custom answer", + areas: ["Server", "Web"], + }), + ).toBe("Scope: A long custom answer\nAreas: Server, Web"); + }); + + it("returns null for an old expiry without persisted answers", () => { + expect(formatExpiredUserInputAnswers([singleSelectQuestion], {})).toBe(null); + }); +}); + +describe("mergeExpiredUserInputWithComposerDraft", () => { + it("does not overwrite text already in the composer", () => { + expect(mergeExpiredUserInputWithComposerDraft("Current draft", "Recovered answer")).toBe( + "Current draft\n\nRecovered answer", + ); + }); + + it("uses the recovered answer directly for an empty composer", () => { + expect(mergeExpiredUserInputWithComposerDraft("", "Recovered answer")).toBe("Recovered answer"); + }); +}); + +describe("expired user-input recovery receipt", () => { + it("remembers a restored request across renderer reloads", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + expect(hasHandledExpiredUserInputRecovery(storage, "req-1")).toBe(false); + markExpiredUserInputRecoveryHandled(storage, "req-1"); + expect(hasHandledExpiredUserInputRecovery(storage, "req-1")).toBe(true); + expect(hasHandledExpiredUserInputRecovery(storage, "req-2")).toBe(false); + }); +}); + describe("omitPendingUserInputRequestIds", () => { it("returns the same object when nothing matches, so state setters do not re-render", () => { const entries = { "req-1": 0 }; diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts index 755925c2c4da..e209a9e97c5e 100644 --- a/apps/web/src/pendingUserInput.ts +++ b/apps/web/src/pendingUserInput.ts @@ -145,6 +145,71 @@ export function formatExpiredUserInputDraft( return lines.length > 0 ? lines.join("\n") : null; } +/** Format answers persisted with an expired response after client state is gone. */ +export function formatExpiredUserInputAnswers( + questions: ReadonlyArray, + answers: Readonly>, +): string | null { + const lines = questions.flatMap((question) => { + const answer = answers[question.id]; + if (typeof answer === "string" && answer.trim().length > 0) { + return [`${question.header}: ${answer}`]; + } + if (Array.isArray(answer) && answer.length > 0) { + return [`${question.header}: ${answer.join(", ")}`]; + } + return []; + }); + return lines.length > 0 ? lines.join("\n") : null; +} + +/** Append a recovered answer without replacing a draft already in progress. */ +export function mergeExpiredUserInputWithComposerDraft( + existingPrompt: string, + recoveredPrompt: string, +): string { + return existingPrompt.trim().length > 0 + ? `${existingPrompt}\n\n${recoveredPrompt}` + : recoveredPrompt; +} + +const EXPIRED_USER_INPUT_RECOVERY_STORAGE_KEY = "t3code:expired-user-input-recovery:v1"; +const MAX_REMEMBERED_EXPIRED_USER_INPUTS = 200; + +function readRecoveredExpiredUserInputIds(storage: Pick): string[] { + try { + const parsed: unknown = JSON.parse( + storage.getItem(EXPIRED_USER_INPUT_RECOVERY_STORAGE_KEY) ?? "[]", + ); + return Array.isArray(parsed) + ? parsed.filter((value): value is string => typeof value === "string") + : []; + } catch { + return []; + } +} + +/** Whether this browser already restored or deliberately dismissed the answer. */ +export function hasHandledExpiredUserInputRecovery( + storage: Pick, + requestId: string, +): boolean { + return readRecoveredExpiredUserInputIds(storage).includes(requestId); +} + +/** Prevent a durable expiry activity from restoring the same answer on every reload. */ +export function markExpiredUserInputRecoveryHandled( + storage: Pick, + requestId: string, +): void { + const ids = readRecoveredExpiredUserInputIds(storage).filter((id) => id !== requestId); + ids.push(requestId); + storage.setItem( + EXPIRED_USER_INPUT_RECOVERY_STORAGE_KEY, + JSON.stringify(ids.slice(-MAX_REMEMBERED_EXPIRED_USER_INPUTS)), + ); +} + /** * Avi Code addition: drop entries for request ids that will never be answered. * The per-request draft maps are keyed by a request id and had no eviction, so diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 95067abcd3cf..ccc697520d65 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -366,10 +366,31 @@ describe("deriveExpiredUserInputs", () => { multiSelect: false, }, ], + submittedAnswers: null, }, ]); }); + it("returns answers persisted with an expired response", () => { + const activities = expiredUserInputActivities(); + activities[1] = makeActivity({ + id: "user-input-expired-with-answers", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "user-input.resolved", + summary: "Question expired", + tone: "info", + payload: { + requestId: "req-user-input-expired-1", + expired: true, + answers: { sandbox_mode: "A long custom answer", areas: ["Server", "Web"] }, + }, + }); + expect(deriveExpiredUserInputs(activities)[0]?.submittedAnswers).toEqual({ + sandbox_mode: "A long custom answer", + areas: ["Server", "Web"], + }); + }); + it("ignores an ordinary answered resolution", () => { expect( deriveExpiredUserInputs([ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 38e99e458c99..e936f4ecea1e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -104,6 +104,7 @@ export interface ExpiredUserInput { requestId: ApprovalRequestId; expiredAt: string; questions: ReadonlyArray; + submittedAnswers: Readonly> | null; } export interface ActivePlanState { @@ -554,7 +555,25 @@ export function deriveExpiredUserInputs( const questions = openByRequestId.get(requestId); openByRequestId.delete(requestId); if (questions && payload?.expired === true) { - expired.push({ requestId, questions, expiredAt: activity.createdAt }); + const rawAnswers = payload.answers; + const submittedAnswers = + rawAnswers && typeof rawAnswers === "object" && !Array.isArray(rawAnswers) + ? Object.fromEntries( + Object.entries(rawAnswers).filter( + (entry): entry is [string, string | string[]] => + typeof entry[1] === "string" || + (Array.isArray(entry[1]) && + entry[1].every((value) => typeof value === "string")), + ), + ) + : null; + expired.push({ + requestId, + questions, + expiredAt: activity.createdAt, + submittedAnswers: + submittedAnswers && Object.keys(submittedAnswers).length > 0 ? submittedAnswers : null, + }); } } }