fix: keep Custodian receipts value-free

This commit is contained in:
jesse-merhi
2026-08-14 12:43:36 +10:00
parent 339453581c
commit bdf79f6b3b
15 changed files with 145 additions and 139 deletions
@@ -97,9 +97,15 @@ describe("OpenClaw chat result protocol", () => {
expect(
Value.Check(SystemAgentChatResultSchema, {
...result,
wizardAction: { kind: "answer", prompt: "Choose a channel" },
wizardAction: { kind: "answer" },
}),
).toBe(true);
expect(
Value.Check(SystemAgentChatResultSchema, {
...result,
wizardAction: { kind: "answer", prompt: "private prompt" },
}),
).toBe(false);
expect(
Value.Check(SystemAgentChatResultSchema, {
...result,
@@ -160,7 +166,6 @@ describe("OpenClaw chat history protocol", () => {
at: 2,
wizardAction: {
kind: "answer",
prompt: "How should OpenClaw appear in Slack?",
},
};
expect(Value.Check(SystemAgentChatHistoryResultSchema, { turns: [turn] })).toBe(true);
@@ -169,6 +174,11 @@ describe("OpenClaw chat history protocol", () => {
turns: [{ ...turn, sessionId: "slack-session" }],
}),
).toBe(false);
expect(
Value.Check(SystemAgentChatHistoryResultSchema, {
turns: [{ ...turn, wizardAction: { ...turn.wizardAction, prompt: "private prompt" } }],
}),
).toBe(false);
expect(
Value.Check(SystemAgentChatHistoryResultSchema, {
turns: [{ ...turn, wizardAction: { ...turn.wizardAction, kind: "unknown" } }],
@@ -80,8 +80,6 @@ export const SystemAgentChatQuestionSchema = closedObject({
export const SystemAgentWizardActionReceiptSchema = closedObject({
kind: Type.Union([Type.Literal("answer"), Type.Literal("cancel")]),
/** Ordinary non-sensitive prompt copy used to label the receipt. */
prompt: Type.Optional(Type.String()),
});
/** One OpenClaw reply; `action` tells clients about conversation handoffs. */
@@ -119,7 +119,7 @@ describe("openclaw.chat.history wizard recovery", () => {
},
0,
{
wizardAction: { kind: "answer", prompt: "Port" },
wizardAction: { kind: "answer" },
wizardActionAccepted: false,
},
);
@@ -239,7 +239,7 @@ describe("openclaw.chat.history wizard recovery", () => {
role: "user" as const,
text: "Alpha",
at: 2,
wizardAction: { kind: "answer" as const, prompt: "Choose one" },
wizardAction: { kind: "answer" as const },
},
];
transcriptStoreMocks.readTranscriptTail.mockReturnValue(durableTurns);
@@ -123,7 +123,7 @@ describe("system-agent chat input", () => {
text: "Choose a channel.",
action: "none",
wizardActionAccepted: false,
wizardAction: { kind: "answer", prompt: "Channel" },
wizardAction: { kind: "answer" },
step: {
id: "channel",
type: "select",
@@ -147,9 +147,9 @@ describe("system-agent chat input", () => {
text: "Next step.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Channel" },
wizardAction: { kind: "answer" },
},
}).wizardAction,
).toEqual({ kind: "answer", prompt: "Channel" });
).toEqual({ kind: "answer" });
});
});
@@ -248,7 +248,7 @@ describe("openclaw.chat reset boundary", () => {
sessionId: "recover-session",
wizardAnswer: { stepId: portStepId, value: "18789" },
});
expect(accepted.wizardAction).toEqual({ kind: "answer", prompt: "Port" });
expect(accepted.wizardAction).toEqual({ kind: "answer" });
const secretStepId = expectDefined(accepted.step?.id, "expected sensitive step");
const cancelled = await sendChat({
sessionId: "recover-session",
@@ -279,7 +279,7 @@ describe("openclaw.chat reset boundary", () => {
"wizardAction",
);
expect(restoredTurns.find((turn) => turn.text === "18789")).toMatchObject({
wizardAction: { kind: "answer", prompt: "Port" },
wizardAction: { kind: "answer" },
});
const restoredCancel = restoredTurns.find((turn) => turn.text === "Cancel");
expect(restoredCancel).toMatchObject({ wizardAction: { kind: "cancel" } });
+5 -4
View File
@@ -460,7 +460,7 @@ describe("SystemAgentChatEngine wizard", () => {
expect(done.step).toBeUndefined();
});
it("submits a typed answer directly and records the server-owned option label", async () => {
it("records the server-owned option label without exposing a configured service URL", async () => {
useTempStateDir();
let selected: unknown;
const engine = new SystemAgentChatEngine({
@@ -470,7 +470,7 @@ describe("SystemAgentChatEngine wizard", () => {
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
selected = await prompter.select({
message: "Choose one",
message: "Use ClickClack at http://10.0.0.7:3000?",
options: [
{ value: "alpha", label: "Alpha" },
{ value: "beta", label: "Beta" },
@@ -485,7 +485,8 @@ describe("SystemAgentChatEngine wizard", () => {
expect(selected).toBe("beta");
expect(answered.wizardActionAccepted).toBe(true);
expect(answered.wizardAction).toEqual({ kind: "answer", prompt: "Choose one" });
expect(answered.wizardAction).toEqual({ kind: "answer" });
expect(JSON.stringify(answered.wizardAction)).not.toContain("http://10.0.0.7:3000");
expect(engine.historySince(0)).toContainEqual({ role: "user", text: "Beta" });
});
@@ -508,7 +509,7 @@ describe("SystemAgentChatEngine wizard", () => {
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
const invalid = await engine.answerWizard({ stepId, value: "banana" });
expect(invalid.wizardActionAccepted).toBe(false);
expect(invalid.wizardAction).toEqual({ kind: "answer", prompt: "Port" });
expect(invalid.wizardAction).toEqual({ kind: "answer" });
expect(invalid.step?.id).toBe(stepId);
expect(invalid.text).toContain("Enter port 18789");
+2 -13
View File
@@ -51,17 +51,6 @@ export type ChatWizardAnswerResult = ChatWizardResult & {
wizardAction: SystemAgentWizardActionReceipt;
};
function wizardActionReceipt(
step: WizardStep,
kind: SystemAgentWizardActionReceipt["kind"],
): SystemAgentWizardActionReceipt {
const prompt =
!step.sensitive && !step.deviceCode && !step.externalUrl
? (step.title ?? step.message)
: undefined;
return { kind, ...(prompt ? { prompt } : {}) };
}
export type ChatWizardHostDependencies = {
runChannelSetupWizard?: (
channel: string,
@@ -330,7 +319,7 @@ export class ChatWizardHost {
...result,
accepted: validationError === undefined,
userHistoryText: formatStructuredWizardAnswerForHistory(step, answer.value),
wizardAction: wizardActionReceipt(step, "answer"),
wizardAction: { kind: "answer" },
};
}
@@ -350,7 +339,7 @@ export class ChatWizardHost {
...(await this.pump()),
accepted: true,
userHistoryText: "Cancel",
wizardAction: wizardActionReceipt(step, "cancel"),
wizardAction: { kind: "cancel" },
};
}
+4 -4
View File
@@ -442,7 +442,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Choose features.",
action: "none",
wizardAction: { kind: "answer", prompt: "Which channel?" },
wizardAction: { kind: "answer" },
wizardInputPending: true,
step: {
id: "features",
@@ -466,7 +466,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Enter the secret.",
action: "none",
wizardAction: { kind: "answer", prompt: "Which features?" },
wizardAction: { kind: "answer" },
sensitive: true,
wizardInputPending: true,
step: {
@@ -528,7 +528,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Confirm setup.",
action: "none",
wizardAction: { kind: "answer", prompt: "Connection name" },
wizardAction: { kind: "answer" },
wizardInputPending: true,
step: {
id: "confirm",
@@ -563,7 +563,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Setup complete.",
action: "none",
wizardAction: { kind: "answer", prompt: "Connect Twitch now?" },
wizardAction: { kind: "answer" },
});
await page.getByText("Setup complete.").waitFor();
expect(
@@ -46,7 +46,6 @@ describe("Custodian wizard reload recovery", () => {
at: 0,
wizardAction: {
kind: "cancel",
prompt: "Choose a previous channel.",
},
},
{ role: "user", text: "connect twitch", at: 1 },
@@ -67,7 +66,6 @@ describe("Custodian wizard reload recovery", () => {
at: 3,
wizardAction: {
kind: "answer",
prompt: "How should OpenClaw appear in Twitch?",
},
},
{
@@ -4,6 +4,7 @@ import { GATEWAY_SERVER_CAPS } from "@openclaw/gateway-protocol";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../../../test/helpers/promise.ts";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { i18n, t } from "../../i18n/index.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
import { createContext, mountPage } from "./custodian-page.test-harness.ts";
@@ -127,7 +128,7 @@ describe("custodian structured wizard", () => {
sessionId: string;
reply: string;
action: "none";
wizardAction: { kind: "answer"; prompt: string };
wizardAction: { kind: "answer" };
}>();
const step = {
id: "port",
@@ -187,7 +188,7 @@ describe("custodian structured wizard", () => {
sessionId: "rotation-session",
reply: "Accepted by the retired client.",
action: "none",
wizardAction: { kind: "answer", prompt: "Gateway port" },
wizardAction: { kind: "answer" },
});
await waitForFast(() =>
@@ -199,94 +200,97 @@ describe("custodian structured wizard", () => {
expect(page.querySelector(".custodian__wizard-step")).not.toBeNull();
});
it("does not duplicate an accepted answer restored during same-scope recovery", async () => {
const actionReply = createDeferred<{
sessionId: string;
reply: string;
action: "none";
wizardAction: { kind: "answer"; prompt: string };
}>();
const step = {
id: "port",
type: "text" as const,
message: "Gateway port",
};
const nextStep = {
id: "host",
type: "text" as const,
message: "Gateway host",
};
const request = vi
.fn()
.mockResolvedValueOnce({
turns: [{ role: "assistant", text: "Earlier setup history.", at: 0 }],
})
.mockResolvedValueOnce({
sessionId: "rotation-session",
reply: "Enter a port.",
action: "none",
wizardInputPending: true,
step,
})
.mockReturnValueOnce(actionReply.promise);
const harness = createContext(request, ["openclaw.chat", "openclaw.chat.history"], {
gatewayCapabilities: [
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY,
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
],
recoveryScope: "principal-a",
});
const { page } = await mountPage(harness.context);
const input = await waitForFast(() => {
const element = page.querySelector<HTMLInputElement>(
'.custodian__wizard-step input[name="wizard-text"]',
);
expect(element).not.toBeNull();
return element!;
});
input.value = "18789";
input.dispatchEvent(new Event("input", { bubbles: true }));
await page.updateComplete;
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")!.click();
await waitForFast(() => expect(page.textContent).toContain("Submitting answer"));
const replacementRequest = vi.fn().mockResolvedValue({
turns: [
{ role: "assistant", text: "Enter a port.", at: 1 },
{
role: "user",
text: "18789",
at: 2,
wizardAction: { kind: "answer", prompt: "Gateway port" },
},
{ role: "assistant", text: "Enter a host.", at: 3 },
],
activeWizard: { sessionId: "rotation-session", step: nextStep },
});
harness.setGatewaySnapshot({
client: {
request: replacementRequest,
it("does not duplicate a localized empty multiselect receipt during recovery", async () => {
await i18n.setLocale("es");
try {
const actionReply = createDeferred<{
sessionId: string;
reply: string;
action: "none";
wizardAction: { kind: "answer" };
}>();
const step = {
id: "features",
type: "multiselect" as const,
message: "Choose features",
options: [{ label: "Chat", value: "chat" }],
};
const nextStep = {
id: "name",
type: "text" as const,
message: "Connection name",
};
const request = vi
.fn()
.mockResolvedValueOnce({
turns: [{ role: "assistant", text: "Earlier setup history.", at: 0 }],
})
.mockResolvedValueOnce({
sessionId: "rotation-session",
reply: "Choose features.",
action: "none",
wizardInputPending: true,
step,
})
.mockReturnValueOnce(actionReply.promise);
const harness = createContext(request, ["openclaw.chat", "openclaw.chat.history"], {
gatewayCapabilities: [
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY,
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
],
recoveryScope: "principal-a",
recoveryScopeReady: true,
} as unknown as GatewayBrowserClient,
});
await waitForFast(() => expect(replacementRequest).toHaveBeenCalledOnce());
await waitForFast(() => expect(page.textContent).toContain("Answer submitted"));
actionReply.resolve({
sessionId: "rotation-session",
reply: "Accepted by the retired client.",
action: "none",
wizardAction: { kind: "answer", prompt: "Gateway port" },
});
});
const { page } = await mountPage(harness.context);
await waitForFast(() =>
expect(page.querySelectorAll(".custodian__structured-response")).toHaveLength(1),
);
expect(page.querySelectorAll(".chat-group.user")).toHaveLength(0);
expect(page.textContent).not.toContain("Earlier setup history.");
expect(page.querySelector(".custodian__wizard-step")?.textContent).toContain("Gateway host");
await waitForFast(() => {
expect(
page.querySelectorAll('.custodian__wizard-step input[type="checkbox"]'),
).toHaveLength(1);
});
expect(t("common.none")).not.toBe("None");
page.querySelector<HTMLButtonElement>(".custodian__wizard-step .btn.primary")!.click();
await waitForFast(() =>
expect(page.querySelector(".custodian__structured-response")).not.toBeNull(),
);
const replacementRequest = vi.fn().mockResolvedValue({
turns: [
{ role: "assistant", text: "Choose features.", at: 1 },
{ role: "user", text: "None", at: 2, wizardAction: { kind: "answer" } },
{ role: "assistant", text: "Choose a name.", at: 3 },
],
activeWizard: { sessionId: "rotation-session", step: nextStep },
});
harness.setGatewaySnapshot({
client: {
request: replacementRequest,
recoveryScope: "principal-a",
recoveryScopeReady: true,
} as unknown as GatewayBrowserClient,
});
await waitForFast(() => expect(replacementRequest).toHaveBeenCalledOnce());
actionReply.resolve({
sessionId: "rotation-session",
reply: "Accepted by the retired client.",
action: "none",
wizardAction: { kind: "answer" },
});
await waitForFast(() =>
expect(page.querySelector(".custodian__structured-response-status")?.textContent).toBe(
t("custodian.structured.submitted"),
),
);
expect(page.querySelectorAll(".custodian__structured-response")).toHaveLength(1);
expect(page.querySelectorAll(".chat-group.user")).toHaveLength(0);
expect(page.textContent).not.toContain("Earlier setup history.");
expect(page.querySelector(".custodian__wizard-step")?.textContent).toContain(
"Connection name",
);
} finally {
await i18n.useSystemLocale();
}
});
it("reconciles a visible fallback across successive same-scope recovery", async () => {
@@ -400,7 +404,7 @@ describe("custodian structured wizard", () => {
role: "user",
text: "18789",
at: 2,
wizardAction: { kind: "answer", prompt: "Gateway port" },
wizardAction: { kind: "answer" },
},
{ role: "user", text: "18888", at: 3 },
{ role: "assistant", text: "Enter a host.", at: 3 },
@@ -100,7 +100,7 @@ describe("custodian page", () => {
sessionId: "rich-wizard-session",
reply: "Choose features.",
action: "none",
wizardAction: { kind: "answer", prompt: "Which channel?" },
wizardAction: { kind: "answer" },
wizardInputPending: true,
step: {
id: "features",
@@ -117,7 +117,7 @@ describe("custodian page", () => {
sessionId: "rich-wizard-session",
reply: "Enter the secret.",
action: "none",
wizardAction: { kind: "answer", prompt: "Which features?" },
wizardAction: { kind: "answer" },
sensitive: true,
wizardInputPending: true,
step: {
@@ -210,7 +210,7 @@ describe("custodian page", () => {
[...page.querySelectorAll(".custodian__structured-response-prompt")].map(
(element) => element.textContent,
),
).toEqual(["Which channel?", "Which features?", "Setup answer"]);
).toEqual(["Setup answer", "Setup answer", "Setup answer"]);
expect(page.querySelector(".chat-group.user")).toBeNull();
expect(page.textContent).not.toContain("fake-client-secret");
expect(page.querySelector(".agent-chat__composer-shell")).not.toBeNull();
@@ -247,7 +247,8 @@ export class CustodianSessionStore extends CustodianTranscriptState {
structuredResponse: null,
};
if (userTurnProjection === "unless-accepted") {
this.stageTranscriptFallback(params.sessionId, userMessage);
const kind = params.wizardCancel === undefined ? "answer" : "cancel";
this.stageTranscriptFallback(params.sessionId, userMessage, kind);
}
if (userTurnProjection === "always") {
this.messages = [...this.messages, userMessage];
@@ -107,7 +107,6 @@ export function createCustodianStructuredInteraction(host: StructuredInteraction
display: params.display,
kind: result.wizardAction.kind,
state: "submitted",
...(result.wizardAction?.prompt ? { prompt: result.wizardAction.prompt } : {}),
};
const messages = withResponse(current.messages, params.message.id, response);
if (messages) {
@@ -1,4 +1,7 @@
import type { SystemAgentChatResult } from "@openclaw/gateway-protocol";
import type {
SystemAgentChatResult,
SystemAgentWizardActionReceipt,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { WizardStep } from "../../api/types.ts";
import type { ApplicationContext } from "../../app/context.ts";
@@ -26,6 +29,7 @@ type CustodianTranscriptTurnPosition = {
};
type CustodianPendingTranscriptFallback = CustodianTranscriptTurnPosition & {
kind: SystemAgentWizardActionReceipt["kind"];
message: CustodianMessage;
};
@@ -62,6 +66,7 @@ export abstract class CustodianTranscriptState {
protected stageTranscriptFallback(
sessionId: string | undefined,
message: CustodianMessage,
kind: CustodianPendingTranscriptFallback["kind"],
): void {
const globalIndex = this.messages.length;
const boundaryIndex =
@@ -69,6 +74,7 @@ export abstract class CustodianTranscriptState {
? -1
: this.messages.findIndex((candidate) => candidate.id === this.earlierBoundaryAfterId);
this.pendingTranscriptFallbacks.push({
kind,
message,
globalIndex,
sessionId,
@@ -128,9 +134,13 @@ export abstract class CustodianTranscriptState {
if (existing === fallback.message || this.messages.includes(fallback.message)) {
return;
}
const recoveredDisplay =
existing?.structuredResponse?.display ?? (existing?.role === "user" ? existing.text : null);
if (recoveredDisplay === fallback.message.text) {
// Recovery owns turn order and action kind; display text is localized and cannot identify the
// same submission. A plain user turn at this slot is the authoritative fallback projection.
const recoveredActionKind = existing?.structuredResponse?.kind;
if (
existing?.role === "user" ||
(recoveredActionKind !== undefined && recoveredActionKind === fallback.kind)
) {
this.removeTranscriptFallback(fallback);
return;
}
+3 -7
View File
@@ -29,7 +29,6 @@ export type CustodianMessage = {
export type CustodianStructuredResponse = {
display: string;
kind: "answer" | "cancel";
prompt?: string;
state: "submitting" | "submitted";
};
@@ -138,7 +137,6 @@ function createCustodianTranscriptMessages(
display,
kind: turn.wizardAction.kind,
state: "submitted",
...(turn.wizardAction.prompt ? { prompt: turn.wizardAction.prompt } : {}),
},
});
continue;
@@ -214,10 +212,6 @@ function renderCustodianEarlierDivider(message: CustodianMessage, boundaryAfterI
: nothing;
}
function structuredPrompt(message: CustodianMessage): string {
return message.structuredResponse?.prompt ?? t("custodian.structured.response");
}
function renderStructuredResponse(message: CustodianMessage) {
const response = message.structuredResponse;
if (!response) {
@@ -244,7 +238,9 @@ function renderStructuredResponse(message: CustodianMessage) {
>${cancelled ? icons.stop : icons.check}</span
>
<span class="custodian__structured-response-copy">
<span class="custodian__structured-response-prompt">${structuredPrompt(message)}</span>
<span class="custodian__structured-response-prompt"
>${t("custodian.structured.response")}</span
>
<strong>${response.display}</strong>
<span class="custodian__structured-response-status">${status}</span>
</span>