fix(ui): preserve setup receipts after restart

This commit is contained in:
jesse-merhi
2026-08-13 11:45:10 +10:00
parent f12ef84238
commit 60e85f3c51
6 changed files with 101 additions and 48 deletions
@@ -125,7 +125,7 @@ export const SystemAgentChatHistoryTurnSchema = closedObject({
role: Type.Union([Type.Literal("user"), Type.Literal("assistant")]),
text: Type.String(),
at: Type.Number(),
/** Present only on accepted typed controls from a live recovered session. */
/** Present only on accepted typed controls; values and sensitive prompts are excluded. */
wizardAction: Type.Optional(SystemAgentChatHistoryWizardActionSchema),
});
@@ -70,7 +70,7 @@ describe("openclaw.chat.history wizard recovery", () => {
transcriptStoreMocks.readTranscriptTail.mockReset().mockReturnValue(turns);
});
it("keeps accepted action metadata on live recovery turns only", () => {
it("keeps accepted action metadata on live and durable recovery turns", () => {
const wizardAction = {
kind: "cancel" as const,
};
@@ -95,12 +95,10 @@ describe("openclaw.chat.history wizard recovery", () => {
}),
]);
expect(vi.mocked(appendTranscriptTurn).mock.calls.map(([turn]) => turn)).toEqual([
expect.objectContaining({ role: "user", text: "Cancel" }),
expect.objectContaining({ role: "user", text: "Cancel", wizardAction }),
expect.objectContaining({ role: "assistant", text: "Twitch setup cancelled." }),
]);
for (const [turn] of vi.mocked(appendTranscriptTurn).mock.calls) {
expect(turn).not.toHaveProperty("wizardAction");
}
expect(vi.mocked(appendTranscriptTurn).mock.calls[1]?.[0]).not.toHaveProperty("wizardAction");
});
it("omits action metadata when the engine rejects the typed answer", () => {
@@ -178,6 +176,16 @@ describe("openclaw.chat.history wizard recovery", () => {
});
it("falls back to the global audit history after a Gateway reload", async () => {
const durableTurns = [
{ role: "assistant" as const, text: "Choose one.", at: 1 },
{
role: "user" as const,
text: "Alpha",
at: 2,
wizardAction: { kind: "answer" as const, prompt: "Choose one" },
},
];
transcriptStoreMocks.readTranscriptTail.mockReturnValue(durableTurns);
const invocation = makeInvocation({ sessionId: "recover-session" });
invocation.context.systemAgentSessions.clear();
@@ -188,7 +196,7 @@ describe("openclaw.chat.history wizard recovery", () => {
expect(invocation.calls).toEqual([
{
ok: true,
payload: { turns },
payload: { turns: durableTurns },
error: undefined,
},
]);
@@ -51,7 +51,7 @@ export function persistSystemAgentEngineHistory(
for (const turn of engine.historySince(startIndex)) {
const action = turn.role === "user" ? wizardAction : undefined;
const recoveryTurn = { ...turn, at, ...(action ? { wizardAction: action } : {}) };
appendTranscriptTurn({ ...turn, at });
appendTranscriptTurn(recoveryTurn);
recoveryTurns.push(recoveryTurn);
if (action) {
wizardAction = undefined;
@@ -4,6 +4,11 @@
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
SystemAgentChatHistoryTurn,
SystemAgentChatParams,
SystemAgentChatResult,
} from "../../../packages/gateway-protocol/src/index.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js";
import { closeOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
@@ -166,7 +171,11 @@ describe("openclaw.chat reset boundary", () => {
{
wizardDependencies: {
runChannelSetupWizard: async (_channel, prompter) => {
await prompter.text({ message: "Bot token" });
await prompter.text({
message: "Port",
validate: (value) => (value === "18789" ? undefined : "Enter port 18789"),
});
await prompter.text({ message: "Bot token", sensitive: true });
},
},
},
@@ -183,29 +192,29 @@ describe("openclaw.chat reset boundary", () => {
],
]);
const context = { systemAgentSessions: sessions } as unknown as GatewayRequestContext;
const chatResponses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
const sendChat = async (params: SystemAgentChatParams): Promise<SystemAgentChatResult> => {
let result: SystemAgentChatResult | undefined;
await expectDefined(
systemAgentHandlers["openclaw.chat"],
'systemAgentHandlers["openclaw.chat"] test invariant',
)({
params,
client,
context,
respond: (ok: boolean, payload?: unknown, error?: unknown) => {
expect(error).toBeUndefined();
expect(ok).toBe(true);
result = payload as SystemAgentChatResult;
},
} as never);
return expectDefined(result, "expected chat result");
};
await expectDefined(
systemAgentHandlers["openclaw.chat"],
'systemAgentHandlers["openclaw.chat"] test invariant',
)({
params: { sessionId: "recover-session", message: "connect telegram" },
client,
context,
respond: (ok: boolean, payload?: unknown, error?: unknown) =>
chatResponses.push({ ok, payload, error }),
} as never);
expect(chatResponses).toEqual([
{
ok: true,
payload: expect.objectContaining({
wizardInputPending: true,
step: expect.objectContaining({ message: "Bot token" }),
}),
error: undefined,
},
]);
const prompt = await sendChat({ sessionId: "recover-session", message: "connect telegram" });
expect(prompt).toMatchObject({
wizardInputPending: true,
step: { message: "Port" },
});
const historyResponses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
await expectDefined(
systemAgentHandlers["openclaw.chat.history"],
@@ -229,6 +238,52 @@ describe("openclaw.chat reset boundary", () => {
},
});
expect(historyResponses[0]).not.toHaveProperty("payload.turns.0.sessionId");
const portStepId = expectDefined(prompt.step?.id, "expected port step");
const rejected = await sendChat({
sessionId: "recover-session",
wizardAnswer: { stepId: portStepId, value: "banana" },
});
expect(rejected.wizardActionAccepted).toBe(false);
const accepted = await sendChat({
sessionId: "recover-session",
wizardAnswer: { stepId: portStepId, value: "18789" },
});
expect(accepted.wizardActionAccepted).toBe(true);
const secretStepId = expectDefined(accepted.step?.id, "expected sensitive step");
const cancelled = await sendChat({
sessionId: "recover-session",
wizardCancel: { stepId: secretStepId },
});
expect(cancelled.wizardActionAccepted).toBe(true);
closeOpenClawStateDatabase();
sessions.clear();
historyResponses.length = 0;
await expectDefined(
systemAgentHandlers["openclaw.chat.history"],
'systemAgentHandlers["openclaw.chat.history"] test invariant',
)({
params: { sessionId: "recover-session" },
client,
context,
respond: (ok: boolean, payload?: unknown, error?: unknown) =>
historyResponses.push({ ok, payload, error }),
} as never);
expect(historyResponses[0]).toMatchObject({ ok: true, error: undefined });
expect(historyResponses[0]).not.toHaveProperty("payload.activeWizard");
const restoredTurns = (
historyResponses[0]?.payload as { turns: SystemAgentChatHistoryTurn[] }
).turns;
expect(restoredTurns.find((turn) => turn.text === "banana")).not.toHaveProperty(
"wizardAction",
);
expect(restoredTurns.find((turn) => turn.text === "18789")).toMatchObject({
wizardAction: { kind: "answer", prompt: "Port" },
});
const restoredCancel = restoredTurns.find((turn) => turn.text === "Cancel");
expect(restoredCancel).toMatchObject({ wizardAction: { kind: "cancel" } });
expect(restoredCancel?.wizardAction).not.toHaveProperty("prompt");
});
});
+6 -11
View File
@@ -1,16 +1,11 @@
// Durable rolling transcript for the machine-wide OpenClaw conversation.
import { randomUUID } from "node:crypto";
import type { SystemAgentChatHistoryTurn } from "../../packages/gateway-protocol/src/index.js";
import { createSqliteAuditRecordStore } from "../infra/sqlite-audit-record-store.js";
type SystemAgentTranscriptEntry = {
role: "user" | "assistant" | "reset";
text: string;
at: number;
};
type SystemAgentTranscriptTurn = Omit<SystemAgentTranscriptEntry, "role"> & {
role: "user" | "assistant";
};
type SystemAgentTranscriptEntry =
| SystemAgentChatHistoryTurn
| { role: "reset"; text: ""; at: number };
const SYSTEM_AGENT_TRANSCRIPT_SCOPE = "system-agent-transcript";
const SYSTEM_AGENT_TRANSCRIPT_MAX_ENTRIES = 1_000;
@@ -43,7 +38,7 @@ export function appendTranscriptReset(opts: { env?: NodeJS.ProcessEnv } = {}): v
export function readTranscriptTail(
limit: number,
opts: { afterLastReset?: boolean; env?: NodeJS.ProcessEnv } = {},
): SystemAgentTranscriptTurn[] {
): SystemAgentChatHistoryTurn[] {
const entries = openTranscriptStore(opts.env)
.latest({ limit })
.toReversed()
@@ -52,5 +47,5 @@ export function readTranscriptTail(
? entries.findLastIndex((turn) => turn.role === "reset")
: -1;
const window = opts.afterLastReset ? entries.slice(resetIndex + 1) : entries;
return window.filter((turn): turn is SystemAgentTranscriptTurn => turn.role !== "reset");
return window.filter((turn): turn is SystemAgentChatHistoryTurn => turn.role !== "reset");
}
+1 -6
View File
@@ -127,15 +127,10 @@ function createCustodianTranscriptMessages(
? t("custodian.sensitiveReply")
: turn.text;
if (turn.role === "user" && turn.wizardAction) {
const previous = messages.at(-1);
const supportingText = previous?.role === "assistant" ? previous.text : "";
if (supportingText) {
messages.pop();
}
messages.push({
id: nextMessageId++,
role: "assistant",
text: supportingText,
text: "",
at: turn.at,
question: null,
step: null,