diff --git a/src/gateway/server-methods/system-agent.test.ts b/src/gateway/server-methods/system-agent.test.ts index 5632beaa8d63..2b9e3bc9d201 100644 --- a/src/gateway/server-methods/system-agent.test.ts +++ b/src/gateway/server-methods/system-agent.test.ts @@ -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({ diff --git a/src/system-agent/chat-engine.passive-poll.test.ts b/src/system-agent/chat-engine.passive-poll.test.ts index 6daca8ba4566..2f50cc1e53fd 100644 --- a/src/system-agent/chat-engine.passive-poll.test.ts +++ b/src/system-agent/chat-engine.passive-poll.test.ts @@ -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> | 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); diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index b9df5087a5d5..17a9f66c7004 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -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 }); }