fix(ui): poll settling QR setup

This commit is contained in:
Jesse Merhi
2026-08-10 01:42:31 +10:00
committed by jesse-merhi
parent 48579d3d59
commit 7d7dc45dfe
4 changed files with 75 additions and 22 deletions
@@ -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<HTMLButtonElement>(".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);
@@ -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);
+4 -3
View File
@@ -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`
<section
class="custodian-surface ${this.compact ? "custodian-surface--panel" : ""} ${emptyError
@@ -66,7 +66,9 @@ export class CustodianQrScheduler {
}
if (outcome !== "rejected" && !delivered) {
delivered = true;
if (this.stepId === stepId) {
// A received acknowledgement may already have scheduled observation of this step.
// Clear only pre-response state so the replacement settlement poll survives.
if (this.stepId === stepId && this.pollTimer === null) {
this.clear();
}
this.callbacks.onExpire(stepId, true);