From 6e851103dc8188b4c8188aa86ba28c90f1c2e1a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 00:55:30 -0700 Subject: [PATCH] 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 --- config/max-lines-baseline.txt | 8 +- .../server-methods/system-agent.test.ts | 22 +- src/system-agent/approval-intent.test.ts | 2 +- src/system-agent/approval-intent.ts | 34 +- .../chat-engine.channel-hooks.test.ts | 158 - src/system-agent/chat-engine.test-support.ts | 421 ++ src/system-agent/chat-engine.test.ts | 3582 +---------------- src/system-agent/chat-engine.ts | 1909 +-------- .../chat-turn-router.approval.test.ts | 674 ++++ .../chat-turn-router.operations.test.ts | 743 ++++ src/system-agent/chat-turn-router.ts | 670 +++ src/system-agent/chat-wizard-host.test.ts | 617 +++ src/system-agent/chat-wizard-host.ts | 645 +++ src/system-agent/hosted-setup.memory.test.ts | 288 ++ src/system-agent/hosted-setup.runtime.test.ts | 719 ++++ src/system-agent/hosted-setup.runtime.ts | 397 ++ src/system-agent/operator-approval.ts | 24 + src/system-agent/rescue-message.ts | 2 +- src/system-agent/tui-backend.ts | 117 +- 19 files changed, 5350 insertions(+), 5682 deletions(-) delete mode 100644 src/system-agent/chat-engine.channel-hooks.test.ts create mode 100644 src/system-agent/chat-engine.test-support.ts create mode 100644 src/system-agent/chat-turn-router.approval.test.ts create mode 100644 src/system-agent/chat-turn-router.operations.test.ts create mode 100644 src/system-agent/chat-turn-router.ts create mode 100644 src/system-agent/chat-wizard-host.test.ts create mode 100644 src/system-agent/chat-wizard-host.ts create mode 100644 src/system-agent/hosted-setup.memory.test.ts create mode 100644 src/system-agent/hosted-setup.runtime.test.ts create mode 100644 src/system-agent/hosted-setup.runtime.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6bdeb533b32a..036e1078417b 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/gateway/server-methods/system-agent.test.ts b/src/gateway/server-methods/system-agent.test.ts index dc01a28f5c81..b50718296876 100644 --- a/src/gateway/server-methods/system-agent.test.ts +++ b/src/gateway/server-methods/system-agent.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([["s1", seededSession({ engine })]]); const context = makeContext(sessions); diff --git a/src/system-agent/approval-intent.test.ts b/src/system-agent/approval-intent.test.ts index 3f2eee5fc2b4..e84a969964e3 100644 --- a/src/system-agent/approval-intent.test.ts +++ b/src/system-agent/approval-intent.test.ts @@ -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, diff --git a/src/system-agent/approval-intent.ts b/src/system-agent/approval-intent.ts index 12ab460ca745..2290d5bfe630 100644 --- a/src/system-agent/approval-intent.ts +++ b/src/system-agent/approval-intent.ts @@ -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:", diff --git a/src/system-agent/chat-engine.channel-hooks.test.ts b/src/system-agent/chat-engine.channel-hooks.test.ts deleted file mode 100644 index 89287f0ad495..000000000000 --- a/src/system-agent/chat-engine.channel-hooks.test.ts +++ /dev/null @@ -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()), - 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), - }); - }); -}); diff --git a/src/system-agent/chat-engine.test-support.ts b/src/system-agent/chat-engine.test-support.ts new file mode 100644 index 000000000000..b1ae3fa372e6 --- /dev/null +++ b/src/system-agent/chat-engine.test-support.ts @@ -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[0]; + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); + +vi.mock("../wizard/setup.shared.js", async (importOriginal) => ({ + ...(await importOriginal()), + readSetupConfigFileSnapshot: mocks.readSetupConfigFileSnapshot, + writeWizardConfigFile: mocks.writeWizardConfigFile, +})); + +vi.mock("../commands/onboard-channels.js", async (importOriginal) => ({ + ...(await importOriginal()), + setupChannels: mocks.setupChannels, + runCollectedChannelOnboardingPostWriteHooks: mocks.runCollectedChannelOnboardingPostWriteHooks, +})); + +vi.mock("../commands/onboard-skills.js", async (importOriginal) => ({ + ...(await importOriginal()), + setupSkills: mocks.setupSkills, +})); + +vi.mock("../flows/search-setup.js", async (importOriginal) => ({ + ...(await importOriginal()), + runSearchSetupFlow: mocks.runSearchSetupFlow, +})); + +vi.mock("../wizard/setup.memory-import.js", () => ({ + runSetupMemoryImportStep: mocks.runSetupMemoryImportStep, +})); + +vi.mock("../plugins/providers.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveOwningPluginIdsForModelRefs: vi.fn(() => []), + resolveOwningPluginIdsForProviderRef: vi.fn(() => []), +})); + +vi.mock("./verified-inference.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSystemAgentVerifiedInferenceRoute: ( + ...args: Parameters + ) => { + // 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[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 & + 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 }; diff --git a/src/system-agent/chat-engine.test.ts b/src/system-agent/chat-engine.test.ts index c6cfb200905c..65baeda3faf8 100644 --- a/src/system-agent/chat-engine.test.ts +++ b/src/system-agent/chat-engine.test.ts @@ -1,684 +1,17 @@ -// 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 { expectDefined } from "@openclaw/normalization-core"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { - fingerprintAuthProfileCredential, - fingerprintOpaqueRuntimeOwner, - fingerprintResolvedProviderAuth, -} from "../agents/execution-auth-binding.js"; -import { hashSystemAgentOperation } from "../agents/tools/system-agent-tool.js"; -import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import type { runSetupMemoryImportStep } from "../wizard/setup.memory-import.js"; -import { runSystemAgentTurnWithDeps } from "./agent-turn.test-support.js"; -import { classifySystemAgentApprovalText } from "./approval-intent.js"; -import { - SystemAgentChatEngine as RuntimeSystemAgentChatEngine, - SystemAgentWizardAnswerError, + fakeOverviewLoader, + useTempStateDir, + configSnapshot, + createAmbientVerifiedBinding, + SystemAgentChatEngine, + RuntimeSystemAgentChatEngine, + SystemAgentInferenceUnavailableError, + type OpenClawConfig, type SystemAgentChatEngineOptions, -} from "./chat-engine.js"; -import { SystemAgentInferenceUnavailableError } from "./inference-error.js"; -import { - resolveSystemAgentConfiguredRouteFromConfig, - type SystemAgentConfiguredRoute, -} from "./inference-route.js"; -import { verifyConfigAfterSystemAgentWrite } from "./post-write-verification.js"; -import { - createSystemAgentVerifiedInferenceTestFixture, - installSystemAgentClaudeCliBackendTestFixture, - 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 () => {}), - chatWarn: vi.fn(), - sharedVerifiedInference: undefined as SystemAgentVerifiedInferenceBinding | undefined, -})); - -type MemoryImportStepParams = Parameters[0]; - -vi.mock("../config/config.js", async (importOriginal) => ({ - ...(await importOriginal()), - readConfigFileSnapshot: mocks.readConfigFileSnapshot, -})); - -vi.mock("../logging/subsystem.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createSubsystemLogger: (subsystem: string) => - subsystem === "system-agent/chat-engine" - ? ({ warn: mocks.chatWarn } as unknown as ReturnType) - : actual.createSubsystemLogger(subsystem), - }; -}); - -vi.mock("../wizard/setup.shared.js", async (importOriginal) => ({ - ...(await importOriginal()), - readSetupConfigFileSnapshot: mocks.readSetupConfigFileSnapshot, - writeWizardConfigFile: mocks.writeWizardConfigFile, -})); - -vi.mock("../commands/onboard-channels.js", async (importOriginal) => ({ - ...(await importOriginal()), - setupChannels: mocks.setupChannels, - runCollectedChannelOnboardingPostWriteHooks: mocks.runCollectedChannelOnboardingPostWriteHooks, -})); - -vi.mock("../commands/onboard-skills.js", async (importOriginal) => ({ - ...(await importOriginal()), - setupSkills: mocks.setupSkills, -})); - -vi.mock("../flows/search-setup.js", async (importOriginal) => ({ - ...(await importOriginal()), - runSearchSetupFlow: mocks.runSearchSetupFlow, -})); - -vi.mock("../wizard/setup.memory-import.js", () => ({ - runSetupMemoryImportStep: mocks.runSetupMemoryImportStep, -})); - -vi.mock("../plugins/providers.js", async (importOriginal) => ({ - ...(await importOriginal()), - resolveOwningPluginIdsForModelRefs: vi.fn(() => []), - resolveOwningPluginIdsForProviderRef: vi.fn(() => []), -})); - -vi.mock("./verified-inference.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveSystemAgentVerifiedInferenceRoute: ( - ...args: Parameters - ) => { - // 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[] = []; - -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; - -let sharedVerifiedInference: SystemAgentVerifiedInferenceBinding | undefined; -let sharedVerifiedInferenceDeps: SystemAgentVerifiedInferenceDeps | undefined; -let pluginMetadataSnapshot: SystemAgentPluginMetadataTestSnapshot | undefined; - -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; -} - -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), - }, - }; -} - -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, - }); -} - -async function createOAuthVerifiedBinding( - config: OpenClawConfig, - credential: Parameters[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, - }, - }); -} - -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 & { - verifiedInference?: SystemAgentVerifiedInferenceBinding; -}; - -/** Every ordinary engine test starts from a real, live-gate-shaped authority grant. */ -class SystemAgentChatEngine extends RuntimeSystemAgentChatEngine { - constructor(opts: TestSystemAgentChatEngineOptions = {}) { - const explicitBinding = opts.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({ - ...opts, - verifiedInference, - deps: { - ...(explicitBinding - ? { validateAgentHarnessRuntimeArtifact: async () => true } - : sharedVerifiedInferenceDeps), - readConfigFileSnapshot: async () => - configSnapshot(structuredClone(sharedVerifiedInferenceConfig)), - ...opts.deps, - }, - }); - } -} - -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 }); - } -}); - -const CANCEL_HINT = "Say `cancel` to stop this setup."; -const countCancelHints = (text: string) => text.split(CANCEL_HINT).length - 1; - -describe("SystemAgentChatEngine", () => { - 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(); - }); +} from "./chat-engine.test-support.js"; +describe("SystemAgentChatEngine facade", () => { it("rejects a seeded approval when its binding changes during classification", async () => { const baseConfig = { agents: { defaults: { model: "openai/gpt-5.5" } }, @@ -742,2130 +75,6 @@ describe("SystemAgentChatEngine", () => { expect(applySetup).not.toHaveBeenCalled(); }); - 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("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> = []; - 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 }, - ) => { - 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.each(["cli", "gateway"] as const)( - "hosts copy-only memory import on the %s surface and audits imported providers", - async (surface) => { - const workspace = useTempStateDir(); - const baseConfig: OpenClawConfig = { - ...sharedVerifiedInferenceConfig, - agents: { - ...sharedVerifiedInferenceConfig.agents, - defaults: { workspace }, - }, - }; - const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite"); - mocks.readSetupConfigFileSnapshot.mockResolvedValue({ - exists: true, - valid: true, - hash: "memory-base-hash", - config: baseConfig, - sourceConfig: {}, - }); - mocks.runSetupMemoryImportStep.mockImplementation(async (params: MemoryImportStepParams) => { - expect(params.config).toEqual(baseConfig); - expect(params.beforeApply).toBeTypeOf("function"); - await params.prompter.note("Codex: 2 memories", "Memories found"); - await params.beforeApply?.(); - return { - status: "completed", - providers: [{ providerId: "codex", label: "Codex", migrated: 2, skipped: 0 }], - }; - }); - const engine = new SystemAgentChatEngine({ - surface, - runAgentTurn: async () => null, - planWithAssistant: async () => null, - appendAuditEntry, - deps: { loadOverview: fakeOverviewLoader() }, - }); - - const reply = await engine.handle("import memory"); - - expect(reply.text).toContain("Imported 2 items from Codex."); - expect(appendAuditEntry).toHaveBeenCalledWith({ - operation: "memory.import", - summary: "Imported memory via chat: Codex (2 items)", - details: { - totalItems: 2, - providers: [{ providerId: "codex", items: 2 }], - }, - }); - expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled(); - }, - ); - - 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"); - }); - - 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> = []; - 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; - }, - ) => { - 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(""); - }); - - 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(""); - }); - - 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.each([ - { outcome: "kept-current" as const, config: {}, reason: "user-skipped" as const }, - { - outcome: "kept-current" as const, - config: {}, - reason: "provider-install-skipped" as const, - providerId: "brave", - }, - ])("reports $reason as an unchanged setup, not a failure", async (result) => { - const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite"); - mocks.readSetupConfigFileSnapshot.mockResolvedValue({ - exists: true, - valid: true, - hash: "search-base-hash", - config: result.config, - sourceConfig: result.config, - }); - mocks.runSearchSetupFlow.mockResolvedValue(result); - 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("kept the current configuration. Nothing was changed"); - expect(reply.text).not.toContain("setup stopped"); - expect(reply.text).not.toContain("Done — web search setup is complete"); - expect(mocks.writeWizardConfigFile).not.toHaveBeenCalled(); - expect(appendAuditEntry).not.toHaveBeenCalled(); - }); - - it.each([ - { - outcome: "kept-current" as const, - config: {}, - reason: "no-providers" as const, - message: "no web search providers are available under the current plugin policy", - }, - { - outcome: "kept-current" as const, - config: {}, - reason: "provider-unavailable" as const, - providerId: "brave", - message: "the selected web search provider is no longer available", - }, - ])("reports $reason as a stopped setup", async ({ message, ...result }) => { - const appendAuditEntry = vi.fn(async () => "state/openclaw.sqlite"); - mocks.readSetupConfigFileSnapshot.mockResolvedValue({ - exists: true, - valid: true, - hash: "search-base-hash", - config: result.config, - sourceConfig: result.config, - }); - mocks.runSearchSetupFlow.mockResolvedValue(result); - 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: ${message}`); - 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("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("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 }) => { - 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(); - }); - - 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("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.each([ - { command: "open setup wizard", action: "none" }, - { command: "configure model provider", action: "none" }, - ] as const)( - "voids stale agent proposals before the exact $command route", - async ({ command, action }) => { - 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({ - surface: "cli", - runAgentTurn: runAgentTurn as never, - classifyApproval: async ({ message }) => classifySystemAgentApprovalText(message), - deps: { loadOverview: fakeOverviewLoader() }, - }); - - await engine.handle("prepare a change for me"); - const handoff = await engine.handle(command); - await engine.handle("yes"); - - expect(handoff.action).toBe(action); - expect(armed).toEqual([false, false]); - }, - ); - - 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("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("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(""); - }); - - 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(""); - 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(""))).toBe(true); - }); - - 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("does not expose a custom planner reply after its inference owner drifts", async () => { const baseConfig = { agents: { defaults: { model: "openai/gpt-5.5" } }, @@ -2904,261 +113,6 @@ describe("SystemAgentChatEngine", () => { ); }); - 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("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("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"); - }); - - 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("fails closed when neither inference path is usable", async () => { const planner = vi.fn(async () => null); const engine = new SystemAgentChatEngine({ @@ -3174,517 +128,3 @@ describe("SystemAgentChatEngine", () => { ); }); }); - -describe("OpenClaw agent loop backends", () => { - let restoreCliBackendFixture: (() => void) | undefined; - - beforeAll(() => { - // These cases own chat routing and CLI session continuity. Anthropic setup tests own loading - // the generated backend artifact, so keep this integration on the same contract-level fixture. - restoreCliBackendFixture = installSystemAgentClaudeCliBackendTestFixture(); - }); - - afterAll(() => { - restoreCliBackendFixture?.(); - }); - - 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) => ({ - 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(mocks.chatWarn).toHaveBeenCalledWith(expect.stringContaining("claude exploded")); - }); -}); - -describe("OpenClaw chat wizard step payload", () => { - // `action` is missing on purpose: no production path or prompter method emits - // a step of that type, so it is unreachable through this seam. The protocol - // round-trip test in packages/gateway-protocol covers it instead. - const cases: Array<{ - name: string; - run: (prompter: WizardPrompter) => Promise; - /** Undefined means no step awaits an answer when the reply is built. */ - step: Record | undefined; - }> = [ - { - name: "text", - // openUrl binds to the next created step, so this proves the fields the - // card projection drops (placeholder/initialValue/sensitive/externalUrl). - // It is optional on the prompter contract; a prompter without it would - // fail the step assertion below on the missing externalUrl. - run: async (prompter) => { - await prompter.openUrl?.("https://example.com/auth"); - await prompter.text({ - message: "Bot token", - initialValue: "seed-token", - placeholder: "123:abc", - sensitive: true, - }); - }, - // initialValue is absent on purpose: the prompt below seeds one, but a - // sensitive step's prefilled value is the secret and must not cross to - // chat-result consumers. Everything else survives verbatim. - step: { - id: expect.any(String), - type: "text", - message: "Bot token", - placeholder: "123:abc", - sensitive: true, - executor: "client", - externalUrl: "https://example.com/auth", - }, - }, - { - name: "select", - run: async (prompter) => { - // Option values avoid "telegram" so tryAutoSelectChannel cannot answer - // this step for us and null the bridge's awaited step. - await prompter.select({ - message: "DM mode", - options: [ - { value: "alpha", label: "Alpha", hint: "First" }, - { value: "beta", label: "Beta" }, - ], - initialValue: "beta", - }); - }, - step: { - id: expect.any(String), - type: "select", - message: "DM mode", - options: [ - { value: "alpha", label: "Alpha", hint: "First" }, - { value: "beta", label: "Beta" }, - ], - initialValue: "beta", - executor: "client", - }, - }, - { - name: "confirm", - run: async (prompter) => { - await prompter.confirm({ message: "Enable delegated auth?", initialValue: false }); - }, - step: { - id: expect.any(String), - type: "confirm", - message: "Enable delegated auth?", - initialValue: false, - executor: "client", - }, - }, - { - name: "multiselect", - run: async (prompter) => { - await prompter.multiselect({ - message: "Features", - options: [ - { value: "alerts", label: "Alerts" }, - { value: "logs", label: "Logs" }, - ], - }); - }, - step: { - id: expect.any(String), - type: "multiselect", - message: "Features", - options: [ - { value: "alerts", label: "Alerts" }, - { value: "logs", label: "Logs" }, - ], - executor: "client", - }, - }, - { - // Informational steps are auto-answered by the pump before the reply is - // built, so they render as prose with no control. Absent `step` here is - // the contract, not a gap. - name: "note", - run: async (prompter) => { - await prompter.note("Open the provider console first."); - }, - step: undefined, - }, - { - name: "progress", - run: async (prompter) => { - prompter.progress("Linking your account"); - }, - step: undefined, - }, - ]; - - it.each(cases)("carries the awaited $name step on the chat reply", async ({ run, step }) => { - useTempStateDir(); - const engine = new SystemAgentChatEngine({ - surface: "gateway", - runAgentTurn: async () => null, - planWithAssistant: async () => null, - deps: { loadOverview: fakeOverviewLoader() }, - runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { - await run(prompter); - }, - }); - - const reply = await engine.handle("connect telegram"); - - if (step) { - expect(reply.step).toEqual(step); - } else { - expect(reply.step).toBeUndefined(); - } - }); - - 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: "" }); - 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"); - }); -}); - -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; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index cdc8d0d69fc7..3afdafeccaac 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -1,38 +1,27 @@ -// OpenClaw chat engine: transport-agnostic conversation over typed operations. +// OpenClaw chat engine: stable transport-agnostic facade over turn and wizard owners. import type { - SystemAgentChatQuestion, SystemAgentWizardCancel, WizardAnswer, } from "../../packages/gateway-protocol/src/index.js"; -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 { - sanitizeWizardStepForClient, - WizardSession, - wizardStepAwaitsInput, - type WizardStep, -} from "../wizard/session.js"; -import type { - MemoryImportProviderOutcome, - SetupMemoryImportOutcome, -} from "../wizard/setup.memory-import.js"; import { cleanupSystemAgentSession, createSystemAgentSession, - runSystemAgentTurn, type SystemAgentSession, - type SystemAgentTurnDirective, type SystemAgentTurnRunner, } from "./agent-turn.js"; -import { - classifySystemAgentApprovalText, - type SystemAgentApprovalClassifier, - type SystemAgentApprovalIntent, -} from "./approval-intent.js"; +import type { SystemAgentApprovalClassifier } from "./approval-intent.js"; import type { SystemAgentAssistantPlanner, SystemAgentAssistantTurn } from "./assistant.js"; -import { approvalQuestion } from "./dialogue.js"; +import { + ChatTurnRouter, + redactSensitiveCommandText, + type SystemAgentChatTurnOptions, +} from "./chat-turn-router.js"; +import { + ChatWizardHost, + type ChatWizardHostDependencies, + type SystemAgentChatReply, +} from "./chat-wizard-host.js"; import type { SystemAgentGreetingFacts, SystemAgentGreetingPlan, @@ -42,712 +31,99 @@ import { SystemAgentInferenceUnavailableError, isSystemAgentInferenceUnavailableError, } from "./inference-error.js"; -import { - describeSystemAgentPersistentOperation, - executeSystemAgentOperation, - isPersistentSystemAgentOperation, - parseSystemAgentOperation, - type SystemAgentCommandDeps, - type SystemAgentOperation, - type SystemAgentOperationResult, -} from "./operations.js"; -import { - resolveOperatorApprovalDecision, - resolvePendingOperatorProposal, -} from "./operator-approval.js"; +import type { SystemAgentCommandDeps, SystemAgentOperation } from "./operations.js"; import { loadSystemAgentOverview, type SystemAgentOverview } from "./overview.js"; import { verifyConfigAfterSystemAgentWrite } from "./post-write-verification.js"; import { resolveSystemAgentVerifiedInferenceRoute, type SystemAgentVerifiedInferenceBinding, } from "./verified-inference.js"; -/** - * One conversation with OpenClaw, independent of transport. The TUI backend - * and the gateway `openclaw.chat` RPC both drive this engine, so onboarding - * behaves the same in a terminal and in the macOS app. - * - * The conversation is AI-backed: free-form messages run through the agent loop - * first and the single-turn planner second. Approval of pending mutations is - * judged from the user's own words by a host-run classifier — never by the - * conversation model itself, which cannot self-approve (see - * system-agent-tool.ts). Hosted wizard replies and host navigation remain - * deterministic because they are structured UI actions, not conversation. - */ + +export { SystemAgentWizardAnswerError } from "./chat-wizard-host.js"; + export type SystemAgentChatEngineOptions = { yes?: boolean; deps?: SystemAgentCommandDeps; planWithAssistant?: SystemAgentAssistantPlanner; - /** Test seam for the one-shot cached caretaker greeting. */ planGreeting?: SystemAgentGreetingPlanner; - /** Test seam for the embedded agent-loop turn runner. */ runAgentTurn?: SystemAgentTurnRunner; - /** Test seam for the approval-intent classifier. */ classifyApproval?: SystemAgentApprovalClassifier; - /** Test seam for the audited host operation executor. */ - executeOperation?: typeof executeSystemAgentOperation; - /** Test seam for best-effort audit persistence. */ - appendAuditEntry?: typeof import("./audit.js").appendSystemAgentAuditEntry; - /** Where side effects run; the gateway surface never manages its own daemon. */ surface?: "cli" | "gateway"; - /** Test seam for the channel-setup wizard hosted by the chat bridge. */ - runChannelSetupWizard?: ( - channel: string, - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, - ) => Promise; - /** Test seam for workspace-skill dependency setup hosted by the chat bridge. */ - runSkillsSetupWizard?: ( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, - ) => Promise; - /** Test seam for web-search provider setup hosted by the chat bridge. */ - runSearchSetupWizard?: ( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, - ) => Promise; - /** Test seam for local Gateway configuration hosted by the chat bridge. */ - runGatewaySetupWizard?: ( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, - ) => Promise; - /** Test seam for copy-only memory import hosted by the chat bridge. */ - runMemoryImportWizard?: ( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, - onProviderOutcome: (outcome: MemoryImportProviderOutcome) => void, - ) => Promise; - /** Exact route/credential that passed the host's live inference gate. */ readonly verifiedInference: SystemAgentVerifiedInferenceBinding; - /** Delegated chats accept approval only from the operator registry. */ operatorApprovalOnly?: boolean; }; -type SystemAgentChatTurnOptions = { - uiContext?: { page: string }; +type SystemAgentChatEngineInternals = { + wizardDependencies?: ChatWizardHostDependencies; + executeOperation?: typeof import("./operations.js").executeSystemAgentOperation; }; -type SystemAgentChatReplyAction = "none" | "exit" | "open-tui" | "open-setup"; - -type SystemAgentChatReply = { - text: string; - action: SystemAgentChatReplyAction; - /** Client-localized draft intent for the destination agent chat. */ - agentDraft?: "hatch"; - /** The next hosted-wizard reply contains a secret and must be masked/redacted by hosts. */ - sensitive?: boolean; - /** The hosted wizard will consume the next message as its current step answer. */ - wizardInputPending?: boolean; - /** Present when the host must leave chat for an interactive handoff. */ - handoff?: SystemAgentOperation; - /** Structured choice mirroring the awaited wizard step for card-capable clients. */ - question?: SystemAgentChatQuestion; - /** The awaited wizard step in full; `question` is its lossy card projection. */ - step?: WizardStep; -}; - -type WizardPrompterLike = import("../wizard/prompts.js").WizardPrompter; - -type HostedWizardCompletion = "applied" | "kept-current"; - -type HostedMemoryImportOutcome = - | SetupMemoryImportOutcome - | { status: "workspace-missing"; providers: []; workspace: string }; - -type HostedWizardRunResult = void | HostedWizardCompletion | HostedMemoryImportOutcome; - -type ActiveWizardBridge = { - session: WizardSession; - step: WizardStep | null; - kind: "channel" | "skills" | "search" | "gateway" | "memory-import"; - label: string; - completion: { - status: HostedWizardCompletion; - memoryImport?: HostedMemoryImportOutcome; - memoryImportProviders?: MemoryImportProviderOutcome[]; - }; - /** Channel to auto-answer in the first selection step ("connect telegram"). */ - autoSelectChannel?: string; -}; - -type CaptureRuntime = RuntimeEnv & { - read: () => string; -}; - -const log = createSubsystemLogger("system-agent/chat-engine"); - -export const GATEWAY_SETUP_AFTER_WRITE = { - mode: "none", - reason: "Gateway setup defers runtime apply until explicit restart", -} as const; - -export function assertLocalGatewaySetupMode( - config: import("../config/types.openclaw.js").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)}`); - }, - }; -} - -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(), - }; -} - -async function runHostedConfigWizard(params: { - label: string; - beforePersistentApply: (runtime: RuntimeEnv) => Promise; - afterWrite?: import("../config/runtime-snapshot.js").ConfigWriteAfterWrite; - run: (context: { - baseConfig: import("../config/types.openclaw.js").OpenClawConfig; - runtime: RuntimeEnv; - }) => Promise< - | { - nextConfig: import("../config/types.openclaw.js").OpenClawConfig; - afterWrite?: ( - committedConfig: import("../config/types.openclaw.js").OpenClawConfig, - ) => Promise; - } - | { keptCurrent: true } - >; -}): Promise { - const { readSetupConfigFileSnapshot, writeWizardConfigFile } = - await import("../wizard/setup.shared.js"); - 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 { defaultRuntime } = await import("../runtime.js"); - const 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"; -} - -async function defaultChannelSetupWizardRunner( - channel: string, - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, -): Promise { - const { - createChannelOnboardingPostWriteHookCollector, - runCollectedChannelOnboardingPostWriteHooks, - setupChannels, - } = await import("../commands/onboard-channels.js"); - const postWriteHooks = createChannelOnboardingPostWriteHookCollector(); - return await runHostedConfigWizard({ - label: "Channel setup", - beforePersistentApply, - run: async ({ baseConfig, runtime }) => ({ - nextConfig: await setupChannels(baseConfig, runtime, prompter, { - initialSelection: [channel], - forceAllowFromChannels: [channel], - allowIMessageInstall: true, - allowSignalInstall: true, - deferStatusUntilSelection: true, - quickstartDefaults: true, - skipDmPolicyPrompt: true, - skipConfirm: true, - beforePersistentEffect: async () => await beforePersistentApply(runtime), - onPostWriteHook: (hook) => postWriteHooks.collect(hook), - }), - afterWrite: async (committedConfig) => { - await runCollectedChannelOnboardingPostWriteHooks({ - hooks: postWriteHooks.drain(), - cfg: committedConfig, - runtime, - beforePersistentEffect: async () => await beforePersistentApply(runtime), - }); - }, - }), - }); -} - -async function defaultSkillsSetupWizardRunner( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, -): Promise { - const [{ setupSkills }, { resolveOnboardingAgentTarget }] = await Promise.all([ - import("../commands/onboard-skills.js"), - import("../commands/onboard-agent-target.js"), - ]); - return await runHostedConfigWizard({ - label: "Skills setup", - beforePersistentApply, - run: async ({ baseConfig, runtime }) => ({ - nextConfig: await setupSkills( - baseConfig, - resolveOnboardingAgentTarget(baseConfig).workspaceDir, - runtime, - prompter, - { beforePersistentEffect: async () => await beforePersistentApply(runtime) }, - ), - }), - }); -} - -async function defaultSearchSetupWizardRunner( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, -): Promise { - const { runSearchSetupFlow } = await import("../flows/search-setup.js"); - return await runHostedConfigWizard({ - label: "Web search setup", - beforePersistentApply, - run: async ({ baseConfig, runtime }) => { - const result = await runSearchSetupFlow(baseConfig, runtime, prompter, { - preserveDisabledSearchState: false, - beforePersistentEffect: async () => await beforePersistentApply(runtime), - }); - 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 }; - }, - }); -} - -async function defaultGatewaySetupWizardRunner( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, -): Promise { - const [ - { resolveGatewayPort }, - { configureGatewayForSetup }, - { resolveQuickstartGatewayDefaults }, - ] = await Promise.all([ - import("../config/config.js"), - import("../wizard/setup.gateway-config.js"), - import("../wizard/setup.shared.js"), - ]); - // A later restart can strand this Gateway-hosted client on a new address or credential. - // The host warns before prompting, persists config only, and never restarts itself. - return await runHostedConfigWizard({ - label: "Gateway setup", - beforePersistentApply, - afterWrite: GATEWAY_SETUP_AFTER_WRITE, - run: async ({ baseConfig, runtime }) => { - assertLocalGatewaySetupMode(baseConfig); - const result = await configureGatewayForSetup({ - flow: "advanced", - baseConfig, - nextConfig: baseConfig, - localPort: resolveGatewayPort(baseConfig), - quickstartGateway: resolveQuickstartGatewayDefaults(baseConfig), - prompter, - runtime, - }); - return { nextConfig: result.nextConfig }; - }, - }); -} - -async function defaultMemoryImportWizardRunner( - prompter: WizardPrompterLike, - beforePersistentApply: (runtime: RuntimeEnv) => Promise, - onProviderOutcome: (outcome: MemoryImportProviderOutcome) => void, -): Promise { - const [ - { resolveAgentWorkspaceDir, resolveDefaultAgentId }, - { defaultRuntime }, - { readSetupConfigFileSnapshot }, - { stat }, - ] = await Promise.all([ - import("../agents/agent-scope.js"), - import("../runtime.js"), - import("../wizard/setup.shared.js"), - import("node:fs/promises"), - ]); - 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; - } - - // Load provider-backed planning only after the default workspace precondition - // passes; missing onboarding state must not run provider detection code. - const { runSetupMemoryImportStep } = await import("../wizard/setup.memory-import.js"); - const runtime = createHostedWizardRuntime(defaultRuntime); - return await runSetupMemoryImportStep({ - config, - prompter, - runtime, - // File copies are the persistent effect, so freeze both authority and the - // planned target immediately before every provider apply. The whole-config - // hash is intentionally strict, matching hosted config-wizard drift rules. - 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, - }); -} - -function formatItemCount(count: number): string { - return `${count} ${count === 1 ? "item" : "items"}`; -} - -type ConfirmedMemoryImportProviderOutcome = Extract< - MemoryImportProviderOutcome, - { migrated: number } ->; - -function hasConfirmedMemoryImportCount( - provider: MemoryImportProviderOutcome, -): provider is ConfirmedMemoryImportProviderOutcome { - return provider.copiesIndeterminate !== true; -} - -function formatMemoryImportProviders(providers: ConfirmedMemoryImportProviderOutcome[]): string { - return providers - .map((provider) => `${provider.label} (${formatItemCount(provider.migrated)})`) - .join(", "); -} - -function formatWizardOptions(step: WizardStep): string[] { - return (step.options ?? []).map((option, index) => { - const hint = option.hint ? ` — ${option.hint}` : ""; - return `${index + 1}. ${option.label}${hint}`; - }); -} - /** - * Mirror the awaited wizard step as a typed question for card clients. Only - * closed choices small enough for cards qualify; everything else stays text. - * Option replies are labels/yes/no because parseWizardAnswer matches those. + * One conversation with OpenClaw, independent of transport. The facade owns + * serialization, history, and the verified inference session; concept owners + * route turns and host setup wizards behind the stable public entrypoint. */ -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"); -} - -const WIZARD_CANCEL_HINT = "Say `cancel` to stop this setup."; - -/** Map a chat reply to a wizard step answer; null means "could not parse". */ -function parseWizardAnswer(step: WizardStep, text: string): { value: unknown } | null { - const trimmed = text.trim(); - if (step.type === "confirm") { - // Wizard confirms are structured form fields, so the closed-list - // classifier decides; ambiguous answers re-render the prompt. - const intent = classifySystemAgentApprovalText(trimmed); - if (intent === "approve") { - return { value: true }; - } - if (intent === "decline") { - return { value: false }; - } - return 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 tokens = trimmed - .split(/[\s,]+/) - .map((token) => token.trim()) - .filter(Boolean); - const values: unknown[] = []; - for (const token of tokens) { - const option = matchOption(token); - if (!option) { - return null; - } - values.push(option.value); - } - return { value: values }; - } - // note/progress/action steps advance on any input. - return { value: step.type === "action" ? true : undefined }; -} - -function formatStructuredWizardAnswerForHistory(step: WizardStep, value: unknown): string { - if (step.sensitive === true) { - return ""; - } - if (step.type === "text") { - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - return ""; - } - if (step.type === "confirm") { - return typeof value === "boolean" ? (value ? "Yes" : "No") : ""; - } - if (step.type === "select") { - return ( - step.options?.find((option) => Object.is(option.value, value))?.label ?? "" - ); - } - if (step.type === "multiselect") { - if (!Array.isArray(value)) { - return ""; - } - 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(", ") - : ""; - } - return "Continue"; -} - -export class SystemAgentWizardAnswerError extends Error {} - -function formatOperationError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return `That did not go through: ${message}`; -} - -/** - * A typed `config set` against a sensitive path carries a raw secret; the - * stored history feeds future planner prompts (and CLI-harness transcripts), - * so the value is masked the same way hosted-wizard secrets are. - */ -function redactSensitiveCommandText(text: string): string { - const operation = parseSystemAgentOperation(text); - if (operation.kind === "config-set" && isSensitiveConfigPath(operation.path)) { - return `config set ${operation.path} `; - } - 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 SystemAgentChatEngine { - private pending: SystemAgentOperation | null = null; - private wizardBridge: ActiveWizardBridge | null = null; - private lastSensitiveChannel: string | undefined; - private awaitingSetupChannel = false; - private proposalResolution: "approved" | "declined" | undefined; private readonly history: SystemAgentAssistantTurn[] = []; private readonly agentSession: SystemAgentSession; + private readonly wizard: ChatWizardHost; + private readonly router: ChatTurnRouter; private verifiedInference: SystemAgentVerifiedInferenceBinding; - /** Turns run strictly one at a time; interleaved handles corrupt wizard/pending state. */ private turnQueue: Promise = Promise.resolve(); - constructor(private readonly opts: SystemAgentChatEngineOptions) { - const binding = opts?.verifiedInference; + constructor( + private readonly options: SystemAgentChatEngineOptions, + internals: SystemAgentChatEngineInternals = {}, + ) { + const binding = options?.verifiedInference; if (!binding) { throw new SystemAgentInferenceUnavailableError("conversation"); } this.verifiedInference = binding; this.agentSession = createSystemAgentSession(binding); + this.wizard = new ChatWizardHost({ + surface: options.surface, + beforePersistentApply: async (runtime) => { + await this.requirePersistentApplyInference(runtime); + }, + dependencies: internals.wizardDependencies, + }); + this.router = new ChatTurnRouter( + options, + { executeOperation: internals.executeOperation }, + this.agentSession, + this.wizard, + { + requireVerifiedInference: async () => await this.requireVerifiedInference(), + requirePersistentApplyInference: async (runtime) => + await this.requirePersistentApplyInference(runtime), + rebindVerifiedInference: (next) => this.rebindVerifiedInference(next), + getVerifiedInference: () => this.verifiedInference, + loadOverview: async () => await this.loadOverview(), + getHistory: () => this.history, + verifyConfigAfterWrite: async () => await this.verifyConfigAfterWrite(), + }, + ); } - /** - * Seed a proposed operation that the user's next approval will apply. Used - * by first-run onboarding: the welcome message states the plan, the user - * just agrees. - */ propose(operation: SystemAgentOperation): string { - this.clearPendingProposals(); - this.pending = operation; - return describeSystemAgentPersistentOperation(operation); + return this.router.propose(operation); } hasPendingProposal(): boolean { - return this.pending !== null; + return this.router.hasPendingProposal(); } + getPendingOperatorProposal(): { operation: SystemAgentOperation; hash: string } | null { - return resolvePendingOperatorProposal(this.pending, this.agentSession.proposalRef); + return this.router.getPendingOperatorProposal(); } + async resolveOperatorApproval( decision: "allow-once" | "allow-always" | "deny" | null, proposalHash: string, ): Promise { const turn = this.turnQueue.then(async () => { - const reply = 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" }), - }); + const reply = await this.router.resolveOperatorApproval(decision, proposalHash); if (reply?.text) { this.history.push({ role: "assistant", text: reply.text }); } @@ -756,12 +132,11 @@ export class SystemAgentChatEngine { this.turnQueue = turn.catch(() => undefined); return await turn; } - /** Record a host-rendered assistant message (welcome) so AI turns see it. */ + noteAssistantMessage(text: string): void { this.history.push({ role: "assistant", text }); } - /** Seed only conversational context; wizard and approval state intentionally stay fresh. */ seedHistory(turns: readonly SystemAgentAssistantTurn[]): void { this.history.push(...turns.map((turn) => ({ ...turn }))); } @@ -770,689 +145,63 @@ export class SystemAgentChatEngine { return this.history.length; } - /** Return copies so the server can persist exactly the engine's sanitized commit. */ historySince(index: number): SystemAgentAssistantTurn[] { return this.history.slice(index).map((turn) => ({ role: turn.role, text: turn.text })); } async dispose(): Promise { - this.wizardBridge?.session.cancel(); - this.wizardBridge = null; - this.lastSensitiveChannel = undefined; - this.awaitingSetupChannel = false; + this.wizard.dispose(); await cleanupSystemAgentSession(this.agentSession); } async handle(text: string, options?: SystemAgentChatTurnOptions): Promise { - const turn = this.turnQueue.then(() => this.handleSerialized(text, options)); - // The queue must survive a failed turn or every later message would reject. + const turn = this.turnQueue.then(async () => { + await this.requireVerifiedInference(); + const sensitiveTurn = this.wizard.sensitiveInputPending; + const reply = await this.router.resolveTurn(text, options); + return this.completeTurn( + reply, + sensitiveTurn ? "" : redactSensitiveCommandText(text), + ); + }); this.turnQueue = turn.catch(() => undefined); return await turn; } async answerWizard(answer: WizardAnswer): Promise { - const turn = this.turnQueue.then(() => this.answerWizardSerialized(answer)); + const turn = this.turnQueue.then(async () => { + await this.requireVerifiedInference(); + const result = await this.router.answerWizard(this.wizard.answer(answer)); + return this.completeTurn({ text: result.text, action: "none" }, result.userHistoryText); + }); this.turnQueue = turn.catch(() => undefined); return await turn; } async cancelWizard(cancel: SystemAgentWizardCancel): Promise { - const turn = this.turnQueue.then(() => this.cancelWizardSerialized(cancel)); + const turn = this.turnQueue.then(async () => { + const result = await this.router.answerWizard(this.wizard.cancel(cancel)); + return this.completeTurn({ text: result.text, action: "none" }, result.userHistoryText); + }); this.turnQueue = turn.catch(() => undefined); return await turn; } - private async handleSerialized( - text: string, - options?: SystemAgentChatTurnOptions, - ): Promise { - await this.requireVerifiedInference(); - // Snapshot before resolving: wizard answers to sensitive steps (tokens, - // passwords) must never enter the AI-visible history. - const sensitiveTurn = this.wizardBridge?.step?.sensitive === true; - const reply = await this.resolveTurn(text, options); - return this.completeTurn( - reply, - sensitiveTurn ? "" : redactSensitiveCommandText(text), - ); - } - - private async answerWizardSerialized(answer: WizardAnswer): Promise { - await this.requireVerifiedInference(); - const bridge = this.wizardBridge; - 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 text = validationError - ? [validationError, renderWizardStep(step)].join("\n\n") - : await this.pumpWizardBridge(); - return this.completeTurn( - { text, action: "none" }, - formatStructuredWizardAnswerForHistory(step, answer.value), - ); - } - - private async cancelWizardSerialized( - cancel: SystemAgentWizardCancel, - ): Promise { - const bridge = this.wizardBridge; - 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."); - } - const text = await this.pumpWizardBridge(); - return this.completeTurn({ text, action: "none" }, "Cancel"); - } - private completeTurn(reply: SystemAgentChatReply, userHistoryText: string): SystemAgentChatReply { - // The hint belongs to the outgoing message, not to each rendered step: one - // turn can concatenate several auto-answered notes, and a wizard that just - // ended must not offer a cancel that can no longer happen. - const awaitedStep = this.wizardBridge?.step; - const completedReply: SystemAgentChatReply = - reply.text && awaitedStep && wizardStepAwaitsInput(awaitedStep) - ? { ...reply, text: `${reply.text}\n${WIZARD_CANCEL_HINT}` } - : reply; + const completed = this.wizard.decorateReply(reply); this.history.push({ role: "user", text: userHistoryText }); - if (completedReply.text) { - this.history.push({ role: "assistant", text: completedReply.text }); + if (completed.text) { + this.history.push({ role: "assistant", text: completed.text }); } - // While a hosted wizard awaits a step, every turn routes to it, so the - // awaited step is always the question this reply asks. - const step = this.wizardBridge?.step ?? null; - const question = wizardStepChatQuestion(step); - const clientStep = step ? sanitizeWizardStepForClient(step) : null; - return { - ...completedReply, - ...(step?.sensitive === true ? { sensitive: true } : {}), - ...(this.wizardBridge ? { wizardInputPending: true } : {}), - ...(question ? { question } : {}), - ...(clientStep ? { step: clientStep } : {}), - }; - } - - private async resolveTurn( - text: string, - options?: SystemAgentChatTurnOptions, - ): Promise { - if (this.wizardBridge) { - // A hosted wizard consumes every reply until it finishes or is cancelled. - return { text: await this.resolveWizardBridgeReply(text), 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)) { - // Leaving the process is a host action, not a conversation the AI owns. - 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.opts.operatorApprovalOnly && this.getPendingOperatorProposal()) { - return { text: "Approval pending. Human must decide in OpenClaw UI.", action: "none" }; - } - // Secret hygiene: an exact `config set` on a sensitive path carries a raw - // token and must never reach a model. The host handles its redacted - // proposal + approval directly, matching the wizard's masked-input rules. - 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") { - // Exact host navigation must not depend on whether a conversation model - // chooses to call the handoff tool. Clear any stale proposal first. - 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" - ) { - // Exact host-navigation commands do not depend on model interpretation. - // Inference/provider setup still exits this session before onboarding. - return await this.runOperation(typed, undefined); - } - - // Approval is judged from the user's own words, host-side. The classifier - // only runs while a proposal is pending, and "other" (questions, new - // requests) keeps the proposal pending and lets the AI carry on. - const intent = this.opts.operatorApprovalOnly - ? "other" - : await this.classifyApprovalIntent(text); - if (this.pending) { - if (intent === "approve") { - // Approval classification may invoke inference. Its result authorizes - // only the route that was verified before classification started. - await this.requireVerifiedInference(); - return await this.applyPendingProposal(); - } - 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") { - // A declined agent-loop proposal must never stay armable: void the - // registered hash now and let the AI acknowledge conversationally. - this.agentSession.proposalRef.current = undefined; - this.agentSession.proposalRef.operation = undefined; - } - - return await this.resolveAssistantTurn( - text, - this.opts.operatorApprovalOnly ? false : intent === "approve", - options?.uiContext, - ); - } - - private async classifyApprovalIntent(text: string): Promise { - const hasProposal = - this.pending !== null || this.agentSession.proposalRef.current !== undefined; - if (!hasProposal) { - return "other"; - } - const classify = - this.opts.classifyApproval ?? - (await import("./approval-intent.js")).classifySystemAgentApprovalIntent; - return await classify({ - message: text, - ...(this.pending ? { proposal: describeSystemAgentPersistentOperation(this.pending) } : {}), - verifiedInference: this.verifiedInference, - }); - } - - private async applyPendingProposal(): Promise { - const pending = this.pending; - this.clearPendingProposals(); - this.proposalResolution = "approved"; - if (!pending) { - return { text: "", action: "none" }; - } - if (pending.kind === "channel-setup") { - return { text: await this.startChannelSetupWizard(pending.channel), action: "none" }; - } - if (pending.kind === "model-setup") { - return await this.startModelSetup(pending.workspace); - } - if (!isPersistentSystemAgentOperation(pending)) { - return await this.runOperation(pending, undefined); - } - return await this.applyApprovedPersistentOperation(pending); - } - - private async applyApprovedPersistentOperation( - operation: SystemAgentOperation, - ): Promise { - if (!isPersistentSystemAgentOperation(operation)) { - throw new Error(`OpenClaw host received a non-persistent approved operation.`); - } - const capture = createCaptureRuntime(); - let result: SystemAgentOperationResult | undefined; - try { - const executeOperation = this.opts.executeOperation ?? executeSystemAgentOperation; - result = await executeOperation(operation, capture, { - approved: true, - deps: this.commandDeps(), - // The model turn, approval classifier, and operation preflight all - // await. Freeze authority at the actual persistent-apply boundary. - beforePersistentApply: async () => { - await this.requirePersistentApplyInference(capture); - }, - onVerifiedInferenceChanged: (binding) => this.rebindVerifiedInference(binding), - }); - } catch (error) { - if (isSystemAgentInferenceUnavailableError(error)) { - throw error; - } - capture.error(formatOperationError(error)); - } - const verify = result?.applied ? await this.verifyConfigAfterWrite() : null; - const followUp = this.armFollowUp(result?.followUp); - const baseText = [capture.read() || "Applied. Audit entry written.", verify, followUp] - .filter(Boolean) - .join("\n\n"); - // The hatch is a ceremony: setup or an explicit creation just seeded the agent, - // so hand the user straight into it instead of parking them here. The - // seeded BOOTSTRAP runs the birth sequence on the agent's first turn. - // Only on clean post-write verification: a non-null verify means the - // written config is suspect, and handing off would bury the warning in an - // agent session that may not answer — stay in setup to repair it. - 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", - }; - } - - /** - * AI turn: the OpenClaw persona answers and acts through the ring-zero - * tool. The single-turn planner is a second inference path; if neither path - * answers, the turn fails closed instead of executing model-free guesses. - */ - private async resolveAssistantTurn( - text: string, - approvalArmed: boolean, - uiContext?: { page: string }, - ): Promise { - const overview = await this.loadOverview(); - - // Preferred path: the real agent loop (embedded runtime, ring-zero tool, - // persistent session). It acts through audited tool calls, so its reply is - // final — no engine-side command extraction or approval bookkeeping. - const agentTurn = this.opts.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 - ? // Hand a host-seeded proposal (onboarding welcome) to the loop so - // the conversation can reshape it through the tool handshake. - `[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 - }`; - // The planner receives the pending proposal structurally (pendingOperation - // below); only the ui-context marker rides its input, or it would see the - // same proposal twice. - const plannerInput = `${uiContextMarker}${text}`; - let agentFailure: unknown; - let loopReply: Awaited>; - try { - loopReply = await agentTurn({ - input: loopInput, - overview, - surface: this.opts.surface ?? "cli", - // Mutations unlock only on host-verified approval of THIS message; - // the model cannot self-approve (see system-agent-tool.ts). - approvalArmed, - session: this.agentSession, - }); - } catch (error) { - log.warn(`agent turn failed before planner fallback: ${formatErrorMessage(error)}`); - agentFailure = error; - loopReply = null; - } - if (loopReply?.text) { - // The native loop saw this marker. Keep it queued across planner fallback - // so a recovered persistent session cannot resurrect resolved host work. - this.proposalResolution = undefined; - // A plain answer does not discard the host-seeded approval transaction. - // Clear it only once the loop registers a replacement or takes a handoff. - if (loopReply.directive) { - this.clearPendingProposals(); - } else if (this.agentSession.proposalRef.current !== undefined) { - this.pending = null; - } - // Directive/wizard failures are host failures, not inference failures; - // never replay them through a second model path. - return await this.applyAgentTurnReply(loopReply); - } - - const planner = - this.opts.planWithAssistant ?? (await import("./assistant.js")).planSystemAgentCommand; - let plannerFailure: unknown; - let plan: Awaited>; - try { - plan = await planner({ - input: plannerInput, - overview, - history: this.history, - ...(this.pending - ? { pendingOperation: formatPendingOperationForAssistant(this.pending) } - : {}), - verifiedInference: this.verifiedInference, - }); - if (plan) { - // Custom planners are test/plugin seams and do not inherit the default - // planner's post-cleanup guard. Check before exposing any plan text. - await this.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]); - } - // A conversational reply is still valid even when its optional command - // falls outside the closed operation vocabulary. - return { text: replyText, action: "none" }; - } - // Security contract: surface the interpreted command and model before - // anything runs (docs/cli/setup.md, AI conversation). - 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 { - // Recheck after the model turn: the route may have changed while inference - // was running, and its stale directive must never cross that boundary. - await this.requireVerifiedInference(); - // Setup wizards and TUI/UI handoffs assume a human at the keyboard. In a - // delegated request the "user" answering them is the machine agent, so they - // would persist channel/config state with no operator decision — refuse. - const refusal = this.refuseDelegatedNavigationDirective(loopReply.directive?.kind); - if (refusal) { - return { text: [loopReply.text, refusal].filter(Boolean).join("\n\n"), action: "none" }; - } - if (loopReply.directive?.kind === "approved-operation") { - const applied = await this.applyApprovedPersistentOperation(loopReply.directive.operation); - return { - ...applied, - text: [loopReply.text, applied.text].filter(Boolean).join("\n\n"), - }; - } - if (loopReply.directive?.kind === "channel-setup") { - const wizardIntro = await this.startChannelSetupWizard(loopReply.directive.channel); - return { - text: [loopReply.text, wizardIntro].filter(Boolean).join("\n\n"), - action: "none", - }; - } - if (loopReply.directive?.kind === "skills-setup") { - const wizardIntro = await this.startSkillsSetupWizard(); - return { - text: [loopReply.text, wizardIntro].filter(Boolean).join("\n\n"), - action: "none", - }; - } - if (loopReply.directive?.kind === "search-setup") { - const wizardIntro = await this.startSearchSetupWizard(); - return { - text: [loopReply.text, wizardIntro].filter(Boolean).join("\n\n"), - action: "none", - }; - } - if (loopReply.directive?.kind === "gateway-config-setup") { - const wizardIntro = await this.startGatewaySetupWizard(); - return { - text: [loopReply.text, wizardIntro].filter(Boolean).join("\n\n"), - action: "none", - }; - } - if (loopReply.directive?.kind === "memory-import") { - const wizardIntro = await this.startMemoryImportWizard(); - return { - text: [loopReply.text, wizardIntro].filter(Boolean).join("\n\n"), - action: "none", - }; - } - if (loopReply.directive?.kind === "model-setup") { - const setup = await this.startModelSetup(loopReply.directive.workspace); - return { - ...setup, - text: [loopReply.text, setup.text].filter(Boolean).join("\n\n"), - }; - } - if (loopReply.directive?.kind === "open-tui") { - // The Gateway keeps this engine after an open-agent handoff. Retire the - // abandoned proposal so a later "yes" cannot arm pre-handoff work. - this.clearPendingProposals(); - return { - text: loopReply.text, - action: "open-tui", - handoff: loopReply.directive, - }; - } - if (loopReply.directive?.kind === "open-setup") { - const handoff = await this.runOperation(loopReply.directive, undefined); - return { - ...handoff, - text: [loopReply.text, handoff.text].filter(Boolean).join("\n\n"), - }; - } - return { text: loopReply.text, action: "none" }; - } - - // Setup wizards and TUI/UI handoffs persist config or need a human at the - // keyboard. A delegated (operator-approval-only) request has no human driving - // them, so refuse rather than let a machine agent complete setup unattended. - private refuseDelegatedNavigationDirective(kind: string | undefined): string | undefined { - if (!this.opts.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 { - // Planning and approval classification are asynchronous. Bind every - // operation to the same inference owner checked at turn start. - await this.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") { - // Host-owned setup replaces the current conversation branch. Void both - // proposal stores before any prompt or handoff so a later "yes" cannot - // approve work from the abandoned branch. - this.clearPendingProposals(); - if (this.opts.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 ( - operation.target !== "channels" && - operation.target !== "search" && - operation.target !== "gateway" - ) { - 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) { - const channel = this.lastSensitiveChannel; - if (!channel) { - this.awaitingSetupChannel = true; - return { - text: "Which channel should I open in the masked terminal wizard?", - action: "none", - }; - } - this.lastSensitiveChannel = undefined; - handoff = { ...handoff, channel }; - } - 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") { - // Starting the wizard is not a write; the wizard collects explicit - // answers and commits only at the end. - return { text: await this.startChannelSetupWizard(operation.channel), action: "none" }; - } - if (operation.kind === "skills-setup") { - return { text: await this.startSkillsSetupWizard(), action: "none" }; - } - if (operation.kind === "search-setup") { - return { text: await this.startSearchSetupWizard(), action: "none" }; - } - if (operation.kind === "gateway-config-setup") { - return { text: await this.startGatewaySetupWizard(), action: "none" }; - } - if (operation.kind === "memory-import") { - return { text: await this.startMemoryImportWizard(), action: "none" }; - } - if (operation.kind === "model-setup") { - return await this.startModelSetup(operation.workspace); - } - - const capture = createCaptureRuntime(); - if (isPersistentSystemAgentOperation(operation) && !this.opts.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", - }; - } - - let result: SystemAgentOperationResult | undefined; - try { - const executeOperation = this.opts.executeOperation ?? executeSystemAgentOperation; - result = await executeOperation(operation, capture, { - approved: this.opts.yes === true || !isPersistentSystemAgentOperation(operation), - deps: this.commandDeps(), - beforePersistentApply: async () => { - await this.requirePersistentApplyInference(capture); - }, - onVerifiedInferenceChanged: (binding) => this.rebindVerifiedInference(binding), - }); - } catch (error) { - if (isSystemAgentInferenceUnavailableError(error)) { - throw error; - } - capture.error(formatOperationError(error)); - } - const verify = result?.applied ? await this.verifyConfigAfterWrite() : null; - const followUp = this.armFollowUp(result?.followUp); - const reply = [provenance, capture.read(), verify, followUp].filter(Boolean).join("\n\n"); - if (operation.kind === "none" && reply.includes("Bye.")) { - return { text: reply, action: "exit" }; - } - return { text: reply, action: "none" }; + return completed; } async loadOverview(): Promise { - const verifiedRoute = await this.requireVerifiedInference(); - const overview = this.opts.deps?.loadOverview - ? await this.opts.deps.loadOverview() + const route = await this.requireVerifiedInference(); + const overview = this.options.deps?.loadOverview + ? await this.options.deps.loadOverview() : await loadSystemAgentOverview(); - return { ...overview, defaultModel: verifiedRoute.modelLabel }; + return { ...overview, defaultModel: route.modelLabel }; } async planGreeting(params: { @@ -1460,18 +209,17 @@ export class SystemAgentChatEngine { facts: SystemAgentGreetingFacts; timeoutMs: number; }): Promise { - const planner = this.opts.planGreeting; + const planner = this.options.planGreeting; const plan = planner ? await planner(params) : await import("./assistant.js").then(({ planSystemAgentGreetingWithConfiguredModel }) => planSystemAgentGreetingWithConfiguredModel({ ...params, verifiedInference: this.verifiedInference, - deps: this.opts.deps, + deps: this.options.deps, }), ); if (plan) { - // Custom planners do not inherit the configured planner's cleanup guard. await this.requireVerifiedInference(); } return plan; @@ -1483,7 +231,7 @@ export class SystemAgentChatEngine { return this.throwInferenceUnavailable(); } try { - const route = await resolveSystemAgentVerifiedInferenceRoute(binding, this.opts.deps); + const route = await resolveSystemAgentVerifiedInferenceRoute(binding, this.options.deps); if (route) { return route; } @@ -1503,7 +251,7 @@ export class SystemAgentChatEngine { const route = await resolvePersistentApplyInference({ binding, runtime, - deps: this.opts.deps, + deps: this.options.deps, }); if (route) { return route; @@ -1521,495 +269,24 @@ export class SystemAgentChatEngine { if (binding.execution.agentId !== this.verifiedInference.execution.agentId) { return; } - // Native CLI continuity is route-owned. Keep the conversation transcript, - // but force the next turn to establish a session for the new verified route. delete this.agentSession.cliSession; this.verifiedInference = binding; this.agentSession.verifiedInference = binding; } private throwInferenceUnavailable(failures: readonly unknown[] = [], cancelWizard = true): never { - // Inference loss retires every authority-bearing branch. The engine itself - // may still be referenced by a host, so leave no proposal, wizard, or CLI - // continuation that a later call could revive. - this.pending = null; - this.proposalResolution = undefined; - this.agentSession.proposalRef.current = undefined; - this.agentSession.proposalRef.operation = undefined; + this.router.clearForInferenceLoss(); delete this.agentSession.cliSession; if (cancelWizard) { - this.wizardBridge?.session.cancel(); + this.wizard.dispose(); } - this.wizardBridge = null; - this.lastSensitiveChannel = undefined; - this.awaitingSetupChannel = false; this.history.splice(0); throw new SystemAgentInferenceUnavailableError("conversation", failures); } - /** - * Post-write hook: re-validate openclaw.json after every applied operation. - * On failure the exact schema issues go straight back into the conversation - * (and to the AI, which proposes one corrective command) so a bad write is - * caught and fixed in the same chat instead of surfacing at gateway start. - */ private async verifyConfigAfterWrite(): Promise { return await verifyConfigAfterSystemAgentWrite((message) => - this.resolveAssistantTurn(message, false), + this.router.resolveAssistantTurn(message, false), ); } - - private commandDeps(): SystemAgentCommandDeps | undefined { - if (!this.opts.deps && !this.opts.surface) { - return undefined; - } - return { - ...this.opts.deps, - ...(this.opts.surface ? { setupSurface: this.opts.surface } : {}), - }; - } - - private clearPendingProposals(): void { - this.pending = null; - this.agentSession.proposalRef.current = undefined; - this.agentSession.proposalRef.operation = undefined; - } - - private armFollowUp(operation: SystemAgentOperation | undefined): string | null { - if (operation?.kind !== "model-setup") { - return null; - } - return [ - "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"); - } - - private async startChannelSetupWizard(channel: string): Promise { - this.clearPendingProposals(); - this.lastSensitiveChannel = undefined; - const beforePersistentApply = async (runtime: RuntimeEnv) => { - await this.requirePersistentApplyInference(runtime); - }; - const runWizard = - this.opts.runChannelSetupWizard ?? - ((ch: string, prompter: WizardPrompterLike, guard: (runtime: RuntimeEnv) => Promise) => - defaultChannelSetupWizardRunner(ch, prompter, guard)); - return await this.startHostedWizard({ - kind: "channel", - label: channel, - autoSelectChannel: channel, - run: (prompter) => runWizard(channel, prompter, beforePersistentApply), - }); - } - - private async startSkillsSetupWizard(): Promise { - this.clearPendingProposals(); - const beforePersistentApply = async (runtime: RuntimeEnv) => { - await this.requirePersistentApplyInference(runtime); - }; - const runWizard = this.opts.runSkillsSetupWizard ?? defaultSkillsSetupWizardRunner; - return await this.startHostedWizard({ - kind: "skills", - label: "skills", - run: (prompter) => runWizard(prompter, beforePersistentApply), - }); - } - - private async startSearchSetupWizard(): Promise { - this.clearPendingProposals(); - const beforePersistentApply = async (runtime: RuntimeEnv) => { - await this.requirePersistentApplyInference(runtime); - }; - const runWizard = this.opts.runSearchSetupWizard ?? defaultSearchSetupWizardRunner; - return await this.startHostedWizard({ - kind: "search", - label: "web search", - run: (prompter) => runWizard(prompter, beforePersistentApply), - }); - } - - private async startGatewaySetupWizard(): Promise { - this.clearPendingProposals(); - const beforePersistentApply = async (runtime: RuntimeEnv) => { - await this.requirePersistentApplyInference(runtime); - }; - const runWizard = this.opts.runGatewaySetupWizard ?? defaultGatewaySetupWizardRunner; - const firstStep = await this.startHostedWizard({ - kind: "gateway", - label: "gateway", - run: (prompter) => runWizard(prompter, beforePersistentApply), - }); - // Warn only while an interactive wizard is actually pending; a refusal - // (remote mode, invalid snapshot) already finished and needs no lockout note. - if (this.opts.surface !== "gateway" || this.wizardBridge === null) { - return firstStep; - } - 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 [warning, firstStep].filter(Boolean).join("\n\n"); - } - - private async startMemoryImportWizard(): Promise { - this.clearPendingProposals(); - const beforePersistentApply = async (runtime: RuntimeEnv) => { - await this.requirePersistentApplyInference(runtime); - }; - const runWizard = this.opts.runMemoryImportWizard ?? defaultMemoryImportWizardRunner; - const providerOutcomes: MemoryImportProviderOutcome[] = []; - return await this.startHostedWizard({ - kind: "memory-import", - label: "memory import", - memoryImportProviders: providerOutcomes, - run: (prompter) => - runWizard(prompter, beforePersistentApply, (outcome) => providerOutcomes.push(outcome)), - }); - } - - private async startHostedWizard(params: { - kind: ActiveWizardBridge["kind"]; - label: string; - autoSelectChannel?: string; - memoryImportProviders?: MemoryImportProviderOutcome[]; - run: (prompter: WizardPrompterLike) => Promise; - }): Promise { - this.lastSensitiveChannel = undefined; - 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.wizardBridge = { - session, - step: null, - kind: params.kind, - label: params.label, - completion, - ...(params.autoSelectChannel ? { autoSelectChannel: params.autoSelectChannel } : {}), - }; - return await this.pumpWizardBridge(); - } - - private async startModelSetup(_workspace: string | undefined): Promise { - 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", - }; - } - - /** - * "connect telegram" already names the channel; answer the wizard's channel - * selection step automatically instead of echoing the full channel wall. - */ - private tryAutoSelectChannel(step: WizardStep): { value: unknown } | null { - const bridge = this.wizardBridge; - const channel = bridge?.autoSelectChannel; - if (!bridge || !channel) { - return null; - } - if (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 }; - } - - /** Advance the hosted wizard to the next interactive step (or completion). */ - private async pumpWizardBridge(): Promise { - const bridge = this.wizardBridge; - if (!bridge) { - return ""; - } - const result = await bridge.session.next(); - if (result.done) { - this.wizardBridge = null; - const label = bridge.label; - if (result.status === "done") { - if (bridge.kind === "memory-import") { - return await this.finishMemoryImportWizard(bridge.completion.memoryImport); - } - if (bridge.completion.status === "kept-current") { - return `${label[0]?.toUpperCase() ?? "S"}${label.slice(1)} setup kept the current configuration. Nothing was changed.`; - } - const audit = - bridge.kind === "channel" - ? { - operation: "channels.setup", - summary: `Configured channel ${label} via chat setup`, - details: { channel: 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 appendAuditEntry = - this.opts.appendAuditEntry ?? (await import("./audit.js")).appendSystemAgentAuditEntry; - await appendAuditEntry(audit); - } catch (error) { - // Hosted setup already committed. Audit failure must not turn its - // truthful success result into a user-facing setup failure. - log.warn( - `${bridge.kind} setup completed without audit entry: ${formatErrorMessage(error)}`, - ); - } - const verify = await this.verifyConfigAfterWrite(); - 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 [...success, verify ?? ""].filter(Boolean).join("\n"); - } - if (bridge.kind === "memory-import") { - await this.auditMemoryImportProviders(bridge.completion.memoryImportProviders ?? []); - } - if (result.status === "cancelled") { - return `${label[0]?.toUpperCase() ?? "S"}${label.slice(1)} setup cancelled. Nothing was changed beyond completed steps.`; - } - return `${label[0]?.toUpperCase() ?? "S"}${label.slice(1)} setup stopped: ${result.error ?? "unknown error"}`; - } - bridge.step = result.step ?? null; - if (bridge.step) { - const auto = this.tryAutoSelectChannel(bridge.step); - if (auto) { - const step = bridge.step; - bridge.step = null; - await bridge.session.answer(step.id, auto.value); - return await this.pumpWizardBridge(); - } - if (this.opts.surface === "cli" && bridge.step.sensitive === true) { - bridge.session.cancel(); - this.wizardBridge = null; - if (bridge.kind === "channel") { - this.lastSensitiveChannel = bridge.label; - return [ - "Sensitive input is not accepted in the OpenClaw chat because terminal input is visible.", - `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.`, - ].join("\n"); - } - if (bridge.kind === "gateway") { - return [ - "Sensitive input is not accepted in the OpenClaw chat because terminal input is visible.", - "Say `open gateway wizard` and I'll hand you to the masked terminal wizard, or run `openclaw configure --section gateway` yourself later.", - ].join("\n"); - } - return [ - "Sensitive input is not accepted in the OpenClaw chat because terminal input is visible.", - "Say `open search wizard` and I'll hand you to the masked terminal wizard, or run `openclaw configure --section web` yourself later.", - ].join("\n"); - } - 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.pumpWizardBridge(); - return [renderWizardStep(step), next].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.pumpWizardBridge(); - } - } - return bridge.step ? renderWizardStep(bridge.step) : ""; - } - - private async auditMemoryImportProviders( - providers: MemoryImportProviderOutcome[], - ): Promise { - 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 auditSummary = - indeterminateProviders.length > 0 - ? `Memory import failed partway via chat: ${[ - providerSummary ? `confirmed ${providerSummary}` : "", - indeterminateSummary, - ] - .filter(Boolean) - .join("; ")}` - : `Imported memory via chat: ${providerSummary}`; - try { - const appendAuditEntry = - this.opts.appendAuditEntry ?? (await import("./audit.js")).appendSystemAgentAuditEntry; - await appendAuditEntry({ - operation: "memory.import", - summary: auditSummary, - 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 } : {}), - }, - ), - }, - }); - } catch (error) { - // Copies may already have occurred. Audit failure must not replace the - // truthful import outcome with a user-facing audit error. - log.warn(`memory import completed without audit entry: ${formatErrorMessage(error)}`); - } - } - - private async finishMemoryImportWizard( - outcome: HostedMemoryImportOutcome | undefined, - ): Promise { - 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 this.auditMemoryImportProviders(outcome.providers); - - 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."; - } - - // Memory import copies files and does not change config, so post-write - // config verification does not apply. - 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"); - } - - private async resolveWizardBridgeReply(text: string): Promise { - const bridge = this.wizardBridge; - if (!bridge) { - return ""; - } - if (/^(cancel|abort|stop|quit|exit)$/i.test(text.trim())) { - bridge.session.cancel(); - return await this.pumpWizardBridge(); - } - const step = bridge.step; - if (!step) { - return await this.pumpWizardBridge(); - } - const answer = parseWizardAnswer(step, text); - if (!answer) { - return ["I could not match that answer.", renderWizardStep(step)].join("\n"); - } - const validationError = await bridge.session.answer(step.id, answer.value); - if (validationError) { - return [validationError, renderWizardStep(step)].join("\n\n"); - } - return await this.pumpWizardBridge(); - } } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/system-agent/chat-turn-router.approval.test.ts b/src/system-agent/chat-turn-router.approval.test.ts new file mode 100644 index 000000000000..aa48a2eece63 --- /dev/null +++ b/src/system-agent/chat-turn-router.approval.test.ts @@ -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(""); + }); + + 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(""); + 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(""))).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"); + }); +}); diff --git a/src/system-agent/chat-turn-router.operations.test.ts b/src/system-agent/chat-turn-router.operations.test.ts new file mode 100644 index 000000000000..6f97b10b7fe2 --- /dev/null +++ b/src/system-agent/chat-turn-router.operations.test.ts @@ -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(); + 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) => ({ + 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")); + }); +}); diff --git a/src/system-agent/chat-turn-router.ts b/src/system-agent/chat-turn-router.ts new file mode 100644 index 000000000000..527b8dbbf67f --- /dev/null +++ b/src/system-agent/chat-turn-router.ts @@ -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} `; + } + 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; + requirePersistentApplyInference: (runtime: RuntimeEnv) => Promise; + rebindVerifiedInference: (binding: SystemAgentVerifiedInferenceBinding) => void; + getVerifiedInference: () => SystemAgentVerifiedInferenceBinding; + loadOverview: () => Promise; + getHistory: () => SystemAgentAssistantTurn[]; + verifyConfigAfterWrite: () => Promise; + }, + ) {} + + 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 { + 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): Promise { + const answer = await result; + return { ...answer, text: await this.finishWizardText(answer) }; + } + + async resolveTurn( + text: string, + options?: SystemAgentChatTurnOptions, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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>; + 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>; + 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 { + 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 { + 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 { + 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): Promise { + // 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, + ): Promise { + const reply = await this.startWizard(result); + return { ...reply, text: [prefix, reply.text].filter(Boolean).join("\n\n") }; + } + + private async finishWizardText(result: ChatWizardResult): Promise { + 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; + } +} diff --git a/src/system-agent/chat-wizard-host.test.ts b/src/system-agent/chat-wizard-host.test.ts new file mode 100644 index 000000000000..91adcd40dc05 --- /dev/null +++ b/src/system-agent/chat-wizard-host.test.ts @@ -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: "" }); + 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"); + }); +}); diff --git a/src/system-agent/chat-wizard-host.ts b/src/system-agent/chat-wizard-host.ts new file mode 100644 index 000000000000..b4689a7936f1 --- /dev/null +++ b/src/system-agent/chat-wizard-host.ts @@ -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, + ) => Promise; + runSkillsSetupWizard?: ( + prompter: WizardPrompter, + beforePersistentApply: (runtime: RuntimeEnv) => Promise, + ) => Promise; + runSearchSetupWizard?: ( + prompter: WizardPrompter, + beforePersistentApply: (runtime: RuntimeEnv) => Promise, + ) => Promise; + runGatewaySetupWizard?: ( + prompter: WizardPrompter, + beforePersistentApply: (runtime: RuntimeEnv) => Promise, + ) => Promise; + runMemoryImportWizard?: ( + prompter: WizardPrompter, + beforePersistentApply: (runtime: RuntimeEnv) => Promise, + onProviderOutcome: (outcome: MemoryImportProviderOutcome) => void, + ) => Promise; + 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 | undefined; + +function loadHostedRuntime(): Promise { + 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 ""; + } + if (step.type === "text") { + return ["string", "number", "boolean", "bigint"].includes(typeof value) + ? String(value) + : ""; + } + if (step.type === "confirm") { + return typeof value === "boolean" ? (value ? "Yes" : "No") : ""; + } + if (step.type === "select") { + return ( + step.options?.find((option) => Object.is(option.value, value))?.label ?? "" + ); + } + if (step.type === "multiselect") { + if (!Array.isArray(value)) { + return ""; + } + 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(", ") + : ""; + } + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + }): Promise { + 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 { + 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 { + 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)}`); + } + } +} diff --git a/src/system-agent/hosted-setup.memory.test.ts b/src/system-agent/hosted-setup.memory.test.ts new file mode 100644 index 000000000000..028af5d4e916 --- /dev/null +++ b/src/system-agent/hosted-setup.memory.test.ts @@ -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"); + }); +}); diff --git a/src/system-agent/hosted-setup.runtime.test.ts b/src/system-agent/hosted-setup.runtime.test.ts new file mode 100644 index 000000000000..76a87735a009 --- /dev/null +++ b/src/system-agent/hosted-setup.runtime.test.ts @@ -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> = []; + 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 }, + ) => { + 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> = []; + 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; + }, + ) => { + 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(""); + }); + + 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(""); + }); + + 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 }) => { + 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), + }); + }); +}); diff --git a/src/system-agent/hosted-setup.runtime.ts b/src/system-agent/hosted-setup.runtime.ts new file mode 100644 index 000000000000..dbb5cd27209a --- /dev/null +++ b/src/system-agent/hosted-setup.runtime.ts @@ -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 | undefined; + +function loadSetupShared(): Promise { + 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; + afterWrite?: import("../config/runtime-snapshot.js").ConfigWriteAfterWrite; + run: (context: { baseConfig: OpenClawConfig; runtime: RuntimeEnv }) => Promise< + | { + nextConfig: OpenClawConfig; + afterWrite?: (committedConfig: OpenClawConfig) => Promise; + } + | { keptCurrent: true } + >; +}): Promise { + 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, + runtime?: RuntimeEnv, +): Promise { + 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, + runtime?: RuntimeEnv, +): Promise { + 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, + runtime?: RuntimeEnv, +): Promise { + 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, + runtime?: RuntimeEnv, +): Promise { + 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, + onProviderOutcome: (outcome: MemoryImportProviderOutcome) => void, +): Promise { + 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 { + 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 { + 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"); +} diff --git a/src/system-agent/operator-approval.ts b/src/system-agent/operator-approval.ts index 8c3988e528b5..f9eba583d3de 100644 --- a/src/system-agent/operator-approval.ts +++ b/src/system-agent/operator-approval.ts @@ -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, diff --git a/src/system-agent/rescue-message.ts b/src/system-agent/rescue-message.ts index ebcc672a05c0..ef57477da098 100644 --- a/src/system-agent/rescue-message.ts +++ b/src/system-agent/rescue-message.ts @@ -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"; /** diff --git a/src/system-agent/tui-backend.ts b/src/system-agent/tui-backend.ts index 2cf0c0caf3b3..b05dab29f0eb 100644 --- a/src/system-agent/tui-backend.ts +++ b/src/system-agent/tui-backend.ts @@ -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, ) => Promise; - 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 =