mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): show Custodian setup action receipts
This commit is contained in:
@@ -10872,6 +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 action: AnyCodable
|
||||
public let agentdraft: String?
|
||||
public let agentid: String?
|
||||
@@ -10885,6 +10886,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
reply: String,
|
||||
sensitive: Bool? = nil,
|
||||
wizardinputpending: Bool? = nil,
|
||||
wizardactionaccepted: Bool? = nil,
|
||||
action: AnyCodable,
|
||||
agentdraft: String? = nil,
|
||||
agentid: String? = nil,
|
||||
@@ -10897,6 +10899,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
self.reply = reply
|
||||
self.sensitive = sensitive
|
||||
self.wizardinputpending = wizardinputpending
|
||||
self.wizardactionaccepted = wizardactionaccepted
|
||||
self.action = action
|
||||
self.agentdraft = agentdraft
|
||||
self.agentid = agentid
|
||||
@@ -10911,6 +10914,7 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
case reply
|
||||
case sensitive
|
||||
case wizardinputpending = "wizardInputPending"
|
||||
case wizardactionaccepted = "wizardActionAccepted"
|
||||
case action
|
||||
case agentdraft = "agentDraft"
|
||||
case agentid = "agentId"
|
||||
@@ -10943,21 +10947,25 @@ public struct SystemAgentChatHistoryTurn: Codable, Sendable {
|
||||
public let role: AnyCodable
|
||||
public let text: String
|
||||
public let at: Double
|
||||
public let wizardaction: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
role: AnyCodable,
|
||||
text: String,
|
||||
at: Double)
|
||||
at: Double,
|
||||
wizardaction: [String: AnyCodable]? = nil)
|
||||
{
|
||||
self.role = role
|
||||
self.text = text
|
||||
self.at = at
|
||||
self.wizardaction = wizardaction
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case role
|
||||
case text
|
||||
case at
|
||||
case wizardaction = "wizardAction"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"daefcf9a55c958e5724fb853b5a8937655770cb40c809ce0541d18f5e0b851f4","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
{"contentHash":"79a4c99fa78209550335cbcf81b39ce261be41a90de04b64eb69526ef84349bc","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"aeb886b36931a7aa47cedcfab228ca2c9cdee8f6029683c169623758a3726c9c","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"fdcb3e2536e7d4e3090322fac2793f497a0b6f331a8b583fa6156007721b177f","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"56054f5fe80bdef27618a0163a04091777dc156391221d25e5ab69f9958cf00c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"3bdfd31e7decd15278ea7864d8c16a0674be246e827b55b389593dcba3f2066c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"cddc0ebd6cc887249ff8aaea292c7dfc400fb39efb08d18c1fda00340e6c2cc0","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
{"contentHash":"a5ff017a6e98b53dee31102591e7304d4d754b150b2a7684c257059921fa2523","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"d413b2031a7ab19e5a6554058418625ec017f9fe5fa3a980a095268f09d69bd8","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"5d62b0618558e9a0093e4c8ad09b6cd143ed5b4c214de2eeefb255781a34c3bd","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"cc4911393909d8e0bc3a1ce72a417af3dcc9b741e4106c0a135301d5e8a405e6","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"32d2d5450bc2e08ebd3e2afc9302cf321469684f5997a059dddf28ace36675a8","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"6f15d12df2378c1185bce1e8ef119d170b769b275dafc03203e2bef2441faf5f","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
{"contentHash":"3fea3804bec8232a55c95d3abb5bab8de5d8778652bddfafbb6362d27a4b3460","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"68125b14406c4772cfbda02053929383acf0f895e19446a1e53ad5b36f3c1c4a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"117093ae03e310c9ce4455f24e43fc678b8737af600eea1d0066137c2fa4651c","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"1e7d0b76629055a48e9156916667dba769f3b050ec71a607e6cb45ebde4a196e","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"b5d69672e4813d9de0f99bf51d1baedd73b167a54126a0c984f65dd84945699c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"1740335f4eaa15f70266b6d3b1a92b378ab40cad4b33fbd066079ac444ecdc7a","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"096282e453532f80d63ac296d2cc53ad65eb38f23703ea27e359cc215c7adfe4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"7cd9f27750463d173499efaeaf378088fb03acff4b3ccede9612b20f8de272d7","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
{"contentHash":"d93e8de2beeea1902998af40aae725e6d6318cb011810d3478a776a016f62723","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"747e38c5637b89bebd30c1ad5872e59b5d49ca192b7d683ab5bc520007e38991","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"60a4f0f0501fb64019a7aa9c3e66f64ce9c388fb38dadd69d71b64a467ecbcbd","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"6856c12fd342a8779ffbddbb10e0792e397ff6bb0cff74f328110f5b80ccf23b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"283ada2bb6c4499207b08f1d23c80919daa4fd139c6e02810c2c15f7a90fec58","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"ad5d7b53a87a8f4e23e1a71c3dc6c796d28bd733dd1b78a3a793256dbb53ee5f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"e5b10b255ad2bd9ed54f10017e1e09440962ed846ba885059e13dab10b27811f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"c251e0eabb3292d4da6814f775ade4cbfdb96df889d384be9fa1380a6c7fe88c","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
{"contentHash":"5055e33c4e0ffe0cb368d7a8e7970d5e97fd7d90913485e8a2bcecb1f97f92ef","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"3fa3d78190c0b7785cb6003f1af5e3249af3585aded8608c26fce00d6b36a9a5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"7f5f39112df30ff5d32513258437bef16e8304e01e1d61a50f754c8e26831cbc","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"b733af3f69aa036b33f3e7586619fb6d988d89a51bc4c3bfff0af0fc5e7a56fc","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"983f60fcee19de0cc99dd2db216bea32e45054bffc50cfe3d6d382a7f51c7a41","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
@@ -15,6 +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.
|
||||
|
||||
## Protocol v4 (current)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const GATEWAY_SERVER_CAPS = {
|
||||
BOARD_WIDGET_PUT_CANVAS_DOC: "board-widget-put-canvas-doc",
|
||||
CHAT_SEND_ROUTING_CONTRACT: "chat-send-routing-contract",
|
||||
SYSTEM_AGENT_WIZARD_CANCEL: "openclaw-chat-wizard-cancel",
|
||||
SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS: "openclaw-chat-wizard-action-receipts",
|
||||
SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY: "openclaw-chat-history-session-recovery",
|
||||
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
|
||||
TASK_SUGGESTIONS_ACCEPT_MODES: "taskSuggestions.acceptModes",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../index.js";
|
||||
import {
|
||||
SystemAgentChatQuestionSchema,
|
||||
SystemAgentChatResultSchema,
|
||||
SystemAgentChatHistoryResultSchema,
|
||||
SystemAgentSetupDetectResultSchema,
|
||||
SystemAgentSetupVerifyResultSchema,
|
||||
@@ -88,6 +89,23 @@ 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", () => {
|
||||
expect(Value.Check(SystemAgentChatResultSchema, result)).toBe(true);
|
||||
expect(
|
||||
Value.Check(SystemAgentChatResultSchema, { ...result, wizardActionAccepted: true }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(SystemAgentChatResultSchema, { ...result, wizardActionAccepted: false }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(SystemAgentChatResultSchema, { ...result, wizardActionAccepted: "yes" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenClaw chat history protocol", () => {
|
||||
it("accepts the default request and bounds explicit limits", () => {
|
||||
expect(validateSystemAgentChatHistoryParams({})).toBe(true);
|
||||
@@ -131,6 +149,42 @@ describe("OpenClaw chat history protocol", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts minimal wizard-action presentation metadata on live session turns", () => {
|
||||
const turn = {
|
||||
role: "user",
|
||||
text: "Slack bot",
|
||||
at: 2,
|
||||
wizardAction: {
|
||||
kind: "answer",
|
||||
prompt: "How should OpenClaw appear in Slack?",
|
||||
},
|
||||
};
|
||||
expect(Value.Check(SystemAgentChatHistoryResultSchema, { turns: [turn] })).toBe(true);
|
||||
expect(
|
||||
Value.Check(SystemAgentChatHistoryResultSchema, {
|
||||
turns: [{ ...turn, sessionId: "slack-session" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(SystemAgentChatHistoryResultSchema, {
|
||||
turns: [{ ...turn, wizardAction: { ...turn.wizardAction, kind: "unknown" } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(SystemAgentChatHistoryResultSchema, {
|
||||
turns: [
|
||||
{
|
||||
...turn,
|
||||
wizardAction: {
|
||||
...turn.wizardAction,
|
||||
externalUrl: "https://auth.example.test/device?token=secret",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenClaw setup detection protocol", () => {
|
||||
|
||||
@@ -86,6 +86,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()),
|
||||
action: Type.Union([
|
||||
Type.Literal("none"),
|
||||
// The user asked to talk to their agent; clients should move to their
|
||||
@@ -113,10 +115,18 @@ 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 from a live recovered session. */
|
||||
wizardAction: Type.Optional(SystemAgentChatHistoryWizardActionSchema),
|
||||
});
|
||||
|
||||
export const SystemAgentChatHistoryResultSchema = closedObject({
|
||||
@@ -379,6 +389,9 @@ 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 SystemAgentChatHistoryTurn = Static<typeof SystemAgentChatHistoryTurnSchema>;
|
||||
export type SystemAgentChatHistoryResult = Static<typeof SystemAgentChatHistoryResultSchema>;
|
||||
export type SystemChangeEntry = Static<typeof SystemChangeEntrySchema>;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import { appendTranscriptTurn } from "../../system-agent/transcript-store.js";
|
||||
import {
|
||||
appendSystemAgentRecoveryHistory,
|
||||
persistSystemAgentEngineHistory,
|
||||
setSystemAgentRecoveryHistory,
|
||||
systemAgentChatHistoryHandler,
|
||||
} from "./system-agent-chat-history.js";
|
||||
@@ -15,10 +17,12 @@ const turns = [
|
||||
];
|
||||
|
||||
const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
appendTranscriptTurn: vi.fn(),
|
||||
readTranscriptTail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-agent/transcript-store.js", () => ({
|
||||
appendTranscriptTurn: transcriptStoreMocks.appendTranscriptTurn,
|
||||
readTranscriptTail: transcriptStoreMocks.readTranscriptTail,
|
||||
}));
|
||||
|
||||
@@ -62,9 +66,64 @@ function makeInvocation(params: {
|
||||
|
||||
describe("openclaw.chat.history wizard recovery", () => {
|
||||
beforeEach(() => {
|
||||
transcriptStoreMocks.appendTranscriptTurn.mockReset();
|
||||
transcriptStoreMocks.readTranscriptTail.mockReset().mockReturnValue(turns);
|
||||
});
|
||||
|
||||
it("keeps accepted action metadata on live recovery turns only", () => {
|
||||
const wizardAction = {
|
||||
kind: "cancel" as const,
|
||||
};
|
||||
const recoveryTurns = persistSystemAgentEngineHistory(
|
||||
{
|
||||
historySince: () => [
|
||||
{ role: "user", text: "Cancel" },
|
||||
{ role: "assistant", text: "Twitch setup cancelled." },
|
||||
],
|
||||
},
|
||||
0,
|
||||
{ wizardAction, wizardActionAccepted: true },
|
||||
);
|
||||
|
||||
expect(recoveryTurns).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
wizardAction,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
}),
|
||||
]);
|
||||
expect(vi.mocked(appendTranscriptTurn).mock.calls.map(([turn]) => turn)).toEqual([
|
||||
expect.objectContaining({ role: "user", text: "Cancel" }),
|
||||
expect.objectContaining({ role: "assistant", text: "Twitch setup cancelled." }),
|
||||
]);
|
||||
for (const [turn] of vi.mocked(appendTranscriptTurn).mock.calls) {
|
||||
expect(turn).not.toHaveProperty("wizardAction");
|
||||
}
|
||||
});
|
||||
|
||||
it("omits action metadata when the engine rejects the typed answer", () => {
|
||||
persistSystemAgentEngineHistory(
|
||||
{
|
||||
historySince: () => [
|
||||
{ role: "user", text: "Invalid value" },
|
||||
{ role: "assistant", text: "Choose again." },
|
||||
],
|
||||
},
|
||||
0,
|
||||
{
|
||||
wizardAction: { kind: "answer", prompt: "Port" },
|
||||
wizardActionAccepted: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(vi.mocked(appendTranscriptTurn)).toHaveBeenCalledTimes(2);
|
||||
for (const [turn] of vi.mocked(appendTranscriptTurn).mock.calls) {
|
||||
expect(turn).not.toHaveProperty("wizardAction");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an active wizard only to its bound owner", async () => {
|
||||
const activeWizardStep = vi.fn().mockResolvedValue({
|
||||
id: "secret",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
validateSystemAgentChatHistoryParams,
|
||||
type SystemAgentChatHistoryTurn,
|
||||
type SystemAgentChatHistoryWizardAction,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
|
||||
import { resolveSystemAgentDelegationKey } from "../../system-agent/delegation-session.js";
|
||||
import { readTranscriptTail } from "../../system-agent/transcript-store.js";
|
||||
import { appendTranscriptTurn, readTranscriptTail } from "../../system-agent/transcript-store.js";
|
||||
import { runSystemAgentGatewayTask } from "./system-agent-gateway-queue.js";
|
||||
import { getSystemAgentSessionQueue } from "./system-agent-session-queue.js";
|
||||
import type { GatewayClient, GatewayRequestHandler } from "./types.js";
|
||||
@@ -36,6 +37,29 @@ function readSystemAgentRecoveryHistory(
|
||||
return (recoveryTurnsByEngine.get(engine) ?? []).slice(-limit);
|
||||
}
|
||||
|
||||
export function persistSystemAgentEngineHistory(
|
||||
engine: Pick<SystemAgentChatEngine, "historySince">,
|
||||
startIndex: number,
|
||||
params: {
|
||||
wizardAction?: SystemAgentChatHistoryWizardAction;
|
||||
wizardActionAccepted?: boolean;
|
||||
} = {},
|
||||
): SystemAgentChatHistoryTurn[] {
|
||||
const at = Date.now();
|
||||
let wizardAction = params.wizardActionAccepted === true ? params.wizardAction : undefined;
|
||||
const recoveryTurns: SystemAgentChatHistoryTurn[] = [];
|
||||
for (const turn of engine.historySince(startIndex)) {
|
||||
const action = turn.role === "user" ? wizardAction : undefined;
|
||||
const recoveryTurn = { ...turn, at, ...(action ? { wizardAction: action } : {}) };
|
||||
appendTranscriptTurn({ ...turn, at });
|
||||
recoveryTurns.push(recoveryTurn);
|
||||
if (action) {
|
||||
wizardAction = undefined;
|
||||
}
|
||||
}
|
||||
return recoveryTurns;
|
||||
}
|
||||
|
||||
export function resolveSystemAgentSessionOwnerKey(params: {
|
||||
delegation?: { agentId?: string; sessionKey?: string };
|
||||
client: GatewayClient | null;
|
||||
|
||||
@@ -123,6 +123,7 @@ describe("system-agent chat input", () => {
|
||||
reply: {
|
||||
text: "Choose a channel.",
|
||||
action: "none",
|
||||
wizardActionAccepted: false,
|
||||
step: {
|
||||
id: "channel",
|
||||
type: "select",
|
||||
@@ -135,6 +136,7 @@ describe("system-agent chat input", () => {
|
||||
sessionId: "s1",
|
||||
reply: "Choose a channel.",
|
||||
action: "none",
|
||||
wizardActionAccepted: false,
|
||||
step: { id: "channel", type: "select" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,9 @@ 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.question ? { question: params.reply.question } : {}),
|
||||
...(params.reply.step ? { step: params.reply.step } : {}),
|
||||
...(params.proposalId ? { needsApproval: true, proposalId: params.proposalId } : {}),
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
validateSystemAgentSetupAuthStartParams,
|
||||
validateSystemAgentSetupDetectParams,
|
||||
validateSystemAgentSetupVerifyParams,
|
||||
type SystemAgentChatHistoryTurn,
|
||||
type SystemAgentChatQuestion,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
@@ -34,11 +33,7 @@ import { isSystemAgentInferenceUnavailableError } from "../../system-agent/infer
|
||||
import { buildNewAgentWelcome } from "../../system-agent/new-agent-welcome.js";
|
||||
import { buildOnboardingWelcome } from "../../system-agent/onboarding-welcome.js";
|
||||
import { describeSystemAgentPersistentOperation } from "../../system-agent/operations.js";
|
||||
import {
|
||||
appendTranscriptReset,
|
||||
appendTranscriptTurn,
|
||||
readTranscriptTail,
|
||||
} from "../../system-agent/transcript-store.js";
|
||||
import { appendTranscriptReset, readTranscriptTail } from "../../system-agent/transcript-store.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { WizardSession } from "../../wizard/session.js";
|
||||
import {
|
||||
@@ -54,6 +49,7 @@ import {
|
||||
} from "./setup-admission.js";
|
||||
import {
|
||||
appendSystemAgentRecoveryHistory,
|
||||
persistSystemAgentEngineHistory,
|
||||
resolveSystemAgentSessionOwnerKey,
|
||||
setSystemAgentRecoveryHistory,
|
||||
systemAgentChatHistoryHandler,
|
||||
@@ -121,23 +117,6 @@ async function evictOldestSession(
|
||||
}
|
||||
}
|
||||
|
||||
function persistEngineHistory(
|
||||
engine: SystemAgentChatSession["engine"],
|
||||
startIndex: number,
|
||||
): SystemAgentChatHistoryTurn[] {
|
||||
const at = Date.now();
|
||||
const turns: SystemAgentChatHistoryTurn[] = [];
|
||||
for (const turn of engine.historySince(startIndex)) {
|
||||
turns.push({ ...turn, at });
|
||||
}
|
||||
for (const turn of turns) {
|
||||
// Engine history is authoritative here: sensitive user text has already
|
||||
// been replaced by the mask marker before it crosses this boundary.
|
||||
appendTranscriptTurn(turn);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function queueDelegatedApproval(params: {
|
||||
context: GatewayRequestContext;
|
||||
sessions: Map<string, SystemAgentChatSession>;
|
||||
@@ -577,7 +556,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, error.message));
|
||||
return;
|
||||
}
|
||||
const recoveryTurns = persistEngineHistory(engine, welcomeHistoryStart);
|
||||
const recoveryTurns = persistSystemAgentEngineHistory(engine, welcomeHistoryStart);
|
||||
await evictOldestSession(sessions, context);
|
||||
session = {
|
||||
engine,
|
||||
@@ -645,7 +624,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
} catch (error) {
|
||||
appendSystemAgentRecoveryHistory(
|
||||
session.engine,
|
||||
persistEngineHistory(session.engine, historyStart),
|
||||
persistSystemAgentEngineHistory(session.engine, historyStart),
|
||||
);
|
||||
if (error instanceof SystemAgentWizardAnswerError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
|
||||
@@ -677,7 +656,10 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
appendSystemAgentRecoveryHistory(
|
||||
session.engine,
|
||||
persistEngineHistory(session.engine, historyStart),
|
||||
persistSystemAgentEngineHistory(session.engine, historyStart, {
|
||||
wizardAction: reply.wizardAction,
|
||||
wizardActionAccepted: reply.wizardActionAccepted,
|
||||
}),
|
||||
);
|
||||
const delegation = params.delegation;
|
||||
let proposalId: string | undefined;
|
||||
|
||||
@@ -162,6 +162,9 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
);
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
);
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY,
|
||||
);
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function sendGatewayHello(
|
||||
GATEWAY_SERVER_CAPS.BOARD_WIDGET_PUT_CANVAS_DOC,
|
||||
GATEWAY_SERVER_CAPS.CHAT_SEND_ROUTING_CONTRACT,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
|
||||
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
|
||||
|
||||
@@ -173,7 +173,15 @@ export class SystemAgentChatEngine {
|
||||
const turn = this.turnQueue.then(async () => {
|
||||
await this.requireVerifiedInference();
|
||||
const result = await this.router.answerWizard(this.wizard.answer(answer));
|
||||
return this.completeTurn({ text: result.text, action: "none" }, result.userHistoryText);
|
||||
return this.completeTurn(
|
||||
{
|
||||
text: result.text,
|
||||
action: "none",
|
||||
wizardActionAccepted: result.accepted,
|
||||
wizardAction: result.wizardAction,
|
||||
},
|
||||
result.userHistoryText,
|
||||
);
|
||||
});
|
||||
this.turnQueue = turn.catch(() => undefined);
|
||||
return await turn;
|
||||
@@ -182,7 +190,15 @@ export class SystemAgentChatEngine {
|
||||
async cancelWizard(cancel: SystemAgentWizardCancel): Promise<SystemAgentChatReply> {
|
||||
const turn = this.turnQueue.then(async () => {
|
||||
const result = await this.router.answerWizard(this.wizard.cancel(cancel));
|
||||
return this.completeTurn({ text: result.text, action: "none" }, result.userHistoryText);
|
||||
return this.completeTurn(
|
||||
{
|
||||
text: result.text,
|
||||
action: "none",
|
||||
wizardActionAccepted: result.accepted,
|
||||
wizardAction: result.wizardAction,
|
||||
},
|
||||
result.userHistoryText,
|
||||
);
|
||||
});
|
||||
this.turnQueue = turn.catch(() => undefined);
|
||||
return await turn;
|
||||
|
||||
@@ -481,12 +481,42 @@ describe("SystemAgentChatEngine wizard", () => {
|
||||
|
||||
const prompt = await engine.handle("connect telegram");
|
||||
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
|
||||
await engine.answerWizard({ stepId, value: "beta" });
|
||||
const answered = await engine.answerWizard({ stepId, value: "beta" });
|
||||
|
||||
expect(selected).toBe("beta");
|
||||
expect(answered.wizardActionAccepted).toBe(true);
|
||||
expect(answered.wizardAction).toEqual({ kind: "answer", prompt: "Choose one" });
|
||||
expect(engine.historySince(0)).toContainEqual({ role: "user", text: "Beta" });
|
||||
});
|
||||
|
||||
it("records typed wizard validation acceptance at the wizard owner", async () => {
|
||||
useTempStateDir();
|
||||
const engine = new SystemAgentChatEngine({
|
||||
surface: "gateway",
|
||||
runAgentTurn: async () => null,
|
||||
planWithAssistant: async () => null,
|
||||
deps: { loadOverview: fakeOverviewLoader() },
|
||||
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
|
||||
await prompter.text({
|
||||
message: "Port",
|
||||
validate: (value) => (value === "18789" ? undefined : "Enter port 18789"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = await engine.handle("connect telegram");
|
||||
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.step?.id).toBe(stepId);
|
||||
expect(invalid.text).toContain("Enter port 18789");
|
||||
|
||||
const accepted = await engine.answerWizard({ stepId, value: "18789" });
|
||||
expect(accepted.wizardActionAccepted).toBe(true);
|
||||
expect(accepted.step).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cancels the current hosted wizard through a typed direct action", async () => {
|
||||
useTempStateDir();
|
||||
const engine = new SystemAgentChatEngine({
|
||||
@@ -495,7 +525,7 @@ describe("SystemAgentChatEngine wizard", () => {
|
||||
planWithAssistant: async () => null,
|
||||
deps: { loadOverview: fakeOverviewLoader() },
|
||||
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
|
||||
await prompter.text({ message: "Bot token" });
|
||||
await prompter.text({ message: "Bot token", sensitive: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -504,6 +534,8 @@ describe("SystemAgentChatEngine wizard", () => {
|
||||
const cancelled = await engine.cancelWizard({ stepId });
|
||||
|
||||
expect(cancelled.text).toContain("cancelled");
|
||||
expect(cancelled.wizardActionAccepted).toBe(true);
|
||||
expect(cancelled.wizardAction).toEqual({ kind: "cancel" });
|
||||
expect(cancelled.step).toBeUndefined();
|
||||
expect(cancelled.wizardInputPending).toBeUndefined();
|
||||
expect(engine.historySince(0)).toContainEqual({ role: "user", text: "Cancel" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
SystemAgentChatQuestion,
|
||||
SystemAgentChatHistoryWizardAction,
|
||||
SystemAgentWizardCancel,
|
||||
WizardAnswer,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
@@ -30,6 +31,9 @@ export type SystemAgentChatReply = {
|
||||
agentDraft?: "hatch";
|
||||
sensitive?: boolean;
|
||||
wizardInputPending?: boolean;
|
||||
/** The submitted typed wizard action passed or failed owner-side validation. */
|
||||
wizardActionAccepted?: boolean;
|
||||
wizardAction?: SystemAgentChatHistoryWizardAction;
|
||||
handoff?: SystemAgentOperation;
|
||||
question?: SystemAgentChatQuestion;
|
||||
step?: WizardStep;
|
||||
@@ -42,9 +46,22 @@ export type ChatWizardResult = {
|
||||
};
|
||||
|
||||
export type ChatWizardAnswerResult = ChatWizardResult & {
|
||||
accepted: boolean;
|
||||
userHistoryText: string;
|
||||
wizardAction: SystemAgentChatHistoryWizardAction;
|
||||
};
|
||||
|
||||
function wizardActionReceipt(
|
||||
step: WizardStep,
|
||||
kind: SystemAgentChatHistoryWizardAction["kind"],
|
||||
): SystemAgentChatHistoryWizardAction {
|
||||
const prompt =
|
||||
!step.sensitive && !step.deviceCode && !step.externalUrl
|
||||
? (step.title ?? step.message)
|
||||
: undefined;
|
||||
return { kind, ...(prompt ? { prompt } : {}) };
|
||||
}
|
||||
|
||||
export type ChatWizardHostDependencies = {
|
||||
runChannelSetupWizard?: (
|
||||
channel: string,
|
||||
@@ -311,7 +328,9 @@ export class ChatWizardHost {
|
||||
: await this.pump();
|
||||
return {
|
||||
...result,
|
||||
accepted: validationError === undefined,
|
||||
userHistoryText: formatStructuredWizardAnswerForHistory(step, answer.value),
|
||||
wizardAction: wizardActionReceipt(step, "answer"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,7 +346,12 @@ export class ChatWizardHost {
|
||||
if (!bridge.session.cancel()) {
|
||||
throw new SystemAgentWizardAnswerError("The hosted wizard cannot be cancelled right now.");
|
||||
}
|
||||
return { ...(await this.pump()), userHistoryText: "Cancel" };
|
||||
return {
|
||||
...(await this.pump()),
|
||||
accepted: true,
|
||||
userHistoryText: "Cancel",
|
||||
wizardAction: wizardActionReceipt(step, "cancel"),
|
||||
};
|
||||
}
|
||||
|
||||
async resolveReply(text: string): Promise<ChatWizardResult> {
|
||||
|
||||
@@ -61,8 +61,24 @@ describe("custodian panel", () => {
|
||||
store.connect(context, "caretaker");
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
|
||||
store.messages = [
|
||||
{ id: 1, role: "assistant", text: "Ready.", at: 1, question: null, step: null },
|
||||
{ id: 2, role: "user", text: "Check this system", at: 2, question: null, step: null },
|
||||
{
|
||||
id: 1,
|
||||
role: "assistant",
|
||||
text: "Ready.",
|
||||
at: 1,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: "user",
|
||||
text: "Check this system",
|
||||
at: 2,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
},
|
||||
];
|
||||
|
||||
panel.suppressed = false;
|
||||
@@ -116,7 +132,15 @@ describe("custodian panel", () => {
|
||||
store.connect(context, "caretaker");
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
|
||||
store.messages = [
|
||||
{ id: 1, role: "user", text: "Check this system", at: 1, question: null, step: null },
|
||||
{
|
||||
id: 1,
|
||||
role: "user",
|
||||
text: "Check this system",
|
||||
at: 1,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
},
|
||||
];
|
||||
panel.available = false;
|
||||
panel.suppressed = false;
|
||||
@@ -141,8 +165,17 @@ describe("custodian panel", () => {
|
||||
at: 1,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: "user",
|
||||
text: "Continue setup",
|
||||
at: 2,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
},
|
||||
{ id: 2, role: "user", text: "Continue setup", at: 2, question: null, step: null },
|
||||
];
|
||||
|
||||
panel.suppressed = false;
|
||||
|
||||
@@ -332,7 +332,10 @@ suite.define(() => {
|
||||
},
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureCapabilities: [GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL],
|
||||
featureCapabilities: [
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
],
|
||||
featureMethods: ["chat.metadata", "chat.startup", "openclaw.chat"],
|
||||
methodResponses: {
|
||||
"openclaw.chat": {
|
||||
@@ -439,6 +442,7 @@ suite.define(() => {
|
||||
sessionId: "e2e-rich-wizard",
|
||||
reply: "Choose features.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "features",
|
||||
@@ -462,6 +466,7 @@ suite.define(() => {
|
||||
sessionId: "e2e-rich-wizard",
|
||||
reply: "Enter the secret.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
sensitive: true,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
@@ -488,6 +493,7 @@ suite.define(() => {
|
||||
sessionId: "e2e-rich-wizard",
|
||||
reply: "Name this connection.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "label",
|
||||
@@ -522,6 +528,7 @@ suite.define(() => {
|
||||
sessionId: "e2e-rich-wizard",
|
||||
reply: "Confirm setup.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "confirm",
|
||||
@@ -535,22 +542,40 @@ suite.define(() => {
|
||||
|
||||
await gateway.deferNext("openclaw.chat");
|
||||
await yesButton.click();
|
||||
await expect.poll(() => noButton.isDisabled()).toBe(true);
|
||||
await expect.poll(() => yesButton.isDisabled()).toBe(true);
|
||||
await expect.poll(() => cancelButton.isDisabled()).toBe(true);
|
||||
for (const button of [noButton, yesButton, cancelButton]) {
|
||||
const restingStyle = await button.evaluate(readInteractionStyle);
|
||||
await button.hover();
|
||||
expect(await button.evaluate(readInteractionStyle)).toEqual(restingStyle);
|
||||
expect(restingStyle.cursor).toBe("not-allowed");
|
||||
const confirmationReceipt = page.locator(".custodian__structured-response", {
|
||||
hasText: "Yes",
|
||||
});
|
||||
await confirmationReceipt.waitFor();
|
||||
expect(
|
||||
await confirmationReceipt.locator(".custodian__structured-response-status").textContent(),
|
||||
).toBe("Submitting answer");
|
||||
expect(await noButton.count()).toBe(0);
|
||||
expect(await yesButton.count()).toBe(0);
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "06-answer-receipt-submitting-desktop.png"),
|
||||
});
|
||||
}
|
||||
|
||||
await gateway.resolveDeferred("openclaw.chat", {
|
||||
sessionId: "e2e-rich-wizard",
|
||||
reply: "Setup complete.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
});
|
||||
await page.getByText("Setup complete.").waitFor();
|
||||
expect(
|
||||
await confirmationReceipt.locator(".custodian__structured-response-status").textContent(),
|
||||
).toBe("Answer submitted");
|
||||
|
||||
if (captureUiProofEnabled) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "07-answer-receipt-submitted-desktop.png"),
|
||||
});
|
||||
}
|
||||
|
||||
const requests = await gateway.getRequests("openclaw.chat");
|
||||
expect(requests.map((request) => request.params)).toEqual([
|
||||
@@ -602,6 +627,7 @@ suite.define(() => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureCapabilities: [
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY,
|
||||
],
|
||||
featureMethods: ["chat.metadata", "chat.startup", "openclaw.chat", "openclaw.chat.history"],
|
||||
@@ -680,6 +706,7 @@ suite.define(() => {
|
||||
sessionId: "e2e-reload-wizard",
|
||||
reply: "Twitch setup cancelled.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByText("Twitch setup cancelled.").waitFor();
|
||||
@@ -692,6 +719,15 @@ suite.define(() => {
|
||||
});
|
||||
expect(requests[1]?.params).not.toHaveProperty("message");
|
||||
expect(await page.locator(".custodian__wizard-step").count()).toBe(0);
|
||||
expect(await page.locator(".custodian__structured-response-status").textContent()).toBe(
|
||||
"Setup cancelled",
|
||||
);
|
||||
if (captureUiProofEnabled) {
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, "08-cancel-receipt-submitted-desktop.png"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
|
||||
@@ -2365,6 +2365,13 @@ export const en: TranslationMap = {
|
||||
sessionRestarted:
|
||||
"{error} OpenClaw started a fresh session; earlier messages remain for context.",
|
||||
unsupportedGateway: "Update the Gateway to continue setup with OpenClaw.",
|
||||
structured: {
|
||||
response: "Setup answer",
|
||||
submitting: "Submitting answer",
|
||||
submitted: "Answer submitted",
|
||||
cancelling: "Cancelling setup",
|
||||
cancelled: "Setup cancelled",
|
||||
},
|
||||
panel: {
|
||||
title: "OpenClaw",
|
||||
close: "Close Ask OpenClaw",
|
||||
|
||||
@@ -16,6 +16,7 @@ const recoveryOwner = { gatewayUrl, recoveryScope };
|
||||
const recoveryClient = { recoveryScope, recoveryScopeReady: true } as never;
|
||||
const recoveryCapabilities = [
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_CHAT_HISTORY_SESSION_RECOVERY,
|
||||
];
|
||||
|
||||
@@ -34,8 +35,46 @@ describe("Custodian wizard reload recovery", () => {
|
||||
if (params.sessionId === "live-wizard" && !cancelled) {
|
||||
return {
|
||||
turns: [
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Choose a previous channel.",
|
||||
at: 0,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
text: "Cancel",
|
||||
at: 0,
|
||||
wizardAction: {
|
||||
kind: "cancel",
|
||||
prompt: "Choose a previous channel.",
|
||||
},
|
||||
},
|
||||
{ role: "user", text: "connect twitch", at: 1 },
|
||||
{ role: "assistant", text: "Enter the secret.", at: 2 },
|
||||
{
|
||||
role: "assistant",
|
||||
text: [
|
||||
"How should OpenClaw appear in Twitch?",
|
||||
"1. Bot",
|
||||
"2. User",
|
||||
"Reply with a number.",
|
||||
"Say `cancel` to stop this setup.",
|
||||
].join("\n"),
|
||||
at: 2,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
text: "Bot",
|
||||
at: 3,
|
||||
wizardAction: {
|
||||
kind: "answer",
|
||||
prompt: "How should OpenClaw appear in Twitch?",
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Enter the secret.\nType your answer.\nSay `cancel` to stop this setup.",
|
||||
at: 4,
|
||||
},
|
||||
],
|
||||
activeWizard: {
|
||||
sessionId: "live-wizard",
|
||||
@@ -59,6 +98,7 @@ describe("Custodian wizard reload recovery", () => {
|
||||
sessionId: "live-wizard",
|
||||
reply: "Twitch setup cancelled.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
};
|
||||
}
|
||||
freshChatCount += 1;
|
||||
@@ -99,6 +139,18 @@ describe("Custodian wizard reload recovery", () => {
|
||||
});
|
||||
expect(recoveredInput.value).toBe("");
|
||||
expect(second.page.textContent).toContain("Enter the secret.");
|
||||
expect(second.page.textContent).toContain("Choose a previous channel.");
|
||||
expect(second.page.textContent).toContain("How should OpenClaw appear in Twitch?");
|
||||
expect(second.page.querySelectorAll(".custodian__structured-response")).toHaveLength(2);
|
||||
expect(
|
||||
second.page.querySelector(".custodian__structured-response-icon--cancelled"),
|
||||
).not.toBeNull();
|
||||
expect(second.page.textContent).toContain("Setup cancelled");
|
||||
expect(
|
||||
second.page.querySelectorAll(".custodian__structured-response")[1]?.textContent,
|
||||
).toContain("Bot");
|
||||
expect(second.page.querySelector(".chat-group.user")?.textContent).toContain("connect twitch");
|
||||
expect(second.page.querySelector("details")).toBeNull();
|
||||
expect(request.mock.calls.filter(([method]) => method === "openclaw.chat")).toHaveLength(1);
|
||||
|
||||
second.page.querySelector<HTMLButtonElement>(".custodian__wizard-cancel")!.click();
|
||||
@@ -107,6 +159,8 @@ describe("Custodian wizard reload recovery", () => {
|
||||
sessionId: "live-wizard",
|
||||
wizardCancel: { stepId: "secret" },
|
||||
});
|
||||
expect(second.page.querySelector(".chat-group.user")?.textContent).not.toContain("Cancel");
|
||||
expect(second.page.querySelectorAll(".custodian__structured-response")).toHaveLength(3);
|
||||
expect(readCustodianRecoveryForClient(recoveryClient, gatewayUrl)).toBeNull();
|
||||
|
||||
second.provider.remove();
|
||||
@@ -115,6 +169,119 @@ describe("Custodian wizard reload recovery", () => {
|
||||
expect(third.page.querySelector(".custodian__wizard-step")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores ordinary question answers as user turns instead of wizard receipts", async () => {
|
||||
reconcileCustodianRecoveryForScope(
|
||||
recoveryOwner,
|
||||
{
|
||||
sessionId: "ordinary-answer-session",
|
||||
reply: "Choose a channel.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "channel",
|
||||
type: "select",
|
||||
message: "Which channel?",
|
||||
options: [{ label: "WhatsApp", value: "whatsapp" }],
|
||||
},
|
||||
},
|
||||
"ordinary-answer-session",
|
||||
);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "openclaw.chat.history") {
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
return {
|
||||
turns: [
|
||||
{
|
||||
role: "assistant",
|
||||
text: "What would you like to do first?",
|
||||
at: 1,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
text: "Connect WhatsApp",
|
||||
at: 2,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Choose a channel.",
|
||||
at: 3,
|
||||
},
|
||||
],
|
||||
activeWizard: {
|
||||
sessionId: "ordinary-answer-session",
|
||||
step: {
|
||||
id: "channel",
|
||||
type: "select",
|
||||
message: "Which channel?",
|
||||
options: [{ label: "WhatsApp", value: "whatsapp" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const { context } = createContext(request, ["openclaw.chat", "openclaw.chat.history"], {
|
||||
gatewayCapabilities: recoveryCapabilities,
|
||||
recoveryScope,
|
||||
});
|
||||
const { page } = await mountPage(context);
|
||||
|
||||
await waitForFast(() => expect(page.querySelector(".custodian__wizard-step")).not.toBeNull());
|
||||
expect(page.querySelector(".chat-group.user")?.textContent).toContain("Connect WhatsApp");
|
||||
expect(page.querySelector(".custodian__structured-response")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a validation-rejected wizard answer without a completed receipt", async () => {
|
||||
reconcileCustodianRecoveryForScope(
|
||||
recoveryOwner,
|
||||
{
|
||||
sessionId: "validation-session",
|
||||
reply: "Enter a port.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step: { id: "port", type: "text", message: "Gateway port" },
|
||||
},
|
||||
"validation-session",
|
||||
);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "openclaw.chat.history") {
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
return {
|
||||
turns: [
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Enter a port.",
|
||||
at: 1,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
text: "banana",
|
||||
at: 2,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
text: "Enter port 18789.",
|
||||
at: 3,
|
||||
},
|
||||
],
|
||||
activeWizard: {
|
||||
sessionId: "validation-session",
|
||||
step: { id: "port", type: "text", message: "Gateway port" },
|
||||
},
|
||||
};
|
||||
});
|
||||
const { context } = createContext(request, ["openclaw.chat", "openclaw.chat.history"], {
|
||||
gatewayCapabilities: recoveryCapabilities,
|
||||
recoveryScope,
|
||||
});
|
||||
const { page } = await mountPage(context);
|
||||
|
||||
await waitForFast(() => expect(page.textContent).toContain("Enter port 18789."));
|
||||
expect(page.querySelector(".custodian__structured-response")).toBeNull();
|
||||
expect(page.querySelector(".chat-group.user")?.textContent).toContain("banana");
|
||||
expect(page.querySelector(".custodian__wizard-step")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("waits for the authenticated recovery scope before starting a fresh session", async () => {
|
||||
reconcileCustodianRecoveryForScope(
|
||||
recoveryOwner,
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
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 { waitForFast } from "../../test-helpers/wait-for.ts";
|
||||
import { createContext, mountPage } from "./custodian-page.test-harness.ts";
|
||||
|
||||
describe("custodian structured wizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(crypto, "randomUUID").mockReturnValue("00000000-0000-4000-8000-000000000001");
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps a rejected typed answer active without showing a submitted receipt", async () => {
|
||||
const step = {
|
||||
id: "port",
|
||||
type: "text" as const,
|
||||
message: "Gateway port",
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "validation-session",
|
||||
reply: "Enter a port.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "validation-session",
|
||||
reply: "Enter port 18789.",
|
||||
action: "none",
|
||||
wizardActionAccepted: false,
|
||||
wizardInputPending: true,
|
||||
step,
|
||||
});
|
||||
const { context } = createContext(request);
|
||||
const { page } = await mountPage(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 = "banana";
|
||||
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("Enter port 18789."));
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({
|
||||
wizardAnswer: { stepId: "port", value: "banana" },
|
||||
});
|
||||
expect(page.querySelector(".custodian__structured-response")).toBeNull();
|
||||
expect(page.querySelector(".custodian__wizard-step")).not.toBeNull();
|
||||
expect(page.querySelector(".chat-group.user")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not confirm an answer after its Gateway client is replaced", async () => {
|
||||
const actionReply = createDeferred<{
|
||||
sessionId: string;
|
||||
reply: string;
|
||||
action: "none";
|
||||
wizardActionAccepted: boolean;
|
||||
}>();
|
||||
const step = {
|
||||
id: "port",
|
||||
type: "text" as const,
|
||||
message: "Gateway port",
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "rotation-session",
|
||||
reply: "Enter a port.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step,
|
||||
})
|
||||
.mockReturnValueOnce(actionReply.promise);
|
||||
const harness = createContext(request, ["openclaw.chat"], {
|
||||
gatewayCapabilities: [
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
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"));
|
||||
|
||||
harness.setGatewaySnapshot({
|
||||
client: {
|
||||
request: vi.fn().mockResolvedValue({
|
||||
sessionId: "replacement-session",
|
||||
reply: "Fresh session ready.",
|
||||
action: "none",
|
||||
}),
|
||||
recoveryScope: "principal-a",
|
||||
recoveryScopeReady: true,
|
||||
} as unknown as GatewayBrowserClient,
|
||||
});
|
||||
actionReply.resolve({
|
||||
sessionId: "rotation-session",
|
||||
reply: "Accepted by the retired client.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
});
|
||||
|
||||
await waitForFast(() => expect(page.textContent).toContain("Gateway connection changed"));
|
||||
expect(page.textContent).not.toContain("Answer submitted");
|
||||
expect(page.querySelector(".custodian__structured-response")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps older-Gateway wizard answers as plain user turns", async () => {
|
||||
const step = {
|
||||
id: "port",
|
||||
type: "text" as const,
|
||||
message: "Gateway port",
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "legacy-validation-session",
|
||||
reply: "Enter a port.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "legacy-validation-session",
|
||||
reply: "Enter port 18789.",
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step,
|
||||
});
|
||||
const { context } = createContext(request, ["openclaw.chat"], {
|
||||
gatewayCapabilities: [GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL],
|
||||
});
|
||||
const { page } = await mountPage(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 = "banana";
|
||||
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("Enter port 18789."));
|
||||
expect(page.querySelector(".custodian__structured-response")).toBeNull();
|
||||
expect(page.querySelector(".chat-group.user")?.textContent).toContain("banana");
|
||||
expect(page.querySelector(".custodian__wizard-step")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps server-authored guidance visible beside typed controls", async () => {
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
display_information: {
|
||||
name: "OpenClaw",
|
||||
description: "OpenClaw connector for OpenClaw",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const question = "How do you want to provide this Slack bot token?";
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
sessionId: "slack-wizard-session",
|
||||
reply: [
|
||||
[
|
||||
"**Slack socket mode tokens**",
|
||||
"1) Create the Slack app from the manifest below",
|
||||
"2) Enable Socket Mode",
|
||||
].join("\n"),
|
||||
manifest,
|
||||
[
|
||||
question,
|
||||
"1. Enter Slack bot token — Stores the credential directly in OpenClaw config",
|
||||
"2. Use external secret provider — Stores a reference to an external provider",
|
||||
"Reply with a number.",
|
||||
"Say `cancel` to stop this setup.",
|
||||
].join("\n"),
|
||||
].join("\n\n"),
|
||||
action: "none",
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "slack-token-source",
|
||||
type: "select",
|
||||
message: question,
|
||||
options: [
|
||||
{
|
||||
label: "Enter Slack bot token",
|
||||
value: "direct",
|
||||
hint: "Stores the credential directly in OpenClaw config",
|
||||
},
|
||||
{
|
||||
label: "Use external secret provider",
|
||||
value: "secret-ref",
|
||||
hint: "Stores a reference to an external provider",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { context } = createContext(request);
|
||||
const { page } = await mountPage(context);
|
||||
|
||||
await waitForFast(() => expect(page.querySelector(".custodian__wizard-step")).not.toBeNull());
|
||||
expect(page.querySelector(".chat-group.assistant")?.textContent).toContain(
|
||||
"Slack socket mode tokens",
|
||||
);
|
||||
expect(page.querySelector(".custodian__wizard-guidance")).toBeNull();
|
||||
expect(page.querySelectorAll('.custodian__wizard-step input[type="radio"]')).toHaveLength(2);
|
||||
expect(page.textContent).toContain("Reply with a number");
|
||||
expect(page.textContent).toContain("Say cancel");
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export function createContext(
|
||||
methods,
|
||||
capabilities: options.gatewayCapabilities ?? [
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_CANCEL,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -57,7 +57,6 @@ describe("custodian page", () => {
|
||||
.querySelector<HTMLImageElement>("img.chat-avatar.assistant")
|
||||
?.getAttribute("src"),
|
||||
).toBe("/favicon.svg");
|
||||
// Onboarding strips the header identity; the thread avatar is the only mascot.
|
||||
expect(page.querySelector(".custodian__mark openclaw-mascot")).toBeNull();
|
||||
const card = page.querySelector("openclaw-option-card")!;
|
||||
await card.updateComplete;
|
||||
@@ -71,13 +70,11 @@ describe("custodian page", () => {
|
||||
await page.updateComplete;
|
||||
expect(request.mock.calls[0]?.[0]).toBe("openclaw.chat");
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({ welcomeVariant: "onboarding" });
|
||||
// LLM-authored option cards remain chat messages; wizard controls use wizardAnswer below.
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({
|
||||
welcomeVariant: "onboarding",
|
||||
message: "connect whatsapp",
|
||||
});
|
||||
const userGroup = page.querySelector<HTMLElement>(".chat-group.user")!;
|
||||
expect(userGroup.textContent).toContain("Connect WhatsApp");
|
||||
expect(page.querySelector(".chat-group.user")?.textContent).toContain("Connect WhatsApp");
|
||||
expect(connectOption.disabled).toBe(true);
|
||||
});
|
||||
|
||||
@@ -103,6 +100,7 @@ describe("custodian page", () => {
|
||||
sessionId: "rich-wizard-session",
|
||||
reply: "Choose features.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
id: "features",
|
||||
@@ -119,6 +117,7 @@ describe("custodian page", () => {
|
||||
sessionId: "rich-wizard-session",
|
||||
reply: "Enter the secret.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
sensitive: true,
|
||||
wizardInputPending: true,
|
||||
step: {
|
||||
@@ -132,6 +131,7 @@ describe("custodian page", () => {
|
||||
sessionId: "rich-wizard-session",
|
||||
reply: "Setup complete.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
});
|
||||
const { context } = createContext(request);
|
||||
const { page } = await mountPage(context);
|
||||
@@ -139,6 +139,7 @@ describe("custodian page", () => {
|
||||
await waitForFast(() =>
|
||||
expect(page.querySelectorAll('.custodian__wizard-step input[type="radio"]')).toHaveLength(5),
|
||||
);
|
||||
expect(page.querySelector(".chat-group.assistant")?.textContent).toContain("Choose a channel.");
|
||||
expect(page.querySelector("openclaw-option-card")).toBeNull();
|
||||
expect(page.querySelector(".agent-chat__composer-shell")).toBeNull();
|
||||
page
|
||||
@@ -169,7 +170,9 @@ describe("custodian page", () => {
|
||||
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledTimes(3));
|
||||
const secretInput = await waitForFast(() => {
|
||||
const input = page.querySelector<HTMLInputElement>("#custodian-wizard-input-5");
|
||||
const input = page.querySelector<HTMLInputElement>(
|
||||
'.custodian__wizard-step input[name="wizard-text"]',
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
return input!;
|
||||
});
|
||||
@@ -183,7 +186,9 @@ describe("custodian page", () => {
|
||||
expect(revealSecret).not.toBeNull();
|
||||
revealSecret!.click();
|
||||
await page.updateComplete;
|
||||
const revealedInput = page.querySelector<HTMLInputElement>("#custodian-wizard-input-5")!;
|
||||
const revealedInput = page.querySelector<HTMLInputElement>(
|
||||
'.custodian__wizard-step input[name="wizard-text"]',
|
||||
)!;
|
||||
expect(revealedInput.type).toBe("text");
|
||||
revealedInput.value = "fake-client-secret";
|
||||
revealedInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
@@ -197,8 +202,11 @@ describe("custodian page", () => {
|
||||
});
|
||||
expect(request.mock.calls[3]?.[1]).not.toHaveProperty("message");
|
||||
expect(page.textContent).toContain("Twitch");
|
||||
expect(page.textContent).toContain("Choose a channel.");
|
||||
expect(page.textContent).toContain("Chat, Announcements");
|
||||
expect(page.textContent).toContain("Sensitive reply sent");
|
||||
expect(page.querySelectorAll(".custodian__structured-response")).toHaveLength(3);
|
||||
expect(page.querySelector(".chat-group.user")).toBeNull();
|
||||
expect(page.textContent).not.toContain("fake-client-secret");
|
||||
expect(page.querySelector(".agent-chat__composer-shell")).not.toBeNull();
|
||||
});
|
||||
@@ -223,6 +231,7 @@ describe("custodian page", () => {
|
||||
sessionId: "cancel-wizard-session",
|
||||
reply: "Channel setup cancelled.",
|
||||
action: "none",
|
||||
wizardActionAccepted: true,
|
||||
});
|
||||
const { context } = createContext(request);
|
||||
const { page } = await mountPage(context);
|
||||
@@ -245,6 +254,15 @@ describe("custodian page", () => {
|
||||
expect(request.mock.calls[1]?.[1]).not.toHaveProperty("message");
|
||||
await waitForFast(() => expect(page.textContent).toContain("Channel setup cancelled."));
|
||||
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")?.textContent).toContain(
|
||||
"Setup cancelled",
|
||||
);
|
||||
const cancellationStatus = page.querySelector(".custodian__structured-response-status");
|
||||
expect(cancellationStatus?.textContent).toBe("Setup cancelled");
|
||||
expect(cancellationStatus?.classList.contains("sr-only")).toBe(false);
|
||||
expect(page.querySelector(".custodian__structured-response-icon--cancelled")).not.toBeNull();
|
||||
expect(page.querySelector(".agent-chat__composer-shell")).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { pathForCustodianAgentHandoff } from "./custodian-navigation.ts";
|
||||
import { readCustodianRecoveryForClient } from "./custodian-recovery.ts";
|
||||
import { createCustodianStructuredInteraction } from "./custodian-structured-interaction.ts";
|
||||
import {
|
||||
CustodianTranscriptState,
|
||||
type CustodianTranscriptHistoryOutcome,
|
||||
} from "./custodian-transcript-state.ts";
|
||||
import { custodianWizardSubmission, initialCustodianWizardValue } from "./custodian-wizard-step.ts";
|
||||
import { initialCustodianWizardValue } from "./custodian-wizard-step.ts";
|
||||
import * as eventNudgeState from "./event-nudge.ts";
|
||||
import {
|
||||
custodianChatParams,
|
||||
@@ -47,6 +48,10 @@ function hasCustodianUserInput(params: SystemAgentChatParams): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasCustodianWizardAction(params: SystemAgentChatParams): boolean {
|
||||
return params.wizardAnswer !== undefined || params.wizardCancel !== undefined;
|
||||
}
|
||||
|
||||
type StoreListener = () => void;
|
||||
type ConfiguredInferenceState = "unresolved" | "required" | "ready";
|
||||
type CustodianSetupIssue = "missing" | "unavailable";
|
||||
@@ -79,6 +84,23 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
private agentCleanup: (() => void) | null = null;
|
||||
private eventCleanup: (() => void) | null = null;
|
||||
private readonly listeners = new Set<StoreListener>();
|
||||
private readonly structuredInteraction = createCustodianStructuredInteraction({
|
||||
state: () => ({
|
||||
activeClient: this.activeClient,
|
||||
chatAvailable: this.chatAvailable,
|
||||
messages: this.messages,
|
||||
sending: this.sending,
|
||||
sessionId: this.sessionId,
|
||||
setupRequired: this.setupRequired,
|
||||
wizardCancelAvailable: this.wizardCancelAvailable,
|
||||
wizardActionReceiptsAvailable: this.wizardActionReceiptsAvailable,
|
||||
wizardInputPending: this.wizardInputPending,
|
||||
}),
|
||||
emit: () => this.emit(),
|
||||
replaceMessages: (messages) => (this.messages = messages),
|
||||
sendUserTurn: (client, params, display, appendUserMessage) =>
|
||||
this.sendUserTurn(client, params, display, true, appendUserMessage),
|
||||
});
|
||||
|
||||
subscribe(listener: StoreListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
@@ -137,7 +159,9 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
}
|
||||
|
||||
hasRealUserTurn(): boolean {
|
||||
return this.messages.some((message) => message.role === "user");
|
||||
return this.messages.some(
|
||||
(message) => message.role === "user" || message.structuredResponse !== null,
|
||||
);
|
||||
}
|
||||
|
||||
get activeVariant(): CustodianSessionVariant {
|
||||
@@ -171,6 +195,15 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
);
|
||||
}
|
||||
|
||||
get wizardActionReceiptsAvailable(): boolean {
|
||||
return (
|
||||
isGatewayCapabilityAdvertised(
|
||||
this.context?.gateway.snapshot ?? {},
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_WIZARD_ACTION_RECEIPTS,
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
const client = this.activeClient;
|
||||
const params = this.retryParams;
|
||||
@@ -211,6 +244,7 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
params: SystemAgentChatParams,
|
||||
displayText: string,
|
||||
questionReply: boolean,
|
||||
appendUserMessage = true,
|
||||
): Promise<eventNudgeState.CustodianSendOutcome> {
|
||||
const questionState = [this.answeredQuestions, this.questionReplyUncertain] as const;
|
||||
if (questionReply) {
|
||||
@@ -218,17 +252,20 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
}
|
||||
this.abandonedTurnOutcomeUnknown = false;
|
||||
this.answeredQuestions = retireCustodianQuestions(this.messages, this.answeredQuestions);
|
||||
this.messages = [
|
||||
...this.messages,
|
||||
{
|
||||
id: this.nextMessageId++,
|
||||
role: "user",
|
||||
text: displayText,
|
||||
at: Date.now(),
|
||||
question: null,
|
||||
step: null,
|
||||
},
|
||||
];
|
||||
if (appendUserMessage) {
|
||||
this.messages = [
|
||||
...this.messages,
|
||||
{
|
||||
id: this.nextMessageId++,
|
||||
role: "user",
|
||||
text: displayText,
|
||||
at: Date.now(),
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
this.input = "";
|
||||
this.emit();
|
||||
const reply = this.requestReply(client, params);
|
||||
@@ -310,45 +347,11 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
}
|
||||
|
||||
answerWizardStep(message: CustodianMessage, value: unknown): void {
|
||||
if (!message.step || !this.wizardInputPending) {
|
||||
return;
|
||||
}
|
||||
const submission = custodianWizardSubmission(message.step, value);
|
||||
const client = this.activeClient;
|
||||
if (!submission || !client || !this.chatAvailable || this.sending || this.setupRequired) {
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
const displayText = message.step.sensitive ? t("custodian.sensitiveReply") : submission.display;
|
||||
void this.sendUserTurn(
|
||||
client,
|
||||
{ sessionId: this.sessionId, wizardAnswer: submission.answer },
|
||||
displayText,
|
||||
true,
|
||||
);
|
||||
this.structuredInteraction.answerWizardStep(message, value);
|
||||
}
|
||||
|
||||
cancelWizardStep(message: CustodianMessage): void {
|
||||
const step = message.step;
|
||||
const client = this.activeClient;
|
||||
if (
|
||||
!step ||
|
||||
!this.wizardInputPending ||
|
||||
!client ||
|
||||
!this.chatAvailable ||
|
||||
!this.wizardCancelAvailable ||
|
||||
this.sending ||
|
||||
this.setupRequired
|
||||
) {
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
void this.sendUserTurn(
|
||||
client,
|
||||
{ sessionId: this.sessionId, wizardCancel: { stepId: step.id } },
|
||||
t("custodian.cancel"),
|
||||
true,
|
||||
);
|
||||
this.structuredInteraction.cancelWizardStep(message);
|
||||
}
|
||||
|
||||
exitSetup(): void {
|
||||
@@ -704,7 +707,10 @@ export class CustodianSessionStore extends CustodianTranscriptState {
|
||||
} else if (result.action === "exit") {
|
||||
this.exitSetup();
|
||||
}
|
||||
return "sent";
|
||||
if (!hasCustodianWizardAction(params) || !this.wizardActionReceiptsAvailable) {
|
||||
return "sent";
|
||||
}
|
||||
return result.wizardActionAccepted === true ? "accepted" : "rejected";
|
||||
} catch (error) {
|
||||
if (epoch === this.requestEpoch && client === this.activeClient) {
|
||||
this.error = custodianErrorMessage(error);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { SystemAgentChatParams } from "@openclaw/gateway-protocol";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { custodianWizardSubmission } from "./custodian-wizard-step.ts";
|
||||
import type * as eventNudgeState from "./event-nudge.ts";
|
||||
import type { CustodianMessage, CustodianStructuredResponse } from "./transcript.ts";
|
||||
|
||||
type StructuredInteractionState = {
|
||||
activeClient: GatewayBrowserClient | null;
|
||||
chatAvailable: boolean;
|
||||
messages: readonly CustodianMessage[];
|
||||
sending: boolean;
|
||||
sessionId: string;
|
||||
setupRequired: boolean;
|
||||
wizardCancelAvailable: boolean;
|
||||
wizardActionReceiptsAvailable: boolean;
|
||||
wizardInputPending: boolean;
|
||||
};
|
||||
|
||||
type StructuredInteractionHost = {
|
||||
state: () => StructuredInteractionState;
|
||||
emit: () => void;
|
||||
replaceMessages: (messages: CustodianMessage[]) => void;
|
||||
sendUserTurn: (
|
||||
client: GatewayBrowserClient,
|
||||
params: SystemAgentChatParams,
|
||||
display: string,
|
||||
appendUserMessage: boolean,
|
||||
) => Promise<eventNudgeState.CustodianSendOutcome>;
|
||||
};
|
||||
|
||||
function withResponse(
|
||||
messages: readonly CustodianMessage[],
|
||||
messageId: number,
|
||||
response: CustodianStructuredResponse | null,
|
||||
): CustodianMessage[] | null {
|
||||
const target = messages.find((message) => message.id === messageId);
|
||||
return target
|
||||
? messages.map((message) =>
|
||||
message === target ? { ...message, structuredResponse: response } : message,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function createCustodianStructuredInteraction(host: StructuredInteractionHost) {
|
||||
const submit = async (params: {
|
||||
client: GatewayBrowserClient;
|
||||
message: CustodianMessage;
|
||||
request: SystemAgentChatParams;
|
||||
display: string;
|
||||
kind: CustodianStructuredResponse["kind"];
|
||||
prompt?: string;
|
||||
}): Promise<eventNudgeState.CustodianSendOutcome> => {
|
||||
const state = host.state();
|
||||
if (
|
||||
!state.chatAvailable ||
|
||||
state.sending ||
|
||||
state.setupRequired ||
|
||||
!state.messages.includes(params.message)
|
||||
) {
|
||||
host.emit();
|
||||
return "rejected";
|
||||
}
|
||||
if (state.wizardActionReceiptsAvailable) {
|
||||
host.replaceMessages(
|
||||
withResponse(state.messages, params.message.id, {
|
||||
display: params.display,
|
||||
kind: params.kind,
|
||||
state: "submitting",
|
||||
...(params.prompt ? { prompt: params.prompt } : {}),
|
||||
}) ?? [...state.messages],
|
||||
);
|
||||
host.emit();
|
||||
}
|
||||
const outcome = await host.sendUserTurn(
|
||||
params.client,
|
||||
params.request,
|
||||
params.display,
|
||||
!state.wizardActionReceiptsAvailable,
|
||||
);
|
||||
if (!state.wizardActionReceiptsAvailable) {
|
||||
return outcome;
|
||||
}
|
||||
const current = host.state();
|
||||
const response: CustodianStructuredResponse | null =
|
||||
outcome !== "accepted"
|
||||
? null
|
||||
: {
|
||||
display: params.display,
|
||||
kind: params.kind,
|
||||
state: "submitted",
|
||||
...(params.prompt ? { prompt: params.prompt } : {}),
|
||||
};
|
||||
const messages = withResponse(current.messages, params.message.id, response);
|
||||
if (messages) {
|
||||
host.replaceMessages(messages);
|
||||
host.emit();
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
return {
|
||||
answerWizardStep(message: CustodianMessage, value: unknown): void {
|
||||
const state = host.state();
|
||||
const submission = message.step ? custodianWizardSubmission(message.step, value) : null;
|
||||
if (!submission || !state.activeClient || !state.wizardInputPending) {
|
||||
host.emit();
|
||||
return;
|
||||
}
|
||||
void submit({
|
||||
client: state.activeClient,
|
||||
message,
|
||||
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,
|
||||
});
|
||||
},
|
||||
|
||||
cancelWizardStep(message: CustodianMessage): void {
|
||||
const state = host.state();
|
||||
const step = message.step;
|
||||
const activeWizardMessage = state.messages.findLast((candidate) => candidate.step !== null);
|
||||
if (
|
||||
!step ||
|
||||
message !== activeWizardMessage ||
|
||||
!state.wizardInputPending ||
|
||||
!state.wizardCancelAvailable ||
|
||||
!state.activeClient
|
||||
) {
|
||||
host.emit();
|
||||
return;
|
||||
}
|
||||
void submit({
|
||||
client: state.activeClient,
|
||||
message,
|
||||
request: { sessionId: state.sessionId, wizardCancel: { stepId: step.id } },
|
||||
display: t("custodian.cancel"),
|
||||
kind: "cancel",
|
||||
prompt: step.title ?? step.message,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -120,6 +120,7 @@ export abstract class CustodianTranscriptState {
|
||||
at: Date.now(),
|
||||
question,
|
||||
step,
|
||||
structuredResponse: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export type CustodianEventNudge = {
|
||||
};
|
||||
|
||||
export type CustodianSendDelivery = "unsent" | "sent" | "received";
|
||||
export type CustodianSendOutcome = "sent" | "rejected" | "unknown";
|
||||
export type CustodianSendOutcome = "accepted" | "sent" | "rejected" | "unknown";
|
||||
|
||||
export function classifyCustodianSendFailure(
|
||||
error: unknown,
|
||||
@@ -27,7 +27,7 @@ export function classifyCustodianSendFailure(
|
||||
}
|
||||
|
||||
export function questionUncertainty(previous: boolean, outcome: CustodianSendOutcome): boolean {
|
||||
if (outcome === "sent") {
|
||||
if (outcome === "accepted" || outcome === "sent") {
|
||||
return false;
|
||||
}
|
||||
return outcome === "unknown" ? true : previous;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
import { html, nothing } from "lit";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { WizardStep } from "../../api/types.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { renderWizardStepControls } from "../../components/wizard-step-controls.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { MessageGroup } from "../../lib/chat/chat-types.ts";
|
||||
@@ -22,6 +23,14 @@ export type CustodianMessage = {
|
||||
at: number;
|
||||
question: CustodianStructuredQuestion | null;
|
||||
step: WizardStep | null;
|
||||
structuredResponse: CustodianStructuredResponse | null;
|
||||
};
|
||||
|
||||
export type CustodianStructuredResponse = {
|
||||
display: string;
|
||||
kind: "answer" | "cancel";
|
||||
prompt?: string;
|
||||
state: "submitting" | "submitted";
|
||||
};
|
||||
|
||||
export function hasUnresolvedCustodianQuestion(
|
||||
@@ -99,7 +108,7 @@ async function readCustodianTranscript(
|
||||
|
||||
/**
|
||||
* Sensitive turns are masked server-side before persistence: the engine pushes
|
||||
* only "<redacted secret>" into history (never raw input), so durable turns
|
||||
* only "<redacted secret>" into history (never raw input), so returned turns
|
||||
* cannot carry credentials. This mapping only localizes that marker to the
|
||||
* same display text live sensitive replies use.
|
||||
*/
|
||||
@@ -108,19 +117,65 @@ const SERVER_SENSITIVE_MASK = "<redacted secret>";
|
||||
function createCustodianTranscriptMessages(
|
||||
turns: readonly SystemAgentChatHistoryTurn[],
|
||||
firstMessageId: number,
|
||||
activeWizard?: SystemAgentChatHistoryResult["activeWizard"],
|
||||
): { messages: CustodianMessage[]; nextMessageId: number } {
|
||||
let nextMessageId = firstMessageId;
|
||||
const messages = turns.map((turn) => ({
|
||||
id: nextMessageId++,
|
||||
role: turn.role,
|
||||
text:
|
||||
const messages: CustodianMessage[] = [];
|
||||
for (const turn of turns) {
|
||||
const display =
|
||||
turn.role === "user" && turn.text === SERVER_SENSITIVE_MASK
|
||||
? t("custodian.sensitiveReply")
|
||||
: turn.text,
|
||||
at: turn.at,
|
||||
question: null,
|
||||
step: null,
|
||||
}));
|
||||
: 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,
|
||||
at: turn.at,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: {
|
||||
display,
|
||||
kind: turn.wizardAction.kind,
|
||||
state: "submitted",
|
||||
...(turn.wizardAction.prompt ? { prompt: turn.wizardAction.prompt } : {}),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
messages.push({
|
||||
id: nextMessageId++,
|
||||
role: turn.role,
|
||||
text: display,
|
||||
at: turn.at,
|
||||
question: null,
|
||||
step: null,
|
||||
structuredResponse: null,
|
||||
});
|
||||
}
|
||||
if (activeWizard) {
|
||||
const activePrompt = messages.findLast(
|
||||
(message) => message.role === "assistant" && message.structuredResponse === null,
|
||||
);
|
||||
if (activePrompt) {
|
||||
activePrompt.step = activeWizard.step;
|
||||
} else {
|
||||
messages.push({
|
||||
id: nextMessageId++,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
at: Date.now(),
|
||||
question: null,
|
||||
step: activeWizard.step,
|
||||
structuredResponse: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { messages, nextMessageId };
|
||||
}
|
||||
|
||||
@@ -137,21 +192,15 @@ export async function loadCustodianTranscriptSnapshot(
|
||||
sessionId?: string,
|
||||
): Promise<CustodianTranscriptSnapshot> {
|
||||
const history = await readCustodianTranscript(client, sessionId);
|
||||
const transcript = createCustodianTranscriptMessages(history.turns, firstMessageId);
|
||||
const transcript = createCustodianTranscriptMessages(
|
||||
history.turns,
|
||||
firstMessageId,
|
||||
sessionId && history.activeWizard?.sessionId === sessionId ? history.activeWizard : undefined,
|
||||
);
|
||||
const earlierBoundaryAfterId = transcript.messages.at(-1)?.id ?? null;
|
||||
const activeWizard = history.activeWizard;
|
||||
const recoveredStep =
|
||||
sessionId && activeWizard?.sessionId === sessionId ? activeWizard.step : null;
|
||||
if (recoveredStep) {
|
||||
transcript.messages.push({
|
||||
id: transcript.nextMessageId++,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
at: Date.now(),
|
||||
question: null,
|
||||
step: recoveredStep,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...transcript,
|
||||
earlierBoundaryAfterId,
|
||||
@@ -170,6 +219,49 @@ function renderCustodianEarlierDivider(message: CustodianMessage, boundaryAfterI
|
||||
: nothing;
|
||||
}
|
||||
|
||||
function structuredPrompt(message: CustodianMessage): string {
|
||||
return (
|
||||
message.structuredResponse?.prompt ??
|
||||
message.step?.title ??
|
||||
message.step?.message ??
|
||||
message.question?.question ??
|
||||
t("custodian.structured.response")
|
||||
);
|
||||
}
|
||||
|
||||
function renderStructuredResponse(message: CustodianMessage) {
|
||||
const response = message.structuredResponse;
|
||||
if (!response) {
|
||||
return nothing;
|
||||
}
|
||||
const cancelled = response.kind === "cancel";
|
||||
const status = cancelled
|
||||
? response.state === "submitting"
|
||||
? t("custodian.structured.cancelling")
|
||||
: t("custodian.structured.cancelled")
|
||||
: response.state === "submitting"
|
||||
? t("custodian.structured.submitting")
|
||||
: t("custodian.structured.submitted");
|
||||
return html`<section
|
||||
class="custodian__structured-response"
|
||||
aria-label=${t("custodian.structured.response")}
|
||||
aria-busy=${response.state === "submitting" ? "true" : "false"}
|
||||
>
|
||||
<span
|
||||
class="custodian__structured-response-icon ${cancelled
|
||||
? "custodian__structured-response-icon--cancelled"
|
||||
: ""}"
|
||||
aria-hidden="true"
|
||||
>${cancelled ? icons.stop : icons.check}</span
|
||||
>
|
||||
<span class="custodian__structured-response-copy">
|
||||
<span class="custodian__structured-response-prompt">${structuredPrompt(message)}</span>
|
||||
<strong>${response.display}</strong>
|
||||
<span class="custodian__structured-response-status">${status}</span>
|
||||
</span>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function renderCustodianTranscriptEntry(params: {
|
||||
message: CustodianMessage;
|
||||
boundaryAfterId: number | null;
|
||||
@@ -190,8 +282,11 @@ export function renderCustodianTranscriptEntry(params: {
|
||||
}) {
|
||||
const question = params.message.question;
|
||||
const step = params.message.step;
|
||||
const hasStructuredResponse = params.message.structuredResponse !== null;
|
||||
const hasActiveWizardStep = params.showWizardStep && step !== null;
|
||||
const showTranscriptMessage = params.message.text;
|
||||
return html`
|
||||
${params.message.text
|
||||
${showTranscriptMessage
|
||||
? renderMessageGroup(toCustodianMessageGroup(params.message), {
|
||||
showReasoning: false,
|
||||
showToolCalls: false,
|
||||
@@ -200,15 +295,17 @@ export function renderCustodianTranscriptEntry(params: {
|
||||
})
|
||||
: nothing}
|
||||
${renderCustodianEarlierDivider(params.message, params.boundaryAfterId)}
|
||||
${params.showQuestion && question
|
||||
? renderCustodianQuestionCard({
|
||||
question,
|
||||
disabled: params.questionDisabled,
|
||||
onSelect: params.onSelect,
|
||||
onSkip: params.onSkip,
|
||||
})
|
||||
: nothing}
|
||||
${params.showWizardStep && step
|
||||
${hasStructuredResponse
|
||||
? renderStructuredResponse(params.message)
|
||||
: params.showQuestion && question
|
||||
? renderCustodianQuestionCard({
|
||||
question,
|
||||
disabled: params.questionDisabled,
|
||||
onSelect: params.onSelect,
|
||||
onSkip: params.onSkip,
|
||||
})
|
||||
: nothing}
|
||||
${hasActiveWizardStep && !hasStructuredResponse && step
|
||||
? html`<section
|
||||
class="custodian__wizard-step"
|
||||
aria-label=${step.title ?? step.message ?? "Setup"}
|
||||
|
||||
@@ -275,6 +275,63 @@ openclaw-custodian-page {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.custodian__structured-response {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 16px 14px 46px;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.custodian__structured-response-icon {
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.custodian__structured-response-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.custodian__structured-response-icon--cancelled {
|
||||
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.custodian__structured-response-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.custodian__structured-response-copy strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.custodian__structured-response-prompt {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custodian__structured-response-status {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.custodian__wizard-title {
|
||||
color: var(--text-strong);
|
||||
font-size: 14px;
|
||||
@@ -518,7 +575,8 @@ openclaw-custodian-page {
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.custodian__option-card,
|
||||
.custodian__wizard-step {
|
||||
.custodian__wizard-step,
|
||||
.custodian__structured-response {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user