fix(ui): use owner-sanitized setup receipts

This commit is contained in:
jesse-merhi
2026-08-13 12:47:31 +10:00
parent ebdc94df20
commit e82cda0b0b
33 changed files with 140 additions and 118 deletions
@@ -10872,7 +10872,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
public let reply: String
public let sensitive: Bool?
public let wizardinputpending: Bool?
public let wizardactionaccepted: Bool?
public let wizardaction: [String: AnyCodable]?
public let action: AnyCodable
public let agentdraft: String?
public let agentid: String?
@@ -10886,7 +10886,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
reply: String,
sensitive: Bool? = nil,
wizardinputpending: Bool? = nil,
wizardactionaccepted: Bool? = nil,
wizardaction: [String: AnyCodable]? = nil,
action: AnyCodable,
agentdraft: String? = nil,
agentid: String? = nil,
@@ -10899,7 +10899,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
self.reply = reply
self.sensitive = sensitive
self.wizardinputpending = wizardinputpending
self.wizardactionaccepted = wizardactionaccepted
self.wizardaction = wizardaction
self.action = action
self.agentdraft = agentdraft
self.agentid = agentid
@@ -10914,7 +10914,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
case reply
case sensitive
case wizardinputpending = "wizardInputPending"
case wizardactionaccepted = "wizardActionAccepted"
case wizardaction = "wizardAction"
case action
case agentdraft = "agentDraft"
case agentid = "agentId"
@@ -1 +1 @@
{"contentHash":"79a4c99fa78209550335cbcf81b39ce261be41a90de04b64eb69526ef84349bc","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"f3bc029eef9d8e93bc73fb0869c9dbfbe4071f0b469f3bacc7018efb8a14ca0e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"fdcb3e2536e7d4e3090322fac2793f497a0b6f331a8b583fa6156007721b177f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"54a4c5b39ea87409b6cd7e417fe0cacbbcf4881ab4590642f3909ffe15fbc7c9","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"3bdfd31e7decd15278ea7864d8c16a0674be246e827b55b389593dcba3f2066c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"ca0cf7a409663f6bc5b5fae0b5085a1e64fc4436f869f7960fab8e56708ffdaa","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"a5ff017a6e98b53dee31102591e7304d4d754b150b2a7684c257059921fa2523","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"9bfe9ccffb96f6291f04c744e4246581a681981fe07b8f3da579362c70fefb16","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"5d62b0618558e9a0093e4c8ad09b6cd143ed5b4c214de2eeefb255781a34c3bd","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"69a39e836698999eb6963c97480dead8ea95a4b2b5d50545e8fd3cb881a57596","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"32d2d5450bc2e08ebd3e2afc9302cf321469684f5997a059dddf28ace36675a8","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"c7d3f4dbfc8b63cdcd6c1d7997e551b1b05177d38c1f58b65bed17fe8948eac9","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"3fea3804bec8232a55c95d3abb5bab8de5d8778652bddfafbb6362d27a4b3460","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"705111ce11d2b0570d78bedbfc0b3aeab0cc88997cad8994be063eb2dc5a2943","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"117093ae03e310c9ce4455f24e43fc678b8737af600eea1d0066137c2fa4651c","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"7a35b9299874b2cd93188087a3eb2b4c40d349a8dfc33100704e4876531a9a16","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"b5d69672e4813d9de0f99bf51d1baedd73b167a54126a0c984f65dd84945699c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"7f7ad827672514d0d732fb17891851102a49d6b45904ee0b4815d5873cc88f2b","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"096282e453532f80d63ac296d2cc53ad65eb38f23703ea27e359cc215c7adfe4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"75dfa879ff76c5637813215d3ed4ff774f7bf5bd4998c2e74b504db1d48eea72","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"d93e8de2beeea1902998af40aae725e6d6318cb011810d3478a776a016f62723","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"8d9b1851d9d4bf3bc2273e5cf9b9071bedd205be3864d6fd4f88acf5f4097e27","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"60a4f0f0501fb64019a7aa9c3e66f64ce9c388fb38dadd69d71b64a467ecbcbd","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"3751be3212fc4f4d78555a72245a8492e0e5dfdfe30e7794cc20c22dd894e9df","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"283ada2bb6c4499207b08f1d23c80919daa4fd139c6e02810c2c15f7a90fec58","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"91cf8791fe2555a36a25bb359d1f14ef4ec2e61e25eb88af17187572ba431d50","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"e5b10b255ad2bd9ed54f10017e1e09440962ed846ba885059e13dab10b27811f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"4a6d7f93ea9d3a0bdb207e007aef6edc12d095d57d5b01a72020c3f4bf2f2a96","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"5055e33c4e0ffe0cb368d7a8e7970d5e97fd7d90913485e8a2bcecb1f97f92ef","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"3087ba183395692801b60510757304c2da517299748c10625ecc3321cbc0afdb","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"7f5f39112df30ff5d32513258437bef16e8304e01e1d61a50f754c8e26831cbc","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"694748a04e198f48a8e848d39b3c43942f8041f6353281d10d35f73f8d10f61b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"983f60fcee19de0cc99dd2db216bea32e45054bffc50cfe3d6d382a7f51c7a41","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"749f32ab3cde36b570b13173a9286da5d9453bd3ae08f81087ff66fd834fe6cb","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+1 -1
View File
@@ -15,7 +15,7 @@ version and the additive schema surface. Dates are authoring dates (2026).
- Remove dead protocol surfaces and add since-vintage metadata to retained schemas and methods.
- Add optional `step` on `SystemAgentChatResult` carrying the full awaited wizard step.
- Add owner-bound active-wizard snapshots to Custodian chat history for reload recovery.
- Add capability-gated accepted-action outcomes and receipt prompts to Custodian chat turns.
- Add capability-gated, owner-sanitized action receipts to Custodian chat turns.
## Protocol v4 (current)
@@ -92,16 +92,19 @@ describe("OpenClaw chat question protocol", () => {
describe("OpenClaw chat result protocol", () => {
const result = { sessionId: "session-1", reply: "Choose again.", action: "none" };
it("accepts explicit typed wizard-action outcomes", () => {
it("accepts only sanitized accepted wizard-action metadata", () => {
expect(Value.Check(SystemAgentChatResultSchema, result)).toBe(true);
expect(
Value.Check(SystemAgentChatResultSchema, { ...result, wizardActionAccepted: true }),
Value.Check(SystemAgentChatResultSchema, {
...result,
wizardAction: { kind: "answer", prompt: "Choose a channel" },
}),
).toBe(true);
expect(
Value.Check(SystemAgentChatResultSchema, { ...result, wizardActionAccepted: false }),
).toBe(true);
expect(
Value.Check(SystemAgentChatResultSchema, { ...result, wizardActionAccepted: "yes" }),
Value.Check(SystemAgentChatResultSchema, {
...result,
wizardAction: { kind: "answer", value: "secret" },
}),
).toBe(false);
});
});
@@ -78,6 +78,12 @@ export const SystemAgentChatQuestionSchema = closedObject({
skipAction: Type.Optional(Type.Literal("exit")),
});
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. */
export const SystemAgentChatResultSchema = closedObject({
sessionId: NonEmptyString,
@@ -86,8 +92,8 @@ export const SystemAgentChatResultSchema = closedObject({
sensitive: Type.Optional(Type.Boolean()),
/** The hosted wizard will consume the next message as its current step answer. */
wizardInputPending: Type.Optional(Type.Boolean()),
/** Whether the submitted typed wizard action passed owner-side validation. */
wizardActionAccepted: Type.Optional(Type.Boolean()),
/** Sanitized receipt metadata for an accepted typed wizard action. */
wizardAction: Type.Optional(SystemAgentWizardActionReceiptSchema),
action: Type.Union([
Type.Literal("none"),
// The user asked to talk to their agent; clients should move to their
@@ -115,18 +121,12 @@ export const SystemAgentChatHistoryParamsSchema = closedObject({
sessionId: Type.Optional(NonEmptyString),
});
export const SystemAgentChatHistoryWizardActionSchema = 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()),
});
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; values and sensitive prompts are excluded. */
wizardAction: Type.Optional(SystemAgentChatHistoryWizardActionSchema),
wizardAction: Type.Optional(SystemAgentWizardActionReceiptSchema),
});
export const SystemAgentChatHistoryResultSchema = closedObject({
@@ -389,9 +389,7 @@ export type SystemAgentWizardCancel = Static<typeof SystemAgentWizardCancelSchem
export type SystemAgentChatQuestion = Static<typeof SystemAgentChatQuestionSchema>;
export type SystemAgentChatResult = Static<typeof SystemAgentChatResultSchema>;
export type SystemAgentChatHistoryParams = Static<typeof SystemAgentChatHistoryParamsSchema>;
export type SystemAgentChatHistoryWizardAction = Static<
typeof SystemAgentChatHistoryWizardActionSchema
>;
export type SystemAgentWizardActionReceipt = Static<typeof SystemAgentWizardActionReceiptSchema>;
export type SystemAgentChatHistoryTurn = Static<typeof SystemAgentChatHistoryTurnSchema>;
export type SystemAgentChatHistoryResult = Static<typeof SystemAgentChatHistoryResultSchema>;
export type SystemChangeEntry = Static<typeof SystemChangeEntrySchema>;
@@ -1,7 +1,7 @@
import {
validateSystemAgentChatHistoryParams,
type SystemAgentChatHistoryTurn,
type SystemAgentChatHistoryWizardAction,
type SystemAgentWizardActionReceipt,
} from "../../../packages/gateway-protocol/src/index.js";
import type { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
import { resolveSystemAgentDelegationKey } from "../../system-agent/delegation-session.js";
@@ -41,7 +41,7 @@ export function persistSystemAgentEngineHistory(
engine: Pick<SystemAgentChatEngine, "historySince">,
startIndex: number,
params: {
wizardAction?: SystemAgentChatHistoryWizardAction;
wizardAction?: SystemAgentWizardActionReceipt;
wizardActionAccepted?: boolean;
} = {},
): SystemAgentChatHistoryTurn[] {
@@ -116,28 +116,40 @@ describe("system-agent chat input", () => {
expect(handle).not.toHaveBeenCalled();
});
it("preserves the enriched wizard step in the gateway result", () => {
it("forwards only owner-accepted wizard receipt metadata", () => {
const rejected = buildSystemAgentChatResult({
sessionId: "s1",
reply: {
text: "Choose a channel.",
action: "none",
wizardActionAccepted: false,
wizardAction: { kind: "answer", prompt: "Channel" },
step: {
id: "channel",
type: "select",
message: "Channel",
options: [{ label: "Twitch", value: "twitch" }],
},
},
});
expect(rejected).toMatchObject({
sessionId: "s1",
reply: "Choose a channel.",
action: "none",
step: { id: "channel", type: "select" },
});
expect(rejected).not.toHaveProperty("wizardAction");
expect(
buildSystemAgentChatResult({
sessionId: "s1",
reply: {
text: "Choose a channel.",
text: "Next step.",
action: "none",
wizardActionAccepted: false,
step: {
id: "channel",
type: "select",
message: "Channel",
options: [{ label: "Twitch", value: "twitch" }],
},
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Channel" },
},
}),
).toMatchObject({
sessionId: "s1",
reply: "Choose a channel.",
action: "none",
wizardActionAccepted: false,
step: { id: "channel", type: "select" },
});
}).wizardAction,
).toEqual({ kind: "answer", prompt: "Channel" });
});
});
@@ -82,8 +82,8 @@ export function buildSystemAgentChatResult(params: {
: {}),
...(params.reply.sensitive === true ? { sensitive: true } : {}),
...(params.reply.wizardInputPending === true ? { wizardInputPending: true } : {}),
...(params.reply.wizardActionAccepted !== undefined
? { wizardActionAccepted: params.reply.wizardActionAccepted }
...(params.reply.wizardActionAccepted === true && params.reply.wizardAction
? { wizardAction: params.reply.wizardAction }
: {}),
...(params.reply.question ? { question: params.reply.question } : {}),
...(params.reply.step ? { step: params.reply.step } : {}),
@@ -243,18 +243,18 @@ describe("openclaw.chat reset boundary", () => {
sessionId: "recover-session",
wizardAnswer: { stepId: portStepId, value: "banana" },
});
expect(rejected.wizardActionAccepted).toBe(false);
expect(rejected).not.toHaveProperty("wizardAction");
const accepted = await sendChat({
sessionId: "recover-session",
wizardAnswer: { stepId: portStepId, value: "18789" },
});
expect(accepted.wizardActionAccepted).toBe(true);
expect(accepted.wizardAction).toEqual({ kind: "answer", prompt: "Port" });
const secretStepId = expectDefined(accepted.step?.id, "expected sensitive step");
const cancelled = await sendChat({
sessionId: "recover-session",
wizardCancel: { stepId: secretStepId },
});
expect(cancelled.wizardActionAccepted).toBe(true);
expect(cancelled.wizardAction).toEqual({ kind: "cancel" });
closeOpenClawStateDatabase();
sessions.clear();
+5 -5
View File
@@ -1,6 +1,6 @@
import type {
SystemAgentChatQuestion,
SystemAgentChatHistoryWizardAction,
SystemAgentWizardActionReceipt,
SystemAgentWizardCancel,
WizardAnswer,
} from "../../packages/gateway-protocol/src/index.js";
@@ -33,7 +33,7 @@ export type SystemAgentChatReply = {
wizardInputPending?: boolean;
/** The submitted typed wizard action passed or failed owner-side validation. */
wizardActionAccepted?: boolean;
wizardAction?: SystemAgentChatHistoryWizardAction;
wizardAction?: SystemAgentWizardActionReceipt;
handoff?: SystemAgentOperation;
question?: SystemAgentChatQuestion;
step?: WizardStep;
@@ -48,13 +48,13 @@ export type ChatWizardResult = {
export type ChatWizardAnswerResult = ChatWizardResult & {
accepted: boolean;
userHistoryText: string;
wizardAction: SystemAgentChatHistoryWizardAction;
wizardAction: SystemAgentWizardActionReceipt;
};
function wizardActionReceipt(
step: WizardStep,
kind: SystemAgentChatHistoryWizardAction["kind"],
): SystemAgentChatHistoryWizardAction {
kind: SystemAgentWizardActionReceipt["kind"],
): SystemAgentWizardActionReceipt {
const prompt =
!step.sensitive && !step.deviceCode && !step.externalUrl
? (step.title ?? step.message)
+6 -6
View File
@@ -442,7 +442,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Choose features.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Which channel?" },
wizardInputPending: true,
step: {
id: "features",
@@ -466,7 +466,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Enter the secret.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Which features?" },
sensitive: true,
wizardInputPending: true,
step: {
@@ -493,7 +493,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Name this connection.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer" },
wizardInputPending: true,
step: {
id: "label",
@@ -528,7 +528,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Confirm setup.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Connection name" },
wizardInputPending: true,
step: {
id: "confirm",
@@ -563,7 +563,7 @@ suite.define(() => {
sessionId: "e2e-rich-wizard",
reply: "Setup complete.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Connect Twitch now?" },
});
await page.getByText("Setup complete.").waitFor();
expect(
@@ -706,7 +706,7 @@ suite.define(() => {
sessionId: "e2e-reload-wizard",
reply: "Twitch setup cancelled.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "cancel" },
});
await page.getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByText("Twitch setup cancelled.").waitFor();
@@ -98,7 +98,7 @@ describe("Custodian wizard reload recovery", () => {
sessionId: "live-wizard",
reply: "Twitch setup cancelled.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "cancel" },
};
}
freshChatCount += 1;
@@ -19,10 +19,7 @@ describe("custodian structured wizard", () => {
vi.restoreAllMocks();
});
it.each([
{ accepted: false, result: "rejected" },
{ accepted: undefined, result: "ambiguous" },
])("keeps a $result typed answer active as a plain user turn", async ({ accepted }) => {
it("keeps a rejected typed answer active as a plain user turn", async () => {
const step = {
id: "port",
type: "text" as const,
@@ -41,7 +38,6 @@ describe("custodian structured wizard", () => {
sessionId: "validation-session",
reply: "Enter port 18789.",
action: "none",
...(accepted === undefined ? {} : { wizardActionAccepted: accepted }),
wizardInputPending: true,
step,
});
@@ -131,7 +127,7 @@ describe("custodian structured wizard", () => {
sessionId: string;
reply: string;
action: "none";
wizardActionAccepted: boolean;
wizardAction: { kind: "answer"; prompt: string };
}>();
const step = {
id: "port",
@@ -185,7 +181,7 @@ describe("custodian structured wizard", () => {
sessionId: "rotation-session",
reply: "Accepted by the retired client.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Gateway port" },
});
await waitForFast(() => expect(page.textContent).toContain("Gateway connection changed"));
+12 -4
View File
@@ -100,7 +100,7 @@ describe("custodian page", () => {
sessionId: "rich-wizard-session",
reply: "Choose features.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Which channel?" },
wizardInputPending: true,
step: {
id: "features",
@@ -117,7 +117,7 @@ describe("custodian page", () => {
sessionId: "rich-wizard-session",
reply: "Enter the secret.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer", prompt: "Which features?" },
sensitive: true,
wizardInputPending: true,
step: {
@@ -131,7 +131,7 @@ describe("custodian page", () => {
sessionId: "rich-wizard-session",
reply: "Setup complete.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "answer" },
});
const { context } = createContext(request);
const { page } = await mountPage(context);
@@ -206,6 +206,11 @@ describe("custodian page", () => {
expect(page.textContent).toContain("Chat, Announcements");
expect(page.textContent).toContain("Sensitive reply sent");
expect(page.querySelectorAll(".custodian__structured-response")).toHaveLength(3);
expect(
[...page.querySelectorAll(".custodian__structured-response-prompt")].map(
(element) => element.textContent,
),
).toEqual(["Which channel?", "Which features?", "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();
@@ -231,7 +236,7 @@ describe("custodian page", () => {
sessionId: "cancel-wizard-session",
reply: "Channel setup cancelled.",
action: "none",
wizardActionAccepted: true,
wizardAction: { kind: "cancel" },
});
const { context } = createContext(request);
const { page } = await mountPage(context);
@@ -256,6 +261,9 @@ describe("custodian page", () => {
expect(page.querySelector(".custodian__wizard-step")).toBeNull();
expect(page.querySelector(".chat-group.user")).toBeNull();
expect(page.querySelector(".custodian__structured-response")?.textContent).toContain("Cancel");
expect(page.querySelector(".custodian__structured-response-prompt")?.textContent).toBe(
"Setup answer",
);
expect(page.querySelector(".custodian__structured-response")?.textContent).toContain(
"Setup cancelled",
);
@@ -3,6 +3,7 @@ import {
readSystemAgentInferenceUnavailableErrorDetails,
type SystemAgentChatParams,
type SystemAgentChatResult,
type SystemAgentWizardActionReceipt,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { selectApplicationSession } from "../../app/agent-selection.ts";
@@ -55,6 +56,10 @@ function hasCustodianWizardAction(params: SystemAgentChatParams): boolean {
type StoreListener = () => void;
type ConfiguredInferenceState = "unresolved" | "required" | "ready";
type CustodianSetupIssue = "missing" | "unavailable";
type CustodianSendResult = {
outcome: eventNudgeState.CustodianSendOutcome;
wizardAction?: SystemAgentWizardActionReceipt;
};
/** One process-local conversation owner shared by the full page and dock surface. */
export class CustodianSessionStore extends CustodianTranscriptState {
@@ -228,7 +233,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
return "rejected";
}
const displayText = this.sensitive ? t("custodian.sensitiveReply") : (display ?? message);
return await this.sendUserTurn(
const result = await this.sendUserTurn(
client,
{
sessionId: this.sessionId,
@@ -237,6 +242,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
displayText,
questionReply,
);
return result.outcome;
}
private async sendUserTurn(
@@ -245,7 +251,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
displayText: string,
questionReply: boolean,
userTurnProjection: "always" | "unless-accepted" = "always",
): Promise<eventNudgeState.CustodianSendOutcome> {
): Promise<CustodianSendResult> {
const questionState = [this.answeredQuestions, this.questionReplyUncertain] as const;
if (questionReply) {
this.questionReplyUncertain = true;
@@ -269,7 +275,8 @@ export class CustodianSessionStore extends CustodianTranscriptState {
this.emit();
const reply = this.requestReply(client, params);
const replyEpoch = this.requestEpoch;
const outcome = await reply;
const result = await reply;
const outcome = result.outcome;
if (userTurnProjection === "unless-accepted" && outcome !== "accepted") {
// Owner-confirmed acceptance replaces the attempted turn with a receipt. Every other
// outcome keeps the masked display in its original transcript position.
@@ -285,7 +292,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
}
this.emit();
}
return outcome;
return result;
}
async sendEventNudge(): Promise<void> {
@@ -637,17 +644,17 @@ export class CustodianSessionStore extends CustodianTranscriptState {
private async requestReply(
client: GatewayBrowserClient,
params: SystemAgentChatParams,
): Promise<eventNudgeState.CustodianSendOutcome> {
): Promise<CustodianSendResult> {
const context = this.context;
if (!context) {
return "rejected";
return { outcome: "rejected" };
}
const snapshot = context.gateway.snapshot;
if (
snapshot.client !== client ||
!canCallGatewayMethod(snapshot, "openclaw.chat", "operator.admin")
) {
return "rejected";
return { outcome: "rejected" };
}
this.requestAbort?.abort();
const requestAbort = new AbortController();
@@ -669,7 +676,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
});
delivery = "received";
if (epoch !== this.requestEpoch || client !== this.activeClient) {
return "sent";
return { outcome: "sent" };
}
this.sessionId = result.sessionId;
this.sensitive = result.sensitive === true;
@@ -690,7 +697,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
if (result.agentId) {
const roster = await context.agents.refreshList();
if (epoch !== this.requestEpoch || client !== this.activeClient) {
return "sent";
return { outcome: "sent" };
}
sessionKey = buildAgentMainSessionKey({
agentId: result.agentId,
@@ -715,9 +722,11 @@ export class CustodianSessionStore extends CustodianTranscriptState {
this.exitSetup();
}
if (!hasCustodianWizardAction(params) || !this.wizardActionReceiptsAvailable) {
return "sent";
return { outcome: "sent" };
}
return result.wizardActionAccepted === true ? "accepted" : "rejected";
return result.wizardAction
? { outcome: "accepted", wizardAction: result.wizardAction }
: { outcome: "rejected" };
} catch (error) {
if (epoch === this.requestEpoch && client === this.activeClient) {
this.error = custodianErrorMessage(error);
@@ -739,7 +748,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
// User turns have no idempotency key and are never replayed after an ambiguous failure.
this.retryParams = null;
}
return eventNudgeState.classifyCustodianSendFailure(error, delivery);
return { outcome: eventNudgeState.classifyCustodianSendFailure(error, delivery) };
} finally {
if (this.requestAbort === requestAbort) {
this.requestAbort = null;
@@ -1,4 +1,7 @@
import type { SystemAgentChatParams } from "@openclaw/gateway-protocol";
import type {
SystemAgentChatParams,
SystemAgentWizardActionReceipt,
} from "@openclaw/gateway-protocol";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { t } from "../../i18n/index.ts";
import { custodianWizardSubmission } from "./custodian-wizard-step.ts";
@@ -26,7 +29,10 @@ type StructuredInteractionHost = {
params: SystemAgentChatParams,
display: string,
userTurnProjection: "always" | "unless-accepted",
) => Promise<eventNudgeState.CustodianSendOutcome>;
) => Promise<{
outcome: eventNudgeState.CustodianSendOutcome;
wizardAction?: SystemAgentWizardActionReceipt;
}>;
};
function withResponse(
@@ -49,7 +55,6 @@ export function createCustodianStructuredInteraction(host: StructuredInteraction
request: SystemAgentChatParams;
display: string;
kind: CustodianStructuredResponse["kind"];
prompt?: string;
}): Promise<eventNudgeState.CustodianSendOutcome> => {
const state = host.state();
if (
@@ -67,36 +72,35 @@ export function createCustodianStructuredInteraction(host: StructuredInteraction
display: params.display,
kind: params.kind,
state: "submitting",
...(params.prompt ? { prompt: params.prompt } : {}),
}) ?? [...state.messages],
);
host.emit();
}
const outcome = await host.sendUserTurn(
const result = await host.sendUserTurn(
params.client,
params.request,
params.display,
state.wizardActionReceiptsAvailable ? "unless-accepted" : "always",
);
if (!state.wizardActionReceiptsAvailable) {
return outcome;
return result.outcome;
}
const current = host.state();
const response: CustodianStructuredResponse | null =
outcome !== "accepted"
result.outcome !== "accepted" || !result.wizardAction
? null
: {
display: params.display,
kind: params.kind,
kind: result.wizardAction.kind,
state: "submitted",
...(params.prompt ? { prompt: params.prompt } : {}),
...(result.wizardAction?.prompt ? { prompt: result.wizardAction.prompt } : {}),
};
const messages = withResponse(current.messages, params.message.id, response);
if (messages) {
host.replaceMessages(messages);
host.emit();
}
return outcome;
return result.outcome;
};
return {
@@ -113,7 +117,6 @@ export function createCustodianStructuredInteraction(host: StructuredInteraction
request: { sessionId: state.sessionId, wizardAnswer: submission.answer },
display: message.step?.sensitive ? t("custodian.sensitiveReply") : submission.display,
kind: "answer",
prompt: message.step?.title ?? message.step?.message,
});
},
@@ -137,7 +140,6 @@ export function createCustodianStructuredInteraction(host: StructuredInteraction
request: { sessionId: state.sessionId, wizardCancel: { stepId: step.id } },
display: t("custodian.cancel"),
kind: "cancel",
prompt: step.title ?? step.message,
});
},
};
+1 -7
View File
@@ -215,13 +215,7 @@ function renderCustodianEarlierDivider(message: CustodianMessage, boundaryAfterI
}
function structuredPrompt(message: CustodianMessage): string {
return (
message.structuredResponse?.prompt ??
message.step?.title ??
message.step?.message ??
message.question?.question ??
t("custodian.structured.response")
);
return message.structuredResponse?.prompt ?? t("custodian.structured.response");
}
function renderStructuredResponse(message: CustodianMessage) {