mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(gateway): recover Custodian wizard sessions after reload
This commit is contained in:
@@ -10923,15 +10923,19 @@ public struct SystemAgentChatResult: Codable, Sendable {
|
||||
|
||||
public struct SystemAgentChatHistoryParams: Codable, Sendable {
|
||||
public let limit: Int?
|
||||
public let sessionid: String?
|
||||
|
||||
public init(
|
||||
limit: Int? = nil)
|
||||
limit: Int? = nil,
|
||||
sessionid: String? = nil)
|
||||
{
|
||||
self.limit = limit
|
||||
self.sessionid = sessionid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case limit
|
||||
case sessionid = "sessionId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10959,15 +10963,19 @@ public struct SystemAgentChatHistoryTurn: Codable, Sendable {
|
||||
|
||||
public struct SystemAgentChatHistoryResult: Codable, Sendable {
|
||||
public let turns: [SystemAgentChatHistoryTurn]
|
||||
public let activewizard: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
turns: [SystemAgentChatHistoryTurn])
|
||||
turns: [SystemAgentChatHistoryTurn],
|
||||
activewizard: [String: AnyCodable]? = nil)
|
||||
{
|
||||
self.turns = turns
|
||||
self.activewizard = activewizard
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case turns
|
||||
case activewizard = "activeWizard"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ version and the additive schema surface. Dates are authoring dates (2026).
|
||||
- Slim worker and session-catalog payloads to the active wire contract.
|
||||
- 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.
|
||||
|
||||
## 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_CHAT_HISTORY_SESSION_RECOVERY: "openclaw-chat-history-session-recovery",
|
||||
SYSTEM_AGENT_SETUP_MODEL_REF: "openclaw-setup-model-ref",
|
||||
TASK_SUGGESTIONS_ACCEPT_MODES: "taskSuggestions.acceptModes",
|
||||
} as const;
|
||||
|
||||
@@ -91,6 +91,8 @@ describe("OpenClaw chat question protocol", () => {
|
||||
describe("OpenClaw chat history protocol", () => {
|
||||
it("accepts the default request and bounds explicit limits", () => {
|
||||
expect(validateSystemAgentChatHistoryParams({})).toBe(true);
|
||||
expect(validateSystemAgentChatHistoryParams({ sessionId: "recover-session" })).toBe(true);
|
||||
expect(validateSystemAgentChatHistoryParams({ sessionId: "" })).toBe(false);
|
||||
expect(validateSystemAgentChatHistoryParams({ limit: 1 })).toBe(true);
|
||||
expect(validateSystemAgentChatHistoryParams({ limit: 500 })).toBe(true);
|
||||
expect(validateSystemAgentChatHistoryParams({ limit: 0 })).toBe(false);
|
||||
@@ -112,6 +114,23 @@ describe("OpenClaw chat history protocol", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an optional sanitized active wizard snapshot", () => {
|
||||
expect(
|
||||
Value.Check(SystemAgentChatHistoryResultSchema, {
|
||||
turns: [],
|
||||
activeWizard: {
|
||||
sessionId: "recover-session",
|
||||
step: {
|
||||
id: "secret",
|
||||
type: "text",
|
||||
message: "Twitch client secret",
|
||||
sensitive: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenClaw setup detection protocol", () => {
|
||||
|
||||
@@ -109,6 +109,8 @@ export const SystemAgentChatResultSchema = closedObject({
|
||||
|
||||
export const SystemAgentChatHistoryParamsSchema = closedObject({
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 500, default: 100 })),
|
||||
/** Exact volatile chat session to inspect for a reload-recoverable active wizard. */
|
||||
sessionId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
export const SystemAgentChatHistoryTurnSchema = closedObject({
|
||||
@@ -119,6 +121,12 @@ export const SystemAgentChatHistoryTurnSchema = closedObject({
|
||||
|
||||
export const SystemAgentChatHistoryResultSchema = closedObject({
|
||||
turns: Type.Array(SystemAgentChatHistoryTurnSchema),
|
||||
activeWizard: Type.Optional(
|
||||
closedObject({
|
||||
sessionId: NonEmptyString,
|
||||
step: WizardStepSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const SystemChangeKindSchema = Type.Union([
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import {
|
||||
appendSystemAgentRecoveryHistory,
|
||||
setSystemAgentRecoveryHistory,
|
||||
systemAgentChatHistoryHandler,
|
||||
} from "./system-agent-chat-history.js";
|
||||
import { runSystemAgentGatewayTask } from "./system-agent-gateway-queue.js";
|
||||
import { getSystemAgentSessionQueue } from "./system-agent-session-queue.js";
|
||||
import type { GatewayClient } from "./types.js";
|
||||
|
||||
const turns = [
|
||||
{ role: "user" as const, text: "one", at: 1 },
|
||||
{ role: "assistant" as const, text: "two", at: 2 },
|
||||
];
|
||||
|
||||
const transcriptStoreMocks = vi.hoisted(() => ({
|
||||
readTranscriptTail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../system-agent/transcript-store.js", () => ({
|
||||
readTranscriptTail: transcriptStoreMocks.readTranscriptTail,
|
||||
}));
|
||||
|
||||
const ownerClient = {
|
||||
connId: "conn-owner",
|
||||
connect: { device: { id: "device-owner" } },
|
||||
} as GatewayClient;
|
||||
|
||||
function makeInvocation(params: {
|
||||
sessionId?: string;
|
||||
limit?: number;
|
||||
client?: GatewayClient;
|
||||
activeWizardStep?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
const activeWizardStep = params.activeWizardStep ?? vi.fn().mockResolvedValue(undefined);
|
||||
const session = {
|
||||
ownerKey: "device:device-owner",
|
||||
engine: {
|
||||
activeWizardStep,
|
||||
},
|
||||
lastUsedAt: 1,
|
||||
};
|
||||
setSystemAgentRecoveryHistory(session.engine, turns);
|
||||
const context = {
|
||||
systemAgentSessions: new Map(params.sessionId ? [[params.sessionId, session]] : []),
|
||||
};
|
||||
const options = {
|
||||
params: {
|
||||
...(params.sessionId ? { sessionId: params.sessionId } : {}),
|
||||
...(params.limit ? { limit: params.limit } : {}),
|
||||
},
|
||||
client: params.client ?? ownerClient,
|
||||
context,
|
||||
respond: (ok: boolean, payload?: unknown, error?: unknown) => {
|
||||
calls.push({ ok, payload, error });
|
||||
},
|
||||
} as never;
|
||||
return { activeWizardStep, calls, context, options, session };
|
||||
}
|
||||
|
||||
describe("openclaw.chat.history wizard recovery", () => {
|
||||
beforeEach(() => {
|
||||
transcriptStoreMocks.readTranscriptTail.mockReset().mockReturnValue(turns);
|
||||
});
|
||||
|
||||
it("returns an active wizard only to its bound owner", async () => {
|
||||
const activeWizardStep = vi.fn().mockResolvedValue({
|
||||
id: "secret",
|
||||
type: "text",
|
||||
message: "Bot token",
|
||||
sensitive: true,
|
||||
});
|
||||
const owner = makeInvocation({ sessionId: "recover-session", activeWizardStep });
|
||||
|
||||
await systemAgentChatHistoryHandler(owner.options);
|
||||
|
||||
expect(owner.calls).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: {
|
||||
turns,
|
||||
activeWizard: {
|
||||
sessionId: "recover-session",
|
||||
step: {
|
||||
id: "secret",
|
||||
type: "text",
|
||||
message: "Bot token",
|
||||
sensitive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
expect(activeWizardStep).toHaveBeenCalledOnce();
|
||||
expect(owner.session.lastUsedAt).toBeGreaterThan(1);
|
||||
const foreign = makeInvocation({
|
||||
sessionId: "recover-session",
|
||||
client: {
|
||||
connId: "conn-foreign",
|
||||
connect: { device: { id: "device-foreign" } },
|
||||
} as GatewayClient,
|
||||
activeWizardStep,
|
||||
});
|
||||
|
||||
await systemAgentChatHistoryHandler(foreign.options);
|
||||
|
||||
expect(foreign.calls).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: { turns },
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
expect(activeWizardStep).toHaveBeenCalledOnce();
|
||||
expect(foreign.session.lastUsedAt).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to the global audit history after a Gateway reload", async () => {
|
||||
const invocation = makeInvocation({ sessionId: "recover-session" });
|
||||
invocation.context.systemAgentSessions.clear();
|
||||
|
||||
await systemAgentChatHistoryHandler(invocation.options);
|
||||
|
||||
expect(transcriptStoreMocks.readTranscriptTail).toHaveBeenCalledWith(100);
|
||||
expect(invocation.activeWizardStep).not.toHaveBeenCalled();
|
||||
expect(invocation.calls).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: { turns },
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds live recovery turns to the history protocol maximum", async () => {
|
||||
const invocation = makeInvocation({ sessionId: "recover-session", limit: 500 });
|
||||
setSystemAgentRecoveryHistory(
|
||||
invocation.session.engine,
|
||||
Array.from({ length: 500 }, (_, index) => ({
|
||||
role: "assistant" as const,
|
||||
text: `old-${index}`,
|
||||
at: index,
|
||||
})),
|
||||
);
|
||||
appendSystemAgentRecoveryHistory(invocation.session.engine, [
|
||||
{ role: "user", text: "new question", at: 500 },
|
||||
{ role: "assistant", text: "new reply", at: 501 },
|
||||
]);
|
||||
|
||||
await systemAgentChatHistoryHandler(invocation.options);
|
||||
|
||||
const recovered = (
|
||||
invocation.calls[0]?.payload as { turns?: Array<{ text: string }> } | undefined
|
||||
)?.turns;
|
||||
expect(recovered).toHaveLength(500);
|
||||
expect(recovered?.[0]?.text).toBe("old-2");
|
||||
expect(recovered?.slice(-2).map((turn) => turn.text)).toEqual(["new question", "new reply"]);
|
||||
});
|
||||
|
||||
it("waits for the session queue before reading the recovery transcript", async () => {
|
||||
const turnStarted = createDeferred();
|
||||
const releaseTurn = createDeferred();
|
||||
const invocation = makeInvocation({ sessionId: "recover-session" });
|
||||
const turn = getSystemAgentSessionQueue(invocation.context.systemAgentSessions).enqueue(
|
||||
"recover-session",
|
||||
async () => {
|
||||
turnStarted.resolve();
|
||||
await releaseTurn.promise;
|
||||
setSystemAgentRecoveryHistory(invocation.session.engine, [
|
||||
{ role: "user", text: "committed question", at: 2 },
|
||||
{ role: "assistant", text: "committed reply", at: 3 },
|
||||
]);
|
||||
},
|
||||
);
|
||||
await turnStarted.promise;
|
||||
|
||||
const history = systemAgentChatHistoryHandler(invocation.options);
|
||||
await Promise.resolve();
|
||||
const callsBeforeRelease = [...invocation.calls];
|
||||
releaseTurn.resolve();
|
||||
await Promise.all([turn, history]);
|
||||
|
||||
expect(callsBeforeRelease).toEqual([]);
|
||||
expect(invocation.calls).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: {
|
||||
turns: [
|
||||
{ role: "user", text: "committed question", at: 2 },
|
||||
{ role: "assistant", text: "committed reply", at: 3 },
|
||||
],
|
||||
},
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("waits for the global Gateway queue before recovering a session", async () => {
|
||||
const taskStarted = createDeferred();
|
||||
const releaseTask = createDeferred();
|
||||
const invocation = makeInvocation({ sessionId: "recover-session" });
|
||||
const globalTask = runSystemAgentGatewayTask(async () => {
|
||||
taskStarted.resolve();
|
||||
await releaseTask.promise;
|
||||
});
|
||||
await taskStarted.promise;
|
||||
|
||||
const history = systemAgentChatHistoryHandler(invocation.options);
|
||||
await Promise.resolve();
|
||||
expect(invocation.calls).toEqual([]);
|
||||
|
||||
releaseTask.resolve();
|
||||
await Promise.all([globalTask, history]);
|
||||
|
||||
expect(invocation.calls).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: { turns },
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not read a predecessor replaced under the same session id", async () => {
|
||||
const taskStarted = createDeferred();
|
||||
const releaseTask = createDeferred();
|
||||
const invocation = makeInvocation({ sessionId: "recover-session" });
|
||||
const globalTask = runSystemAgentGatewayTask(async () => {
|
||||
taskStarted.resolve();
|
||||
await releaseTask.promise;
|
||||
});
|
||||
await taskStarted.promise;
|
||||
|
||||
const history = systemAgentChatHistoryHandler(invocation.options);
|
||||
const replacementActiveWizardStep = vi.fn().mockResolvedValue(undefined);
|
||||
invocation.context.systemAgentSessions.set("recover-session", {
|
||||
ownerKey: invocation.session.ownerKey,
|
||||
engine: { activeWizardStep: replacementActiveWizardStep },
|
||||
lastUsedAt: 1,
|
||||
});
|
||||
releaseTask.resolve();
|
||||
await Promise.all([globalTask, history]);
|
||||
|
||||
expect(invocation.activeWizardStep).not.toHaveBeenCalled();
|
||||
expect(replacementActiveWizardStep).not.toHaveBeenCalled();
|
||||
expect(invocation.calls).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: { turns },
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
validateSystemAgentChatHistoryParams,
|
||||
type SystemAgentChatHistoryTurn,
|
||||
} 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 { runSystemAgentGatewayTask } from "./system-agent-gateway-queue.js";
|
||||
import { getSystemAgentSessionQueue } from "./system-agent-session-queue.js";
|
||||
import type { GatewayClient, GatewayRequestHandler } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
const DEFAULT_SYSTEM_AGENT_HISTORY_LIMIT = 100;
|
||||
const MAX_SYSTEM_AGENT_RECOVERY_TURNS = 500;
|
||||
const recoveryTurnsByEngine = new WeakMap<object, SystemAgentChatHistoryTurn[]>();
|
||||
|
||||
export function setSystemAgentRecoveryHistory(
|
||||
engine: object,
|
||||
turns: readonly SystemAgentChatHistoryTurn[],
|
||||
): void {
|
||||
recoveryTurnsByEngine.set(engine, turns.slice(-MAX_SYSTEM_AGENT_RECOVERY_TURNS));
|
||||
}
|
||||
|
||||
export function appendSystemAgentRecoveryHistory(
|
||||
engine: object,
|
||||
turns: readonly SystemAgentChatHistoryTurn[],
|
||||
): void {
|
||||
const recoveryTurns = [...(recoveryTurnsByEngine.get(engine) ?? []), ...turns];
|
||||
recoveryTurnsByEngine.set(engine, recoveryTurns.slice(-MAX_SYSTEM_AGENT_RECOVERY_TURNS));
|
||||
}
|
||||
|
||||
function readSystemAgentRecoveryHistory(
|
||||
engine: object,
|
||||
limit = DEFAULT_SYSTEM_AGENT_HISTORY_LIMIT,
|
||||
): SystemAgentChatHistoryTurn[] {
|
||||
return (recoveryTurnsByEngine.get(engine) ?? []).slice(-limit);
|
||||
}
|
||||
|
||||
export function resolveSystemAgentSessionOwnerKey(params: {
|
||||
delegation?: { agentId?: string; sessionKey?: string };
|
||||
client: GatewayClient | null;
|
||||
}): string | undefined {
|
||||
const delegationKey = resolveSystemAgentDelegationKey(params.delegation);
|
||||
if (delegationKey !== undefined) {
|
||||
// Delegation is the host-only, cross-connection owner asserted by the regular-agent
|
||||
// tool path. Keep its agent/session tuple authoritative across gateway reconnects.
|
||||
return delegationKey;
|
||||
}
|
||||
// Authenticated users survive reconnects and may span paired devices. Otherwise
|
||||
// bind to the verified device, with the server-issued connection as a last resort.
|
||||
const userId = params.client?.authenticatedUserId?.trim();
|
||||
if (userId) {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
const deviceId = params.client?.connect.device?.id.trim();
|
||||
if (deviceId) {
|
||||
return `device:${deviceId}`;
|
||||
}
|
||||
const connId = params.client?.connId?.trim();
|
||||
return connId ? `connection:${connId}` : undefined;
|
||||
}
|
||||
|
||||
export const systemAgentChatHistoryHandler: GatewayRequestHandler = async ({
|
||||
params,
|
||||
respond,
|
||||
client,
|
||||
context,
|
||||
}) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateSystemAgentChatHistoryParams,
|
||||
"openclaw.chat.history",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestedSessionId = params.sessionId;
|
||||
const session = requestedSessionId
|
||||
? context.systemAgentSessions.get(requestedSessionId)
|
||||
: undefined;
|
||||
const ownerKey = resolveSystemAgentSessionOwnerKey({ client });
|
||||
const recovery =
|
||||
requestedSessionId && session && ownerKey === session.ownerKey
|
||||
? await runSystemAgentGatewayTask(
|
||||
async () =>
|
||||
await getSystemAgentSessionQueue(context.systemAgentSessions).enqueue(
|
||||
requestedSessionId,
|
||||
async () => {
|
||||
if (context.systemAgentSessions.get(requestedSessionId) !== session) {
|
||||
return undefined;
|
||||
}
|
||||
session.lastUsedAt = Date.now();
|
||||
const engine = session.engine as typeof session.engine &
|
||||
Pick<SystemAgentChatEngine, "activeWizardStep">;
|
||||
return {
|
||||
turns: readSystemAgentRecoveryHistory(
|
||||
engine,
|
||||
params.limit ?? DEFAULT_SYSTEM_AGENT_HISTORY_LIMIT,
|
||||
),
|
||||
step: await engine.activeWizardStep(),
|
||||
};
|
||||
},
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const turns =
|
||||
recovery?.turns ?? readTranscriptTail(params.limit ?? DEFAULT_SYSTEM_AGENT_HISTORY_LIMIT);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
turns,
|
||||
...(requestedSessionId && recovery?.step
|
||||
? { activeWizard: { sessionId: requestedSessionId, step: recovery.step } }
|
||||
: {}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
import { enqueueCommandInLane, setCommandLaneConcurrency } from "../../process/command-queue.js";
|
||||
import { CommandLane } from "../../process/lanes.js";
|
||||
|
||||
const SYSTEM_AGENT_GATEWAY_EXECUTION_KEY = "gateway";
|
||||
const systemAgentGatewayExecutionQueue = new KeyedAsyncQueue();
|
||||
|
||||
export async function runSystemAgentGatewayTask<T>(task: () => Promise<T>): Promise<T> {
|
||||
// Track every accepted RPC as active, never queued: restart draining snapshots
|
||||
// active ids, so a queued OpenClaw request could otherwise outlive its socket.
|
||||
setCommandLaneConcurrency(CommandLane.SystemAgent, Number.MAX_SAFE_INTEGER);
|
||||
return await enqueueCommandInLane(CommandLane.SystemAgent, () =>
|
||||
// Bound expensive detection, activation, and agent turns without hiding
|
||||
// accepted work from restart draining. This also makes session eviction and
|
||||
// setup writes atomic with respect to other OpenClaw gateway requests.
|
||||
systemAgentGatewayExecutionQueue.enqueue(SYSTEM_AGENT_GATEWAY_EXECUTION_KEY, task),
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,13 @@ import { closeOpenClawStateDatabase } from "../../state/openclaw-state-db.js";
|
||||
import { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
|
||||
import { SystemAgentInferenceUnavailableError } from "../../system-agent/inference-error.js";
|
||||
import { createSystemAgentVerifiedInferenceTestFixture } from "../../system-agent/system-agent.test-helpers.js";
|
||||
import { appendTranscriptTurn, readTranscriptTail } from "../../system-agent/transcript-store.js";
|
||||
import {
|
||||
appendTranscriptReset,
|
||||
appendTranscriptTurn,
|
||||
readTranscriptTail,
|
||||
} from "../../system-agent/transcript-store.js";
|
||||
import { withTestDir } from "../../test-helpers/temp-dir.js";
|
||||
import { setSystemAgentRecoveryHistory } from "./system-agent-chat-history.js";
|
||||
import { systemAgentHandlers, type SystemAgentChatSession } from "./system-agent.js";
|
||||
import type { GatewayClient, GatewayRequestContext } from "./types.js";
|
||||
|
||||
@@ -133,6 +138,139 @@ async function withTranscriptState(prefix: string, run: () => Promise<void>): Pr
|
||||
}
|
||||
|
||||
describe("openclaw.chat reset boundary", () => {
|
||||
it("recovers the live session transcript without global pre-reset turns", async () => {
|
||||
await withTranscriptState("openclaw-session-recovery-boundary-", async () => {
|
||||
appendTranscriptTurn({ role: "user", text: "discarded setup request", at: 1 });
|
||||
appendTranscriptTurn({ role: "assistant", text: "discarded setup reply", at: 2 });
|
||||
appendTranscriptReset();
|
||||
const fixture = await createSystemAgentVerifiedInferenceTestFixture(verifiedConfig);
|
||||
const engine = new SystemAgentChatEngine(
|
||||
{
|
||||
surface: "gateway",
|
||||
verifiedInference: fixture.binding,
|
||||
deps: {
|
||||
...fixture.deps,
|
||||
readConfigFileSnapshot: async () =>
|
||||
({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
hash: "verified-config",
|
||||
config: verifiedConfig,
|
||||
runtimeConfig: verifiedConfig,
|
||||
sourceConfig: verifiedConfig,
|
||||
issues: [],
|
||||
}) as never,
|
||||
},
|
||||
},
|
||||
{
|
||||
wizardDependencies: {
|
||||
runChannelSetupWizard: async (_channel, prompter) => {
|
||||
await prompter.text({ message: "Bot token" });
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const sessions = new Map<string, SystemAgentChatSession>([
|
||||
[
|
||||
"recover-session",
|
||||
{
|
||||
engine,
|
||||
welcome: "welcome text",
|
||||
lastUsedAt: 1,
|
||||
ownerKey: "device:device-test",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const context = { systemAgentSessions: sessions } as unknown as GatewayRequestContext;
|
||||
const chatResponses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
|
||||
await expectDefined(
|
||||
systemAgentHandlers["openclaw.chat"],
|
||||
'systemAgentHandlers["openclaw.chat"] test invariant',
|
||||
)({
|
||||
params: { sessionId: "recover-session", message: "connect telegram" },
|
||||
client,
|
||||
context,
|
||||
respond: (ok: boolean, payload?: unknown, error?: unknown) =>
|
||||
chatResponses.push({ ok, payload, error }),
|
||||
} as never);
|
||||
|
||||
expect(chatResponses).toEqual([
|
||||
{
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
wizardInputPending: true,
|
||||
step: expect.objectContaining({ message: "Bot token" }),
|
||||
}),
|
||||
error: undefined,
|
||||
},
|
||||
]);
|
||||
const historyResponses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
|
||||
await expectDefined(
|
||||
systemAgentHandlers["openclaw.chat.history"],
|
||||
'systemAgentHandlers["openclaw.chat.history"] test invariant',
|
||||
)({
|
||||
params: { sessionId: "recover-session" },
|
||||
client,
|
||||
context,
|
||||
respond: (ok: boolean, payload?: unknown, error?: unknown) =>
|
||||
historyResponses.push({ ok, payload, error }),
|
||||
} as never);
|
||||
|
||||
expect(historyResponses[0]).toMatchObject({
|
||||
ok: true,
|
||||
payload: {
|
||||
turns: [
|
||||
{ role: "user", text: "connect telegram" },
|
||||
{ role: "assistant", text: expect.any(String) },
|
||||
],
|
||||
activeWizard: expect.objectContaining({ sessionId: "recover-session" }),
|
||||
},
|
||||
});
|
||||
expect(historyResponses[0]).not.toHaveProperty("payload.turns.0.sessionId");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps another live session's recovery turns when one session resets", async () => {
|
||||
await withTranscriptState("openclaw-session-reset-isolation-", async () => {
|
||||
const recoveryTurns = [
|
||||
{ role: "user" as const, text: "other live question", at: 1 },
|
||||
{ role: "assistant" as const, text: "other live answer", at: 2 },
|
||||
];
|
||||
const sessions = discardableSessions(async () => undefined);
|
||||
const engine = {
|
||||
activeWizardStep: vi.fn(async () => undefined),
|
||||
};
|
||||
sessions.set("s2", {
|
||||
engine,
|
||||
welcome: "welcome text",
|
||||
lastUsedAt: 2,
|
||||
ownerKey: "device:device-test",
|
||||
} as unknown as SystemAgentChatSession);
|
||||
setSystemAgentRecoveryHistory(engine, recoveryTurns);
|
||||
inferenceFallbackMocks.verifySystemAgentInferenceWithFallback.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: "unavailable",
|
||||
error: "no configured model",
|
||||
});
|
||||
|
||||
await resetSession({ sessions });
|
||||
const responses: Array<{ ok: boolean; payload?: unknown }> = [];
|
||||
await expectDefined(
|
||||
systemAgentHandlers["openclaw.chat.history"],
|
||||
'systemAgentHandlers["openclaw.chat.history"] test invariant',
|
||||
)({
|
||||
params: { sessionId: "s2" },
|
||||
client,
|
||||
context: { systemAgentSessions: sessions } as unknown as GatewayRequestContext,
|
||||
respond: (ok: boolean, payload?: unknown) => responses.push({ ok, payload }),
|
||||
} as never);
|
||||
|
||||
expect(responses).toEqual([{ ok: true, payload: { turns: recoveryTurns } }]);
|
||||
});
|
||||
});
|
||||
|
||||
// The reset discards the live session before initialization runs, so the
|
||||
// durable boundary has to survive a failed replacement. Otherwise the next
|
||||
// ordinary session seeds from the pre-reset transcript and undoes the reset.
|
||||
|
||||
@@ -36,6 +36,7 @@ vi.mock("../../system-agent/greeting.js", () => ({
|
||||
}));
|
||||
|
||||
type FakeEngine = {
|
||||
activeWizardStep: ReturnType<typeof vi.fn>;
|
||||
answerWizard: ReturnType<typeof vi.fn>;
|
||||
cancelWizard: ReturnType<typeof vi.fn>;
|
||||
handle: ReturnType<typeof vi.fn>;
|
||||
@@ -51,6 +52,7 @@ type FakeEngine = {
|
||||
|
||||
function makeEngine(): FakeEngine {
|
||||
return {
|
||||
activeWizardStep: vi.fn(async () => undefined),
|
||||
answerWizard: vi.fn(async () => {
|
||||
throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting an answer.");
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
|
||||
type SystemAgentSessionCollection = ReadonlyMap<string, unknown>;
|
||||
|
||||
const systemAgentSessionQueues = new WeakMap<SystemAgentSessionCollection, KeyedAsyncQueue>();
|
||||
|
||||
export function getSystemAgentSessionQueue(
|
||||
sessions: SystemAgentSessionCollection,
|
||||
): KeyedAsyncQueue {
|
||||
let queue = systemAgentSessionQueues.get(sessions);
|
||||
if (!queue) {
|
||||
queue = new KeyedAsyncQueue();
|
||||
systemAgentSessionQueues.set(sessions, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
validateSystemAgentChatParams,
|
||||
validateSystemAgentChatHistoryParams,
|
||||
validateSystemAgentSetupActivateParams,
|
||||
validateSystemAgentSetupAuthStartParams,
|
||||
validateSystemAgentSetupDetectParams,
|
||||
validateSystemAgentSetupVerifyParams,
|
||||
type SystemAgentChatHistoryTurn,
|
||||
type SystemAgentChatQuestion,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
@@ -18,16 +18,12 @@ import {
|
||||
SYSTEM_AGENT_APPROVAL_TIMEOUT_MS,
|
||||
type SystemAgentApprovalRequestPayload,
|
||||
} from "../../infra/system-agent-approvals.js";
|
||||
import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js";
|
||||
import { enqueueCommandInLane, setCommandLaneConcurrency } from "../../process/command-queue.js";
|
||||
import { runWithGatewayIndependentRootWorkContinuation } from "../../process/gateway-work-admission.js";
|
||||
import { CommandLane } from "../../process/lanes.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
SystemAgentChatEngine,
|
||||
SystemAgentWizardAnswerError,
|
||||
} from "../../system-agent/chat-engine.js";
|
||||
import { resolveSystemAgentDelegationKey } from "../../system-agent/delegation-session.js";
|
||||
import {
|
||||
acknowledgeSystemAgentGreetingDelivery,
|
||||
buildSystemAgentGreetingQuestion,
|
||||
@@ -56,14 +52,21 @@ import {
|
||||
SETUP_ADMISSION_BUSY_MESSAGE,
|
||||
SetupAdmissionBusyError,
|
||||
} from "./setup-admission.js";
|
||||
import {
|
||||
appendSystemAgentRecoveryHistory,
|
||||
resolveSystemAgentSessionOwnerKey,
|
||||
setSystemAgentRecoveryHistory,
|
||||
systemAgentChatHistoryHandler,
|
||||
} from "./system-agent-chat-history.js";
|
||||
import { sanitizeSystemAgentChatParams } from "./system-agent-chat-params.js";
|
||||
import {
|
||||
buildSystemAgentChatResult,
|
||||
getSystemAgentChatInputError,
|
||||
runSystemAgentChatInput,
|
||||
} from "./system-agent-chat-turn.js";
|
||||
import type { GatewayClient, GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
import type { RespondFn } from "./types.js";
|
||||
import { runSystemAgentGatewayTask } from "./system-agent-gateway-queue.js";
|
||||
import { getSystemAgentSessionQueue } from "./system-agent-session-queue.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
/**
|
||||
@@ -72,35 +75,17 @@ import { assertValidParams } from "./validation.js";
|
||||
* the pre-inference phase; a new chat session starts only after a live model
|
||||
* turn succeeds.
|
||||
*
|
||||
* The bounded session map owns only in-flight wizard and approval state. The
|
||||
* sanitized conversation is a durable machine-wide logbook; `reset: true`
|
||||
* replaces the in-memory session without deleting that transcript.
|
||||
* The bounded session map owns in-flight wizard, approval, and recovery state.
|
||||
* The sanitized conversation is also a durable machine-wide logbook;
|
||||
* `reset: true` replaces the in-memory session without deleting that audit log.
|
||||
*/
|
||||
export type SystemAgentChatSession =
|
||||
GatewayRequestContext["systemAgentSessions"] extends Map<string, infer Session> ? Session : never;
|
||||
|
||||
const MAX_SYSTEM_AGENT_SESSIONS = 8;
|
||||
const SYSTEM_AGENT_SEED_HISTORY_LIMIT = 30;
|
||||
const DEFAULT_SYSTEM_AGENT_HISTORY_LIMIT = 100;
|
||||
const PROVIDER_AUTH_SESSION_TIMEOUT_MS = 25 * 60 * 1000;
|
||||
const PROVIDER_PREPARE_SESSION_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
||||
const SYSTEM_AGENT_GATEWAY_EXECUTION_KEY = "gateway";
|
||||
const systemAgentGatewayExecutionQueue = new KeyedAsyncQueue();
|
||||
const systemAgentSessionQueues = new WeakMap<
|
||||
Map<string, SystemAgentChatSession>,
|
||||
KeyedAsyncQueue
|
||||
>();
|
||||
|
||||
function getSystemAgentSessionQueue(
|
||||
sessions: Map<string, SystemAgentChatSession>,
|
||||
): KeyedAsyncQueue {
|
||||
let queue = systemAgentSessionQueues.get(sessions);
|
||||
if (!queue) {
|
||||
queue = new KeyedAsyncQueue();
|
||||
systemAgentSessionQueues.set(sessions, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
function acknowledgeDeliveredSystemAgentWelcome(session: SystemAgentChatSession): void {
|
||||
const auditSequence = session.welcomeAuditSequence;
|
||||
@@ -111,42 +96,6 @@ function acknowledgeDeliveredSystemAgentWelcome(session: SystemAgentChatSession)
|
||||
delete session.welcomeAuditSequence;
|
||||
}
|
||||
|
||||
async function runSystemAgentGatewayTask<T>(task: () => Promise<T>): Promise<T> {
|
||||
// Track every accepted RPC as active, never queued: restart draining snapshots
|
||||
// active ids, so a queued OpenClaw request could otherwise outlive its socket.
|
||||
setCommandLaneConcurrency(CommandLane.SystemAgent, Number.MAX_SAFE_INTEGER);
|
||||
return await enqueueCommandInLane(CommandLane.SystemAgent, () =>
|
||||
// Bound expensive detection, activation, and agent turns without hiding
|
||||
// accepted work from restart draining. This also makes session eviction and
|
||||
// setup writes atomic with respect to other OpenClaw gateway requests.
|
||||
systemAgentGatewayExecutionQueue.enqueue(SYSTEM_AGENT_GATEWAY_EXECUTION_KEY, task),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSystemAgentSessionOwnerKey(params: {
|
||||
delegation?: { agentId?: string; sessionKey?: string };
|
||||
client: GatewayClient | null;
|
||||
}): string | undefined {
|
||||
const delegationKey = resolveSystemAgentDelegationKey(params.delegation);
|
||||
if (delegationKey !== undefined) {
|
||||
// Delegation is the host-only, cross-connection owner asserted by the regular-agent
|
||||
// tool path. Keep its agent/session tuple authoritative across gateway reconnects.
|
||||
return delegationKey;
|
||||
}
|
||||
// Authenticated users survive reconnects and may span paired devices. Otherwise
|
||||
// bind to the verified device, with the server-issued connection as a last resort.
|
||||
const userId = params.client?.authenticatedUserId?.trim();
|
||||
if (userId) {
|
||||
return `user:${userId}`;
|
||||
}
|
||||
const deviceId = params.client?.connect.device?.id.trim();
|
||||
if (deviceId) {
|
||||
return `device:${deviceId}`;
|
||||
}
|
||||
const connId = params.client?.connId?.trim();
|
||||
return connId ? `connection:${connId}` : undefined;
|
||||
}
|
||||
|
||||
async function evictOldestSession(
|
||||
sessions: Map<string, SystemAgentChatSession>,
|
||||
context: GatewayRequestContext,
|
||||
@@ -172,13 +121,18 @@ async function evictOldestSession(
|
||||
}
|
||||
}
|
||||
|
||||
function persistEngineHistory(engine: SystemAgentChatSession["engine"], startIndex: number): void {
|
||||
function persistEngineHistory(
|
||||
engine: SystemAgentChatSession["engine"],
|
||||
startIndex: number,
|
||||
): SystemAgentChatHistoryTurn[] {
|
||||
const at = Date.now();
|
||||
for (const turn of engine.historySince(startIndex)) {
|
||||
const turns = engine.historySince(startIndex).map((turn) => ({ ...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, at });
|
||||
appendTranscriptTurn(turn);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function queueDelegatedApproval(params: {
|
||||
@@ -263,23 +217,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"openclaw.chat.history": ({ params, respond }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateSystemAgentChatHistoryParams,
|
||||
"openclaw.chat.history",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{ turns: readTranscriptTail(params.limit ?? DEFAULT_SYSTEM_AGENT_HISTORY_LIMIT) },
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"openclaw.chat.history": systemAgentChatHistoryHandler,
|
||||
/** Structured onboarding: list reusable AI access on this host. */
|
||||
"openclaw.setup.detect": async ({ params, respond }) => {
|
||||
if (
|
||||
@@ -636,7 +574,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, error.message));
|
||||
return;
|
||||
}
|
||||
persistEngineHistory(engine, welcomeHistoryStart);
|
||||
const recoveryTurns = persistEngineHistory(engine, welcomeHistoryStart);
|
||||
await evictOldestSession(sessions, context);
|
||||
session = {
|
||||
engine,
|
||||
@@ -648,6 +586,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
lastUsedAt: Date.now(),
|
||||
ownerKey,
|
||||
};
|
||||
setSystemAgentRecoveryHistory(engine, recoveryTurns);
|
||||
sessions.set(sessionId, session);
|
||||
if (welcomeOnly) {
|
||||
respond(
|
||||
@@ -701,7 +640,10 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
reply = turnReply;
|
||||
} catch (error) {
|
||||
persistEngineHistory(session.engine, historyStart);
|
||||
appendSystemAgentRecoveryHistory(
|
||||
session.engine,
|
||||
persistEngineHistory(session.engine, historyStart),
|
||||
);
|
||||
if (error instanceof SystemAgentWizardAnswerError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
|
||||
return;
|
||||
@@ -730,7 +672,10 @@ export const systemAgentHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
persistEngineHistory(session.engine, historyStart);
|
||||
appendSystemAgentRecoveryHistory(
|
||||
session.engine,
|
||||
persistEngineHistory(session.engine, historyStart),
|
||||
);
|
||||
const delegation = params.delegation;
|
||||
let proposalId: string | undefined;
|
||||
if (delegation) {
|
||||
|
||||
@@ -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_CHAT_HISTORY_SESSION_RECOVERY,
|
||||
);
|
||||
expect(payload?.features?.capabilities).toContain(
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
|
||||
);
|
||||
|
||||
@@ -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_CHAT_HISTORY_SESSION_RECOVERY,
|
||||
GATEWAY_SERVER_CAPS.SYSTEM_AGENT_SETUP_MODEL_REF,
|
||||
GATEWAY_SERVER_CAPS.TASK_SUGGESTIONS_ACCEPT_MODES,
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type {
|
||||
SystemAgentWizardCancel,
|
||||
WizardAnswer,
|
||||
WizardStep,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
@@ -187,6 +188,17 @@ export class SystemAgentChatEngine {
|
||||
return await turn;
|
||||
}
|
||||
|
||||
async activeWizardStep(): Promise<WizardStep | undefined> {
|
||||
// Recovery snapshots share the turn queue so they cannot observe a step
|
||||
// while its answer or cancellation mutation is still in flight.
|
||||
const snapshot = this.turnQueue.then(() => this.wizard.clientStep);
|
||||
this.turnQueue = snapshot.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return await snapshot;
|
||||
}
|
||||
|
||||
private completeTurn(reply: SystemAgentChatReply, userHistoryText: string): SystemAgentChatReply {
|
||||
const completed = this.wizard.decorateReply(reply);
|
||||
this.history.push({ role: "user", text: userHistoryText });
|
||||
|
||||
@@ -411,6 +411,32 @@ describe("SystemAgentChatEngine wizard", () => {
|
||||
expect(plain.step?.initialValue).toBe("123456:REAL-SECRET");
|
||||
});
|
||||
|
||||
it("snapshots only the sanitized active wizard step for reload recovery", 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: "Bot token",
|
||||
initialValue: "REAL-SECRET",
|
||||
sensitive: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = await engine.handle("connect telegram");
|
||||
const snapshot = await engine.activeWizardStep();
|
||||
expect(snapshot).toEqual(prompt.step);
|
||||
expect(snapshot).not.toHaveProperty("initialValue");
|
||||
|
||||
const stepId = expectDefined(snapshot?.id, "expected an active wizard step");
|
||||
await engine.cancelWizard({ stepId });
|
||||
await expect(engine.activeWizardStep()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits the wizard step outside an awaiting hosted wizard", async () => {
|
||||
useTempStateDir();
|
||||
const engine = new SystemAgentChatEngine({
|
||||
|
||||
@@ -269,6 +269,11 @@ export class ChatWizardHost {
|
||||
return this.bridge?.step?.sensitive === true;
|
||||
}
|
||||
|
||||
get clientStep(): WizardStep | undefined {
|
||||
const step = this.bridge?.step;
|
||||
return step ? sanitizeWizardStepForClient(step) : undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.bridge?.session.cancel();
|
||||
this.bridge = null;
|
||||
@@ -281,7 +286,7 @@ export class ChatWizardHost {
|
||||
? { ...reply, text: `${reply.text}\n${WIZARD_CANCEL_HINT}` }
|
||||
: reply;
|
||||
const question = wizardStepChatQuestion(step);
|
||||
const clientStep = step ? sanitizeWizardStepForClient(step) : null;
|
||||
const clientStep = this.clientStep;
|
||||
return {
|
||||
...completedReply,
|
||||
...(step?.sensitive === true ? { sensitive: true } : {}),
|
||||
|
||||
Reference in New Issue
Block a user