fix(gateway): record QR follow-up prompts

This commit is contained in:
jesse-merhi
2026-08-13 05:14:51 +10:00
parent 77c390279e
commit 08102533e3
3 changed files with 53 additions and 15 deletions
@@ -966,6 +966,9 @@ describe("openclaw.chat", () => {
).resolves.toMatchObject({ payload: { wizardSettling: true } });
releaseFollowUp.resolve();
await followUpPresented.promise;
const historyBeforeObservation = engine.historyLength();
await engine.resolveOperatorApproval(null, "queue-drain");
expect(engine.historyLength()).toBe(historyBeforeObservation);
const droppedReply = await callChat(context, {
sessionId: "lost-follow-up",
@@ -976,9 +979,18 @@ describe("openclaw.chat", () => {
throw new Error("retained follow-up response must contain a step");
}
const followUpStepId = droppedReply.payload.step.id;
const followUpText = droppedReply.payload.reply;
if (typeof followUpStepId !== "string") {
throw new Error("retained follow-up step must contain an id");
}
if (typeof followUpText !== "string") {
throw new Error("retained follow-up response must contain reply text");
}
expect(
transcriptStoreMocks.appendTranscriptTurn.mock.calls.filter(
([turn]) => turn.role === "assistant" && turn.text === followUpText,
),
).toHaveLength(1);
expect(engine.hasPendingQrCode()).toBe(false);
expect(engine.hasRecoverableQrReply()).toBe(true);
@@ -996,6 +1008,11 @@ describe("openclaw.chat", () => {
await expect(
callChat(context, { sessionId: "lost-follow-up", pollStepId: qrStepId }),
).resolves.toEqual(droppedReply);
expect(
transcriptStoreMocks.appendTranscriptTurn.mock.calls.filter(
([turn]) => turn.role === "assistant" && turn.text === followUpText,
),
).toHaveLength(1);
await expect(
callChat(context, {
@@ -1004,6 +1021,19 @@ describe("openclaw.chat", () => {
}),
).resolves.toMatchObject({ ok: true });
expect(engine.hasRecoverableQrReply()).toBe(false);
const persistedTurns = transcriptStoreMocks.appendTranscriptTurn.mock.calls.map(
([turn]) => turn,
);
expect(
persistedTurns.filter(
(turn) =>
(turn.role === "assistant" && turn.text === followUpText) ||
(turn.role === "user" && turn.text === "OpenClaw"),
),
).toEqual([
expect.objectContaining({ role: "assistant", text: followUpText }),
expect.objectContaining({ role: "user", text: "OpenClaw" }),
]);
await expect(
callChat(context, { sessionId: "lost-follow-up", pollStepId: qrStepId }),
).resolves.toMatchObject({
@@ -141,15 +141,25 @@ describe("SystemAgentChatEngine passive QR polling", () => {
await expect(engine.pollStep(qrStepId)).resolves.toMatchObject({ wizardSettling: true });
releaseFollowUp.resolve();
await followUpPresented.promise;
const historyBeforeObservation = engine.historyLength();
await engine.resolveOperatorApproval(null, "queue-drain");
expect(engine.historyLength()).toBe(historyBeforeObservation);
let followUp: Awaited<ReturnType<SystemAgentChatEngine["pollStep"]>> | undefined;
await vi.waitFor(async () => {
followUp = await engine.pollStep(qrStepId);
expect(followUp.step?.type).toBe("text");
});
const droppedReply = expectDefined(followUp, "follow-up reply");
const droppedReply = await engine.pollStep(qrStepId);
expect(droppedReply.step?.type).toBe("text");
const followUpStep = expectDefined(droppedReply.step, "follow-up step");
const followUpText = droppedReply.text;
expect(
engine
.historySince(0)
.filter((turn) => turn.role === "assistant" && turn.text === followUpText),
).toHaveLength(1);
await expect(engine.pollStep(qrStepId)).resolves.toEqual(droppedReply);
expect(
engine
.historySince(0)
.filter((turn) => turn.role === "assistant" && turn.text === followUpText),
).toHaveLength(1);
expect(engine.hasPendingQrCode()).toBe(false);
expect(engine.hasRecoverableQrReply()).toBe(true);
+7 -9
View File
@@ -58,7 +58,7 @@ export type SystemAgentChatEngineOptions = {
type RetainedPollReply = {
expiresAtMs: number;
terminalHistoryRecorded: boolean;
historyRecorded: boolean;
reply: SystemAgentChatReply;
};
@@ -145,8 +145,7 @@ export class SystemAgentChatEngine {
this.wizard.hasPendingQrCode() ||
this.passivePollObservations.size > 0 ||
[...this.retainedPollReplies.values()].some(
({ reply, terminalHistoryRecorded }) =>
isTerminalPollReply(reply) && !terminalHistoryRecorded,
({ reply, historyRecorded }) => isTerminalPollReply(reply) && !historyRecorded,
)
);
}
@@ -155,8 +154,7 @@ export class SystemAgentChatEngine {
hasRecoverableQrReply(): boolean {
this.pruneExpiredPollReplies();
return [...this.retainedPollReplies.values()].some(
({ reply, terminalHistoryRecorded }) =>
!isTerminalPollReply(reply) || terminalHistoryRecorded,
({ reply, historyRecorded }) => !isTerminalPollReply(reply) || historyRecorded,
);
}
@@ -266,7 +264,7 @@ export class SystemAgentChatEngine {
expiresAtMs:
result.passiveQrRetentionExpiresAtMs ??
Date.now() + SYSTEM_AGENT_HOSTED_WIZARD_TIMEOUT_MS,
terminalHistoryRecorded: false,
historyRecorded: false,
reply: { ...reply },
});
}
@@ -301,10 +299,10 @@ export class SystemAgentChatEngine {
}
private recordObservedPollReply(retained: RetainedPollReply): SystemAgentChatReply {
if (isTerminalPollReply(retained.reply) && !retained.terminalHistoryRecorded) {
// Only the request that observes completion may make it durable. The
if (!retained.historyRecorded) {
// Only the request that observes a retained reply may make it durable. The
// background observer can finish after its Gateway request has returned.
retained.terminalHistoryRecorded = true;
retained.historyRecorded = true;
if (retained.reply.text) {
this.history.push({ role: "assistant", text: retained.reply.text });
}