Files
openclaw/src/system-agent/rescue-message.test.ts
Peter Steinberger edecdbd05e refactor(config): config-surface reduction tranche 3 — product consolidations (review request) (#111527)
* refactor(config): consolidate media model lists

* refactor(config): unify memory configuration

* refactor(config): consolidate TTS ownership

* refactor(config): move typing policy to agents

* refactor(config): retire product-level config surfaces

* refactor(config): share scoped tool policy type

* chore(config): refresh generated baselines

* fix(config): honor agent typing overrides

* fix(config): migrate sibling config consumers

* refactor(infra): keep base64url decoder private

* fix(config): strip invalid legacy TTS values

* chore(config): refresh rebased baseline hash

* fix(doctor): route legacy messages.tts.realtime voice to talk during tts move

* refactor(config): polish final layout names

* refactor(config): freeze retired tuning defaults

* feat(config): add fast mode default symmetry

* refactor(config): key agent entries by id

* docs(config): update final layout reference

* test(config): cover final layout migrations

* chore(config): refresh final layout baselines

* fix(config): align final layout runtime readers

* fix(config): align remaining readers

* fix(config): stabilize final layout migrations

* fix(config): finalize config projection proof

* fix(config): address final layout review

* docs(release): preserve historical config names

* fix(config): complete keyed agent migration

* fix(config): close final migration gaps

* fix(config): finish full-branch review

* fix(config): complete runtime secret detection

* fix(config): close final review findings

* fix(config): finish canonical docs and heartbeat migration

* fix(config): integrate latest main after rebase

* refactor(env): isolate test-only controls

* refactor(env): isolate build and development controls

* refactor(env): collapse process identity indirection

* refactor(env): remove duplicate config and temp aliases

* docs(env): define the operator-facing allowlist

* ci(env): ratchet production variable count

* fix(env): remove stale provider helper import

* fix(env): make ratchet sorting explicit

* test(env): keep test seam in dead-code audit

* test(env): cover ratchet growth and boundary; document surface budgets

* docs(config): document tier-eval consolidations

* docs(config): clarify speech preference ownership

* test(memory): align retired tuning fixtures

* refactor(memory): freeze engine heuristics

* refactor(config): apply tier-eval tranche

* refactor(tts): move persona shaping to providers

* refactor(compaction): move prompt policy to providers

* test(config): align hookified prompt fixtures

* chore(deadcode): classify test-only exports

* chore(github): remove unused spawn helper

* chore(deadcode): classify queue diagnostics

* chore(deadcode): remove unused lane snapshot export

* chore(plugin-sdk): ratchet consolidated surface

* fix(config): integrate latest main after rebase
2026-07-21 20:28:43 -07:00

707 lines
25 KiB
TypeScript

// OpenClaw rescue message tests cover generated rescue message content.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommandContext } from "../auto-reply/reply/commands-types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
createCorePluginStateSyncKeyedStore,
resetPluginStateStoreForTests,
} from "../plugin-state/plugin-state-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import { listSystemAgentAuditEntriesForTests } from "./audit.test-support.js";
import { extractSystemAgentRescueMessage, runSystemAgentRescueMessage } from "./rescue-message.js";
let tempRoot = "";
let tempDirId = 0;
type TestConfig = Record<string, unknown>;
function readLastAuditEntry(): Record<string, unknown> {
return (listSystemAgentAuditEntriesForTests().at(-1)?.value ?? {}) as Record<string, unknown>;
}
const mockConfig = vi.hoisted(() => {
const state = {
path: "/tmp/openclaw.json",
config: {} as TestConfig,
hash: "mock-hash-0" as string | undefined,
};
const cloneConfig = () => structuredClone(state.config);
const snapshot = () => {
const config = cloneConfig();
return {
path: state.path,
exists: true,
raw: `${JSON.stringify(config)}\n`,
parsed: config,
sourceConfig: config,
resolved: config,
valid: true,
runtimeConfig: config,
config,
hash: state.hash,
issues: [],
warnings: [],
legacyIssues: [],
};
};
return {
reset() {
state.path = "/tmp/openclaw.json";
state.config = {};
state.hash = "mock-hash-0";
},
currentConfig() {
return cloneConfig();
},
readConfigFileSnapshot: vi.fn(async () => snapshot()),
mutateConfigFile: vi.fn(
async (params: {
mutate: (
draft: TestConfig,
context: { snapshot: ReturnType<typeof snapshot> },
) => Promise<void> | void;
}) => {
const before = snapshot();
const draft = cloneConfig();
await params.mutate(draft, { snapshot: before });
state.config = draft;
state.hash = "mock-hash-1";
return {
path: state.path,
previousHash: before.hash ?? null,
persistedHash: before.hash ?? null,
snapshot: before,
nextConfig: cloneConfig(),
result: undefined,
};
},
),
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
clearConfigCache: vi.fn(),
mutateConfigFile: mockConfig.mutateConfigFile,
readConfigFileSnapshot: mockConfig.readConfigFileSnapshot,
};
});
vi.mock("../commands/models/shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../commands/models/shared.js")>();
return {
...actual,
applyDefaultModelPrimaryUpdate: ({
cfg,
modelRaw,
field,
}: {
cfg: TestConfig;
modelRaw: string;
field: "model" | "imageModel";
}) => ({
...cfg,
agents: {
...(cfg.agents as TestConfig | undefined),
defaults: {
...(cfg.agents as { defaults?: TestConfig } | undefined)?.defaults,
[field]: { primary: modelRaw },
},
},
}),
};
});
vi.mock("../config/model-input.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/model-input.js")>();
return {
...actual,
resolveAgentModelPrimaryValue: (model?: string | { primary?: string }) =>
typeof model === "string" ? model : model?.primary,
};
});
async function makeStateDir(prefix: string): Promise<string> {
const dir = path.join(tempRoot, `${prefix}${tempDirId++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
}
async function withRescueStateDir(
prefix: string,
run: (stateDir: string) => Promise<void>,
): Promise<void> {
const stateDir = await makeStateDir(prefix);
resetPluginStateStoreForTests();
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => await run(stateDir));
} finally {
resetPluginStateStoreForTests();
}
}
function commandContext(overrides: Partial<CommandContext> = {}): CommandContext {
return {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
accountId: "default",
ownerList: ["user:owner"],
senderIsOwner: true,
isAuthorizedSender: true,
senderId: "user:owner",
rawBodyNormalized: "/openclaw models",
commandBodyNormalized: "/openclaw models",
from: "user:owner",
to: "account:default",
...overrides,
};
}
function openRescuePendingTestStore() {
return createCorePluginStateSyncKeyedStore<unknown>({
ownerId: "core:system-agent",
namespace: "rescue-pending",
maxEntries: 1_024,
overflowPolicy: "reject-new",
});
}
function requireFirstMockCall<T>(mock: { mock: { calls: T[][] } }, label: string): T[] {
const call = mock.mock.calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
async function runRescue(
commandBody: string,
cfg: OpenClawConfig,
ctx = commandContext(),
deps?: Parameters<typeof runSystemAgentRescueMessage>[0]["deps"],
) {
return await runSystemAgentRescueMessage({
cfg,
command: { ...ctx, commandBodyNormalized: commandBody },
commandBody,
isGroup: false,
deps,
});
}
describe("OpenClaw rescue message", () => {
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "system-agent-rescue-"));
});
beforeEach(() => {
mockConfig.reset();
});
afterAll(async () => {
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
afterEach(() => {
vi.useRealTimers();
resetPluginStateStoreForTests();
});
it("recognizes the OpenClaw rescue command", () => {
expect(extractSystemAgentRescueMessage("/openclaw status")).toBe("status");
expect(extractSystemAgentRescueMessage("/openclaw")).toBe("");
expect(extractSystemAgentRescueMessage("/status")).toBeNull();
});
it("denies rescue when sandboxing is active", async () => {
await expect(
runRescue("/openclaw status", {
agents: { defaults: { sandbox: { mode: "all" } } },
}),
).resolves.toContain("sandboxing is active");
});
it("refuses TUI handoff from remote rescue", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runTui: vi.fn(async () => {
throw new Error("remote rescue must not open the TUI");
}),
};
await expect(
runRescue("/openclaw talk to agent", cfg, commandContext(), deps),
).resolves.toContain("cannot open the local TUI");
await expect(runRescue("/openclaw chat", cfg, commandContext(), deps)).resolves.toContain(
"cannot open the local TUI",
);
expect(deps.runTui).not.toHaveBeenCalled();
});
it("rejects natural language instead of guessing an operation", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runGatewayStop: vi.fn(async () => {}),
runGatewayRestart: vi.fn(async () => {}),
};
// Questions must never become mutation plans (previously "why did my
// gateway stop" keyword-matched into a gateway-stop proposal).
await expect(
runRescue("/openclaw why did my gateway stop", cfg, commandContext(), deps),
).resolves.toContain("I can run doctor/status/health");
await expect(
runRescue("/openclaw explain how restart gateway works", cfg, commandContext(), deps),
).resolves.toContain("I can run doctor/status/health");
expect(deps.runGatewayStop).not.toHaveBeenCalled();
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
});
it("refuses channel setup from remote rescue with a local pointer", async () => {
const cfg: OpenClawConfig = {};
await expect(runRescue("/openclaw connect telegram", cfg)).resolves.toContain(
"cannot host the interactive channel setup",
);
});
it("refuses model provider setup from remote rescue with a local pointer", async () => {
const cfg: OpenClawConfig = {};
const reply = await runRescue("/openclaw configure model provider", cfg);
expect(reply).toContain("cannot host model-provider credential setup");
expect(reply).toContain("openclaw onboard");
});
it("refuses doctor repairs without creating a pending approval", async () => {
await withRescueStateDir("doctor-fix-refused-", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runDoctor: vi.fn(async () => {
throw new Error("remote rescue must not run doctor repair");
}),
};
await expect(
runRescue("/openclaw doctor fix", cfg, commandContext(), deps),
).resolves.toContain("run `openclaw doctor --fix` in a terminal");
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
expect(deps.runDoctor).not.toHaveBeenCalled();
});
});
it("drops a pending rescue change on decline", async () => {
await withRescueStateDir("decline-", async () => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await expect(
runRescue("/openclaw restart gateway", cfg, commandContext(), deps),
).resolves.toContain("Reply /openclaw yes to apply");
await expect(runRescue("/openclaw no", cfg, commandContext(), deps)).resolves.toContain(
"Dropped the pending OpenClaw rescue change",
);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
});
});
it("revokes a pending write when a fresh read-only command arrives", async () => {
await withRescueStateDir("read-revokes-", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runGatewayRestart: vi.fn(async () => {}),
runPluginsList: vi.fn(async (runtime: RuntimeEnv) => runtime.log("plugin rows")),
};
await expect(
runRescue("/openclaw restart gateway", cfg, commandContext(), deps),
).resolves.toContain("Reply /openclaw yes to apply");
await expect(runRescue("/openclaw plugins list", cfg, commandContext(), deps)).resolves.toBe(
"plugin rows",
);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
});
});
it("consumes a pending approval at most once under concurrent approvals", async () => {
await withRescueStateDir("concurrent-approve-", async () => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await runRescue("/openclaw restart gateway", cfg, commandContext(), deps);
const replies = await Promise.all([
runRescue("/openclaw yes", cfg, commandContext(), deps),
runRescue("/openclaw yes", cfg, commandContext(), deps),
]);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
expect(replies).toContain("No pending OpenClaw rescue change is waiting for approval.");
expect(replies.some((reply) => reply?.includes("[openclaw] done: gateway.restart"))).toBe(
true,
);
});
});
it("keeps failed execution consumed", async () => {
await withRescueStateDir("failed-consumed-", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runGatewayRestart: vi.fn(async () => {
throw new Error("restart failed");
}),
};
await runRescue("/openclaw restart gateway", cfg, commandContext(), deps);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).rejects.toThrow(
"restart failed",
);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
});
});
it("preserves a new plan created while the consumed plan executes", async () => {
await withRescueStateDir("replacement-during-execute-", async () => {
const cfg: OpenClawConfig = {};
let releaseRestart: (() => void) | undefined;
let noteRestartEntered: (() => void) | undefined;
const restartEntered = new Promise<void>((resolve) => {
noteRestartEntered = resolve;
});
const restartGate = new Promise<void>((resolve) => {
releaseRestart = resolve;
});
const deps = {
runGatewayRestart: vi.fn(async () => {
noteRestartEntered?.();
await restartGate;
}),
runGatewayStart: vi.fn(async () => {}),
};
await runRescue("/openclaw restart gateway", cfg, commandContext(), deps);
const approval = runRescue("/openclaw yes", cfg, commandContext(), deps);
await restartEntered;
await runRescue("/openclaw start gateway", cfg, commandContext(), deps);
releaseRestart?.();
await expect(approval).resolves.toContain("[openclaw] done: gateway.restart");
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toContain(
"[openclaw] done: gateway.start",
);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
expect(deps.runGatewayStart).toHaveBeenCalledTimes(1);
});
});
it("publishes concurrently invoked persistent plans in call order", async () => {
await withRescueStateDir("latest-plan-", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runGatewayRestart: vi.fn(async () => {}),
runGatewayStart: vi.fn(async () => {}),
};
const olderPlan = runRescue("/openclaw restart gateway", cfg, commandContext(), deps);
const newerPlan = runRescue("/openclaw start gateway", cfg, commandContext(), deps);
await expect(olderPlan).resolves.toContain("restart the Gateway");
await expect(newerPlan).resolves.toContain("start the Gateway");
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toContain(
"[openclaw] done: gateway.start",
);
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
expect(deps.runGatewayStart).toHaveBeenCalledTimes(1);
});
});
it("persists a pending approval only in SQLite across store reopen", async () => {
await withRescueStateDir("sqlite-reopen-", async (stateDir) => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await runRescue("/openclaw restart gateway", cfg, commandContext(), deps);
resetPluginStateStoreForTests();
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toContain(
"[openclaw] done: gateway.restart",
);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
await expect(fs.access(path.join(stateDir, "openclaw", "rescue-pending"))).rejects.toThrow(
/ENOENT/,
);
});
});
it("isolates pending approvals by account, channel, and sender", async () => {
await withRescueStateDir("route-isolation-", async () => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
const original = commandContext();
await runRescue("/openclaw restart gateway", cfg, original, deps);
for (const isolated of [
commandContext({ accountId: "secondary" }),
commandContext({ channelId: "telegram" }),
commandContext({ from: "user:other", senderId: "user:other" }),
]) {
await expect(runRescue("/openclaw yes", cfg, isolated, deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
}
await expect(runRescue("/openclaw yes", cfg, original, deps)).resolves.toContain(
"[openclaw] done: gateway.restart",
);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
});
});
it("falls back to the channel destination when account id is absent", async () => {
await withRescueStateDir("route-account-fallback-", async () => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
const original = commandContext({ accountId: undefined, to: "bot:primary" });
await runRescue("/openclaw restart gateway", cfg, original, deps);
await expect(
runRescue(
"/openclaw yes",
cfg,
commandContext({ accountId: undefined, to: "bot:secondary" }),
deps,
),
).resolves.toBe("No pending OpenClaw rescue change is waiting for approval.");
await expect(runRescue("/openclaw yes", cfg, original, deps)).resolves.toContain(
"[openclaw] done: gateway.restart",
);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
});
});
it("refuses plugin install from remote rescue", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runPluginInstall: vi.fn(async () => {
throw new Error("remote rescue must not install plugins");
}),
};
await expect(
runRescue("/openclaw plugin install clawhub:openclaw-demo", cfg, commandContext(), deps),
).resolves.toContain("cannot install plugins from a message channel");
expect(deps.runPluginInstall).not.toHaveBeenCalled();
});
it("allows plugin list and search from remote rescue", async () => {
const cfg: OpenClawConfig = {};
const deps = {
runPluginsList: vi.fn(async (runtime: RuntimeEnv) => {
runtime.log("plugin rows");
}),
runPluginsSearch: vi.fn(async (query: string, runtime: RuntimeEnv) => {
runtime.log(`search rows: ${query}`);
}),
};
await expect(
runRescue("/openclaw plugins list", cfg, commandContext(), deps),
).resolves.toContain("plugin rows");
await expect(
runRescue("/openclaw plugins search calendar", cfg, commandContext(), deps),
).resolves.toContain("search rows: calendar");
expect(deps.runPluginsList).toHaveBeenCalledTimes(1);
expect(deps.runPluginsSearch).toHaveBeenCalledTimes(1);
const [searchQuery, searchRuntime] = requireFirstMockCall(
deps.runPluginsSearch,
"plugins search",
);
expect(searchQuery).toBe("calendar");
expect(searchRuntime).toBeTypeOf("object");
});
it("queues and applies persistent writes through conversational approval", async () => {
await withRescueStateDir("models-", async () => {
const cfg: OpenClawConfig = {};
const deps = {
verifyInferenceConfig: vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.2",
latencyMs: 17,
})),
};
await expect(
runRescue("/openclaw set default model openai/gpt-5.2", cfg, commandContext(), deps),
).resolves.toContain("Reply /openclaw yes to apply");
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toContain(
"Default model: openai/gpt-5.2",
);
const currentConfig = mockConfig.currentConfig() as {
agents?: { defaults?: { model?: string | { primary?: string } } };
};
const model = currentConfig.agents?.defaults?.model;
expect(typeof model === "string" ? model : model?.primary).toBe("openai/gpt-5.2");
const audit = readLastAuditEntry() as {
details?: { rescue?: boolean; channel?: string; accountId?: string; senderId?: string };
};
expect(audit.details?.rescue).toBe(true);
expect(audit.details?.channel).toBe("whatsapp");
expect(audit.details?.accountId).toBe("default");
expect(audit.details?.senderId).toBe("user:owner");
});
});
it("queues and applies gateway restart through conversational approval", async () => {
await withRescueStateDir("gateway-", async () => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await expect(
runRescue("/openclaw restart gateway", cfg, commandContext(), deps),
).resolves.toBe("Plan: restart the Gateway. Reply /openclaw yes to apply.");
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toContain(
"[openclaw] done: gateway.restart",
);
expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1);
const audit = readLastAuditEntry() as {
operation?: string;
details?: { rescue?: boolean; channel?: string; senderId?: string };
};
expect(audit.operation).toBe("gateway.restart");
expect(audit.details?.rescue).toBe(true);
expect(audit.details?.channel).toBe("whatsapp");
expect(audit.details?.senderId).toBe("user:owner");
});
});
it("does not queue persistent rescue approval when expiry would exceed the Date range", async () => {
await withRescueStateDir("overflow-expiry-", async (tempDir) => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const cfg: OpenClawConfig = {};
await expect(
runRescue("/openclaw restart gateway", cfg, commandContext()),
).resolves.toContain("expiry clock is invalid");
await expect(fs.readdir(path.join(tempDir, "openclaw", "rescue-pending"))).rejects.toThrow(
/ENOENT/,
);
} finally {
vi.useRealTimers();
}
});
});
it("expires pending approvals through the SQLite row TTL", async () => {
await withRescueStateDir("expired-", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await runRescue("/openclaw restart gateway", {}, commandContext(), deps);
vi.advanceTimersByTime(15 * 60_000 + 1);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
});
});
it("consumes malformed pending rows without executing them", async () => {
await withRescueStateDir("malformed-", async () => {
const cfg: OpenClawConfig = {};
const deps = { runGatewayRestart: vi.fn(async () => {}) };
await runRescue("/openclaw restart gateway", cfg, commandContext(), deps);
const store = openRescuePendingTestStore();
const [entry] = store.entries();
if (!entry) {
throw new Error("expected pending rescue row");
}
store.register(
entry.key,
{ version: 1, operation: { kind: "gateway-restart", unexpected: true } },
{ ttlMs: 60_000 },
);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe(
"No pending OpenClaw rescue change is waiting for approval.",
);
expect(deps.runGatewayRestart).not.toHaveBeenCalled();
});
});
it("queues and applies agent creation through conversational approval", async () => {
await withRescueStateDir("agent-", async () => {
const cfg: OpenClawConfig = {};
const deps = {
createAgent: vi.fn(async () => ({
status: "created" as const,
agentId: "work",
name: "work",
workspace: "/tmp/work",
agentDir: "/tmp/agent-work",
bootstrapPending: true,
})),
};
await expect(
runRescue("/openclaw create agent work workspace /tmp/work", cfg, commandContext(), deps),
).resolves.toBe(
"Plan: create agent work with workspace /tmp/work. Reply /openclaw yes to apply.",
);
await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toContain(
"[openclaw] done: agents.create",
);
expect(deps.createAgent).toHaveBeenCalledTimes(1);
const [agentParams] = requireFirstMockCall(deps.createAgent, "agents add") as unknown as [
{ name: string; workspace: string },
];
expect(agentParams).toEqual({
name: "work",
workspace: "/tmp/work",
});
const audit = readLastAuditEntry() as {
operation?: string;
details?: {
rescue?: boolean;
channel?: string;
senderId?: string;
agentId?: string;
workspace?: string;
};
};
expect(audit.operation).toBe("agents.create");
expect(audit.details?.rescue).toBe(true);
expect(audit.details?.channel).toBe("whatsapp");
expect(audit.details?.senderId).toBe("user:owner");
expect(audit.details?.agentId).toBe("work");
expect(audit.details?.workspace).toBe("/tmp/work");
});
});
});