refactor(system-agent): split chat engine into concept modules (#121884)

* refactor(system-agent): split chat engine into concept modules

* refactor(system-agent): deduplicate hosted setup flows

* fix(system-agent): record interactive exit as a fact

* chore(lint): ratchet max-lines baseline after chat-engine split

* fix(system-agent): clear sensitive wizard state between sessions
This commit is contained in:
Peter Steinberger
2026-08-11 00:55:30 -07:00
committed by GitHub
parent 579c38dc68
commit 6e851103dc
19 changed files with 5350 additions and 5682 deletions
+3 -5
View File
@@ -319,8 +319,6 @@ packages/tool-call-repair/src/stream-normalizer.test.ts
packages/tool-call-repair/src/stream-normalizer.ts
src/acp/control-plane/manager.test.ts
src/acp/control-plane/manager.turn-results.test.ts
src/agents/subagents/spawn/acp-spawn-parent-stream.test.ts
src/agents/subagents/spawn/acp-spawn.test.ts
src/agents/agent-bundle-mcp-runtime.test.ts
src/agents/agent-bundle-mcp-runtime.ts
src/agents/agent-command.live-model-switch.test.ts
@@ -378,10 +376,10 @@ src/agents/embedded-agent-runner/model.test.ts
src/agents/embedded-agent-runner/replay-history.ts
src/agents/embedded-agent-runner/run.incomplete-turn.test.ts
src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts
src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts
src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts
src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.ts
src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts
src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts
src/agents/embedded-agent-runner/run/attempt.test.ts
src/agents/embedded-agent-runner/run/attempt.tool-call-argument-repair.ts
src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts
@@ -449,6 +447,8 @@ src/agents/subagents/registry/subagent-registry-lifecycle.test.ts
src/agents/subagents/registry/subagent-registry-run-manager.ts
src/agents/subagents/registry/subagent-registry.steer-restart.test.ts
src/agents/subagents/registry/subagent-registry.test.ts
src/agents/subagents/spawn/acp-spawn-parent-stream.test.ts
src/agents/subagents/spawn/acp-spawn.test.ts
src/agents/subagents/spawn/subagent-spawn.test.ts
src/agents/system-prompt.test.ts
src/agents/system-prompt.ts
@@ -895,8 +895,6 @@ src/snapshot/local-repository.ts
src/state/openclaw-agent-db.test.ts
src/state/openclaw-state-db.test.ts
src/status/status-message.ts
src/system-agent/chat-engine.test.ts
src/system-agent/chat-engine.ts
src/system-agent/setup-inference.test.ts
src/system-agent/verified-inference.ts
src/tasks/task-executor.test.ts
@@ -188,6 +188,10 @@ function makeVerifiedEngine(): SystemAgentChatEngine {
});
}
async function runSensitiveChannelSetup(_channel: string, prompter: WizardPrompter) {
await prompter.text({ message: "Bot token", sensitive: true });
}
function stubEngineOverview() {
return vi.spyOn(SystemAgentChatEngine.prototype, "loadOverview").mockResolvedValue({
config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null },
@@ -763,16 +767,16 @@ describe("openclaw.chat", () => {
});
it("persists only the mask marker for a sensitive hosted-wizard answer", async () => {
const engine = new SystemAgentChatEngine({
surface: "gateway",
verifiedInference: requireVerifiedInferenceFixture(),
deps: requireVerifiedInferenceDeps(),
runAgentTurn: async () => null,
planWithAssistant: async () => null,
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token", sensitive: true });
const engine = new SystemAgentChatEngine(
{
surface: "gateway",
verifiedInference: requireVerifiedInferenceFixture(),
deps: requireVerifiedInferenceDeps(),
runAgentTurn: async () => null,
planWithAssistant: async () => null,
},
});
{ wizardDependencies: { runChannelSetupWizard: runSensitiveChannelSetup } },
);
const sessions = new Map<string, SystemAgentChatSession>([["s1", seededSession({ engine })]]);
const context = makeContext(sessions);
+1 -1
View File
@@ -3,9 +3,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
classifySystemAgentApprovalIntent,
classifySystemAgentApprovalText,
type SystemAgentApprovalIntentDeps,
} from "./approval-intent.js";
import { classifySystemAgentApprovalText } from "./operator-approval.js";
import {
createSystemAgentVerifiedInferenceTestFixture,
installSystemAgentClaudeCliBackendTestFixture,
+4 -30
View File
@@ -4,6 +4,10 @@ import {
completeWithPreparedSimpleCompletionModel,
prepareSimpleCompletionModelForAgent,
} from "../agents/simple-completion-runtime.js";
import {
classifySystemAgentApprovalText,
type SystemAgentApprovalIntent,
} from "./operator-approval.js";
import {
resolveSystemAgentVerifiedInferenceRoute,
type SystemAgentVerifiedInferenceBinding,
@@ -19,8 +23,6 @@ import {
* model is usable the closed list is the whole decision — "other" (the safe
* default) keeps the proposal pending and the conversation re-asks.
*/
export type SystemAgentApprovalIntent = "approve" | "decline" | "other";
export type SystemAgentApprovalClassifier = (params: {
message: string;
/** Human-readable proposal description when the host knows it. */
@@ -32,34 +34,6 @@ export type SystemAgentApprovalClassifier = (params: {
const APPROVAL_INTENT_TIMEOUT_MS = 10_000;
const APPROVAL_INTENT_MAX_TOKENS = 8;
// Approvals arm a mutation, so the deterministic list is whole-message only;
// declines merely drop a proposal, so a leading match ("no thanks") suffices.
const APPROVE_RE =
/^(?:y|yes|yeah|yep|yup|sure|ok|okay|approve|approved|apply|confirm|confirmed|do it|go ahead|sounds good|yes please|please do)$/i;
const DECLINE_RE = /^(?:n|no|nope|nah|skip|not now|cancel|stop|abort|later|decline|don'?t)\b/i;
function normalizeApprovalText(message: string): string {
return message
.trim()
.replace(/[.!?,\s]+$/u, "")
.toLowerCase();
}
/** Closed-list classification: exact affirmatives, prefix declines. */
export function classifySystemAgentApprovalText(message: string): SystemAgentApprovalIntent {
const normalized = normalizeApprovalText(message);
if (!normalized) {
return "other";
}
if (APPROVE_RE.test(normalized)) {
return "approve";
}
if (DECLINE_RE.test(normalized)) {
return "decline";
}
return "other";
}
const APPROVAL_INTENT_SYSTEM_PROMPT = [
"You classify one chat message from a user who was just asked to approve a pending configuration change.",
"Reply with exactly one word:",
@@ -1,158 +0,0 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import { SystemAgentChatEngine } from "./chat-engine.js";
import { createSystemAgentVerifiedInferenceTestFixture } from "./system-agent.test-helpers.js";
import type {
SystemAgentVerifiedInferenceBinding,
SystemAgentVerifiedInferenceDeps,
} from "./verified-inference.js";
const verifiedInferenceConfig = {
agents: { defaults: { model: "openai/gpt-5.5" } },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test-key",
auth: "api-key",
models: [],
},
},
},
} satisfies OpenClawConfig;
let verifiedInference: SystemAgentVerifiedInferenceBinding;
let verifiedInferenceDeps: SystemAgentVerifiedInferenceDeps;
function verifiedConfigSnapshot(): ConfigFileSnapshot {
const config = structuredClone(verifiedInferenceConfig);
return {
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "hash",
raw: null,
parsed: config,
config,
runtimeConfig: config,
sourceConfig: config,
resolved: config,
issues: [],
warnings: [],
legacyIssues: [],
};
}
const mocks = vi.hoisted(() => {
const hook = { channel: "matrix", accountId: "default", run: vi.fn() };
return {
hook,
writeWizardConfigFile: vi.fn(async () => ({
channels: { matrix: { enabled: true, committed: true } },
})),
runCollectedChannelOnboardingPostWriteHooks: vi.fn(async () => {}),
setupChannels: vi.fn(async (_cfg, _runtime, _prompter, options) => {
options?.onPostWriteHook?.(hook);
return { channels: { matrix: { enabled: true } } };
}),
};
});
vi.mock("../wizard/setup.shared.js", () => ({
readSetupConfigFileSnapshot: vi.fn(async () => ({
exists: true,
valid: true,
hash: "hash",
config: {},
sourceConfig: {},
})),
writeWizardConfigFile: mocks.writeWizardConfigFile,
}));
vi.mock("../commands/onboard-channels.js", () => ({
createChannelOnboardingPostWriteHookCollector: () => {
const hooks: unknown[] = [];
return {
collect: (hook: unknown) => hooks.push(hook),
drain: () => hooks.splice(0),
};
},
runCollectedChannelOnboardingPostWriteHooks: mocks.runCollectedChannelOnboardingPostWriteHooks,
setupChannels: mocks.setupChannels,
}));
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: vi.fn(async () => ({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "hash",
config: {},
sourceConfig: {},
issues: [],
})),
}));
beforeAll(async () => {
const fixture = await createSystemAgentVerifiedInferenceTestFixture(verifiedInferenceConfig);
verifiedInference = fixture.binding;
verifiedInferenceDeps = fixture.deps;
});
describe("OpenClaw chat channel setup", () => {
it("runs collected channel hooks after writing config", async () => {
const engine = new SystemAgentChatEngine({
verifiedInference,
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: {
...verifiedInferenceDeps,
readConfigFileSnapshot: async () => verifiedConfigSnapshot(),
loadOverview: async () =>
({
config: {
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
hash: "h",
},
agents: [],
defaultAgentId: "main",
tools: {
codex: { command: "codex", found: false },
claude: { command: "claude", found: false },
gemini: { command: "gemini", found: false },
apiKeys: { openai: false, anthropic: false },
},
gateway: { url: "ws://127.0.0.1:18789", source: "local", reachable: false },
references: {
docsUrl: "https://docs.openclaw.ai",
sourceUrl: "https://github.com/openclaw/openclaw",
},
}) as never,
},
});
const reply = await engine.handle("connect matrix");
expect(reply.text).toContain("matrix is configured");
expect(mocks.writeWizardConfigFile).toHaveBeenCalledWith(
{ channels: { matrix: { enabled: true } } },
{ allowConfigSizeDrop: false, baseHash: "hash" },
);
expect(mocks.setupChannels).toHaveBeenCalledWith(
{},
expect.any(Object),
expect.any(Object),
expect.objectContaining({ beforePersistentEffect: expect.any(Function) }),
);
expect(mocks.runCollectedChannelOnboardingPostWriteHooks).toHaveBeenCalledWith({
hooks: [mocks.hook],
cfg: { channels: { matrix: { enabled: true, committed: true } } },
runtime: expect.any(Object),
beforePersistentEffect: expect.any(Function),
});
});
});
@@ -0,0 +1,421 @@
// Chat engine tests: proposals, approvals, and the chat-hosted channel wizard.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
import {
fingerprintAuthProfileCredential,
fingerprintOpaqueRuntimeOwner,
fingerprintResolvedProviderAuth,
} from "../agents/execution-auth-binding.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import type { runSetupMemoryImportStep } from "../wizard/setup.memory-import.js";
import {
SystemAgentChatEngine as RuntimeSystemAgentChatEngine,
type SystemAgentChatEngineOptions,
} from "./chat-engine.js";
import type { ChatWizardHostDependencies } from "./chat-wizard-host.js";
import {
resolveSystemAgentConfiguredRouteFromConfig,
type SystemAgentConfiguredRoute,
} from "./inference-route.js";
import {
createSystemAgentVerifiedInferenceTestFixture,
installSystemAgentPluginMetadataTestSnapshot,
type SystemAgentPluginMetadataTestSnapshot,
} from "./system-agent.test-helpers.js";
import {
createSystemAgentVerifiedInferenceBinding,
type SystemAgentVerifiedInferenceBinding,
type SystemAgentVerifiedInferenceDeps,
} from "./verified-inference.js";
const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(async () => ({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "h",
config: {},
sourceConfig: {},
issues: [],
})),
readSetupConfigFileSnapshot: vi.fn(),
setupChannels: vi.fn(),
setupSkills: vi.fn(),
runSearchSetupFlow: vi.fn(),
runSetupMemoryImportStep: vi.fn(),
writeWizardConfigFile: vi.fn(),
runCollectedChannelOnboardingPostWriteHooks: vi.fn(async () => {}),
sharedVerifiedInference: undefined as SystemAgentVerifiedInferenceBinding | undefined,
}));
export type MemoryImportStepParams = Parameters<typeof runSetupMemoryImportStep>[0];
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("../wizard/setup.shared.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../wizard/setup.shared.js")>()),
readSetupConfigFileSnapshot: mocks.readSetupConfigFileSnapshot,
writeWizardConfigFile: mocks.writeWizardConfigFile,
}));
vi.mock("../commands/onboard-channels.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../commands/onboard-channels.js")>()),
setupChannels: mocks.setupChannels,
runCollectedChannelOnboardingPostWriteHooks: mocks.runCollectedChannelOnboardingPostWriteHooks,
}));
vi.mock("../commands/onboard-skills.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../commands/onboard-skills.js")>()),
setupSkills: mocks.setupSkills,
}));
vi.mock("../flows/search-setup.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../flows/search-setup.js")>()),
runSearchSetupFlow: mocks.runSearchSetupFlow,
}));
vi.mock("../wizard/setup.memory-import.js", () => ({
runSetupMemoryImportStep: mocks.runSetupMemoryImportStep,
}));
vi.mock("../plugins/providers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugins/providers.js")>()),
resolveOwningPluginIdsForModelRefs: vi.fn(() => []),
resolveOwningPluginIdsForProviderRef: vi.fn(() => []),
}));
vi.mock("./verified-inference.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./verified-inference.js")>();
return {
...actual,
resolveSystemAgentVerifiedInferenceRoute: (
...args: Parameters<typeof actual.resolveSystemAgentVerifiedInferenceRoute>
) => {
// Most cases own chat state, not inference ownership. Explicit bindings
// still run the real resolver so every drift and apply-boundary test stays end-to-end.
if (args[0] === mocks.sharedVerifiedInference) {
return Promise.resolve(args[0].execution);
}
return actual.resolveSystemAgentVerifiedInferenceRoute(...args);
},
};
});
const tempDirs: string[] = [];
export const sharedVerifiedInferenceConfig = {
agents: {
list: [
{
id: "main",
default: true,
agentDir: "/tmp/openclaw-openclaw-chat-engine-agent",
model: "openai/gpt-5.5",
},
],
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test-key",
auth: "api-key",
models: [],
},
},
},
} satisfies OpenClawConfig;
export let sharedVerifiedInference: SystemAgentVerifiedInferenceBinding | undefined;
let sharedVerifiedInferenceDeps: SystemAgentVerifiedInferenceDeps | undefined;
let pluginMetadataSnapshot: SystemAgentPluginMetadataTestSnapshot | undefined;
export function useTempStateDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-engine-"));
tempDirs.push(dir);
vi.stubEnv("OPENCLAW_STATE_DIR", dir);
pluginMetadataSnapshot?.rebindForCurrentEnv();
return dir;
}
export function configSnapshot(config: OpenClawConfig): ConfigFileSnapshot {
return {
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "h",
raw: null,
parsed: config,
config,
runtimeConfig: config,
sourceConfig: config,
resolved: config,
issues: [],
warnings: [],
legacyIssues: [],
};
}
function testHarnessBinding(route: SystemAgentConfiguredRoute) {
if (route.runner !== "embedded") {
return { auth: {}, deps: {} };
}
const agentHarnessId =
route.agentHarnessRuntimeOverride === "auto"
? "openclaw"
: (route.agentHarnessRuntimeOverride ?? "codex");
if (agentHarnessId === "openclaw") {
return { auth: { agentHarnessId }, deps: {} };
}
return {
auth: {
agentHarnessId,
runtimeOwnerKind: "plugin-harness" as const,
runtimeOwnerId: agentHarnessId,
runtimeArtifactId: `${agentHarnessId}-test-artifact`,
runtimeArtifactFingerprint: `${agentHarnessId}-test-fingerprint`,
},
deps: {
validateAgentHarnessRuntimeArtifact: vi.fn(async () => true),
},
};
}
export async function createAmbientVerifiedBinding(config: OpenClawConfig) {
const route = await resolveSystemAgentConfiguredRouteFromConfig(config);
if (!route) {
throw new Error("missing test route");
}
const authFingerprint = fingerprintResolvedProviderAuth({
apiKey: "test-key",
source: "models.json",
mode: "api-key",
});
if (!authFingerprint) {
throw new Error("missing test ambient auth fingerprint");
}
const harnessBinding = testHarnessBinding(route);
return await createSystemAgentVerifiedInferenceBinding({
configuredRoute: route,
executionRoute: route,
auth: {
authFingerprint,
modelId: route.model,
modelApi: route.provider === "anthropic" ? "anthropic-messages" : "openai-responses",
...harnessBinding.auth,
},
deps: harnessBinding.deps,
});
}
export async function createOAuthVerifiedBinding(
config: OpenClawConfig,
credential: Parameters<typeof fingerprintAuthProfileCredential>[0]["credential"],
) {
const route = await resolveSystemAgentConfiguredRouteFromConfig(config);
if (!route) {
throw new Error("missing test OAuth route");
}
const profileId = "anthropic:oauth";
const authFingerprint = fingerprintAuthProfileCredential({ profileId, credential });
if (!authFingerprint) {
throw new Error("missing test OAuth fingerprint");
}
const harnessBinding = testHarnessBinding(route);
return await createSystemAgentVerifiedInferenceBinding({
configuredRoute: route,
executionRoute: route,
auth: { authProfileId: profileId, authFingerprint, ...harnessBinding.auth },
deps: {
...harnessBinding.deps,
ensureAuthProfileStore: vi.fn(() => ({
version: 1,
profiles: { [profileId]: credential },
})) as never,
},
});
}
export async function createCliVerifiedBinding(config: OpenClawConfig) {
const route = await resolveSystemAgentConfiguredRouteFromConfig(config);
if (!route || route.runner !== "cli") {
throw new Error("missing test CLI route");
}
const runtimeArtifactId = route.provider;
const runtimeArtifactFingerprint = `${runtimeArtifactId}-test-artifact`;
const runtimeOwnerFingerprint = fingerprintOpaqueRuntimeOwner({
kind: "cli-runtime",
runner: "cli",
provider: route.provider,
backendId: runtimeArtifactId,
runtimeArtifactFingerprint,
});
if (!runtimeOwnerFingerprint) {
throw new Error("missing test CLI runtime-owner fingerprint");
}
const deps: SystemAgentVerifiedInferenceDeps = {
resolveCliRuntimeArtifactFingerprint: vi.fn(async () => runtimeArtifactFingerprint),
resolveCliRuntimeOwnerFingerprint: vi.fn(async () => runtimeOwnerFingerprint),
};
const binding = await createSystemAgentVerifiedInferenceBinding({
configuredRoute: route,
executionRoute: route,
auth: {
runtimeOwnerFingerprint,
runtimeOwnerKind: "cli-runtime",
runtimeOwnerId: runtimeArtifactId,
runtimeArtifactId,
runtimeArtifactFingerprint,
},
deps,
});
return { binding, deps };
}
type TestSystemAgentChatEngineOptions = Omit<SystemAgentChatEngineOptions, "verifiedInference"> &
ChatWizardHostDependencies & {
executeOperation?: typeof import("./operations.js").executeSystemAgentOperation;
verifiedInference?: SystemAgentVerifiedInferenceBinding;
};
/** Every ordinary engine test starts from a real, live-gate-shaped authority grant. */
export class SystemAgentChatEngine extends RuntimeSystemAgentChatEngine {
constructor(opts: TestSystemAgentChatEngineOptions = {}) {
const {
runChannelSetupWizard,
runSkillsSetupWizard,
runSearchSetupWizard,
runGatewaySetupWizard,
runMemoryImportWizard,
appendAuditEntry,
executeOperation,
...engineOptions
} = opts;
const explicitBinding = engineOptions.verifiedInference;
const verifiedInference = explicitBinding ?? sharedVerifiedInference;
if (!verifiedInference) {
throw new Error("shared verified inference fixture was not initialized");
}
if (!sharedVerifiedInferenceDeps) {
throw new Error("shared verified inference dependencies were not initialized");
}
super(
{
...engineOptions,
verifiedInference,
deps: {
...(explicitBinding
? { validateAgentHarnessRuntimeArtifact: async () => true }
: sharedVerifiedInferenceDeps),
readConfigFileSnapshot: async () =>
configSnapshot(structuredClone(sharedVerifiedInferenceConfig)),
...engineOptions.deps,
},
},
{
wizardDependencies: {
...(runChannelSetupWizard ? { runChannelSetupWizard } : {}),
...(runSkillsSetupWizard ? { runSkillsSetupWizard } : {}),
...(runSearchSetupWizard ? { runSearchSetupWizard } : {}),
...(runGatewaySetupWizard ? { runGatewaySetupWizard } : {}),
...(runMemoryImportWizard ? { runMemoryImportWizard } : {}),
...(appendAuditEntry ? { appendAuditEntry } : {}),
},
...(executeOperation ? { executeOperation } : {}),
},
);
}
}
export async function advanceGatewayWizardToToken(engine: SystemAgentChatEngine) {
const portStep = await engine.handle("configure gateway");
expect((await engine.handle("19001")).text).toContain("Gateway bind address");
expect((await engine.handle("2")).text).toContain("Gateway access protection");
expect((await engine.handle("1")).text).toContain("Tailscale exposure");
expect((await engine.handle("1")).text).toContain("provide the gateway token");
const tokenStep = await engine.handle("1");
return { portStep, tokenStep };
}
beforeAll(async () => {
pluginMetadataSnapshot = installSystemAgentPluginMetadataTestSnapshot(
sharedVerifiedInferenceConfig,
);
const fixture = await createSystemAgentVerifiedInferenceTestFixture(
sharedVerifiedInferenceConfig,
);
sharedVerifiedInference = fixture.binding;
mocks.sharedVerifiedInference = fixture.binding;
sharedVerifiedInferenceDeps = fixture.deps;
mocks.readConfigFileSnapshot.mockResolvedValue(
configSnapshot(structuredClone(sharedVerifiedInferenceConfig)) as never,
);
});
afterAll(() => {
pluginMetadataSnapshot?.restore();
});
afterEach(() => {
vi.unstubAllEnvs();
pluginMetadataSnapshot?.rebindForCurrentEnv();
vi.clearAllMocks();
mocks.readConfigFileSnapshot.mockResolvedValue(
configSnapshot(structuredClone(sharedVerifiedInferenceConfig)) as never,
);
mocks.readSetupConfigFileSnapshot.mockReset();
mocks.setupChannels.mockReset();
mocks.setupSkills.mockReset();
mocks.runSearchSetupFlow.mockReset();
mocks.runSetupMemoryImportStep.mockReset();
mocks.writeWizardConfigFile.mockReset();
mocks.runCollectedChannelOnboardingPostWriteHooks.mockReset();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
export const CANCEL_HINT = "Say `cancel` to stop this setup.";
export const countCancelHints = (text: string) => text.split(CANCEL_HINT).length - 1;
export function fakeOverviewLoader(
overrides: { defaultModel?: string; claudeFound?: boolean; codexFound?: boolean } = {},
) {
return async () =>
({
config: { path: "/tmp/openclaw.json", exists: false, valid: true, issues: [], hash: null },
agents: [],
defaultAgentId: "main",
defaultModel: overrides.defaultModel,
tools: {
codex: { command: "codex", found: overrides.codexFound ?? false },
claude: { command: "claude", found: overrides.claudeFound ?? false },
gemini: { command: "gemini", found: false },
apiKeys: { openai: false, anthropic: false },
},
gateway: { url: "ws://127.0.0.1:18789", source: "local", reachable: false },
references: {
docsUrl: "https://docs.openclaw.ai",
sourceUrl: "https://github.com/openclaw/openclaw",
},
}) as never;
}
export { expectDefined } from "@openclaw/normalization-core";
export { hashSystemAgentOperation } from "../agents/tools/system-agent-tool.js";
export type { OpenClawConfig } from "../config/types.openclaw.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { runSystemAgentTurnWithDeps } from "./agent-turn.test-support.js";
export { classifySystemAgentApprovalText } from "./operator-approval.js";
export { SystemAgentWizardAnswerError } from "./chat-engine.js";
export type { SystemAgentChatEngineOptions } from "./chat-engine.js";
export { SystemAgentInferenceUnavailableError } from "./inference-error.js";
export { verifyConfigAfterSystemAgentWrite } from "./post-write-verification.js";
export type { SystemAgentVerifiedInferenceBinding } from "./verified-inference.js";
export { RuntimeSystemAgentChatEngine };
export { mocks };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,674 @@
import { describe, expect, it, vi } from "vitest";
import {
fakeOverviewLoader,
sharedVerifiedInference,
classifySystemAgentApprovalText,
mocks,
useTempStateDir,
SystemAgentChatEngine,
expectDefined,
hashSystemAgentOperation,
type SystemAgentVerifiedInferenceBinding,
} from "./chat-engine.test-support.js";
describe("SystemAgentChatEngine approval", () => {
it("lets only an operator arm delegated persistent writes", async () => {
useTempStateDir();
const operation = { kind: "config-set" as const, path: "gateway.port", value: "19001" };
const proposalHash = hashSystemAgentOperation(operation);
const armed: boolean[] = [];
const observedInputs: string[] = [];
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({
operatorApprovalOnly: true,
runAgentTurn: async (params) => {
armed.push(params.approvalArmed);
observedInputs.push(params.input);
if (observedInputs.length === 1) {
params.session.proposalRef.current = proposalHash;
params.session.proposalRef.operation = operation;
}
return { text: "Change ready." };
},
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
await engine.handle("Change port.");
const agentApproval = await engine.handle("yes");
expect(agentApproval.text).toContain("Approval pending");
expect(armed).toEqual([false]);
expect(runConfigSet).not.toHaveBeenCalled();
const wrongProposal = await engine.resolveOperatorApproval("allow-once", "wrong-hash");
expect(wrongProposal).toBeNull();
expect(runConfigSet).not.toHaveBeenCalled();
const applied = await engine.resolveOperatorApproval("allow-once", proposalHash);
const duplicate = await engine.resolveOperatorApproval("allow-once", proposalHash);
await engine.handle("what changed?");
expect(armed).toEqual([false, false]);
expect(runConfigSet).toHaveBeenCalledOnce();
expect(runConfigSet).toHaveBeenCalledWith({
path: "gateway.port",
value: "19001",
cliOptions: {},
});
expect(applied?.text).toContain("[openclaw] done: config.set");
expect(duplicate).toBeNull();
expect(observedInputs[1]).toContain("[proposal-resolved]");
expect(observedInputs[1]).toContain("was approved");
expect(observedInputs[1]).not.toContain("host-seeded");
});
it("refuses delegated hosted-setup directives instead of starting wizards", async () => {
useTempStateDir();
const runChannelSetupWizard = vi.fn(async () => {});
const runSkillsSetupWizard = vi.fn(async () => {});
const runSearchSetupWizard = vi.fn(async () => {});
const runMemoryImportWizard = vi.fn(async () => ({
status: "nothing-to-import" as const,
providers: [],
}));
const engine = new SystemAgentChatEngine({
operatorApprovalOnly: true,
runAgentTurn: async () => ({
text: "Setting up.",
directive: { kind: "channel-setup", channel: "telegram" },
}),
runChannelSetupWizard,
runSkillsSetupWizard,
runSearchSetupWizard,
runMemoryImportWizard,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("connect telegram");
expect(reply.text).toContain("human operator");
expect(reply.action).toBe("none");
expect((await engine.handle("configure skills")).text).toContain("human operator");
expect((await engine.handle("configure search")).text).toContain("human operator");
expect((await engine.handle("import memory")).text).toContain("human operator");
expect(runChannelSetupWizard).not.toHaveBeenCalled();
expect(runSkillsSetupWizard).not.toHaveBeenCalled();
expect(runSearchSetupWizard).not.toHaveBeenCalled();
expect(runMemoryImportWizard).not.toHaveBeenCalled();
});
it("applies a delegated host proposal without another model turn", async () => {
useTempStateDir();
const runAgentTurn = vi.fn(async () => ({ text: "must not run" }));
const runConfigSet = vi.fn(async () => {});
const operation = { kind: "config-set" as const, path: "gateway.port", value: "19001" };
const engine = new SystemAgentChatEngine({
operatorApprovalOnly: true,
runAgentTurn,
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
engine.propose(operation);
const pending = await engine.handle("yes");
const applied = await engine.resolveOperatorApproval(
"allow-once",
hashSystemAgentOperation(operation),
);
expect(pending.text).toContain("Approval pending");
expect(runAgentTurn).not.toHaveBeenCalled();
expect(runConfigSet).toHaveBeenCalledOnce();
expect(applied?.text).toContain("[openclaw] done: config.set");
expect(engine.hasPendingProposal()).toBe(false);
});
it("applies a seeded proposal on a bare yes with verified inference", async () => {
useTempStateDir();
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({ deps: { runConfigSet } });
const plan = engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
expect(plan).toContain("gateway.port");
expect(engine.hasPendingProposal()).toBe(true);
const reply = await engine.handle("yes");
expect(runConfigSet).toHaveBeenCalledOnce();
expect(reply.action).toBe("none");
expect(reply.text).toContain("[openclaw] done: config.set");
expect(engine.hasPendingProposal()).toBe(false);
});
it("hatches into the agent after a fresh setup applies", async () => {
useTempStateDir();
const verifyInferenceConfig = vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.5",
latencyMs: 100,
}));
const applySetup = vi.fn(async () => ({
configPath: "/tmp/openclaw.json",
configHashBefore: "before",
configHashAfter: "after",
bootstrapPending: true,
workspaceReady: true,
gateway: { status: "ready" as const, action: "reused" as const },
lines: ["Workspace: /tmp/hatch-work"],
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: {
applySetup,
verifyInferenceConfig,
loadOverview: fakeOverviewLoader({ defaultModel: "openai/gpt-5.5" }),
},
});
engine.propose({ kind: "setup", workspace: "/tmp/hatch-work" });
const reply = await engine.handle("yes");
expect(applySetup).toHaveBeenCalledOnce();
expect(reply.action).toBe("open-tui");
expect(reply.agentDraft).toBe("hatch");
expect(reply.handoff).toMatchObject({
kind: "open-tui",
workspace: "/tmp/hatch-work",
agentDraft: "hatch",
});
expect(reply.text).toContain("Your agent is hatching");
expect(reply.text).toContain("Settings → Ask OpenClaw");
});
it("hatches into a newly created agent and carries its id", async () => {
useTempStateDir();
const createAgent = vi.fn(async () => ({
status: "created" as const,
agentId: "researcher",
name: "researcher",
workspace: "/tmp/researcher",
agentDir: "/tmp/agent-researcher",
bootstrapPending: true,
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: { createAgent, loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "create-agent", agentId: "researcher" });
const reply = await engine.handle("yes");
expect(createAgent).toHaveBeenCalledWith({ name: "researcher" });
expect(reply.action).toBe("open-tui");
expect(reply.handoff).toMatchObject({
kind: "open-tui",
agentId: "researcher",
agentDraft: "hatch",
});
});
it("stays in setup when an established workspace has no bootstrap pending", async () => {
useTempStateDir();
const applySetup = vi.fn(async () => ({
configPath: "/tmp/openclaw.json",
configHashBefore: "before",
configHashAfter: "after",
bootstrapPending: false,
workspaceReady: true,
gateway: { status: "ready" as const, action: "reused" as const },
lines: ["Workspace: /tmp/established-work"],
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: {
applySetup,
verifyInferenceConfig: vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.5",
latencyMs: 100,
})),
loadOverview: fakeOverviewLoader({ defaultModel: "openai/gpt-5.5" }),
},
});
engine.propose({ kind: "setup", workspace: "/tmp/established-work" });
const reply = await engine.handle("yes");
expect(reply.action).toBe("none");
expect(reply.agentDraft).toBeUndefined();
expect(reply.handoff).toBeUndefined();
expect(reply.text).not.toContain("Your agent is hatching");
});
it("stays in setup when post-write verification flags the config", async () => {
useTempStateDir();
const verifyInferenceConfig = vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.5",
latencyMs: 100,
}));
let applied = false;
const applySetup = vi.fn(async () => {
applied = true;
return {
configPath: "/tmp/openclaw.json",
configHashBefore: "before",
configHashAfter: "after",
bootstrapPending: true,
workspaceReady: true,
gateway: { status: "ready" as const, action: "reused" as const },
lines: ["Workspace: /tmp/hatch-work"],
};
});
// The written config turns out invalid: post-write verification must hold
// the user in setup instead of hatching into an agent that cannot answer.
// Reads stay valid through preflight/apply and flip only after the write.
const validSnapshot = mocks.readConfigFileSnapshot.getMockImplementation()!;
mocks.readConfigFileSnapshot.mockImplementation(async () => {
const snapshot = await validSnapshot();
return applied
? ({
...snapshot,
valid: false,
issues: [{ path: "agents", message: "broken" }],
} as never)
: snapshot;
});
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => ({ text: "repair suggestion" }),
planWithAssistant: async () => null,
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: {
applySetup,
verifyInferenceConfig,
loadOverview: fakeOverviewLoader({ defaultModel: "openai/gpt-5.5" }),
},
});
engine.propose({ kind: "setup", workspace: "/tmp/hatch-work" });
const reply = await engine.handle("yes");
expect(applySetup).toHaveBeenCalledOnce();
expect(reply.action).toBe("none");
expect(reply.agentDraft).toBeUndefined();
expect(reply.handoff).toBeUndefined();
expect(reply.text).not.toContain("Your agent is hatching");
});
it("does not hand off when a non-setup persistent operation applies", async () => {
useTempStateDir();
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({ deps: { runConfigSet } });
engine.propose({ kind: "config-set", path: "gateway.port", value: "19002" });
const reply = await engine.handle("yes");
expect(reply.action).toBe("none");
expect(reply.agentDraft).toBeUndefined();
expect(reply.handoff).toBeUndefined();
});
it("routes model provider changes out of the active inference session", async () => {
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("configure model provider workspace /tmp/gateway-work");
expect(reply.action).toBe("none");
expect(reply.handoff).toBeUndefined();
expect(reply.sensitive).toBeUndefined();
expect(reply.text).toContain("replace the inference route powering this session");
// A gateway reader is in a browser or the app and cannot "exit OpenClaw"
// into a shell; the copy must name where the command runs instead.
expect(reply.text).toContain("`openclaw onboard`");
expect(reply.text).toContain("machine running OpenClaw");
expect(reply.text).toContain("Stop the OpenClaw host");
expect(reply.text).toContain("restart the host");
expect(reply.text).toContain("return to OpenClaw");
expect(reply.text).not.toContain("Exit OpenClaw");
});
it("keeps the current inference route when model provider setup is declined", async () => {
const engine = new SystemAgentChatEngine();
engine.propose({ kind: "model-setup" });
const reply = await engine.handle("not now");
expect(reply.text).toContain("current inference route is unchanged");
expect(engine.hasPendingProposal()).toBe(false);
});
it("drops the proposal when the user declines", async () => {
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({ deps: { runConfigSet } });
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
const reply = await engine.handle("no thanks");
expect(runConfigSet).not.toHaveBeenCalled();
expect(reply.text).toContain("Skipped");
expect(engine.hasPendingProposal()).toBe(false);
});
it("voids an agent-loop proposal on decline and lets the AI acknowledge", async () => {
let observedProposalOnSecondTurn: string | undefined = "sentinel";
const runAgentTurn = vi.fn(
async (params: { session: { proposalRef: { current?: string } } }) => {
if (runAgentTurn.mock.calls.length === 1) {
params.session.proposalRef.current = "registered-operation";
return { text: "I can change that after your approval." };
}
observedProposalOnSecondTurn = params.session.proposalRef.current;
return { text: "Okay, leaving it as is." };
},
);
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
classifyApproval: async ({ message }) => classifySystemAgentApprovalText(message),
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("change the model");
const declined = await engine.handle("no thanks");
// The decline voids the registered hash before the AI turn, so a later
// generic approval can never arm the stale mutation.
expect(observedProposalOnSecondTurn).toBeUndefined();
expect(declined.text).toContain("leaving it as is");
expect(runAgentTurn).toHaveBeenCalledTimes(2);
});
it("arms an agent turn when the classifier approves in the user's own words", async () => {
const armedFlags: boolean[] = [];
let classifierBinding: SystemAgentVerifiedInferenceBinding | undefined;
const runAgentTurn = vi.fn(
async (params: {
approvalArmed: boolean;
session: { proposalRef: { current?: string } };
}) => {
armedFlags.push(params.approvalArmed);
params.session.proposalRef.current = "op-hash";
return { text: "ok" };
},
);
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
classifyApproval: async ({ message, verifiedInference }) => {
classifierBinding = verifiedInference;
return message.includes("sounds great") ? "approve" : "other";
},
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("switch me to gpt");
await engine.handle("that sounds great, please");
expect(armedFlags).toEqual([false, true]);
expect(classifierBinding).toBe(sharedVerifiedInference);
});
it("clears a stale host proposal once the agent loop owns the conversation", async () => {
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
params.session.proposalRef.current = "agent-proposal";
return { text: "loop reply" };
},
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
await engine.handle("actually, tell me about workspaces first");
// A later approval must arm the loop's own proposal, not the stale one.
expect(engine.hasPendingProposal()).toBe(false);
});
it("keeps a host setup proposal when the loop only answers a question", async () => {
let observedInput = "";
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
observedInput = params.input;
return { text: "A workspace is where your agent keeps its project files." };
},
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader() },
});
engine.propose({
kind: "setup",
workspace: "/tmp/work",
model: "openai/gpt-5.5",
});
await engine.handle("what does workspace mean?");
expect(engine.hasPendingProposal()).toBe(true);
expect(observedInput).toContain('"model":"openai/gpt-5.5"');
expect(observedInput).toContain("Keep the verified model");
});
it("preserves the verified setup model when planner fallback changes only the workspace", async () => {
useTempStateDir();
const verifyInferenceConfig = vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.5",
latencyMs: 100,
}));
const applySetup = vi.fn(async () => ({
configPath: "/tmp/openclaw.json",
configHashBefore: "before",
configHashAfter: "after",
bootstrapPending: false,
workspaceReady: true,
gateway: { status: "ready" as const, action: "reused" as const },
lines: ["Workspace: /tmp/new-work"],
}));
let pendingOperation = "";
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async (params) => {
pendingOperation = params.pendingOperation ?? "";
return {
reply: "I'll use the new workspace and keep the selected AI route.",
command: "setup workspace /tmp/new-work",
modelLabel: "planner",
};
},
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: {
applySetup,
verifyInferenceConfig,
loadOverview: fakeOverviewLoader({ defaultModel: "openai/gpt-5.5" }),
},
});
engine.propose({
kind: "setup",
workspace: "/tmp/old-work",
model: "openai/gpt-5.5",
});
const revised = await engine.handle("put the workspace under /tmp/new-work instead");
expect(revised.text).toContain("Model choice: keep verified default openai/gpt-5.5.");
expect(pendingOperation).toContain('"model":"openai/gpt-5.5"');
await engine.handle("yes");
expect(verifyInferenceConfig).toHaveBeenCalledOnce();
expect(applySetup).toHaveBeenCalledWith(
expect.objectContaining({
workspace: "/tmp/new-work",
expectedInferenceRoute: expect.objectContaining({
route: expect.objectContaining({ modelLabel: "openai/gpt-5.5" }),
}),
}),
expect.any(Object),
);
});
it("tells the agent loop when a preserved proposal was resolved", async () => {
const observedInputs: string[] = [];
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
observedInputs.push(params.input);
return { text: "answer" };
},
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: { loadOverview: fakeOverviewLoader(), runConfigSet },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
await engine.handle("why that port?");
await engine.handle("yes");
await engine.handle("what next?");
expect(runConfigSet).toHaveBeenCalledOnce();
expect(observedInputs).toHaveLength(2);
expect(observedInputs[1]).toContain("[proposal-resolved]");
expect(observedInputs[1]).toContain("was approved");
});
it("keeps a host-resolution marker queued across planner fallback", async () => {
const observedInputs: string[] = [];
const runConfigSet = vi.fn(async () => {});
const runAgentTurn = vi.fn(async (params: { input: string }) => {
observedInputs.push(params.input);
return observedInputs.length === 1 ? null : { text: "native reply" };
});
const planner = vi.fn(async () => ({ reply: "planner fallback", modelLabel: "planner" }));
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner,
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: { loadOverview: fakeOverviewLoader(), runConfigSet },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
await engine.handle("yes");
await engine.handle("what next?");
await engine.handle("try the native session again");
await engine.handle("and now?");
expect(planner).toHaveBeenCalledOnce();
expect(observedInputs).toHaveLength(3);
expect(observedInputs[0]).toContain("was approved");
expect(observedInputs[1]).toContain("was approved");
expect(observedInputs[2]).not.toContain("proposal-resolved");
});
it("clears both proposal stores when the agent takes a directive", async () => {
const armedFlags: boolean[] = [];
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
armedFlags.push(params.approvalArmed);
if (armedFlags.length === 1) {
params.session.proposalRef.current = "agent-proposal";
return {
text: "Opening setup.",
directive: { kind: "open-setup" as const, target: "guided" as const },
};
}
return { text: "No pending change." };
},
classifyApproval: async ({ message }) => (message === "yes" ? "approve" : "other"),
deps: { loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
await engine.handle("use the wizard instead");
await engine.handle("yes");
expect(engine.hasPendingProposal()).toBe(false);
expect(armedFlags).toEqual([false, false]);
});
it("never injects exact sensitive config JSON into a follow-up model turn", async () => {
let observedInput = "";
const secret = "123:very-secret";
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
observedInput = params.input;
return { text: "That is the Telegram bot credential." };
},
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader(), runConfigSet: vi.fn(async () => {}) },
});
await engine.handle(`config set channels.telegram.botToken ${secret}`);
await engine.handle("what is that setting?");
expect(observedInput).not.toContain(secret);
expect(observedInput).toContain("<redacted>");
});
it("keeps an exact sensitive config set away from every model path", async () => {
useTempStateDir();
const runAgentTurn = vi.fn(async () => ({ text: "should never run" }));
const planner = vi.fn(async () => ({ reply: "should never run" }));
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
planWithAssistant: planner as never,
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
const proposed = await engine.handle("config set channels.telegram.botToken 123:very-secret");
expect(runAgentTurn).not.toHaveBeenCalled();
expect(planner).not.toHaveBeenCalled();
expect(proposed.text).toContain("<redacted>");
expect(proposed.text).not.toContain("very-secret");
expect(engine.hasPendingProposal()).toBe(true);
const applied = await engine.handle("yes");
expect(runConfigSet).toHaveBeenCalledOnce();
expect(applied.text).toContain("[openclaw] done: config.set");
});
it("redacts sensitive config-set values from the AI-visible history", async () => {
const planner = vi.fn(async (_params: { history?: Array<{ role: string; text: string }> }) => ({
reply: "noted",
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner as never,
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("config set channels.telegram.botToken 123:very-secret");
await engine.handle("did that work?");
const history = planner.mock.calls.at(-1)?.[0]?.history ?? [];
const userTurns = history.filter((turn) => turn.role === "user").map((turn) => turn.text);
expect(userTurns.some((text) => text.includes("very-secret"))).toBe(false);
expect(userTurns.some((text) => text.includes("<redacted secret>"))).toBe(true);
});
it("keeps a pending proposal when the user asks a question instead of yes/no", async () => {
const planner = vi.fn(async (_params: { input: string; pendingOperation?: string }) => ({
reply: "A workspace is where your agent keeps its files.",
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner,
classifyApproval: async () => "other",
deps: { loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
const reply = await engine.handle("wait, what's a workspace?");
expect(reply.text).toContain("agent keeps its files");
expect(engine.hasPendingProposal()).toBe(true);
const call = expectDefined(planner.mock.calls[0], "planner.mock.calls[0] test invariant")[0];
expect(call.pendingOperation).toContain("gateway.port");
});
});
@@ -0,0 +1,743 @@
import { describe, expect, it, vi } from "vitest";
import {
fakeOverviewLoader,
sharedVerifiedInferenceConfig,
classifySystemAgentApprovalText,
runSystemAgentTurnWithDeps,
mocks,
useTempStateDir,
configSnapshot,
createAmbientVerifiedBinding,
createOAuthVerifiedBinding,
createCliVerifiedBinding,
SystemAgentChatEngine,
expectDefined,
SystemAgentInferenceUnavailableError,
verifyConfigAfterSystemAgentWrite,
type OpenClawConfig,
type WizardPrompter,
} from "./chat-engine.test-support.js";
const loggingMocks = vi.hoisted(() => ({ chatWarn: vi.fn() }));
vi.mock("../logging/subsystem.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../logging/subsystem.js")>();
return {
...actual,
createSubsystemLogger: (subsystem: string) =>
subsystem === "system-agent/chat-engine"
? ({ warn: loggingMocks.chatWarn } as unknown as ReturnType<
typeof actual.createSubsystemLogger
>)
: actual.createSubsystemLogger(subsystem),
};
});
describe("SystemAgentChatEngine operations", () => {
it("signals the exact agent handoff without an inference turn", async () => {
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("talk to agent");
expect(reply.action).toBe("open-tui");
expect(reply.handoff?.kind).toBe("open-tui");
});
it("handles the exact agent handoff without consulting a usable model", async () => {
const runAgentTurn = vi.fn(async () => ({ text: "model reply without a directive" }));
const engine = new SystemAgentChatEngine({
runAgentTurn,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("talk to agent");
expect(runAgentTurn).not.toHaveBeenCalled();
expect(reply.action).toBe("open-tui");
expect(reply.handoff).toEqual({ kind: "open-tui" });
});
it("executes an open-tui directive from the agent loop", async () => {
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => ({
text: "Handing you over. *waves claw*",
directive: { kind: "open-tui" as const, agentId: "work" },
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("I want to talk to my work agent now");
expect(reply.action).toBe("open-tui");
expect(reply.handoff).toMatchObject({ kind: "open-tui", agentId: "work" });
expect(reply.text).toContain("Handing you over");
});
it("retires an agent proposal before a reusable Gateway handoff", async () => {
const armed: boolean[] = [];
let turn = 0;
const classifyApproval = vi.fn(async ({ message }: { message: string }) =>
classifySystemAgentApprovalText(message),
);
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
turn += 1;
armed.push(params.approvalArmed);
if (turn === 1) {
params.session.proposalRef.current = "stale-operation";
}
return turn === 2
? {
text: "Handing you over.",
directive: { kind: "open-tui" as const, agentId: "work" },
}
: { text: "Agent reply." };
},
classifyApproval: classifyApproval as never,
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("prepare a change");
expect((await engine.handle("please hand me back now")).action).toBe("open-tui");
await engine.handle("yes");
expect(classifyApproval).toHaveBeenCalledOnce();
expect(armed).toEqual([false, false, false]);
});
it("does not replay a failed host directive through the planner", async () => {
const planner = vi.fn(async () => ({ reply: "should not run" }));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => ({
text: "Opening setup.",
directive: { kind: "channel-setup" as const, channel: "telegram" },
}),
planWithAssistant: planner,
runChannelSetupWizard: async () => {
throw new Error("wizard exploded");
},
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("connect telegram for me");
expect(reply.text).toContain("wizard exploded");
expect(planner).not.toHaveBeenCalled();
});
it("routes an inference-setup directive out of the agent loop", async () => {
const engine = new SystemAgentChatEngine({
surface: "cli",
runAgentTurn: async () => ({
text: "Opening the menu wizard.",
directive: { kind: "open-setup" as const, target: "guided" as const },
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("I would rather use menus");
expect(reply.action).toBe("none");
expect(reply.handoff).toBeUndefined();
expect(reply.text).toContain("Opening the menu wizard");
expect(reply.text).toContain("run `openclaw onboard`");
});
it("starts the channel wizard from an agent-loop directive", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => ({
text: "Telegram it is — setup questions follow.",
directive: { kind: "channel-setup" as const, channel: "telegram" },
}),
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
});
const reply = await engine.handle("hook me up with telegram please");
expect(reply.text).toContain("Telegram it is");
expect(reply.text).toContain("Bot token");
});
it("rejects an agent directive when the verified route changes during its turn", async () => {
const baseConfig = {
agents: { defaults: { model: "openai/gpt-5.5" } },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test-key",
auth: "api-key",
models: [],
},
},
},
} satisfies OpenClawConfig;
const changedConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-8" } },
} satisfies OpenClawConfig;
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
const readConfigFileSnapshot = vi
.fn()
.mockResolvedValueOnce(configSnapshot(baseConfig))
.mockResolvedValueOnce(configSnapshot(baseConfig))
.mockResolvedValue(configSnapshot(changedConfig));
const runChannelSetupWizard = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({
verifiedInference,
runAgentTurn: async () => ({
text: "Telegram it is.",
directive: { kind: "channel-setup" as const, channel: "telegram" },
}),
deps: {
readConfigFileSnapshot: readConfigFileSnapshot as never,
loadOverview: fakeOverviewLoader(),
},
runChannelSetupWizard,
});
await expect(engine.handle("please connect a messaging channel")).rejects.toBeInstanceOf(
SystemAgentInferenceUnavailableError,
);
expect(runChannelSetupWizard).not.toHaveBeenCalled();
});
it("rejects an approved agent operation when OAuth rotates at the persistent-apply boundary", async () => {
const config = {
agents: { defaults: { model: "anthropic/claude-opus-4-8@anthropic:oauth" } },
auth: { profiles: { "anthropic:oauth": { provider: "anthropic", mode: "oauth" } } },
} satisfies OpenClawConfig;
let credential = {
type: "oauth" as const,
provider: "anthropic",
access: "access-a",
refresh: "refresh-a",
expires: 1,
};
const verifiedInference = await createOAuthVerifiedBinding(config, credential);
const runConfigSet = vi.fn(async () => {});
let authReads = 0;
const engine = new SystemAgentChatEngine({
verifiedInference,
runAgentTurn: async () => ({
text: "Applying the approved port change.",
directive: {
kind: "approved-operation" as const,
operation: { kind: "config-set" as const, path: "gateway.port", value: "19001" },
},
}),
deps: {
readConfigFileSnapshot: vi.fn(async () => configSnapshot(config)) as never,
ensureAuthProfileStore: vi.fn(() => {
authReads += 1;
// Turn start, overview, and post-agent checks see the verified grant.
// The fourth read is the last-moment guard inside applyPersistentOperation.
if (authReads === 4) {
credential = { ...credential, access: "access-b", refresh: "refresh-b" };
}
return { version: 1, profiles: { "anthropic:oauth": credential } };
}) as never,
runConfigSet,
loadOverview: fakeOverviewLoader(),
},
});
await expect(engine.handle("yes, apply that exact port change")).rejects.toBeInstanceOf(
SystemAgentInferenceUnavailableError,
);
expect(runConfigSet).not.toHaveBeenCalled();
});
it("applies an approved agent operation across a stable-identity OAuth refresh", async () => {
useTempStateDir();
const config = {
agents: { defaults: { model: "anthropic/claude-opus-4-8@anthropic:oauth" } },
auth: { profiles: { "anthropic:oauth": { provider: "anthropic", mode: "oauth" } } },
} satisfies OpenClawConfig;
let credential = {
type: "oauth" as const,
provider: "anthropic",
access: "access-a",
refresh: "refresh-a",
expires: 1,
accountId: "account-1",
};
const verifiedInference = await createOAuthVerifiedBinding(config, credential);
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({
verifiedInference,
runAgentTurn: async () => {
credential = { ...credential, access: "access-b", refresh: "refresh-b", expires: 2 };
return {
text: "Applying the approved port change.",
directive: {
kind: "approved-operation" as const,
operation: { kind: "config-set" as const, path: "gateway.port", value: "19001" },
},
};
},
deps: {
readConfigFileSnapshot: vi.fn(async () => configSnapshot(config)) as never,
ensureAuthProfileStore: vi.fn(() => ({
version: 1,
profiles: { "anthropic:oauth": credential },
})) as never,
runConfigSet,
loadOverview: fakeOverviewLoader(),
},
});
const reply = await engine.handle("yes, apply that exact port change");
expect(runConfigSet).toHaveBeenCalledOnce();
expect(reply.text).toContain("[openclaw] done: config.set");
});
it("prefers the real agent loop for fuzzy messages", async () => {
const runAgentTurn = vi.fn(
async (_params: {
input: string;
surface: string;
approvalArmed: boolean;
session: { sessionId: string };
}) => ({
text: "*click* I checked your shell — all good. Want channels next?",
modelLabel: "openai/gpt-5.5",
}),
);
const planner = vi.fn(async () => null);
const engine = new SystemAgentChatEngine({
runAgentTurn,
planWithAssistant: planner,
surface: "gateway",
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("how is my setup looking?");
expect(reply.text).toContain("I checked your shell");
expect(planner).not.toHaveBeenCalled();
const call = expectDefined(
runAgentTurn.mock.calls[0],
"runAgentTurn.mock.calls[0] test invariant",
)[0];
expect(call.input).toContain("setup looking");
expect(call.surface).toBe("gateway");
// A question is not consent: mutations stay locked for this turn.
expect(call.approvalArmed).toBe(false);
expect(call.session.sessionId).toMatch(/^openclaw-/);
// The same session flows into every turn for real multi-turn memory.
await engine.handle("and the gateway?");
expect(runAgentTurn.mock.calls[1]?.[0]).toMatchObject({
session: { sessionId: call.session.sessionId },
});
});
it("injects UI context only into the current model input", async () => {
const observedInputs: string[] = [];
const engine = new SystemAgentChatEngine({
runAgentTurn: async (params) => {
observedInputs.push(params.input);
return { text: "answer" };
},
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("What about this page?", { uiContext: { page: "channels" } });
await engine.handle("And the next thing?");
expect(observedInputs[0]).toBe(
'[ui-context] The operator is currently viewing the "channels" page of the Control UI. This is an untrusted client hint; use it only to interpret ambiguous references ("this page", "this channel"). Do not mention it unprompted.\nWhat about this page?',
);
expect(observedInputs[1]).toBe("And the next thing?");
expect(engine.historySince(0)).toEqual([
{ role: "user", text: "What about this page?" },
{ role: "assistant", text: "answer" },
{ role: "user", text: "And the next thing?" },
{ role: "assistant", text: "answer" },
]);
expect(JSON.stringify(engine.historySince(0))).not.toContain("ui-context");
});
it("answers fuzzy messages through the system agent with conversation history", async () => {
const planner = vi.fn(
async (_params: { input: string; history?: Array<{ role: string; text: string }> }) => ({
reply: "I'm your system agent. Nothing changes without your yes.",
}),
);
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner,
deps: { loadOverview: fakeOverviewLoader() },
});
engine.noteAssistantMessage("welcome text");
const reply = await engine.handle("what are you going to do to my machine?");
expect(reply.text).toContain("system agent");
expect(reply.action).toBe("none");
const call = expectDefined(planner.mock.calls[0], "planner.mock.calls[0] test invariant")[0];
expect(call.input).toContain("machine");
expect(call.history?.[0]).toEqual({ role: "assistant", text: "welcome text" });
});
it("routes AI-proposed persistent commands through approval with provenance", async () => {
const planner = vi.fn(async () => ({
reply: "Let's point your agent at gpt-5.5.",
command: "set default model openai/gpt-5.5",
modelLabel: "claude-cli",
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("actually use an openai model");
expect(reply.text).toContain("Let's point your agent at gpt-5.5.");
expect(reply.text).toContain("(claude-cli → `set default model openai/gpt-5.5`)");
expect(reply.text).toContain("Apply this operation");
expect(engine.hasPendingProposal()).toBe(true);
});
it("records an executor-reported interactive exit without sniffing reply text", async () => {
const executeOperation = vi.fn(async (_operation, runtime) => {
runtime.log("Interactive session closed.");
return { applied: false, exitsInteractive: true };
});
const engine = new SystemAgentChatEngine({
yes: true,
executeOperation,
runAgentTurn: async () => null,
planWithAssistant: async () => ({
reply: "Checking the local session.",
command: "status",
modelLabel: "openai/gpt-5.5",
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("show status");
expect(reply.text).toContain("Interactive session closed.");
expect(reply.action).toBe("exit");
});
it("rebinds the live conversation after changing its default model", async () => {
useTempStateDir();
const baseConfig = structuredClone(sharedVerifiedInferenceConfig);
const changedConfig = {
...baseConfig,
agents: {
...baseConfig.agents,
list: baseConfig.agents.list.map((agent) => ({ ...agent, model: "openai/gpt-5.6-sol" })),
},
} satisfies OpenClawConfig;
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
const reboundInference = await createAmbientVerifiedBinding(changedConfig);
let currentConfig: OpenClawConfig = baseConfig;
const executeOperation = vi.fn(async (_operation, runtime, options) => {
currentConfig = changedConfig;
options.onVerifiedInferenceChanged?.(reboundInference);
runtime.log("Default model: openai/gpt-5.6-sol");
return { applied: true };
});
const runAgentTurn = vi.fn(async (params) => {
if (currentConfig === baseConfig) {
return null;
}
return { text: `using ${params.session.verifiedInference.execution.modelLabel}` };
});
const engine = new SystemAgentChatEngine({
yes: true,
verifiedInference,
executeOperation,
runAgentTurn,
planWithAssistant: async () => ({
reply: "Switching models.",
command: "set default model openai/gpt-5.6-sol",
modelLabel: "openai/gpt-5.5",
}),
deps: {
readConfigFileSnapshot: vi.fn(async () => configSnapshot(currentConfig)) as never,
loadOverview: fakeOverviewLoader({ defaultModel: "openai/gpt-5.5" }),
},
});
const changed = await engine.handle("switch models");
const next = await engine.handle("which model is active now?");
expect(changed.text).toContain("Default model: openai/gpt-5.6-sol");
expect(next.text).toBe("using openai/gpt-5.6-sol");
expect(executeOperation).toHaveBeenCalledOnce();
expect(runAgentTurn).toHaveBeenLastCalledWith(
expect.objectContaining({
session: expect.objectContaining({ verifiedInference: reboundInference }),
}),
);
});
it("verifies config after an applied write and drives a self-fix turn", async () => {
useTempStateDir();
const planner = vi.fn(async (params: { input: string }) => {
if (params.input.startsWith("[config-verify]")) {
return {
reply: "That port was not a number — here is the fix.",
command: "config set gateway.port 18789",
modelLabel: "claude-cli",
};
}
return null;
});
// The write flips the config to invalid: every snapshot read after the
// stubbed set reports validation issues (audit reads happen before/after).
const runInvalidConfigSet = vi.fn(async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
path: "/tmp/openclaw.json",
hash: "h",
config: {},
sourceConfig: {},
issues: [{ path: "gateway.port", message: "Expected number, received string" }],
} as never);
});
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner as never,
deps: { runConfigSet: runInvalidConfigSet, loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "banana" });
const reply = await engine.handle("yes");
expect(reply.text).toContain("failed validation");
expect(reply.text).toContain("gateway.port: Expected number, received string");
expect(reply.text).toContain("That port was not a number");
expect(reply.text).toContain("config set gateway.port 18789");
// The corrective write is proposed, not auto-applied.
expect(engine.hasPendingProposal()).toBe(true);
expect(planner.mock.calls[0]?.[0]?.input).toContain("[config-verify]");
});
it("reports an applied invalid write when inference cannot propose a repair", async () => {
useTempStateDir();
const runInvalidConfigSet = vi.fn(async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
path: "/tmp/openclaw.json",
hash: "h",
config: {},
sourceConfig: {},
issues: [{ path: "gateway.port", message: "Expected number, received string" }],
} as never);
});
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => {
throw new SystemAgentInferenceUnavailableError("agent-turn");
},
planWithAssistant: async () => null,
deps: { runConfigSet: runInvalidConfigSet, loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "banana" });
const reply = await engine.handle("yes");
expect(runInvalidConfigSet).toHaveBeenCalledOnce();
expect(reply.text).toContain("failed validation");
expect(reply.text).toContain("The write was applied");
expect(reply.text).toContain("openclaw doctor --fix");
});
it("keeps doctor repair outside OpenClaw when no post-write repair is proposed", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
path: "/tmp/openclaw.json",
hash: "h",
config: {},
sourceConfig: {},
issues: [{ path: "gateway.port", message: "Expected number" }],
} as never);
const reply = await verifyConfigAfterSystemAgentWrite(async () => ({ text: "" }));
expect(reply).toContain("with OpenClaw stopped");
expect(reply).toContain("openclaw doctor --fix");
expect(reply).toContain("machine running it");
});
it("warns when an applied write leaves no config to verify", async () => {
useTempStateDir();
const runConfigSet = vi.fn(async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
path: "/tmp/openclaw.json",
hash: null,
config: {},
sourceConfig: {},
issues: [],
} as never);
});
const engine = new SystemAgentChatEngine({ deps: { runConfigSet } });
engine.propose({ kind: "config-set", path: "gateway.port", value: "18789" });
const reply = await engine.handle("yes");
expect(runConfigSet).toHaveBeenCalledOnce();
expect(reply.text).toContain("The write was applied");
expect(reply.text).toContain("post-write verification is unavailable");
expect(reply.text).toContain("openclaw.json was not found");
expect(reply.text).toContain("openclaw doctor --fix");
});
it("warns when the applied write cannot be read back for verification", async () => {
useTempStateDir();
const validSnapshot = {
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "h",
config: {},
sourceConfig: {},
issues: [],
} as never;
mocks.readConfigFileSnapshot
.mockResolvedValueOnce(validSnapshot)
.mockResolvedValueOnce(validSnapshot)
.mockRejectedValueOnce(new Error("snapshot read failed"));
const runConfigSet = vi.fn(async () => {});
const engine = new SystemAgentChatEngine({ deps: { runConfigSet } });
engine.propose({ kind: "config-set", path: "gateway.port", value: "18789" });
const reply = await engine.handle("yes");
expect(runConfigSet).toHaveBeenCalledOnce();
expect(reply.text).toContain("The write was applied");
expect(reply.text).toContain("post-write verification is unavailable");
expect(reply.text).toContain("openclaw.json could not be read");
expect(reply.text).toContain("openclaw doctor --fix");
});
it("stays quiet when the post-write validation passes", async () => {
useTempStateDir();
const runConfigSet = vi.fn(async () => {});
const planner = vi.fn(async () => null);
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: planner as never,
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "18789" });
const reply = await engine.handle("yes");
expect(reply.text).not.toContain("failed validation");
expect(planner).not.toHaveBeenCalled();
});
it("runs a configured claude-cli model through the CLI loop with the ring-zero MCP tool", async () => {
useTempStateDir();
const config = {
agents: {
defaults: {
model: { primary: "claude-cli/claude-opus-4-8" },
},
},
} satisfies OpenClawConfig;
const snapshot = configSnapshot(config);
const inference = await createCliVerifiedBinding(config);
const inferenceDeps = {
...inference.deps,
readConfigFileSnapshot: (async () => snapshot) as never,
};
const runCliAgent = vi.fn(async (_params: Record<string, unknown>) => ({
payloads: [{ text: "*click* CLI loop checked your shell." }],
meta: { agentMeta: { cliSessionBinding: { sessionId: "native-1" } } },
}));
const planner = vi.fn(async () => null);
const engine = new SystemAgentChatEngine({
verifiedInference: inference.binding,
runAgentTurn: (params) =>
runSystemAgentTurnWithDeps(params, {
...inferenceDeps,
runCliAgent: runCliAgent as never,
}),
planWithAssistant: planner,
deps: {
...inferenceDeps,
loadOverview: fakeOverviewLoader({ defaultModel: "claude-cli/claude-opus-4-8" }),
},
});
const reply = await engine.handle("how is my setup looking?");
expect(reply.text).toContain("CLI loop checked your shell");
expect(planner).not.toHaveBeenCalled();
const call = expectDefined(
runCliAgent.mock.calls[0],
"runCliAgent.mock.calls[0] test invariant",
)[0];
expect(call.provider).toBe("claude-cli");
expect(call.model).toBe("claude-opus-4-8");
expect(call.systemAgentTool).toEqual({
surface: "cli",
approvalArmed: false,
proposalRef: {},
directiveRef: {},
});
// CLI harnesses reject toolsAllow; the restriction rides on the MCP config.
expect(call.toolsAllow).toBeUndefined();
expect(call.cliSessionBinding).toBeUndefined();
expect(call.cleanupCliLiveSessionOnRunEnd).toBe(true);
// The captured native CLI session resumes on the next turn.
await engine.handle("and the gateway?");
expect(
expectDefined(runCliAgent.mock.calls[1], "runCliAgent.mock.calls[1] test invariant")[0]
.cliSessionBinding,
).toEqual({ sessionId: "native-1" });
});
it("falls back to the single-turn planner when the CLI loop fails", async () => {
useTempStateDir();
const config = {
agents: {
defaults: {
model: { primary: "claude-cli/claude-opus-4-8" },
},
},
} satisfies OpenClawConfig;
const snapshot = configSnapshot(config);
const inference = await createCliVerifiedBinding(config);
const inferenceDeps = {
...inference.deps,
readConfigFileSnapshot: (async () => snapshot) as never,
};
const runCliAgent = vi.fn(async () => {
throw new Error("claude exploded");
});
const planner = vi.fn(async () => ({ reply: "planner fallback reply" }));
const engine = new SystemAgentChatEngine({
verifiedInference: inference.binding,
runAgentTurn: (params) =>
runSystemAgentTurnWithDeps(params, {
...inferenceDeps,
runCliAgent: runCliAgent as never,
}),
planWithAssistant: planner,
deps: {
...inferenceDeps,
loadOverview: fakeOverviewLoader({ defaultModel: "claude-cli/claude-opus-4-8" }),
},
});
const reply = await engine.handle("do a health check");
expect(runCliAgent).toHaveBeenCalledOnce();
expect(reply.text).toContain("planner fallback reply");
expect(loggingMocks.chatWarn).toHaveBeenCalledWith(expect.stringContaining("claude exploded"));
});
});
+670
View File
@@ -0,0 +1,670 @@
import { isSensitiveConfigPath } from "../config/sensitive-paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { RuntimeEnv } from "../runtime.js";
import type {
SystemAgentSession,
SystemAgentTurnDirective,
SystemAgentTurnRunner,
} from "./agent-turn.js";
import { runSystemAgentTurn } from "./agent-turn.js";
import type { SystemAgentApprovalClassifier } from "./approval-intent.js";
import type { SystemAgentAssistantPlanner, SystemAgentAssistantTurn } from "./assistant.js";
import type {
ChatWizardAnswerResult,
ChatWizardHost,
ChatWizardResult,
SystemAgentChatReply,
} from "./chat-wizard-host.js";
import { approvalQuestion } from "./dialogue.js";
import {
SystemAgentInferenceUnavailableError,
isSystemAgentInferenceUnavailableError,
} from "./inference-error.js";
import {
describeSystemAgentPersistentOperation,
executeSystemAgentOperation,
isPersistentSystemAgentOperation,
parseSystemAgentOperation,
type SystemAgentCommandDeps,
type SystemAgentOperation,
type SystemAgentOperationResult,
} from "./operations.js";
import {
resolveOperatorApprovalDecision,
resolvePendingOperatorProposal,
type SystemAgentApprovalIntent,
} from "./operator-approval.js";
import type { SystemAgentOverview } from "./overview.js";
import type { SystemAgentVerifiedInferenceBinding } from "./verified-inference.js";
const log = createSubsystemLogger("system-agent/chat-engine");
export type SystemAgentChatTurnOptions = {
uiContext?: { page: string };
};
type ChatTurnRouterOptions = {
yes?: boolean;
deps?: SystemAgentCommandDeps;
planWithAssistant?: SystemAgentAssistantPlanner;
runAgentTurn?: SystemAgentTurnRunner;
classifyApproval?: SystemAgentApprovalClassifier;
surface?: "cli" | "gateway";
operatorApprovalOnly?: boolean;
};
type CaptureRuntime = RuntimeEnv & { read: () => string };
function createCaptureRuntime(): CaptureRuntime {
const lines: string[] = [];
return {
log: (...args) => lines.push(args.join(" ")),
error: (...args) => lines.push(args.join(" ")),
exit: (code) => {
throw new Error(`OpenClaw operation exited with code ${String(code)}`);
},
read: () => lines.join("\n").trim(),
};
}
function formatOperationError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return `That did not go through: ${message}`;
}
export function redactSensitiveCommandText(text: string): string {
const operation = parseSystemAgentOperation(text);
if (operation.kind === "config-set" && isSensitiveConfigPath(operation.path)) {
return `config set ${operation.path} <redacted secret>`;
}
return text;
}
function formatPendingOperationForAssistant(operation: SystemAgentOperation): string {
const description = describeSystemAgentPersistentOperation(operation);
return operation.kind === "setup"
? `${description}. Exact setup JSON: ${JSON.stringify(operation)}. Keep the verified model unless the user explicitly asks to leave OpenClaw and reconfigure inference.`
: description;
}
function preservePendingSetupModel(
pending: SystemAgentOperation | null,
operation: SystemAgentOperation,
): SystemAgentOperation {
if (pending?.kind !== "setup" || operation.kind !== "setup") {
return operation;
}
const pendingModel = pending.model?.trim();
const requestedModel = operation.model?.trim();
if (requestedModel && requestedModel !== pendingModel) {
return operation;
}
return { ...operation, ...(requestedModel ? {} : pendingModel ? { model: pendingModel } : {}) };
}
export class ChatTurnRouter {
private pending: SystemAgentOperation | null = null;
private awaitingSetupChannel = false;
private lastSensitiveChannel: string | undefined;
private proposalResolution: "approved" | "declined" | undefined;
constructor(
private readonly options: ChatTurnRouterOptions,
private readonly dependencies: {
executeOperation?: typeof executeSystemAgentOperation;
},
private readonly agentSession: SystemAgentSession,
private readonly wizard: ChatWizardHost,
private readonly callbacks: {
requireVerifiedInference: () => Promise<unknown>;
requirePersistentApplyInference: (runtime: RuntimeEnv) => Promise<unknown>;
rebindVerifiedInference: (binding: SystemAgentVerifiedInferenceBinding) => void;
getVerifiedInference: () => SystemAgentVerifiedInferenceBinding;
loadOverview: () => Promise<SystemAgentOverview>;
getHistory: () => SystemAgentAssistantTurn[];
verifyConfigAfterWrite: () => Promise<string | null>;
},
) {}
propose(operation: SystemAgentOperation): string {
this.clearPendingProposals();
this.pending = operation;
return describeSystemAgentPersistentOperation(operation);
}
hasPendingProposal(): boolean {
return this.pending !== null;
}
getPendingOperatorProposal(): { operation: SystemAgentOperation; hash: string } | null {
return resolvePendingOperatorProposal(this.pending, this.agentSession.proposalRef);
}
async resolveOperatorApproval(
decision: "allow-once" | "allow-always" | "deny" | null,
proposalHash: string,
): Promise<SystemAgentChatReply | null> {
return await resolveOperatorApprovalDecision({
decision,
proposalHash,
getProposal: () => this.getPendingOperatorProposal(),
clear: () => this.clearPendingProposals(),
apply: async (operation) => {
this.proposalResolution = "approved";
return await this.applyApprovedPersistentOperation(operation);
},
denied: () => ({ text: "Denied. No change.", action: "none" as const }),
});
}
clearForInferenceLoss(): void {
this.pending = null;
this.proposalResolution = undefined;
this.agentSession.proposalRef.current = undefined;
this.agentSession.proposalRef.operation = undefined;
this.awaitingSetupChannel = false;
this.lastSensitiveChannel = undefined;
}
async answerWizard(result: Promise<ChatWizardAnswerResult>): Promise<ChatWizardAnswerResult> {
const answer = await result;
return { ...answer, text: await this.finishWizardText(answer) };
}
async resolveTurn(
text: string,
options?: SystemAgentChatTurnOptions,
): Promise<SystemAgentChatReply> {
if (this.wizard.active) {
const result = await this.wizard.resolveReply(text);
return { text: await this.finishWizardText(result), action: "none" };
}
const trimmed = text.trim();
if (!trimmed) {
return {
text: "Tiny claw tap: tell me what you want — setup, repair, channels, anything config.",
action: "none",
};
}
if (/^(quit|exit)$/i.test(trimmed)) {
return { text: "OpenClaw retracts into shell. Bye.", action: "exit" };
}
if (this.awaitingSetupChannel) {
if (/^(cancel|abort|stop)$/i.test(trimmed)) {
this.awaitingSetupChannel = false;
return { text: "Channel wizard handoff cancelled.", action: "none" };
}
if (!/^[a-z0-9_-]+$/i.test(trimmed)) {
return {
text: "Reply with one channel id, such as `slack` or `telegram`, or say `cancel`.",
action: "none",
};
}
this.awaitingSetupChannel = false;
return await this.runOperation(
{ kind: "open-setup", target: "channels", channel: trimmed.toLowerCase() },
undefined,
);
}
if (this.options.operatorApprovalOnly && this.getPendingOperatorProposal()) {
return { text: "Approval pending. Human must decide in OpenClaw UI.", action: "none" };
}
const typed = parseSystemAgentOperation(text);
if (typed.kind === "config-set" && isSensitiveConfigPath(typed.path)) {
return await this.runOperation(typed, undefined);
}
const typedRefusal = this.refuseDelegatedNavigationDirective(typed.kind);
if (typedRefusal) {
return { text: typedRefusal, action: "none" };
}
if (typed.kind === "open-tui") {
this.clearPendingProposals();
return await this.runOperation(typed, undefined);
}
if (
typed.kind === "open-setup" ||
typed.kind === "channel-setup" ||
typed.kind === "skills-setup" ||
typed.kind === "search-setup" ||
typed.kind === "gateway-config-setup" ||
typed.kind === "memory-import" ||
typed.kind === "model-setup"
) {
return await this.runOperation(typed, undefined);
}
const intent = this.options.operatorApprovalOnly
? "other"
: await this.classifyApprovalIntent(text);
if (this.pending) {
if (intent === "approve") {
await this.callbacks.requireVerifiedInference();
return await this.applyPendingProposal(this.pending);
}
if (intent === "decline") {
const skippedModelSetup = this.pending.kind === "model-setup";
this.clearPendingProposals();
this.proposalResolution = "declined";
return {
text: skippedModelSetup
? "Skipped. The current inference route is unchanged."
: "Skipped. No barnacles on config today.",
action: "none",
};
}
}
if (intent === "decline") {
this.agentSession.proposalRef.current = undefined;
this.agentSession.proposalRef.operation = undefined;
}
return await this.resolveAssistantTurn(
text,
this.options.operatorApprovalOnly ? false : intent === "approve",
options?.uiContext,
);
}
private async classifyApprovalIntent(text: string): Promise<SystemAgentApprovalIntent> {
const hasProposal =
this.pending !== null || this.agentSession.proposalRef.current !== undefined;
if (!hasProposal) {
return "other";
}
const classify =
this.options.classifyApproval ??
(await import("./approval-intent.js")).classifySystemAgentApprovalIntent;
return await classify({
message: text,
...(this.pending ? { proposal: describeSystemAgentPersistentOperation(this.pending) } : {}),
verifiedInference: this.callbacks.getVerifiedInference(),
});
}
private async applyPendingProposal(pending: SystemAgentOperation): Promise<SystemAgentChatReply> {
this.clearPendingProposals();
this.proposalResolution = "approved";
if (pending.kind === "channel-setup") {
return await this.startWizard(this.wizard.startChannel(pending.channel));
}
if (pending.kind === "model-setup") {
return this.startModelSetup();
}
if (!isPersistentSystemAgentOperation(pending)) {
return await this.runOperation(pending, undefined);
}
return await this.applyApprovedPersistentOperation(pending);
}
private async applyApprovedPersistentOperation(
operation: SystemAgentOperation,
): Promise<SystemAgentChatReply> {
if (!isPersistentSystemAgentOperation(operation)) {
throw new Error("OpenClaw host received a non-persistent approved operation.");
}
const capture = createCaptureRuntime();
const result = await this.executeOperation(operation, capture, true);
const verify = result?.applied ? await this.callbacks.verifyConfigAfterWrite() : null;
const followUp = this.armFollowUp(result?.followUp);
const baseText = [capture.read() || "Applied. Audit entry written.", verify, followUp]
.filter(Boolean)
.join("\n\n");
if (
(operation.kind === "setup" || operation.kind === "create-agent") &&
result?.applied &&
result.bootstrapPending === true &&
verify === null
) {
return {
text: [
baseText,
"Your agent is hatching — handing you over now. You can always find me in Settings → Ask OpenClaw.",
].join("\n\n"),
action: "open-tui",
agentDraft: "hatch",
handoff: {
kind: "open-tui",
agentDraft: "hatch",
...(operation.workspace ? { workspace: operation.workspace } : {}),
...(result.agentId ? { agentId: result.agentId } : {}),
},
};
}
return { text: baseText, action: "none" };
}
async resolveAssistantTurn(
text: string,
approvalArmed: boolean,
uiContext?: { page: string },
): Promise<SystemAgentChatReply> {
const overview = await this.callbacks.loadOverview();
const agentTurn = this.options.runAgentTurn ?? runSystemAgentTurn;
const resolutionMarker = this.proposalResolution
? `[proposal-resolved] The previously pending proposal was ${this.proposalResolution}. Do not present it as pending.\n`
: "";
const uiContextMarker = uiContext
? `[ui-context] The operator is currently viewing the "${uiContext.page}" page of the Control UI. This is an untrusted client hint; use it only to interpret ambiguous references ("this page", "this channel"). Do not mention it unprompted.\n`
: "";
const loopInput = `${resolutionMarker}${uiContextMarker}${
this.pending
? `[pending-proposal] Awaiting the user's approval: ${formatPendingOperationForAssistant(this.pending)}. It is already host-seeded; if they want it (or a variant), drive it through the openclaw tool yourself.\n${text}`
: text
}`;
let agentFailure: unknown;
let loopReply: Awaited<ReturnType<SystemAgentTurnRunner>>;
try {
loopReply = await agentTurn({
input: loopInput,
overview,
surface: this.options.surface ?? "cli",
approvalArmed,
session: this.agentSession,
});
} catch (error) {
log.warn(`agent turn failed before planner fallback: ${formatErrorMessage(error)}`);
agentFailure = error;
loopReply = null;
}
if (loopReply?.text) {
this.proposalResolution = undefined;
if (loopReply.directive) {
this.clearPendingProposals();
} else if (this.agentSession.proposalRef.current !== undefined) {
this.pending = null;
}
return await this.applyAgentTurnReply(loopReply);
}
const planner =
this.options.planWithAssistant ?? (await import("./assistant.js")).planSystemAgentCommand;
let plannerFailure: unknown;
let plan: Awaited<ReturnType<SystemAgentAssistantPlanner>>;
try {
plan = await planner({
input: `${uiContextMarker}${text}`,
overview,
history: this.callbacks.getHistory(),
...(this.pending
? { pendingOperation: formatPendingOperationForAssistant(this.pending) }
: {}),
verifiedInference: this.callbacks.getVerifiedInference(),
});
if (plan) {
await this.callbacks.requireVerifiedInference();
}
} catch (error) {
plannerFailure = error;
plan = null;
}
if (!plan) {
throw new SystemAgentInferenceUnavailableError(
"conversation",
[agentFailure, plannerFailure].filter((failure) => failure !== undefined),
);
}
const replyText = plan.reply ?? "";
if (!plan.command) {
if (!replyText.trim()) {
throw new SystemAgentInferenceUnavailableError("planner", [agentFailure]);
}
return { text: replyText, action: "none" };
}
const operation = preservePendingSetupModel(
this.pending,
parseSystemAgentOperation(plan.command),
);
if (operation.kind === "none") {
if (!replyText.trim()) {
throw new SystemAgentInferenceUnavailableError("planner", [agentFailure]);
}
return { text: replyText, action: "none" };
}
const provenance = `(${plan.modelLabel ?? "model"}\`${plan.command}\`)`;
const executed = await this.runOperation(operation, provenance);
return { ...executed, text: [replyText, executed.text].filter(Boolean).join("\n\n") };
}
private async applyAgentTurnReply(loopReply: {
text: string;
directive?: SystemAgentTurnDirective;
}): Promise<SystemAgentChatReply> {
await this.callbacks.requireVerifiedInference();
const directive = loopReply.directive;
const refusal = this.refuseDelegatedNavigationDirective(directive?.kind);
if (refusal) {
return { text: [loopReply.text, refusal].filter(Boolean).join("\n\n"), action: "none" };
}
if (directive?.kind === "approved-operation") {
const applied = await this.applyApprovedPersistentOperation(directive.operation);
return { ...applied, text: [loopReply.text, applied.text].filter(Boolean).join("\n\n") };
}
if (directive?.kind === "channel-setup") {
return await this.prependWizard(loopReply.text, this.wizard.startChannel(directive.channel));
}
if (directive?.kind === "skills-setup") {
return await this.prependWizard(loopReply.text, this.wizard.startSkills());
}
if (directive?.kind === "search-setup") {
return await this.prependWizard(loopReply.text, this.wizard.startSearch());
}
if (directive?.kind === "gateway-config-setup") {
return await this.prependWizard(loopReply.text, this.wizard.startGateway());
}
if (directive?.kind === "memory-import") {
return await this.prependWizard(loopReply.text, this.wizard.startMemoryImport());
}
if (directive?.kind === "model-setup") {
const setup = this.startModelSetup();
return { ...setup, text: [loopReply.text, setup.text].filter(Boolean).join("\n\n") };
}
if (directive?.kind === "open-tui") {
this.clearPendingProposals();
return { text: loopReply.text, action: "open-tui", handoff: directive };
}
if (directive?.kind === "open-setup") {
const handoff = await this.runOperation(directive, undefined);
return { ...handoff, text: [loopReply.text, handoff.text].filter(Boolean).join("\n\n") };
}
return { text: loopReply.text, action: "none" };
}
private refuseDelegatedNavigationDirective(kind: string | undefined): string | undefined {
if (!this.options.operatorApprovalOnly) {
return undefined;
}
if (
kind === "channel-setup" ||
kind === "skills-setup" ||
kind === "search-setup" ||
kind === "gateway-config-setup" ||
kind === "memory-import" ||
kind === "model-setup" ||
kind === "open-setup" ||
kind === "open-tui"
) {
return "Channel, model, and setup flows need a human operator in the OpenClaw app; they cannot run from a delegated agent request.";
}
return undefined;
}
private async runOperation(
operation: SystemAgentOperation,
provenance: string | undefined,
): Promise<SystemAgentChatReply> {
await this.callbacks.requireVerifiedInference();
if (operation.kind === "open-tui") {
this.clearPendingProposals();
return {
text: "Opening your normal agent TUI. Use /openclaw there to come back.",
action: "open-tui",
handoff: operation,
};
}
if (operation.kind === "open-setup") {
this.clearPendingProposals();
if (this.options.surface === "gateway") {
return {
text: "Open Settings to change your model or connect a channel. To change providers from a shell, run `openclaw onboard` on the machine running OpenClaw.",
action: "none",
};
}
if (!["channels", "search", "gateway"].includes(operation.target)) {
return {
text: "Setup can replace the inference route powering this session. Exit OpenClaw and run `openclaw onboard`; it saves only a route that passes a live test. Then start OpenClaw again.",
action: "none",
};
}
let handoff = operation;
if (handoff.target === "channels" && !handoff.channel) {
if (!this.lastSensitiveChannel) {
this.awaitingSetupChannel = true;
return {
text: "Which channel should I open in the masked terminal wizard?",
action: "none",
};
}
handoff = { ...handoff, channel: this.lastSensitiveChannel };
this.lastSensitiveChannel = undefined;
}
this.awaitingSetupChannel = false;
const label =
handoff.target === "channels"
? `${handoff.channel ?? "channel"} setup`
: handoff.target === "search"
? "web search setup"
: "Gateway setup";
return { text: `Opening the ${label} wizard.`, action: "open-setup", handoff };
}
if (operation.kind === "channel-setup") {
return await this.startWizard(this.wizard.startChannel(operation.channel));
}
if (operation.kind === "skills-setup") {
return await this.startWizard(this.wizard.startSkills());
}
if (operation.kind === "search-setup") {
return await this.startWizard(this.wizard.startSearch());
}
if (operation.kind === "gateway-config-setup") {
return await this.startWizard(this.wizard.startGateway());
}
if (operation.kind === "memory-import") {
return await this.startWizard(this.wizard.startMemoryImport());
}
if (operation.kind === "model-setup") {
return this.startModelSetup();
}
const capture = createCaptureRuntime();
if (isPersistentSystemAgentOperation(operation) && !this.options.yes) {
this.clearPendingProposals();
this.pending = operation;
await executeSystemAgentOperation(operation, capture, {
approved: false,
deps: this.commandDeps(),
});
return {
text: [provenance, capture.read(), approvalQuestion(operation)]
.filter(Boolean)
.join("\n\n"),
action: "none",
};
}
const result = await this.executeOperation(
operation,
capture,
this.options.yes === true || !isPersistentSystemAgentOperation(operation),
);
const verify = result?.applied ? await this.callbacks.verifyConfigAfterWrite() : null;
const followUp = this.armFollowUp(result?.followUp);
const reply = [provenance, capture.read(), verify, followUp].filter(Boolean).join("\n\n");
if (result?.exitsInteractive === true) {
return { text: reply, action: "exit" };
}
return { text: reply, action: "none" };
}
private async executeOperation(
operation: SystemAgentOperation,
capture: CaptureRuntime,
approved: boolean,
): Promise<SystemAgentOperationResult | undefined> {
try {
const execute = this.dependencies.executeOperation ?? executeSystemAgentOperation;
return await execute(operation, capture, {
approved,
deps: this.commandDeps(),
beforePersistentApply: async () => {
await this.callbacks.requirePersistentApplyInference(capture);
},
onVerifiedInferenceChanged: this.callbacks.rebindVerifiedInference,
});
} catch (error) {
if (isSystemAgentInferenceUnavailableError(error)) {
throw error;
}
capture.error(formatOperationError(error));
return undefined;
}
}
private async startWizard(result: Promise<ChatWizardResult>): Promise<SystemAgentChatReply> {
// A masked channel handoff belongs only to the immediately preceding wizard.
this.lastSensitiveChannel = undefined;
this.clearPendingProposals();
const resolved = await result;
if (resolved.sensitiveChannel) {
this.lastSensitiveChannel = resolved.sensitiveChannel;
}
return { text: await this.finishWizardText(resolved), action: "none" };
}
private async prependWizard(
prefix: string,
result: Promise<ChatWizardResult>,
): Promise<SystemAgentChatReply> {
const reply = await this.startWizard(result);
return { ...reply, text: [prefix, reply.text].filter(Boolean).join("\n\n") };
}
private async finishWizardText(result: ChatWizardResult): Promise<string> {
const verify = result.configWritten ? await this.callbacks.verifyConfigAfterWrite() : null;
return [result.text, verify].filter(Boolean).join("\n");
}
private startModelSetup(): SystemAgentChatReply {
this.clearPendingProposals();
return {
text: [
"Changing provider credentials would replace the inference route powering this session.",
"Stop the OpenClaw host through whatever started it. Run `openclaw onboard` on the machine running OpenClaw: it stages credentials, live-tests the new route, and saves only a passing setup. Then restart the host and return to OpenClaw.",
].join("\n"),
action: "none",
};
}
private commandDeps(): SystemAgentCommandDeps | undefined {
if (!this.options.deps && !this.options.surface) {
return undefined;
}
return {
...this.options.deps,
...(this.options.surface ? { setupSurface: this.options.surface } : {}),
};
}
private clearPendingProposals(): void {
this.pending = null;
this.agentSession.proposalRef.current = undefined;
this.agentSession.proposalRef.operation = undefined;
}
private armFollowUp(operation: SystemAgentOperation | undefined): string | null {
return operation?.kind === "model-setup"
? [
"No usable inference route is configured, so OpenClaw cannot continue.",
"Run `openclaw onboard` on the machine running OpenClaw; it saves only a route that passes a live test.",
].join("\n")
: null;
}
}
+617
View File
@@ -0,0 +1,617 @@
import { describe, expect, it, vi } from "vitest";
import {
fakeOverviewLoader,
classifySystemAgentApprovalText,
useTempStateDir,
configSnapshot,
createAmbientVerifiedBinding,
SystemAgentChatEngine,
CANCEL_HINT,
countCancelHints,
expectDefined,
SystemAgentWizardAnswerError,
type OpenClawConfig,
type WizardPrompter,
} from "./chat-engine.test-support.js";
describe("SystemAgentChatEngine wizard", () => {
it("recommends the confirm option matching the initial value", async () => {
let enabled: boolean | undefined;
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
enabled = await prompter.confirm({
message: "Enable delegated auth?",
initialValue: false,
});
},
});
const confirmStep = await engine.handle("connect telegram");
expect(confirmStep.question).toEqual({
id: expect.any(String),
header: "Confirm",
question: "Enable delegated auth?",
options: [
{ label: "Yes", reply: "yes" },
{ label: "No", reply: "no", recommended: true },
],
});
await engine.handle("no");
expect(enabled).toBe(false);
const defaultEngine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.confirm({ message: "Continue?" });
},
});
const defaultConfirmStep = await defaultEngine.handle("connect telegram");
expect(defaultConfirmStep.question?.options).toEqual([
{ label: "Yes", reply: "yes", recommended: true },
{ label: "No", reply: "no" },
]);
await defaultEngine.handle("yes");
});
it("rejects non-decimal menu numbers in hosted wizard choices", async () => {
useTempStateDir();
const runs: unknown[] = [];
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel, prompter) => {
runs.push(
await prompter.select({
message: "DM mode",
options: [
{ value: "pair", label: "Pairing" },
{ value: "open", label: "Open" },
],
}),
);
runs.push(
await prompter.multiselect({
message: "Features",
options: [
{ value: "alerts", label: "Alerts" },
{ value: "logs", label: "Logs" },
],
}),
);
},
});
expect((await engine.handle("connect telegram")).text).toContain("1. Pairing");
expect((await engine.handle("1e0")).text).toContain("I could not match that answer.");
expect(runs).toEqual([]);
expect((await engine.handle("1")).text).toContain("1. Alerts");
expect((await engine.handle("0x1")).text).toContain("I could not match that answer.");
expect(await engine.handle("1,2")).toHaveProperty(
"text",
expect.stringContaining("telegram is configured"),
);
expect(runs).toEqual(["pair", ["alerts", "logs"]]);
});
it("marks sensitive hosted-wizard replies and auto-advances notes", 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.note("Before entering the token, open the provider console.");
await prompter.text({ message: "Bot token", sensitive: true });
},
});
const tokenStep = await engine.handle("connect telegram");
expect(tokenStep.text).toContain("Before entering the token");
expect(tokenStep.text).toContain("Bot token");
expect(tokenStep.sensitive).toBe(true);
expect(tokenStep.wizardInputPending).toBe(true);
});
it("marks a non-card hosted-wizard step as pending input", 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 label" });
},
});
const textStep = await engine.handle("connect telegram");
expect(textStep.text).toContain("Bot label");
expect(textStep.question).toBeUndefined();
expect(textStep.sensitive).toBeUndefined();
expect(textStep.wizardInputPending).toBe(true);
});
it("routes sensitive CLI wizard prompts to the masked channel setup flow", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
surface: "cli",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token", sensitive: true });
},
});
const reply = await engine.handle("connect telegram");
expect(reply.text).toContain("Sensitive input is not accepted");
expect(reply.text).toContain("openclaw channels add --channel telegram");
expect(reply.sensitive).toBeUndefined();
const handoff = await engine.handle("open channel wizard");
expect(handoff.action).toBe("open-setup");
expect(handoff.handoff).toEqual({
kind: "open-setup",
target: "channels",
channel: "telegram",
});
const channelRequired = await engine.handle("open channel wizard");
expect(channelRequired.action).toBe("none");
expect(channelRequired.text).toContain("Which channel");
const selectedChannel = await engine.handle("slack");
expect(selectedChannel.action).toBe("open-setup");
expect(selectedChannel.handoff).toEqual({
kind: "open-setup",
target: "channels",
channel: "slack",
});
});
it("clears a sensitive channel before a different wizard session starts", async () => {
const engine = new SystemAgentChatEngine({
surface: "cli",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token", sensitive: true });
},
runSkillsSetupWizard: async () => {},
});
const sensitive = await engine.handle("connect telegram");
expect(sensitive.text).toContain("masked terminal wizard for telegram");
await engine.handle("configure skills");
const handoff = await engine.handle("open channel wizard");
expect(handoff.action).toBe("none");
expect(handoff.handoff).toBeUndefined();
expect(handoff.text).toContain("Which channel");
});
it("routes inference setup out of both CLI and gateway sessions", async () => {
const common = {
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
};
const cli = new SystemAgentChatEngine({ ...common, surface: "cli" });
for (const command of ["open setup wizard", "open classic wizard"]) {
const cliReply = await cli.handle(command);
expect(cliReply.action).toBe("none");
expect(cliReply.handoff).toBeUndefined();
expect(cliReply.text).toContain("run `openclaw onboard`");
}
const gateway = new SystemAgentChatEngine({ ...common, surface: "gateway" });
const gatewayReply = await gateway.handle("open setup wizard");
expect(gatewayReply.action).toBe("none");
expect(gatewayReply.handoff).toBeUndefined();
// The gateway surface has real setup screens, so the reply names them
// rather than sending the reader to a terminal they may not have.
expect(gatewayReply.text).toContain("Settings");
expect(gatewayReply.text).toContain("change providers from a shell");
expect(gatewayReply.text).toContain("machine running OpenClaw");
expect(gatewayReply.text).not.toContain("does the same job");
expect(gatewayReply.text).not.toContain("Exit OpenClaw");
});
it("keeps hosted-wizard validation errors on the current prompt", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
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");
expect(prompt.text).toContain("Port");
const invalid = await engine.handle("banana");
expect(invalid.text).toContain("Enter port 18789");
expect(invalid.text).toContain("Port");
expect(countCancelHints(invalid.text)).toBe(1);
expect(invalid.text.endsWith(CANCEL_HINT)).toBe(true);
const done = await engine.handle("18789");
expect(done.text).toContain("telegram is configured");
});
it("hints cancel once per message, only while a step awaits an answer", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.note("Open the linked-devices screen.", "Step 1");
await prompter.note("Scan the code shown next.", "Step 2");
await prompter.note("Keep the phone online.", "Step 3");
await prompter.text({ message: "Phone number" });
await prompter.note("Linked.", "Step 4");
},
});
// Three auto-answered notes concatenate into the prompt's message; the hint
// is the message's, not each step's.
const prompt = await engine.handle("connect telegram");
expect(prompt.text).toContain("Step 3");
expect(prompt.text).toContain("Phone number");
expect(countCancelHints(prompt.text)).toBe(1);
expect(prompt.text.endsWith(CANCEL_HINT)).toBe(true);
expect(engine.historySince(0).at(-1)).toEqual({ role: "assistant", text: prompt.text });
const done = await engine.handle("+15551230000");
expect(done.text).toContain("Step 4");
expect(done.text).toContain("telegram is configured");
expect(countCancelHints(done.text)).toBe(0);
});
it("drops the cancel hint from the cancellation message", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
});
const prompt = await engine.handle("connect discord");
expect(countCancelHints(prompt.text)).toBe(1);
const cancelled = await engine.handle("cancel");
expect(cancelled.text).toContain("cancelled");
expect(countCancelHints(cancelled.text)).toBe(0);
});
it("cancels a hosted wizard mid-flight", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
});
const tokenStep = await engine.handle("connect discord");
expect(tokenStep.text).toContain("Bot token");
const cancelled = await engine.handle("cancel");
expect(cancelled.text).toContain("cancelled");
});
it("voids a stale host proposal before an exact wizard, including cancellation", async () => {
const runConfigSet = vi.fn(async () => {});
const runAgentTurn = vi.fn(async (params: { approvalArmed: boolean }) => ({
text: params.approvalArmed ? "unexpected approval" : "No pending change.",
}));
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
classifyApproval: async ({ message }) => classifySystemAgentApprovalText(message),
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
deps: { runConfigSet, loadOverview: fakeOverviewLoader() },
});
engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" });
await engine.handle("connect discord");
const cancelled = await engine.handle("cancel");
const laterApproval = await engine.handle("yes");
expect(cancelled.text).toContain("cancelled");
expect(engine.hasPendingProposal()).toBe(false);
expect(runConfigSet).not.toHaveBeenCalled();
expect(runAgentTurn.mock.calls.at(-1)?.[0]?.approvalArmed).toBe(false);
expect(laterApproval.text).toContain("No pending change");
});
it("voids a stale agent proposal after an exact wizard completes", async () => {
useTempStateDir();
const armed: boolean[] = [];
const runAgentTurn = vi.fn(
async (params: {
approvalArmed: boolean;
session: { proposalRef: { current?: string } };
}) => {
armed.push(params.approvalArmed);
if (armed.length === 1) {
params.session.proposalRef.current = "stale-operation";
}
return { text: "No pending change." };
},
);
const engine = new SystemAgentChatEngine({
runAgentTurn: runAgentTurn as never,
classifyApproval: async ({ message }) => classifySystemAgentApprovalText(message),
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
deps: { loadOverview: fakeOverviewLoader() },
});
await engine.handle("prepare a change for me");
await engine.handle("connect telegram");
const done = await engine.handle("123:abc");
await engine.handle("yes");
expect(done.text).toContain("telegram is configured");
expect(armed).toEqual([false, false]);
});
it("strips a sensitive step's prefilled value but keeps a plain one", async () => {
useTempStateDir();
const makeEngine = (sensitive: boolean) =>
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: "123456:REAL-SECRET",
...(sensitive ? { sensitive: true } : {}),
});
},
});
const secret = await makeEngine(true).handle("connect telegram");
expect(secret.step?.sensitive).toBe(true);
expect(secret.step).not.toHaveProperty("initialValue");
expect(JSON.stringify(secret)).not.toContain("REAL-SECRET");
// Redaction is scoped to sensitive steps; ordinary prefill still reaches
// clients, otherwise every edit-in-place prompt would lose its default.
const plain = await makeEngine(false).handle("connect telegram");
expect(plain.step?.initialValue).toBe("123456:REAL-SECRET");
});
it("omits the wizard step outside an awaiting hosted wizard", async () => {
useTempStateDir();
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => ({ text: "*click* Everything looks healthy." }),
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
});
const ordinary = await engine.handle("how is my setup looking?");
expect(ordinary.step).toBeUndefined();
const awaiting = await engine.handle("connect telegram");
expect(awaiting.step?.type).toBe("text");
const done = await engine.handle("123:abc");
expect(done.text).toContain("telegram is configured");
expect(done.step).toBeUndefined();
});
it("submits a typed answer directly and records the server-owned option label", async () => {
useTempStateDir();
let selected: unknown;
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
selected = await prompter.select({
message: "Choose one",
options: [
{ value: "alpha", label: "Alpha" },
{ value: "beta", label: "Beta" },
],
});
},
});
const prompt = await engine.handle("connect telegram");
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
await engine.answerWizard({ stepId, value: "beta" });
expect(selected).toBe("beta");
expect(engine.historySince(0)).toContainEqual({ role: "user", text: "Beta" });
});
it("cancels the current hosted wizard through a typed direct action", 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" });
},
});
const prompt = await engine.handle("connect telegram");
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
const cancelled = await engine.cancelWizard({ stepId });
expect(cancelled.text).toContain("cancelled");
expect(cancelled.step).toBeUndefined();
expect(cancelled.wizardInputPending).toBeUndefined();
expect(engine.historySince(0)).toContainEqual({ role: "user", text: "Cancel" });
});
it("cancels the local hosted wizard after its inference binding drifts", async () => {
useTempStateDir();
const baseConfig = {
agents: { defaults: { model: "openai/gpt-5.5" } },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test-key",
auth: "api-key",
models: [],
},
},
},
} satisfies OpenClawConfig;
const changedConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-8" } },
} satisfies OpenClawConfig;
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
let currentConfig: OpenClawConfig = baseConfig;
const engine = new SystemAgentChatEngine({
surface: "gateway",
verifiedInference,
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: {
readConfigFileSnapshot: vi.fn(async () => configSnapshot(currentConfig)) as never,
loadOverview: fakeOverviewLoader(),
},
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
await prompter.text({ message: "Bot token" });
},
});
const prompt = await engine.handle("connect telegram");
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
currentConfig = changedConfig;
const cancelled = await engine.cancelWizard({ stepId });
expect(cancelled.text).toContain("cancelled");
expect(cancelled.step).toBeUndefined();
});
it("rejects a stale typed cancel without changing the active step", 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" });
},
});
const prompt = await engine.handle("connect telegram");
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
await expect(engine.cancelWizard({ stepId: "stale-step" })).rejects.toBeInstanceOf(
SystemAgentWizardAnswerError,
);
const cancelled = await engine.cancelWizard({ stepId });
expect(cancelled.text).toContain("cancelled");
});
it("rejects a stale structured answer without changing the active step", 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" });
},
});
const prompt = await engine.handle("connect telegram");
await expect(
engine.answerWizard({ stepId: "stale-step", value: "ignored" }),
).rejects.toBeInstanceOf(SystemAgentWizardAnswerError);
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
const done = await engine.answerWizard({ stepId, value: "123:abc" });
expect(done.step).toBeUndefined();
expect(JSON.stringify(engine.historySince(0))).not.toContain("ignored");
});
it("redacts a sensitive structured answer from engine history", 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", sensitive: true });
},
});
const prompt = await engine.handle("connect telegram");
const stepId = expectDefined(prompt.step?.id, "expected an active wizard step");
await engine.answerWizard({ stepId, value: "raw-secret-value" });
expect(engine.historySince(0)).toContainEqual({ role: "user", text: "<redacted secret>" });
expect(JSON.stringify(engine.historySince(0))).not.toContain("raw-secret-value");
});
it("keeps the numbered text grammar for text-only wizard clients", async () => {
useTempStateDir();
let selected: unknown;
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => {
selected = await prompter.select({
message: "Choose one",
options: [
{ value: "alpha", label: "Alpha" },
{ value: "beta", label: "Beta" },
],
});
},
});
await engine.handle("connect telegram");
await engine.handle("2");
expect(selected).toBe("beta");
});
});
+645
View File
@@ -0,0 +1,645 @@
import type {
SystemAgentChatQuestion,
SystemAgentWizardCancel,
WizardAnswer,
} from "../../packages/gateway-protocol/src/index.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { RuntimeEnv } from "../runtime.js";
import {
sanitizeWizardStepForClient,
WizardSession,
wizardStepAwaitsInput,
type WizardStep,
} from "../wizard/session.js";
import type { MemoryImportProviderOutcome } from "../wizard/setup.memory-import.js";
import type { SystemAgentOperation } from "./operations.js";
import { classifySystemAgentApprovalText } from "./operator-approval.js";
type WizardPrompter = import("../wizard/prompts.js").WizardPrompter;
type HostedRuntime = typeof import("./hosted-setup.runtime.js");
type HostedSetupCompletion = import("./hosted-setup.runtime.js").HostedSetupCompletion;
type HostedMemoryImportOutcome = import("./hosted-setup.runtime.js").HostedMemoryImportOutcome;
type HostedWizardRunResult = void | HostedSetupCompletion | HostedMemoryImportOutcome;
type SystemAgentChatReplyAction = "none" | "exit" | "open-tui" | "open-setup";
export type SystemAgentChatReply = {
text: string;
action: SystemAgentChatReplyAction;
agentDraft?: "hatch";
sensitive?: boolean;
wizardInputPending?: boolean;
handoff?: SystemAgentOperation;
question?: SystemAgentChatQuestion;
step?: WizardStep;
};
export type ChatWizardResult = {
text: string;
configWritten: boolean;
sensitiveChannel?: string;
};
export type ChatWizardAnswerResult = ChatWizardResult & {
userHistoryText: string;
};
export type ChatWizardHostDependencies = {
runChannelSetupWizard?: (
channel: string,
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
) => Promise<void | HostedSetupCompletion>;
runSkillsSetupWizard?: (
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
) => Promise<void | HostedSetupCompletion>;
runSearchSetupWizard?: (
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
) => Promise<void | HostedSetupCompletion>;
runGatewaySetupWizard?: (
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
) => Promise<void | HostedSetupCompletion>;
runMemoryImportWizard?: (
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
onProviderOutcome: (outcome: MemoryImportProviderOutcome) => void,
) => Promise<HostedMemoryImportOutcome>;
appendAuditEntry?: typeof import("./audit.js").appendSystemAgentAuditEntry;
};
type ActiveWizardBridge = {
session: WizardSession;
step: WizardStep | null;
kind: "channel" | "skills" | "search" | "gateway" | "memory-import";
label: string;
completion: {
status: HostedSetupCompletion;
memoryImport?: HostedMemoryImportOutcome;
memoryImportProviders?: MemoryImportProviderOutcome[];
};
autoSelectChannel?: string;
};
const log = createSubsystemLogger("system-agent/chat-wizard-host");
const WIZARD_CANCEL_HINT = "Say `cancel` to stop this setup.";
let hostedRuntimePromise: Promise<HostedRuntime> | undefined;
function loadHostedRuntime(): Promise<HostedRuntime> {
return (hostedRuntimePromise ??= import("./hosted-setup.runtime.js"));
}
function formatWizardOptions(step: WizardStep): string[] {
return (step.options ?? []).map((option, index) => {
const hint = option.hint ? `${option.hint}` : "";
return `${index + 1}. ${option.label}${hint}`;
});
}
function wizardStepChatQuestion(step: WizardStep | null): SystemAgentChatQuestion | undefined {
if (!step) {
return undefined;
}
if (step.type === "confirm") {
const yesRecommended = step.initialValue !== false;
return {
id: step.id,
header: step.title ?? "Confirm",
question: step.message ?? "Continue?",
options: [
{ label: "Yes", reply: "yes", ...(yesRecommended ? { recommended: true } : {}) },
{ label: "No", reply: "no", ...(!yesRecommended ? { recommended: true } : {}) },
],
};
}
if (step.type !== "select") {
return undefined;
}
const options = step.options ?? [];
if (options.length < 2 || options.length > 4) {
return undefined;
}
return {
id: step.id,
header: step.title ?? "Choose one",
question: step.message ?? "Choose one.",
options: options.map((option) => {
const mapped: SystemAgentChatQuestion["options"][number] = { label: option.label };
if (option.hint) {
mapped.description = option.hint;
}
if (step.initialValue !== undefined && option.value === step.initialValue) {
mapped.recommended = true;
}
return mapped;
}),
};
}
function renderWizardStep(step: WizardStep): string {
const lines: string[] = [];
if (step.title) {
lines.push(`**${step.title}**`);
}
if (step.message) {
lines.push(step.message);
}
switch (step.type) {
case "select":
lines.push(...formatWizardOptions(step), "Reply with a number.");
break;
case "multiselect":
lines.push(...formatWizardOptions(step), "Reply with numbers (e.g. 1,3) or `none`.");
break;
case "confirm":
lines.push("Reply yes or no.");
break;
case "text":
if (step.placeholder) {
lines.push(`(e.g. ${step.placeholder})`);
}
lines.push("Type your answer.");
break;
default:
break;
}
return lines.filter(Boolean).join("\n");
}
function parseWizardAnswer(step: WizardStep, text: string): { value: unknown } | null {
const trimmed = text.trim();
if (step.type === "confirm") {
const intent = classifySystemAgentApprovalText(trimmed);
return intent === "approve" ? { value: true } : intent === "decline" ? { value: false } : null;
}
if (step.type === "text") {
return { value: trimmed };
}
const options = step.options ?? [];
const matchOption = (token: string) => {
if (/^\d+$/.test(token)) {
const index = Number(token);
if (Number.isSafeInteger(index) && index >= 1 && index <= options.length) {
return options[index - 1];
}
}
const lower = token.toLowerCase();
return options.find(
(option) =>
option.label.toLowerCase() === lower ||
(typeof option.value === "string" && option.value.toLowerCase() === lower),
);
};
if (step.type === "select") {
const option = matchOption(trimmed);
return option ? { value: option.value } : null;
}
if (step.type === "multiselect") {
if (/^none$/i.test(trimmed)) {
return { value: [] };
}
const values: unknown[] = [];
for (const token of trimmed.split(/[\s,]+/).filter(Boolean)) {
const option = matchOption(token);
if (!option) {
return null;
}
values.push(option.value);
}
return { value: values };
}
return { value: step.type === "action" ? true : undefined };
}
function formatStructuredWizardAnswerForHistory(step: WizardStep, value: unknown): string {
if (step.sensitive === true) {
return "<redacted secret>";
}
if (step.type === "text") {
return ["string", "number", "boolean", "bigint"].includes(typeof value)
? String(value)
: "<wizard answer>";
}
if (step.type === "confirm") {
return typeof value === "boolean" ? (value ? "Yes" : "No") : "<wizard answer>";
}
if (step.type === "select") {
return (
step.options?.find((option) => Object.is(option.value, value))?.label ?? "<wizard answer>"
);
}
if (step.type === "multiselect") {
if (!Array.isArray(value)) {
return "<wizard answer>";
}
if (value.length === 0) {
return "None";
}
const labels = value.map(
(entry) => step.options?.find((option) => Object.is(option.value, entry))?.label,
);
return labels.every((label): label is string => label !== undefined)
? labels.join(", ")
: "<wizard answer>";
}
return "Continue";
}
export class SystemAgentWizardAnswerError extends Error {}
export class ChatWizardHost {
private bridge: ActiveWizardBridge | null = null;
constructor(
private readonly options: {
surface?: "cli" | "gateway";
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>;
dependencies?: ChatWizardHostDependencies;
},
) {}
get active(): boolean {
return this.bridge !== null;
}
get sensitiveInputPending(): boolean {
return this.bridge?.step?.sensitive === true;
}
dispose(): void {
this.bridge?.session.cancel();
this.bridge = null;
}
decorateReply(reply: SystemAgentChatReply): SystemAgentChatReply {
const step = this.bridge?.step ?? null;
const completedReply =
reply.text && step && wizardStepAwaitsInput(step)
? { ...reply, text: `${reply.text}\n${WIZARD_CANCEL_HINT}` }
: reply;
const question = wizardStepChatQuestion(step);
const clientStep = step ? sanitizeWizardStepForClient(step) : null;
return {
...completedReply,
...(step?.sensitive === true ? { sensitive: true } : {}),
...(this.bridge ? { wizardInputPending: true } : {}),
...(question ? { question } : {}),
...(clientStep ? { step: clientStep } : {}),
};
}
async answer(answer: WizardAnswer): Promise<ChatWizardAnswerResult> {
const bridge = this.bridge;
const step = bridge?.step;
if (!bridge || !step) {
throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting an answer.");
}
if (answer.stepId !== step.id) {
throw new SystemAgentWizardAnswerError("The hosted wizard answer targets a stale step.");
}
const validationError = await bridge.session.answer(step.id, answer.value);
const result = validationError
? { text: [validationError, renderWizardStep(step)].join("\n\n"), configWritten: false }
: await this.pump();
return {
...result,
userHistoryText: formatStructuredWizardAnswerForHistory(step, answer.value),
};
}
async cancel(cancel: SystemAgentWizardCancel): Promise<ChatWizardAnswerResult> {
const bridge = this.bridge;
const step = bridge?.step;
if (!bridge || !step) {
throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting cancellation.");
}
if (cancel.stepId !== step.id) {
throw new SystemAgentWizardAnswerError("The hosted wizard cancel targets a stale step.");
}
if (!bridge.session.cancel()) {
throw new SystemAgentWizardAnswerError("The hosted wizard cannot be cancelled right now.");
}
return { ...(await this.pump()), userHistoryText: "Cancel" };
}
async resolveReply(text: string): Promise<ChatWizardResult> {
const bridge = this.bridge;
if (!bridge) {
return { text: "", configWritten: false };
}
if (/^(cancel|abort|stop|quit|exit)$/i.test(text.trim())) {
bridge.session.cancel();
return await this.pump();
}
const step = bridge.step;
if (!step) {
return await this.pump();
}
const answer = parseWizardAnswer(step, text);
if (!answer) {
return {
text: ["I could not match that answer.", renderWizardStep(step)].join("\n"),
configWritten: false,
};
}
const validationError = await bridge.session.answer(step.id, answer.value);
return validationError
? { text: [validationError, renderWizardStep(step)].join("\n\n"), configWritten: false }
: await this.pump();
}
async startChannel(channel: string): Promise<ChatWizardResult> {
const run = this.options.dependencies?.runChannelSetupWizard;
return await this.start({
kind: "channel",
label: channel,
autoSelectChannel: channel,
run: async (prompter) =>
run
? await run(channel, prompter, this.options.beforePersistentApply)
: await (
await loadHostedRuntime()
).runHostedChannelSetup(channel, prompter, this.options.beforePersistentApply),
});
}
async startSkills(): Promise<ChatWizardResult> {
const run = this.options.dependencies?.runSkillsSetupWizard;
return await this.start({
kind: "skills",
label: "skills",
run: async (prompter) =>
run
? await run(prompter, this.options.beforePersistentApply)
: await (
await loadHostedRuntime()
).runHostedSkillsSetup(prompter, this.options.beforePersistentApply),
});
}
async startSearch(): Promise<ChatWizardResult> {
const run = this.options.dependencies?.runSearchSetupWizard;
return await this.start({
kind: "search",
label: "web search",
run: async (prompter) =>
run
? await run(prompter, this.options.beforePersistentApply)
: await (
await loadHostedRuntime()
).runHostedSearchSetup(prompter, this.options.beforePersistentApply),
});
}
async startGateway(): Promise<ChatWizardResult> {
const run = this.options.dependencies?.runGatewaySetupWizard;
const result = await this.start({
kind: "gateway",
label: "gateway",
run: async (prompter) =>
run
? await run(prompter, this.options.beforePersistentApply)
: await (
await loadHostedRuntime()
).runHostedGatewaySetup(prompter, this.options.beforePersistentApply),
});
if (this.options.surface !== "gateway" || !this.bridge) {
return result;
}
const warning = [
"Before we start: changing the Gateway port, bind address, or auth credential requires a Gateway restart to apply.",
"That restart may disconnect this chat, and you may need to sign in to the Control UI again with the new address or credential.",
].join(" ");
return { ...result, text: [warning, result.text].filter(Boolean).join("\n\n") };
}
async startMemoryImport(): Promise<ChatWizardResult> {
const run = this.options.dependencies?.runMemoryImportWizard;
const providers: MemoryImportProviderOutcome[] = [];
return await this.start({
kind: "memory-import",
label: "memory import",
memoryImportProviders: providers,
run: async (prompter) =>
run
? await run(prompter, this.options.beforePersistentApply, (value) =>
providers.push(value),
)
: await (
await loadHostedRuntime()
).runHostedMemoryImport(prompter, this.options.beforePersistentApply, (value) =>
providers.push(value),
),
});
}
private async start(params: {
kind: ActiveWizardBridge["kind"];
label: string;
autoSelectChannel?: string;
memoryImportProviders?: MemoryImportProviderOutcome[];
run: (prompter: WizardPrompter) => Promise<HostedWizardRunResult>;
}): Promise<ChatWizardResult> {
const completion: ActiveWizardBridge["completion"] = {
status: "applied",
...(params.memoryImportProviders
? { memoryImportProviders: params.memoryImportProviders }
: {}),
};
const session = new WizardSession(async (prompter) => {
const result = await params.run(prompter);
if (typeof result === "string") {
completion.status = result;
} else if (result) {
completion.memoryImport = result;
}
});
this.bridge = {
session,
step: null,
kind: params.kind,
label: params.label,
completion,
...(params.autoSelectChannel ? { autoSelectChannel: params.autoSelectChannel } : {}),
};
return await this.pump();
}
private tryAutoSelect(step: WizardStep): { value: unknown } | null {
const bridge = this.bridge;
const channel = bridge?.autoSelectChannel;
if (!bridge || !channel || (step.type !== "select" && step.type !== "multiselect")) {
return null;
}
const match = (step.options ?? []).find(
(option) => typeof option.value === "string" && option.value.toLowerCase() === channel,
);
if (!match) {
return null;
}
bridge.autoSelectChannel = undefined;
return { value: step.type === "multiselect" ? [match.value] : match.value };
}
private async pump(): Promise<ChatWizardResult> {
const bridge = this.bridge;
if (!bridge) {
return { text: "", configWritten: false };
}
const result = await bridge.session.next();
if (result.done) {
this.bridge = null;
const label = bridge.label;
if (result.status === "done") {
if (bridge.kind === "memory-import") {
try {
return {
text: await (
await loadHostedRuntime()
).renderMemoryImport(
bridge.completion.memoryImport,
this.options.dependencies?.appendAuditEntry,
),
configWritten: false,
};
} catch (error) {
log.warn(`memory import completed without audit entry: ${formatErrorMessage(error)}`);
return {
text: await (
await loadHostedRuntime()
).renderMemoryImport(bridge.completion.memoryImport, async () => ""),
configWritten: false,
};
}
}
if (bridge.completion.status === "kept-current") {
return {
text: `${label[0]?.toUpperCase() ?? "S"}${label.slice(1)} setup kept the current configuration. Nothing was changed.`,
configWritten: false,
};
}
await this.auditSetup(bridge);
const success =
bridge.kind === "channel"
? [
`Done — ${label} is configured.`,
"Say `restart gateway` to apply channel changes, or `channels` to review.",
]
: bridge.kind === "skills"
? ["Done — skills dependency setup is complete."]
: bridge.kind === "search"
? [
"Done — web search setup is complete.",
"Restart the Gateway if the selected provider or plugin changed.",
]
: [
"Done — gateway settings saved.",
"Restart the Gateway to apply them (`restart gateway`).",
];
return { text: success.join("\n"), configWritten: true };
}
if (bridge.kind === "memory-import") {
try {
await (
await loadHostedRuntime()
).auditMemoryImport(
bridge.completion.memoryImportProviders ?? [],
this.options.dependencies?.appendAuditEntry,
);
} catch (error) {
log.warn(`memory import completed without audit entry: ${formatErrorMessage(error)}`);
}
}
if (result.status === "cancelled") {
return {
text: `${label[0]?.toUpperCase() ?? "S"}${label.slice(1)} setup cancelled. Nothing was changed beyond completed steps.`,
configWritten: false,
};
}
return {
text: `${label[0]?.toUpperCase() ?? "S"}${label.slice(1)} setup stopped: ${result.error ?? "unknown error"}`,
configWritten: false,
};
}
bridge.step = result.step ?? null;
if (bridge.step) {
const auto = this.tryAutoSelect(bridge.step);
if (auto) {
const step = bridge.step;
bridge.step = null;
await bridge.session.answer(step.id, auto.value);
return await this.pump();
}
if (this.options.surface === "cli" && bridge.step.sensitive === true) {
bridge.session.cancel();
this.bridge = null;
const target =
bridge.kind === "channel"
? `Say \`open channel wizard\` and I'll hand you to the masked terminal wizard for ${bridge.label}, or run \`openclaw channels add --channel ${bridge.label}\` yourself later.`
: bridge.kind === "gateway"
? "Say `open gateway wizard` and I'll hand you to the masked terminal wizard, or run `openclaw configure --section gateway` yourself later."
: "Say `open search wizard` and I'll hand you to the masked terminal wizard, or run `openclaw configure --section web` yourself later.";
return {
text: [
"Sensitive input is not accepted in the OpenClaw chat because terminal input is visible.",
target,
].join("\n"),
configWritten: false,
...(bridge.kind === "channel" ? { sensitiveChannel: bridge.label } : {}),
};
}
if (bridge.step.type === "note" || bridge.step.type === "progress") {
const step = bridge.step;
bridge.step = null;
await bridge.session.answer(step.id, undefined);
const next = await this.pump();
return { ...next, text: [renderWizardStep(step), next.text].filter(Boolean).join("\n\n") };
}
if (bridge.step.type === "action" && bridge.step.executor !== "client") {
const step = bridge.step;
bridge.step = null;
await bridge.session.answer(step.id, true);
return await this.pump();
}
}
return { text: bridge.step ? renderWizardStep(bridge.step) : "", configWritten: false };
}
private async auditSetup(bridge: ActiveWizardBridge): Promise<void> {
const entry =
bridge.kind === "channel"
? {
operation: "channels.setup",
summary: `Configured channel ${bridge.label} via chat setup`,
details: { channel: bridge.label },
}
: bridge.kind === "skills"
? {
operation: "skills.setup",
summary: "Completed skills dependency setup via chat",
details: { capability: "skills" },
}
: bridge.kind === "search"
? {
operation: "search.setup",
summary: "Configured web search via chat setup",
details: { capability: "web-search" },
}
: {
operation: "gateway.setup",
summary: "Configured Gateway via chat setup",
details: { capability: "gateway" },
};
try {
const append =
this.options.dependencies?.appendAuditEntry ??
(await import("./audit.js")).appendSystemAgentAuditEntry;
await append(entry);
} catch (error) {
log.warn(`${bridge.kind} setup completed without audit entry: ${formatErrorMessage(error)}`);
}
}
}
@@ -0,0 +1,288 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
fakeOverviewLoader,
sharedVerifiedInferenceConfig,
mocks,
useTempStateDir,
configSnapshot,
createAmbientVerifiedBinding,
SystemAgentChatEngine,
type MemoryImportStepParams,
type OpenClawConfig,
} from "./chat-engine.test-support.js";
describe("SystemAgentChatEngine memory", () => {
it("refuses memory import before provider discovery when the default workspace is missing", async () => {
const root = useTempStateDir();
const workspace = path.join(root, "missing-workspace");
const baseConfig: OpenClawConfig = {
...sharedVerifiedInferenceConfig,
agents: {
...sharedVerifiedInferenceConfig.agents,
defaults: { workspace },
},
};
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "memory-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("memory import");
expect(reply.text).toContain("default agent workspace does not exist");
expect(reply.text).toContain("Finish onboarding first with `openclaw onboard`");
expect(mocks.runSetupMemoryImportStep).not.toHaveBeenCalled();
expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled();
});
it("rechecks inference authority immediately before a hosted memory copy", async () => {
const workspace = useTempStateDir();
const baseConfig: OpenClawConfig = {
...sharedVerifiedInferenceConfig,
agents: {
...sharedVerifiedInferenceConfig.agents,
defaults: { workspace },
},
};
const changedConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-8" } } },
};
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
let currentConfig = structuredClone(baseConfig);
const copyEffect = vi.fn();
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "memory-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
mocks.runSetupMemoryImportStep.mockImplementation(async (params: MemoryImportStepParams) => {
const confirmed = await params.prompter.confirm({
message: "Import detected memory?",
initialValue: true,
});
if (!confirmed) {
return { status: "skipped", providers: [] };
}
// Route changes mid-wizard, after the turn gate: only the copy-boundary
// recheck can catch it.
currentConfig = changedConfig;
await params.beforeApply?.();
copyEffect();
return {
status: "completed",
providers: [{ providerId: "codex", label: "Codex", migrated: 1, skipped: 0 }],
};
});
const engine = new SystemAgentChatEngine({
surface: "gateway",
verifiedInference,
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: {
loadOverview: fakeOverviewLoader(),
readConfigFileSnapshot: vi.fn(async () => configSnapshot(currentConfig)) as never,
},
});
const confirm = await engine.handle("import memory");
expect(confirm.text).toContain("Import detected memory?");
const stopped = await engine.handle("yes");
expect(stopped.text).toContain("Memory import setup stopped");
expect(copyEffect).not.toHaveBeenCalled();
});
it("stops a hosted memory copy when config drifts after planning", async () => {
const workspace = useTempStateDir();
const baseConfig: OpenClawConfig = {
...sharedVerifiedInferenceConfig,
agents: {
...sharedVerifiedInferenceConfig.agents,
defaults: { workspace },
},
};
let currentHash = "memory-base-hash";
const copyEffect = vi.fn();
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
mocks.readSetupConfigFileSnapshot.mockImplementation(async () => ({
exists: true,
valid: true,
hash: currentHash,
config: baseConfig,
sourceConfig: baseConfig,
}));
mocks.runSetupMemoryImportStep.mockImplementation(async (params: MemoryImportStepParams) => {
const confirmed = await params.prompter.confirm({
message: "Import detected memory?",
initialValue: true,
});
if (!confirmed) {
return { status: "skipped", providers: [] };
}
params.onProviderOutcome?.({
providerId: "claude",
label: "Claude",
failure: "copy failed after partial progress",
copiesIndeterminate: true,
});
currentHash = "changed-during-wizard";
await params.beforeApply?.();
copyEffect();
return {
status: "completed",
providers: [{ providerId: "codex", label: "Codex", migrated: 1, skipped: 0 }],
};
});
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
deps: { loadOverview: fakeOverviewLoader() },
});
const confirm = await engine.handle("import memory");
expect(confirm.text).toContain("Import detected memory?");
const stopped = await engine.handle("yes");
expect(stopped.text).toContain("Memory import setup stopped");
expect(stopped.text).toContain(
"configuration changed during memory import; nothing further was copied",
);
expect(copyEffect).not.toHaveBeenCalled();
expect(appendAuditEntry).toHaveBeenCalledWith({
operation: "memory.import",
summary: "Memory import failed partway via chat: Claude (copy count indeterminate)",
details: {
confirmedItems: 0,
copiesIndeterminate: true,
providers: [{ providerId: "claude", copiesIndeterminate: true }],
},
});
});
it("reports nothing to import without writing config or audit", async () => {
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
runMemoryImportWizard: async () => ({ status: "nothing-to-import", providers: [] }),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("import memories");
expect(reply.text).toContain("Nothing to import");
expect(reply.text).not.toContain("Done");
expect(appendAuditEntry).not.toHaveBeenCalled();
expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled();
});
it("reports all-provider failure without a false success", async () => {
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
runMemoryImportWizard: async () => ({
status: "completed",
providers: [
{
providerId: "codex",
label: "Codex",
migrated: 0,
skipped: 0,
failure: "copy failed",
},
{
providerId: "claude",
label: "Claude",
migrated: 0,
skipped: 0,
failure: "copy failed",
},
],
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("memory import");
expect(reply.text).toContain("Memory import did not complete");
expect(reply.text).toContain("Failed providers: Codex, Claude");
expect(reply.text).not.toContain("Done");
expect(appendAuditEntry).not.toHaveBeenCalled();
});
it("audits an apply failure with indeterminate partial-copy progress", async () => {
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
runMemoryImportWizard: async () => ({
status: "completed",
providers: [
{
providerId: "codex",
label: "Codex",
failure: "copy failed after writing one file",
copiesIndeterminate: true,
},
],
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("memory import");
expect(reply.text).toContain("Memory import failed partway");
expect(reply.text).toContain("Some files may have been copied before the failure");
expect(reply.text).not.toContain("No files were copied");
expect(appendAuditEntry).toHaveBeenCalledWith({
operation: "memory.import",
summary: "Memory import failed partway via chat: Codex (copy count indeterminate)",
details: {
confirmedItems: 0,
copiesIndeterminate: true,
providers: [{ providerId: "codex", copiesIndeterminate: true }],
},
});
});
it("keeps a successful memory-import result when audit persistence fails", async () => {
const appendAuditEntry = vi.fn(async () => {
throw new Error("audit store is read-only");
});
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
runMemoryImportWizard: async () => ({
status: "completed",
providers: [{ providerId: "codex", label: "Codex", migrated: 1, skipped: 0 }],
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("import memory");
expect(reply.text).toContain("Imported 1 item from Codex.");
expect(reply.text).not.toContain("audit store is read-only");
});
});
@@ -0,0 +1,719 @@
import { describe, expect, it, vi } from "vitest";
import {
fakeOverviewLoader,
sharedVerifiedInferenceConfig,
mocks,
useTempStateDir,
configSnapshot,
createAmbientVerifiedBinding,
SystemAgentChatEngine,
advanceGatewayWizardToToken,
type OpenClawConfig,
type WizardPrompter,
} from "./chat-engine.test-support.js";
describe("SystemAgentChatEngine runtime", () => {
it("hosts a channel setup wizard as chat turns", async () => {
useTempStateDir();
const wizardRuns: string[] = [];
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async (channel: string, prompter: WizardPrompter) => {
wizardRuns.push(channel);
const token = await prompter.text({ message: "Bot token" });
wizardRuns.push(`token:${token}`);
const mode = await prompter.select({
message: "DM mode",
options: [
{ value: "pair", label: "Pairing" },
{ value: "open", label: "Open" },
],
});
wizardRuns.push(`mode:${mode}`);
},
});
// Starting the wizard is not a write: it begins immediately, no approval step.
const tokenStep = await engine.handle("connect telegram");
expect(tokenStep.text).toContain("Bot token");
// Text steps stay prose-only; only closed choices become typed questions.
expect(tokenStep.question).toBeUndefined();
const modeStep = await engine.handle("123:abc");
expect(modeStep.text).toContain("1. Pairing");
// The awaited select step is mirrored for card-capable clients; labels are
// the replies parseWizardAnswer accepts.
expect(modeStep.question).toEqual({
id: expect.any(String),
header: "Choose one",
question: "DM mode",
options: [{ label: "Pairing" }, { label: "Open" }],
});
const done = await engine.handle("Open");
expect(done.text).toContain("telegram is configured");
expect(done.question).toBeUndefined();
expect(wizardRuns).toEqual(["telegram", "token:123:abc", "mode:open"]);
});
it("hosts the real skills setup flow and guards installs plus the final config write", async () => {
const baseConfig: OpenClawConfig = {
agents: { defaults: { workspace: "/tmp/skills-workspace" } },
};
const beforeEffects: Array<() => Promise<void>> = [];
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "skills-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
mocks.setupSkills.mockImplementation(
async (
config: OpenClawConfig,
workspaceDir: string,
_runtime: unknown,
prompter: WizardPrompter,
options: { beforePersistentEffect?: () => Promise<void> },
) => {
expect(workspaceDir).toBe("/tmp/skills-workspace");
expect(options.beforePersistentEffect).toBeTypeOf("function");
beforeEffects.push(options.beforePersistentEffect!);
await prompter.note("Eligible: 2\nMissing requirements: 1", "Skills status");
await options.beforePersistentEffect?.();
return { ...config, skills: { install: { nodeManager: "npm" } } };
},
);
mocks.writeWizardConfigFile.mockImplementation(async (config: OpenClawConfig) => config);
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("configure skills");
expect(reply.text).toContain("skills dependency setup is complete");
expect(beforeEffects).toHaveLength(1);
expect(mocks.writeWizardConfigFile).toHaveBeenCalledWith(
expect.objectContaining({ skills: { install: { nodeManager: "npm" } } }),
{
allowConfigSizeDrop: false,
baseHash: "skills-base-hash",
},
);
expect(appendAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({ operation: "skills.setup" }),
);
});
it("hosts search setup as question cards and keeps gateway credentials out of model history", async () => {
const baseConfig: OpenClawConfig = {};
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
const beforePersistentEffects: Array<() => Promise<void>> = [];
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "search-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
mocks.runSearchSetupFlow.mockImplementation(
async (
config: OpenClawConfig,
_runtime: unknown,
prompter: WizardPrompter,
options: {
preserveDisabledSearchState?: boolean;
beforePersistentEffect?: () => Promise<void>;
},
) => {
expect(options.preserveDisabledSearchState).toBe(false);
beforePersistentEffects.push(options.beforePersistentEffect!);
const provider = await prompter.select({
message: "Search provider",
options: [
{ value: "brave", label: "Brave" },
{ value: "grok", label: "Grok" },
],
initialValue: "brave",
});
const key = await prompter.text({ message: "Provider API key", sensitive: true });
expect(key).toBe("search-secret-value");
await options.beforePersistentEffect?.();
return {
outcome: "completed",
config: {
...config,
tools: { web: { search: { enabled: true, provider } } },
} as OpenClawConfig,
};
},
);
mocks.writeWizardConfigFile.mockImplementation(async (config: OpenClawConfig) => config);
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
deps: { loadOverview: fakeOverviewLoader() },
});
const providerStep = await engine.handle("configure search");
expect(providerStep.question).toEqual({
id: expect.any(String),
header: "Choose one",
question: "Search provider",
options: [{ label: "Brave", recommended: true }, { label: "Grok" }],
});
const secretStep = await engine.handle("Brave");
expect(secretStep.text).toContain("Provider API key");
expect(secretStep.sensitive).toBe(true);
expect(secretStep.question).toBeUndefined();
const done = await engine.handle("search-secret-value");
expect(done.text).toContain("web search setup is complete");
expect(beforePersistentEffects).toHaveLength(1);
expect(appendAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({ operation: "search.setup" }),
);
expect(JSON.stringify(engine.historySince(0))).not.toContain("search-secret-value");
expect(JSON.stringify(engine.historySince(0))).toContain("<redacted secret>");
});
it("hosts full Gateway setup with a lockout warning, audited config write, and no restart", async () => {
const baseConfig: OpenClawConfig = {
...structuredClone(sharedVerifiedInferenceConfig),
gateway: { mode: "local" },
};
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "");
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "gateway-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
mocks.writeWizardConfigFile.mockImplementation(async (config: OpenClawConfig) => config);
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
deps: { loadOverview: fakeOverviewLoader() },
});
const { portStep, tokenStep } = await advanceGatewayWizardToToken(engine);
expect(portStep.text).toContain(
"changing the Gateway port, bind address, or auth credential requires a Gateway restart",
);
expect(portStep.text).toContain(
"sign in to the Control UI again with the new address or credential",
);
expect(portStep.text).toContain("Gateway port");
expect(tokenStep.text).toContain("Gateway token");
expect(tokenStep.sensitive).toBe(true);
const done = await engine.handle("gateway-secret-value");
expect(done.text).toContain("Done — gateway settings saved.");
expect(done.text).toContain("Restart the Gateway to apply them (`restart gateway`).");
expect(done.text).not.toContain("restarted");
expect(mocks.writeWizardConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({
port: 19001,
bind: "lan",
auth: expect.objectContaining({ mode: "token", token: "gateway-secret-value" }),
tailscale: expect.objectContaining({ mode: "off" }),
}),
}),
{
allowConfigSizeDrop: false,
baseHash: "gateway-base-hash",
afterWrite: {
mode: "none",
reason: "Gateway setup defers runtime apply until explicit restart",
},
},
);
expect(appendAuditEntry).toHaveBeenCalledWith({
operation: "gateway.setup",
summary: "Configured Gateway via chat setup",
details: { capability: "gateway" },
});
expect(JSON.stringify(engine.historySince(0))).not.toContain("gateway-secret-value");
expect(JSON.stringify(engine.historySince(0))).toContain("<redacted secret>");
});
it("rechecks inference authority immediately before a hosted Gateway write", async () => {
useTempStateDir();
const baseConfig: OpenClawConfig = {
...structuredClone(sharedVerifiedInferenceConfig),
gateway: { mode: "local" },
};
const currentConfig = structuredClone(baseConfig);
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "");
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "gateway-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
mocks.writeWizardConfigFile.mockImplementation(async (config: OpenClawConfig) => config);
const changedConfig: OpenClawConfig = {
agents: { defaults: { model: "anthropic/claude-opus-4-8" } },
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "changed-test-key",
auth: "api-key",
models: [],
},
},
},
};
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
// The route flips between the final turn's entry gate and the
// persistent-apply recheck; only the apply boundary can catch it.
let baseReadsRemaining = Number.POSITIVE_INFINITY;
const engine = new SystemAgentChatEngine({
surface: "gateway",
verifiedInference,
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: {
loadOverview: fakeOverviewLoader(),
readConfigFileSnapshot: vi.fn(async () => {
const config = baseReadsRemaining > 0 ? currentConfig : changedConfig;
baseReadsRemaining -= 1;
return configSnapshot(config);
}) as never,
},
});
const { tokenStep } = await advanceGatewayWizardToToken(engine);
expect(tokenStep.sensitive).toBe(true);
baseReadsRemaining = 1;
const stopped = await engine.handle("gateway-secret-value");
expect(stopped.text).toContain("Gateway setup stopped");
expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled();
});
it("keeps remote Gateway mode guidance-only", async () => {
const baseConfig: OpenClawConfig = { gateway: { mode: "remote" } };
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "remote-gateway-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("configure gateway");
expect(reply.text).toContain("manages only a local Gateway");
expect(reply.text).toContain("`openclaw onboard` for fresh setup");
expect(reply.text).toContain("`openclaw configure` for the mode question");
expect(reply.text).not.toContain("Gateway port");
expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled();
});
it("hands CLI Gateway credentials to the masked terminal wizard", async () => {
const engine = new SystemAgentChatEngine({
surface: "cli",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runGatewaySetupWizard: async (prompter) => {
await prompter.text({ message: "Gateway token", sensitive: true });
},
});
const stopped = await engine.handle("configure gateway");
expect(stopped.text).toContain("Sensitive input is not accepted");
expect(stopped.text).toContain("open gateway wizard");
expect(stopped.text).toContain("openclaw configure --section gateway");
expect(stopped.sensitive).toBeUndefined();
const handoff = await engine.handle("open gateway wizard");
expect(handoff.action).toBe("open-setup");
expect(handoff.handoff).toEqual({ kind: "open-setup", target: "gateway" });
});
it("reports a failed hosted search-provider install without writing or auditing", async () => {
const baseConfig: OpenClawConfig = {};
const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite");
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "search-base-hash",
config: baseConfig,
sourceConfig: baseConfig,
});
mocks.runSearchSetupFlow.mockResolvedValue({
outcome: "install-failed",
config: baseConfig,
providerId: "brave",
reason: "failed",
});
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
appendAuditEntry,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("configure search");
expect(reply.text).toContain(
"Web search setup stopped: Error: web search provider brave installation failed",
);
expect(reply.text).not.toContain("Done — web search setup is complete");
expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled();
expect(appendAuditEntry).not.toHaveBeenCalled();
});
it("hands CLI search credentials to the masked terminal wizard", async () => {
const engine = new SystemAgentChatEngine({
surface: "cli",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runSearchSetupWizard: async (prompter) => {
await prompter.text({ message: "Provider API key", sensitive: true });
},
});
const stopped = await engine.handle("configure search");
expect(stopped.text).toContain("Sensitive input is not accepted");
expect(stopped.text).toContain("open search wizard");
expect(stopped.sensitive).toBeUndefined();
const handoff = await engine.handle("open search wizard");
expect(handoff.action).toBe("open-setup");
expect(handoff.handoff).toEqual({ kind: "open-setup", target: "search" });
});
it("does not promise Doctor will repair every invalid channel setup config", async () => {
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
path: "/tmp/openclaw.json",
hash: "invalid-hash",
config: {},
sourceConfig: {},
issues: [{ path: "gateway.port", message: "Expected number" }],
});
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("connect telegram");
expect(reply.text).toContain("machine running OpenClaw");
expect(reply.text).toContain("openclaw doctor --fix");
expect(reply.text).toContain("remaining validation errors");
expect(reply.text).not.toContain("repairs it");
});
it("reports hosted channel setup success when audit persistence fails", async () => {
const appendAuditEntry = vi.fn(async () => {
throw new Error("audit store is read-only");
});
const engine = new SystemAgentChatEngine({
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
runChannelSetupWizard: async () => {},
appendAuditEntry,
});
const reply = await engine.handle("connect telegram");
expect(reply.text).toContain("Done — telegram is configured.");
expect(reply.text).not.toContain("audit store is read-only");
expect(appendAuditEntry).toHaveBeenCalledOnce();
});
it("rejects a hosted channel commit after a concurrent inference-route change", async () => {
useTempStateDir();
const baseConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
auth: {
profiles: { "openai:main": { provider: "openai", mode: "api_key" } },
},
};
let currentConfig = structuredClone(baseConfig);
let currentHash = "base-hash";
mocks.readSetupConfigFileSnapshot.mockImplementation(async () => ({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: currentHash,
config: structuredClone(currentConfig),
sourceConfig: structuredClone(currentConfig),
issues: [],
}));
mocks.setupChannels.mockImplementation(
async (config: OpenClawConfig, _runtime: unknown, prompter: WizardPrompter) => {
const token = await prompter.text({ message: "Bot token" });
return {
...config,
channels: {
...config.channels,
telegram: { botToken: token },
},
};
},
);
mocks.writeWizardConfigFile.mockImplementation(
async (nextConfig: OpenClawConfig, opts: { baseHash?: string }) => {
if (opts.baseHash !== currentHash) {
throw new Error("configuration changed during channel setup");
}
currentConfig = structuredClone(nextConfig);
currentHash = "committed-hash";
return nextConfig;
},
);
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const tokenStep = await engine.handle("connect telegram");
expect(tokenStep.text).toContain("Bot token");
const concurrentConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-8" } } },
auth: {
profiles: { "anthropic:main": { provider: "anthropic", mode: "api_key" } },
},
};
currentConfig = structuredClone(concurrentConfig);
currentHash = "concurrent-hash";
const stopped = await engine.handle("123:abc");
expect(stopped.text).toContain("Telegram setup stopped");
expect(stopped.text).toContain("configuration changed during channel setup");
expect(mocks.writeWizardConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: expect.objectContaining({ telegram: { botToken: "123:abc" } }),
}),
expect.objectContaining({
baseHash: "base-hash",
}),
);
expect(currentConfig).toEqual(concurrentConfig);
});
it("rechecks inference authority immediately before a hosted channel write", async () => {
useTempStateDir();
const baseConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
auth: { profiles: { "openai:main": { provider: "openai", mode: "api_key" } } },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test-key",
auth: "api-key",
models: [],
},
},
},
};
const changedConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-8" } } },
};
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
let currentConfig = structuredClone(baseConfig);
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "base-hash",
config: structuredClone(baseConfig),
sourceConfig: structuredClone(baseConfig),
issues: [],
});
mocks.setupChannels.mockImplementation(
async (config: OpenClawConfig, _runtime: unknown, prompter: WizardPrompter) => {
const token = await prompter.text({ message: "Bot token" });
currentConfig = structuredClone(changedConfig);
return {
...config,
channels: { telegram: { botToken: token } },
};
},
);
mocks.writeWizardConfigFile.mockImplementation(async (config: OpenClawConfig) => config);
const engine = new SystemAgentChatEngine({
surface: "gateway",
verifiedInference,
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: {
loadOverview: fakeOverviewLoader(),
readConfigFileSnapshot: vi.fn(async () => configSnapshot(currentConfig)) as never,
},
});
const tokenStep = await engine.handle("connect telegram");
expect(tokenStep.text).toContain("Bot token");
const stopped = await engine.handle("123:abc");
expect(stopped.text).toContain("Telegram setup stopped");
expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled();
expect(mocks.runCollectedChannelOnboardingPostWriteHooks).not.toHaveBeenCalled();
});
it("rechecks inference authority before hosted channel post-write hooks", async () => {
useTempStateDir();
const baseConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
auth: { profiles: { "openai:main": { provider: "openai", mode: "api_key" } } },
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "test-key",
auth: "api-key",
models: [],
},
},
},
};
const changedConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-8" } } },
};
const verifiedInference = await createAmbientVerifiedBinding(baseConfig);
let currentConfig = structuredClone(baseConfig);
const hook = { channel: "telegram", accountId: "default", run: vi.fn() };
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "base-hash",
config: structuredClone(baseConfig),
sourceConfig: structuredClone(baseConfig),
issues: [],
});
mocks.setupChannels.mockImplementation(
async (
config: OpenClawConfig,
_runtime: unknown,
prompter: WizardPrompter,
options: { onPostWriteHook?: (hook: unknown) => void },
) => {
const token = await prompter.text({ message: "Bot token" });
options.onPostWriteHook?.(hook);
return {
...config,
channels: { telegram: { botToken: token } },
};
},
);
mocks.writeWizardConfigFile.mockImplementation(async (config: OpenClawConfig) => {
currentConfig = structuredClone(changedConfig);
return config;
});
mocks.runCollectedChannelOnboardingPostWriteHooks.mockImplementationOnce(
async (params?: { beforePersistentEffect?: () => Promise<void> }) => {
await params?.beforePersistentEffect?.();
},
);
const engine = new SystemAgentChatEngine({
surface: "gateway",
verifiedInference,
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: {
loadOverview: fakeOverviewLoader(),
readConfigFileSnapshot: vi.fn(async () => configSnapshot(currentConfig)) as never,
},
});
const tokenStep = await engine.handle("connect telegram");
expect(tokenStep.text).toContain("Bot token");
const stopped = await engine.handle("123:abc");
expect(stopped.text).toContain("Telegram setup stopped");
expect(mocks.writeWizardConfigFile).toHaveBeenCalledOnce();
expect(mocks.runCollectedChannelOnboardingPostWriteHooks).toHaveBeenCalledOnce();
expect(hook.run).not.toHaveBeenCalled();
});
});
describe("hosted channel post-write hooks", () => {
it("runs collected channel hooks after writing config", async () => {
const hook = { channel: "matrix", accountId: "default", run: vi.fn() };
mocks.readSetupConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
hash: "hook-base-hash",
config: {},
sourceConfig: {},
});
mocks.setupChannels.mockImplementation(
async (
_config: OpenClawConfig,
_runtime: unknown,
_prompter: WizardPrompter,
options: { onPostWriteHook?: (value: typeof hook) => void },
) => {
options.onPostWriteHook?.(hook);
return { channels: { matrix: { enabled: true } } };
},
);
const committed = { channels: { matrix: { enabled: true, committed: true } } };
mocks.writeWizardConfigFile.mockResolvedValue(committed);
const engine = new SystemAgentChatEngine({
surface: "gateway",
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("connect matrix");
expect(reply.text).toContain("matrix is configured");
expect(mocks.writeWizardConfigFile).toHaveBeenCalledWith(
{ channels: { matrix: { enabled: true } } },
{ allowConfigSizeDrop: false, baseHash: "hook-base-hash" },
);
expect(mocks.runCollectedChannelOnboardingPostWriteHooks).toHaveBeenCalledWith({
hooks: [hook],
cfg: committed,
runtime: expect.any(Object),
beforePersistentEffect: expect.any(Function),
});
});
});
+397
View File
@@ -0,0 +1,397 @@
import { stat } from "node:fs/promises";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import type {
MemoryImportProviderOutcome,
SetupMemoryImportOutcome,
} from "../wizard/setup.memory-import.js";
import { appendSystemAgentAuditEntry } from "./audit.js";
type SetupSharedModule = typeof import("../wizard/setup.shared.js");
let setupSharedPromise: Promise<SetupSharedModule> | undefined;
function loadSetupShared(): Promise<SetupSharedModule> {
return (setupSharedPromise ??= import("../wizard/setup.shared.js"));
}
export const GATEWAY_WRITE_POLICY = {
mode: "none",
reason: "Gateway setup defers runtime apply until explicit restart",
} as const;
export type HostedSetupCompletion = "applied" | "kept-current";
export type HostedMemoryImportOutcome =
| SetupMemoryImportOutcome
| { status: "workspace-missing"; providers: []; workspace: string };
export function requireLocalGateway(config: OpenClawConfig): void {
if (config.gateway?.mode === "local") {
return;
}
throw new Error(
"Hosted Gateway setup manages only a local Gateway. Use `openclaw onboard` for fresh setup or `openclaw configure` for the mode question, then retry after selecting local mode.",
);
}
function createHostedWizardRuntime(runtime: RuntimeEnv): RuntimeEnv {
return {
...runtime,
exit: (code): never => {
throw new Error(`hosted wizard exited with code ${String(code)}`);
},
};
}
export async function runHostedSetup(params: {
label: string;
runtime?: RuntimeEnv;
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>;
afterWrite?: import("../config/runtime-snapshot.js").ConfigWriteAfterWrite;
run: (context: { baseConfig: OpenClawConfig; runtime: RuntimeEnv }) => Promise<
| {
nextConfig: OpenClawConfig;
afterWrite?: (committedConfig: OpenClawConfig) => Promise<void>;
}
| { keptCurrent: true }
>;
}): Promise<HostedSetupCompletion> {
const { readSetupConfigFileSnapshot, writeWizardConfigFile } = await loadSetupShared();
const snapshot = await readSetupConfigFileSnapshot();
if (!snapshot.exists || !snapshot.valid || !snapshot.hash) {
throw new Error(
`${params.label} requires a valid saved config snapshot. On the machine running OpenClaw, run \`openclaw doctor --fix\` and resolve any remaining validation errors; then retry.`,
);
}
const baseConfig = snapshot.sourceConfig ?? snapshot.config;
const runtime = params.runtime ?? createHostedWizardRuntime(defaultRuntime);
const result = await params.run({ baseConfig, runtime });
if ("keptCurrent" in result) {
return "kept-current";
}
await params.beforePersistentApply(runtime);
const committedConfig = await writeWizardConfigFile(result.nextConfig, {
allowConfigSizeDrop: false,
baseHash: snapshot.hash,
...(params.afterWrite ? { afterWrite: params.afterWrite } : {}),
});
await result.afterWrite?.(committedConfig);
return "applied";
}
export async function runHostedChannelSetup(
channel: string,
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
runtime?: RuntimeEnv,
): Promise<HostedSetupCompletion> {
const {
createChannelOnboardingPostWriteHookCollector,
runCollectedChannelOnboardingPostWriteHooks,
setupChannels,
} = await import("../commands/onboard-channels.js");
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
return await runHostedSetup({
label: "Channel setup",
runtime,
beforePersistentApply,
run: async ({ baseConfig, runtime: setupRuntime }) => ({
nextConfig: await setupChannels(baseConfig, setupRuntime, prompter, {
initialSelection: [channel],
forceAllowFromChannels: [channel],
allowIMessageInstall: true,
allowSignalInstall: true,
deferStatusUntilSelection: true,
quickstartDefaults: true,
skipDmPolicyPrompt: true,
skipConfirm: true,
beforePersistentEffect: async () => await beforePersistentApply(setupRuntime),
onPostWriteHook: (hook) => postWriteHooks.collect(hook),
}),
afterWrite: async (committedConfig) => {
await runCollectedChannelOnboardingPostWriteHooks({
hooks: postWriteHooks.drain(),
cfg: committedConfig,
runtime: setupRuntime,
beforePersistentEffect: async () => await beforePersistentApply(setupRuntime),
});
},
}),
});
}
export async function runHostedSkillsSetup(
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
runtime?: RuntimeEnv,
): Promise<HostedSetupCompletion> {
const [{ setupSkills }, { resolveOnboardingAgentTarget }] = await Promise.all([
import("../commands/onboard-skills.js"),
import("../commands/onboard-agent-target.js"),
]);
return await runHostedSetup({
label: "Skills setup",
runtime,
beforePersistentApply,
run: async ({ baseConfig, runtime: setupRuntime }) => ({
nextConfig: await setupSkills(
baseConfig,
resolveOnboardingAgentTarget(baseConfig).workspaceDir,
setupRuntime,
prompter,
{ beforePersistentEffect: async () => await beforePersistentApply(setupRuntime) },
),
}),
});
}
export async function runHostedSearchSetup(
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
runtime?: RuntimeEnv,
): Promise<HostedSetupCompletion> {
const { runSearchSetupFlow } = await import("../flows/search-setup.js");
return await runHostedSetup({
label: "Web search setup",
runtime,
beforePersistentApply,
run: async ({ baseConfig, runtime: setupRuntime }) => {
const result = await runSearchSetupFlow(baseConfig, setupRuntime, prompter, {
preserveDisabledSearchState: false,
beforePersistentEffect: async () => await beforePersistentApply(setupRuntime),
});
if (result.outcome === "install-failed") {
const failure = result.reason === "timed-out" ? "timed out" : "failed";
throw new Error(`web search provider ${result.providerId} installation ${failure}`);
}
if (result.outcome === "kept-current") {
if (result.reason === "user-skipped" || result.reason === "provider-install-skipped") {
return { keptCurrent: true };
}
const reason =
result.reason === "no-providers"
? "no web search providers are available under the current plugin policy"
: "the selected web search provider is no longer available";
throw new Error(reason);
}
return { nextConfig: result.config };
},
});
}
export async function runHostedGatewaySetup(
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
runtime?: RuntimeEnv,
): Promise<HostedSetupCompletion> {
const [
{ resolveGatewayPort },
{ configureGatewayForSetup },
{ resolveQuickstartGatewayDefaults },
] = await Promise.all([
import("../config/config.js"),
import("../wizard/setup.gateway-config.js"),
loadSetupShared(),
]);
return await runHostedSetup({
label: "Gateway setup",
runtime,
beforePersistentApply,
afterWrite: GATEWAY_WRITE_POLICY,
run: async ({ baseConfig, runtime: setupRuntime }) => {
requireLocalGateway(baseConfig);
const result = await configureGatewayForSetup({
flow: "advanced",
baseConfig,
nextConfig: baseConfig,
localPort: resolveGatewayPort(baseConfig),
quickstartGateway: resolveQuickstartGatewayDefaults(baseConfig),
prompter,
runtime: setupRuntime,
});
return { nextConfig: result.nextConfig };
},
});
}
export async function runHostedMemoryImport(
prompter: WizardPrompter,
beforePersistentApply: (runtime: RuntimeEnv) => Promise<void>,
onProviderOutcome: (outcome: MemoryImportProviderOutcome) => void,
): Promise<HostedMemoryImportOutcome> {
const [{ resolveAgentWorkspaceDir, resolveDefaultAgentId }, { readSetupConfigFileSnapshot }] =
await Promise.all([import("../agents/agent-scope.js"), loadSetupShared()]);
const snapshot = await readSetupConfigFileSnapshot();
if (!snapshot.exists || !snapshot.valid || !snapshot.hash) {
throw new Error(
"Memory import requires a valid saved config. Run `openclaw doctor --fix`, then retry.",
);
}
const baseHash = snapshot.hash;
const config = snapshot.config;
const agentId = resolveDefaultAgentId(config);
const workspace = resolveAgentWorkspaceDir(config, agentId);
try {
if (!(await stat(workspace)).isDirectory()) {
return { status: "workspace-missing", providers: [], workspace };
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return { status: "workspace-missing", providers: [], workspace };
}
throw error;
}
const { runSetupMemoryImportStep } = await import("../wizard/setup.memory-import.js");
const runtime = createHostedWizardRuntime(defaultRuntime);
return await runSetupMemoryImportStep({
config,
prompter,
runtime,
beforeApply: async () => {
await beforePersistentApply(runtime);
const currentSnapshot = await readSetupConfigFileSnapshot();
if (!currentSnapshot.exists || !currentSnapshot.valid || currentSnapshot.hash !== baseHash) {
throw new Error(
"configuration changed during memory import; nothing further was copied — retry to import against the current setup",
);
}
},
onProviderOutcome,
});
}
type ConfirmedMemoryImportProviderOutcome = Extract<
MemoryImportProviderOutcome,
{ migrated: number }
>;
function hasConfirmedMemoryImportCount(
provider: MemoryImportProviderOutcome,
): provider is ConfirmedMemoryImportProviderOutcome {
return provider.copiesIndeterminate !== true;
}
function formatItemCount(count: number): string {
return `${count} ${count === 1 ? "item" : "items"}`;
}
function formatMemoryImportProviders(providers: ConfirmedMemoryImportProviderOutcome[]): string {
return providers
.map((provider) => `${provider.label} (${formatItemCount(provider.migrated)})`)
.join(", ");
}
export async function auditMemoryImport(
providers: MemoryImportProviderOutcome[],
appendAuditEntry = appendSystemAgentAuditEntry,
): Promise<void> {
const confirmedProviders = providers.filter(hasConfirmedMemoryImportCount);
const importedProviders = confirmedProviders.filter((provider) => provider.migrated > 0);
const indeterminateProviders = providers.filter(
(provider) => provider.copiesIndeterminate === true,
);
const importedItems = importedProviders.reduce((total, provider) => total + provider.migrated, 0);
if (importedItems === 0 && indeterminateProviders.length === 0) {
return;
}
const providerSummary = formatMemoryImportProviders(importedProviders);
const indeterminateSummary = indeterminateProviders
.map((provider) => `${provider.label} (copy count indeterminate)`)
.join(", ");
const summary =
indeterminateProviders.length > 0
? `Memory import failed partway via chat: ${[
providerSummary ? `confirmed ${providerSummary}` : "",
indeterminateSummary,
]
.filter(Boolean)
.join("; ")}`
: `Imported memory via chat: ${providerSummary}`;
await appendAuditEntry({
operation: "memory.import",
summary,
details: {
...(indeterminateProviders.length > 0
? { confirmedItems: importedItems, copiesIndeterminate: true }
: { totalItems: importedItems }),
providers: providers.map((provider) =>
provider.copiesIndeterminate === true
? { providerId: provider.providerId, copiesIndeterminate: true }
: {
providerId: provider.providerId,
items: provider.migrated,
...(provider.failure ? { partial: true } : {}),
},
),
},
});
}
export async function renderMemoryImport(
outcome: HostedMemoryImportOutcome | undefined,
appendAuditEntry = appendSystemAgentAuditEntry,
): Promise<string> {
if (!outcome) {
return "Memory import did not complete. No outcome was reported, and no success was assumed.";
}
if (outcome.status === "workspace-missing") {
return [
`Memory import is unavailable because the default agent workspace does not exist at ${outcome.workspace}.`,
"Finish onboarding first with `openclaw onboard`, then retry.",
].join("\n");
}
if (outcome.status === "nothing-to-import") {
return "Nothing to import — no new memory files were detected in supported local agent homes.";
}
if (outcome.status === "skipped") {
return "Memory import skipped. Nothing was copied.";
}
const confirmedProviders = outcome.providers.filter(hasConfirmedMemoryImportCount);
const importedProviders = confirmedProviders.filter((provider) => provider.migrated > 0);
const failedProviders = confirmedProviders.filter((provider) => provider.failure);
const indeterminateProviders = outcome.providers.filter(
(provider) => provider.copiesIndeterminate === true,
);
const importedItems = importedProviders.reduce((total, provider) => total + provider.migrated, 0);
const providerSummary = formatMemoryImportProviders(importedProviders);
await auditMemoryImport(outcome.providers, appendAuditEntry);
if (importedItems === 0) {
if (indeterminateProviders.length > 0) {
return [
"Memory import failed partway. Some files may have been copied before the failure.",
`Copy counts are indeterminate for: ${indeterminateProviders
.map((provider) => provider.label)
.join(", ")}.`,
].join("\n");
}
if (failedProviders.length > 0) {
return [
"Memory import did not complete. No files were copied.",
`Failed providers: ${failedProviders.map((provider) => provider.label).join(", ")}.`,
].join("\n");
}
return "Nothing was imported. No files were copied.";
}
const sourceSummary =
importedProviders.length === 1 ? importedProviders[0]!.label : providerSummary;
return [
`Imported ${formatItemCount(importedItems)} from ${sourceSummary}.`,
indeterminateProviders.length > 0
? `Memory import failed partway for ${indeterminateProviders
.map((provider) => provider.label)
.join(", ")}; some additional files may have been copied before the failure.`
: failedProviders.length > 0
? `Some providers did not complete: ${failedProviders
.map((provider) => provider.label)
.join(", ")}.`
: "",
]
.filter(Boolean)
.join("\n");
}
+24
View File
@@ -4,6 +4,30 @@ import { isPersistentSystemAgentOperation, type SystemAgentOperation } from "./o
type ProposalRef = { current?: string; operation?: SystemAgentOperation };
export type SystemAgentApprovalIntent = "approve" | "decline" | "other";
const APPROVE_RE =
/^(?:y|yes|yeah|yep|yup|sure|ok|okay|approve|approved|apply|confirm|confirmed|do it|go ahead|sounds good|yes please|please do)$/i;
const DECLINE_RE = /^(?:n|no|nope|nah|skip|not now|cancel|stop|abort|later|decline|don'?t)\b/i;
/** Deterministic whole-message approvals and prefix declines. */
export function classifySystemAgentApprovalText(message: string): SystemAgentApprovalIntent {
const normalized = message
.trim()
.replace(/[.!?,\s]+$/u, "")
.toLowerCase();
if (!normalized) {
return "other";
}
if (APPROVE_RE.test(normalized)) {
return "approve";
}
if (DECLINE_RE.test(normalized)) {
return "decline";
}
return "other";
}
export function resolvePendingOperatorProposal(
pending: SystemAgentOperation | null,
proposalRef: ProposalRef,
+1 -1
View File
@@ -9,7 +9,6 @@ import type { CommandContext } from "../auto-reply/reply/commands-types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createCorePluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js";
import type { RuntimeEnv } from "../runtime.js";
import { classifySystemAgentApprovalText } from "./approval-intent.js";
import {
executeSystemAgentOperation,
formatSystemAgentPersistentPlan,
@@ -18,6 +17,7 @@ import {
type SystemAgentCommandDeps,
type SystemAgentOperation,
} from "./operations.js";
import { classifySystemAgentApprovalText } from "./operator-approval.js";
import { resolveSystemAgentRescuePolicy } from "./rescue-policy.js";
/**
+26 -91
View File
@@ -18,45 +18,35 @@ import type {
TuiSessionCreateOptions,
} from "../tui/tui-backend.js";
import { SYSTEM_AGENT_ID } from "./agent-id.js";
import type { SystemAgentAssistantPlanner } from "./assistant.js";
import {
assertLocalGatewaySetupMode,
GATEWAY_SETUP_AFTER_WRITE,
SystemAgentChatEngine,
type SystemAgentChatEngineOptions,
} from "./chat-engine.js";
import { SystemAgentChatEngine, type SystemAgentChatEngineOptions } from "./chat-engine.js";
import {
SystemAgentInferenceUnavailableError,
isSystemAgentInferenceUnavailableError,
} from "./inference-error.js";
import { buildOnboardingWelcome } from "./onboarding-welcome.js";
import {
executeSystemAgentOperation,
type SystemAgentCommandDeps,
type SystemAgentOperation,
} from "./operations.js";
import { executeSystemAgentOperation, type SystemAgentOperation } from "./operations.js";
import { formatSystemAgentStartupMessage, loadSystemAgentOverview } from "./overview.js";
import {
resolveSystemAgentVerifiedInferenceState,
type SystemAgentVerifiedInferenceBinding,
} from "./verified-inference.js";
import { resolveSystemAgentVerifiedInferenceState } from "./verified-inference.js";
type RunTui = typeof import("../tui/tui.js").runTui;
export type SystemAgentTuiOptions = {
yes?: boolean;
deps?: SystemAgentCommandDeps;
planWithAssistant?: SystemAgentAssistantPlanner;
async function loadHostedSetupForTui() {
const [{ createClackPrompter }, hostedSetup] = await Promise.all([
import("../wizard/clack-prompter.js"),
import("./hosted-setup.runtime.js"),
]);
return { createClackPrompter, hostedSetup };
}
export type SystemAgentTuiOptions = Pick<
SystemAgentChatEngineOptions,
"yes" | "deps" | "planWithAssistant" | "verifiedInference"
> & {
runTui?: RunTui;
/** "onboarding" swaps the greeting for the first-run setup proposal. */
welcomeVariant?: "onboarding";
/** Workspace override for the proposed first-run setup (from --workspace). */
setupWorkspace?: string;
/** Test seam for the channel-setup wizard hosted by the chat bridge. */
runChannelSetupWizard?: SystemAgentChatEngineOptions["runChannelSetupWizard"];
runSkillsSetupWizard?: SystemAgentChatEngineOptions["runSkillsSetupWizard"];
runSearchSetupWizard?: SystemAgentChatEngineOptions["runSearchSetupWizard"];
runGatewaySetupWizard?: SystemAgentChatEngineOptions["runGatewaySetupWizard"];
runChannelsAdd?: (
opts: ChannelsAddOptions,
runtime: RuntimeEnv,
@@ -70,7 +60,6 @@ export type SystemAgentTuiOptions = {
runtime: RuntimeEnv,
beforePersistentEffect: () => Promise<void>,
) => Promise<void>;
readonly verifiedInference: SystemAgentVerifiedInferenceBinding;
};
type SystemAgentHistoryMessage = {
@@ -94,10 +83,6 @@ function createChatEngine(opts: SystemAgentTuiOptions): SystemAgentChatEngine {
planWithAssistant: opts.planWithAssistant,
surface: "cli",
verifiedInference: opts.verifiedInference,
...(opts.runChannelSetupWizard ? { runChannelSetupWizard: opts.runChannelSetupWizard } : {}),
...(opts.runSkillsSetupWizard ? { runSkillsSetupWizard: opts.runSkillsSetupWizard } : {}),
...(opts.runSearchSetupWizard ? { runSearchSetupWizard: opts.runSearchSetupWizard } : {}),
...(opts.runGatewaySetupWizard ? { runGatewaySetupWizard: opts.runGatewaySetupWizard } : {}),
});
}
@@ -437,40 +422,12 @@ async function runSetupHandoff(
runtime.log("Done — gateway settings saved. Run `openclaw gateway restart` to apply them.");
return;
}
const [
{ resolveGatewayPort },
{ createClackPrompter },
{ configureGatewayForSetup },
{ readSetupConfigFileSnapshot, resolveQuickstartGatewayDefaults, writeWizardConfigFile },
] = await Promise.all([
import("../config/config.js"),
import("../wizard/clack-prompter.js"),
import("../wizard/setup.gateway-config.js"),
import("../wizard/setup.shared.js"),
]);
const snapshot = await readSetupConfigFileSnapshot();
if (!snapshot.exists || !snapshot.valid || !snapshot.hash) {
throw new Error(
"Gateway setup requires a valid saved config snapshot. Run `openclaw doctor --fix`, then retry.",
);
}
const baseConfig = snapshot.sourceConfig ?? snapshot.config;
assertLocalGatewaySetupMode(baseConfig);
const result = await configureGatewayForSetup({
flow: "advanced",
baseConfig,
nextConfig: baseConfig,
localPort: resolveGatewayPort(baseConfig),
quickstartGateway: resolveQuickstartGatewayDefaults(baseConfig),
prompter: createClackPrompter(),
const { createClackPrompter, hostedSetup } = await loadHostedSetupForTui();
await hostedSetup.runHostedGatewaySetup(
createClackPrompter(),
async () => await beforePersistentEffect(),
runtime,
});
await beforePersistentEffect();
await writeWizardConfigFile(result.nextConfig, {
allowConfigSizeDrop: false,
baseHash: snapshot.hash,
afterWrite: GATEWAY_SETUP_AFTER_WRITE,
});
);
runtime.log("Done — gateway settings saved. Run `openclaw gateway restart` to apply them.");
return;
}
@@ -479,34 +436,12 @@ async function runSetupHandoff(
await opts.runSearchSetupHandoff(runtime, beforePersistentEffect);
return;
}
const [
{ runSearchSetupFlow },
{ createClackPrompter },
{ readSetupConfigFileSnapshot, writeWizardConfigFile },
] = await Promise.all([
import("../flows/search-setup.js"),
import("../wizard/clack-prompter.js"),
import("../wizard/setup.shared.js"),
]);
const snapshot = await readSetupConfigFileSnapshot();
if (!snapshot.exists || !snapshot.valid || !snapshot.hash) {
throw new Error(
"Web search setup requires a valid saved config snapshot. Run `openclaw doctor --fix`, then retry.",
);
}
const baseConfig = snapshot.sourceConfig ?? snapshot.config;
const searchSetup = await runSearchSetupFlow(baseConfig, runtime, createClackPrompter(), {
preserveDisabledSearchState: false,
beforePersistentEffect,
});
if (searchSetup.outcome !== "completed") {
return;
}
await beforePersistentEffect();
await writeWizardConfigFile(searchSetup.config, {
allowConfigSizeDrop: false,
baseHash: snapshot.hash,
});
const { createClackPrompter, hostedSetup } = await loadHostedSetupForTui();
await hostedSetup.runHostedSearchSetup(
createClackPrompter(),
async () => await beforePersistentEffect(),
runtime,
);
return;
}
const runChannelsAdd =