diff --git a/ui/src/pages/custodian/custodian-page.qr.test.ts b/ui/src/pages/custodian/custodian-page.qr.test.ts index dab379bb1681..e3872ba0c6b9 100644 --- a/ui/src/pages/custodian/custodian-page.qr.test.ts +++ b/ui/src/pages/custodian/custodian-page.qr.test.ts @@ -110,6 +110,37 @@ describe("custodian QR wizard step", () => { }); }); + it("keeps polling when acknowledgement enters non-input settlement", async () => { + vi.useFakeTimers(); + const settlingResult = { + sessionId: SESSION_ID, + reply: "Setup is still finishing the QR attempt.", + action: "none" as const, + wizardSettling: true, + }; + const request = vi + .fn() + .mockResolvedValueOnce(qrResult()) + .mockResolvedValueOnce(settlingResult) + .mockResolvedValueOnce(terminalResult("Device linked.")); + const { page } = await mountPage(createContext(request).context); + await vi.advanceTimersByTimeAsync(0); + + page.querySelector(".custodian__wizard-step .btn.primary")?.click(); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(2)); + expect(page.store.wizardInputPending).toBe(false); + expect(page.store.wizardSettling).toBe(true); + + await vi.advanceTimersByTimeAsync(1_000); + await waitForFast(() => expect(request).toHaveBeenCalledTimes(3)); + expect(request.mock.calls[2]?.[1]).toEqual({ + sessionId: SESSION_ID, + pollStepId: "qr-step", + }); + expect(page.store.wizardSettling).toBe(false); + expect(page.textContent).toContain("Device linked."); + }); + it("keeps the QR and resumes polling when Continue was definitely unsent", async () => { vi.useFakeTimers(); const request = vi @@ -599,7 +630,7 @@ describe("custodian QR wizard step", () => { sessionId: SESSION_ID, reply: "Setup is still finishing the QR attempt.", action: "none" as const, - wizardInputPending: true, + wizardSettling: true, }; const request = vi .fn() @@ -614,6 +645,8 @@ describe("custodian QR wizard step", () => { await page.updateComplete; expect(page.querySelector(".wizard-step__qr")).toBeNull(); expect(page.textContent).toContain("This QR code expired."); + expect(page.store.wizardInputPending).toBe(false); + expect(page.store.wizardSettling).toBe(true); await vi.advanceTimersByTimeAsync(1_000); expect(request.mock.calls.filter((call) => call[1]?.pollStepId === "qr-step")).toHaveLength(2); diff --git a/ui/src/pages/custodian/custodian-session-store.ts b/ui/src/pages/custodian/custodian-session-store.ts index 93055c60a15e..f63e759db027 100644 --- a/ui/src/pages/custodian/custodian-session-store.ts +++ b/ui/src/pages/custodian/custodian-session-store.ts @@ -58,6 +58,7 @@ export class CustodianSessionStore { sending = false; sensitive = false; wizardInputPending = false; + wizardSettling = false; wizardValue: unknown; wizardSecretVisible = false; questionReplyUncertain = false; @@ -331,22 +332,26 @@ export class CustodianSessionStore { } answerWizardStep(message: CustodianMessage, value: unknown): void { - if (!message.step || !this.wizardInputPending) { + const step = message.step; + if ( + !step || + (!this.wizardInputPending && !(this.wizardSettling && step.type === "qr" && value === false)) + ) { return; } - const submission = custodianWizardSubmission(message.step, value); + const submission = custodianWizardSubmission(step, value); const client = this.activeClient; if (!submission || !client || !this.chatAvailable || this.sending || this.setupRequired) { this.emit(); return; } - const displayText = message.step.sensitive ? t("custodian.sensitiveReply") : submission.display; + const displayText = step.sensitive ? t("custodian.sensitiveReply") : submission.display; const sessionContinuity = this.sessionContinuity; const settleQrAcknowledgement = - message.step.type === "qr" + step.type === "qr" ? this.qrScheduler.beginAcknowledgement( client, - message.step.id, + step.id, () => this.sessionContinuity === sessionContinuity && this.activeClient === client, ) : undefined; @@ -450,7 +455,10 @@ export class CustodianSessionStore { this.input = ""; this.wizardValue = undefined; this.wizardSecretVisible = false; - this.sensitive = this.wizardInputPending = this.questionReplyUncertain = false; + this.sensitive = false; + this.wizardInputPending = false; + this.wizardSettling = false; + this.questionReplyUncertain = false; this.error = null; this.setupIssue = null; this.earlierBoundaryAfterId = this.messages.at(-1)?.id ?? null; @@ -478,9 +486,10 @@ export class CustodianSessionStore { client !== this.sessionClient; const ownershipChanged = this.sessionContinuity !== null && continuity.key !== this.sessionContinuity.key; - const pendingQrStepId = this.wizardInputPending - ? this.messages.findLast((message) => message.step?.type === "qr")?.step?.id - : undefined; + const pendingQrStepId = + this.wizardInputPending || this.wizardSettling + ? this.messages.findLast((message) => message.step?.type === "qr")?.step?.id + : undefined; if ( client === this.activeClient && !variantChanged && @@ -554,9 +563,10 @@ export class CustodianSessionStore { if (!this.retryParams) { this.error = requestWasPending ? this.error : null; } - const pendingStep = this.wizardInputPending - ? this.messages.findLast((message) => message.step !== null)?.step - : null; + const pendingStep = + this.wizardInputPending || this.wizardSettling + ? this.messages.findLast((message) => message.step !== null)?.step + : null; if (pendingStep?.type === "qr") { // A reconnect invalidates the old timer, but the Gateway still owns the QR session. this.qrScheduler.schedulePoll(client, pendingStep.id); @@ -619,7 +629,10 @@ export class CustodianSessionStore { this.input = ""; this.wizardValue = undefined; this.wizardSecretVisible = false; - this.sensitive = this.wizardInputPending = this.questionReplyUncertain = false; + this.sensitive = false; + this.wizardInputPending = false; + this.wizardSettling = false; + this.questionReplyUncertain = false; this.earlierBoundaryAfterId = null; } @@ -647,6 +660,7 @@ export class CustodianSessionStore { const requestAbort = new AbortController(); this.requestAbort = requestAbort; const pollStepId = options?.pollStepId; + const settlingStepId = pollStepId ?? params.wizardAnswer?.stepId; const epoch = ++this.requestEpoch; let delivery: eventNudgeState.CustodianSendDelivery = "unsent"; if (!pollStepId) { @@ -687,16 +701,18 @@ export class CustodianSessionStore { } this.messages = replaceCustodianQrStep(this.messages, result.step); this.wizardInputPending = result.wizardInputPending === true; + this.wizardSettling = false; this.qrScheduler.scheduleStep(client, result.step); return "sent"; } - if (pollStepId && result.wizardInputPending === true && result.step === undefined) { + if (settlingStepId && result.wizardSettling === true && result.step === undefined) { // An externally owned QR can outlive its presentation. Keep observations short and // poll again so Cancel remains responsive while the owner settles in the background. - this.messages = scrubCustodianQrSteps(this.messages, pollStepId); + this.messages = scrubCustodianQrSteps(this.messages, settlingStepId); this.questionReplyUncertain = false; - this.wizardInputPending = true; - this.qrScheduler.schedulePoll(client, pollStepId); + this.wizardInputPending = false; + this.wizardSettling = true; + this.qrScheduler.schedulePoll(client, settlingStepId); return "sent"; } this.qrScheduler.clear(); @@ -706,6 +722,7 @@ export class CustodianSessionStore { } this.sensitive = result.sensitive === true; this.wizardInputPending = result.wizardInputPending === true; + this.wizardSettling = result.wizardSettling === true; [this.retryParams, this.setupIssue] = [null, null]; const step = result.step ?? null; const question = step ? null : parseCustodianQuestion(result.question); diff --git a/ui/src/pages/custodian/custodian-surface.ts b/ui/src/pages/custodian/custodian-surface.ts index 0c6dfb501267..cb18e4528ca7 100644 --- a/ui/src/pages/custodian/custodian-surface.ts +++ b/ui/src/pages/custodian/custodian-surface.ts @@ -133,9 +133,10 @@ class CustodianSurface extends OpenClawLightDomElement { `; } const emptyError = store.messages.length === 0 && store.error !== null && !store.sending; - const activeWizardMessage = store.wizardInputPending - ? store.messages.findLast((message) => message.step !== null) - : undefined; + const activeWizardMessage = + store.wizardInputPending || store.wizardSettling + ? store.messages.findLast((message) => message.step !== null) + : undefined; return html`