mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): recover hung QR acknowledgements
This commit is contained in:
@@ -252,6 +252,47 @@ describe("custodian QR wizard step", () => {
|
||||
expect(page.querySelector('[role="alert"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers through polling when a sent acknowledgement never settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
let acknowledgementSignal: AbortSignal | undefined;
|
||||
const acknowledgement = new Promise<never>(() => {});
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(qrResult())
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: { onSent?: () => void; signal?: AbortSignal },
|
||||
): Promise<never> => {
|
||||
acknowledgementSignal = options?.signal;
|
||||
options?.onSent?.();
|
||||
return await acknowledgement;
|
||||
},
|
||||
)
|
||||
.mockResolvedValueOnce(terminalResult("Device linked after recovery."));
|
||||
const { page } = await mountPage(createContext(request).context);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")?.click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(page.store.sending).toBe(true);
|
||||
expect(page.querySelector(".wizard-step__qr")).toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await page.updateComplete;
|
||||
|
||||
expect(acknowledgementSignal?.aborted).toBe(true);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
expect(request.mock.calls[2]?.[1]).toEqual({ sessionId: SESSION_ID, pollStepId: "qr-step" });
|
||||
expect(page.store.sending).toBe(false);
|
||||
expect(page.textContent).toContain("Device linked after recovery.");
|
||||
expect(page.store.hasUnresolvedQuestion()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a failed acknowledgement error when recovery returns the active QR", async () => {
|
||||
vi.useFakeTimers();
|
||||
let rejectAcknowledgement!: (error: Error) => void;
|
||||
|
||||
@@ -98,6 +98,7 @@ export class CustodianSessionStore {
|
||||
}
|
||||
},
|
||||
onPoll: (client, stepId, presentationGeneration) => {
|
||||
this.retirePendingQrAcknowledgement(stepId);
|
||||
void this.requestReply(
|
||||
client,
|
||||
{ sessionId: this.sessionId, pollStepId: stepId },
|
||||
@@ -640,6 +641,22 @@ export class CustodianSessionStore {
|
||||
this.questionReplyUncertain = false;
|
||||
}
|
||||
|
||||
private retirePendingQrAcknowledgement(stepId: string): void {
|
||||
const pendingStepId =
|
||||
this.retryParams?.wizardAnswer?.stepId ?? this.retryParams?.wizardCancel?.stepId;
|
||||
if (!this.sending || pendingStepId !== stepId) {
|
||||
return;
|
||||
}
|
||||
// QR owner state is authoritative after delivery. Release the unanswered request before
|
||||
// polling so a lost reply cannot retain the sending gate or overwrite the observation.
|
||||
this.requestAbort?.abort();
|
||||
this.requestAbort = null;
|
||||
this.requestEpoch += 1;
|
||||
this.sending = false;
|
||||
this.retryParams = null;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private clearConversation(): void {
|
||||
this.qrScheduler.clear();
|
||||
this.messages = [];
|
||||
|
||||
@@ -59,21 +59,33 @@ export class CustodianQrScheduler {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
const presentationGeneration = this.presentationGeneration;
|
||||
let delivered = false;
|
||||
return (outcome) => {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
const presentationCurrent = this.presentationGeneration === presentationGeneration;
|
||||
if (outcome !== "rejected" && !delivered) {
|
||||
delivered = true;
|
||||
if (!presentationCurrent) {
|
||||
// The authoritative reply already replaced scheduler state, but clients without an
|
||||
// onSent hook still need the acknowledged credential removed from the transcript.
|
||||
this.callbacks.onExpire(stepId, true);
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
// Delivery only proves that the Gateway received the answer. Observe owner state so a
|
||||
// lost or hung reply cannot leave the UI waiting for the general chat timeout.
|
||||
this.schedulePoll(client, stepId);
|
||||
return;
|
||||
}
|
||||
if (outcome !== "sent") {
|
||||
if (presentationCurrent && outcome !== "sent") {
|
||||
this.schedulePoll(client, stepId);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user