From a861da41b5add556bfd08ce06dcacdff2ecb1b87 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 09:23:13 +0100 Subject: [PATCH] test: trim CLI and doctor hotspots --- .../command/attempt-execution.cli.test.ts | 183 +++++++++++ src/commands/agent.cli-provider.test.ts | 288 ------------------ src/commands/doctor-security.test.ts | 9 +- src/commands/doctor.matrix-migration.test.ts | 66 ---- ...es-slack-discord-dm-policy-aliases.test.ts | 43 --- .../doctor.update-repair-no-restart.test.ts | 89 ------ src/commands/health.test.ts | 23 -- src/flows/doctor-health-contributions.ts | 15 +- ...doctor-startup-channel-maintenance.test.ts | 58 ++++ .../doctor-startup-channel-maintenance.ts | 28 ++ 10 files changed, 280 insertions(+), 522 deletions(-) create mode 100644 src/agents/command/attempt-execution.cli.test.ts delete mode 100644 src/commands/agent.cli-provider.test.ts delete mode 100644 src/commands/doctor.matrix-migration.test.ts delete mode 100644 src/commands/doctor.migrates-slack-discord-dm-policy-aliases.test.ts delete mode 100644 src/commands/doctor.update-repair-no-restart.test.ts create mode 100644 src/flows/doctor-startup-channel-maintenance.test.ts create mode 100644 src/flows/doctor-startup-channel-maintenance.ts diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts new file mode 100644 index 00000000000..495a55e0e01 --- /dev/null +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -0,0 +1,183 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { FailoverError } from "../failover-error.js"; +import type { EmbeddedPiRunResult } from "../pi-embedded.js"; +import { persistCliTurnTranscript, runAgentAttempt } from "./attempt-execution.js"; + +const runCliAgentMock = vi.hoisted(() => vi.fn()); + +vi.mock("../cli-runner.js", () => ({ + runCliAgent: runCliAgentMock, +})); + +vi.mock("../model-selection.js", () => ({ + isCliProvider: (provider: string) => provider.trim().toLowerCase() === "claude-cli", + normalizeProviderId: (provider: string) => provider.trim().toLowerCase(), +})); + +vi.mock("../pi-embedded.js", () => ({ + runEmbeddedPiAgent: vi.fn(), +})); + +function makeCliResult(text: string): EmbeddedPiRunResult { + return { + payloads: [{ text }], + meta: { + durationMs: 5, + finalAssistantVisibleText: text, + agentMeta: { + sessionId: "session-cli", + provider: "claude-cli", + model: "opus", + usage: { + input: 12, + output: 4, + cacheRead: 3, + cacheWrite: 0, + total: 19, + }, + }, + executionTrace: { + winnerProvider: "claude-cli", + winnerModel: "opus", + fallbackUsed: false, + runner: "cli", + }, + }, + }; +} + +async function readSessionMessages(sessionFile: string) { + const raw = await fs.readFile(sessionFile, "utf-8"); + return raw + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as { type?: string; message?: unknown }) + .filter((entry) => entry.type === "message") + .map( + (entry) => + entry.message as { role?: string; content?: unknown; provider?: string; model?: string }, + ); +} + +describe("CLI attempt execution", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-attempt-")); + storePath = path.join(tmpDir, "sessions.json"); + runCliAgentMock.mockReset(); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("clears stale Claude CLI session IDs before retrying after session expiration", async () => { + const sessionKey = "agent:main:subagent:cli-expired"; + const sessionEntry: SessionEntry = { + sessionId: "session-cli-123", + updatedAt: Date.now(), + cliSessionIds: { "claude-cli": "stale-cli-session" }, + claudeCliSessionId: "stale-legacy-session", + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + runCliAgentMock + .mockRejectedValueOnce( + new FailoverError("session expired", { + reason: "session_expired", + provider: "claude-cli", + model: "opus", + status: 410, + }), + ) + .mockResolvedValueOnce(makeCliResult("hello from cli")); + + await runAgentAttempt({ + providerOverride: "claude-cli", + modelOverride: "opus", + cfg: {} as OpenClawConfig, + sessionEntry, + sessionId: sessionEntry.sessionId, + sessionKey, + sessionAgentId: "main", + sessionFile: path.join(tmpDir, "session.jsonl"), + workspaceDir: tmpDir, + body: "retry this", + isFallbackRetry: false, + resolvedThinkLevel: "medium", + timeoutMs: 1_000, + runId: "run-cli-expired", + opts: { senderIsOwner: false } as Parameters[0]["opts"], + runContext: {} as Parameters[0]["runContext"], + spawnedBy: undefined, + messageChannel: undefined, + skillsSnapshot: undefined, + resolvedVerboseLevel: undefined, + agentDir: tmpDir, + onAgentEvent: vi.fn(), + authProfileProvider: "claude-cli", + sessionStore, + storePath, + sessionHasHistory: false, + }); + + expect(runCliAgentMock).toHaveBeenCalledTimes(2); + expect(runCliAgentMock.mock.calls[0]?.[0]?.cliSessionId).toBe("stale-cli-session"); + expect(runCliAgentMock.mock.calls[1]?.[0]?.cliSessionId).toBeUndefined(); + expect(sessionStore[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined(); + expect(sessionStore[sessionKey]?.claudeCliSessionId).toBeUndefined(); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + SessionEntry + >; + expect(persisted[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined(); + expect(persisted[sessionKey]?.claudeCliSessionId).toBeUndefined(); + }); + + it("persists CLI replies into the session transcript", async () => { + const sessionKey = "agent:main:subagent:cli-transcript"; + const sessionEntry: SessionEntry = { + sessionId: "session-cli-transcript", + updatedAt: Date.now(), + }; + const sessionStore: Record = { [sessionKey]: sessionEntry }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8"); + + const updatedEntry = await persistCliTurnTranscript({ + body: "persist this", + result: makeCliResult("hello from cli"), + sessionId: sessionEntry.sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId: "main", + sessionCwd: tmpDir, + }); + + const sessionFile = updatedEntry?.sessionFile; + expect(sessionFile).toBeTruthy(); + const messages = await readSessionMessages(sessionFile!); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: "user", + content: "persist this", + }); + expect(messages[1]).toMatchObject({ + role: "assistant", + api: "cli", + provider: "claude-cli", + model: "opus", + content: [{ type: "text", text: "hello from cli" }], + }); + }); +}); diff --git a/src/commands/agent.cli-provider.test.ts b/src/commands/agent.cli-provider.test.ts deleted file mode 100644 index e17d8268c49..00000000000 --- a/src/commands/agent.cli-provider.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import "./agent-command.test-mocks.js"; -import "../cron/isolated-agent.mocks.js"; -import * as cliRunnerModule from "../agents/cli-runner.js"; -import { FailoverError } from "../agents/failover-error.js"; -import { loadModelCatalog } from "../agents/model-catalog.js"; -import * as modelSelectionModule from "../agents/model-selection.js"; -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import * as configIoModule from "../config/io.js"; -import { createDefaultAgentCommandResult } from "./agent-command.test-support.js"; -import { - mockSharedAgentCommandConfig, - resetSharedAgentCommandRuntimeState, - runtime, - withSharedAgentCommandTempHome, -} from "./agent-runtime-config.test-support.js"; -import { agentCommand } from "./agent.js"; - -const configSpy = vi.spyOn(configIoModule, "loadConfig"); -const readConfigFileSnapshotForWriteSpy = vi.spyOn( - configIoModule, - "readConfigFileSnapshotForWrite", -); -const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); - -async function withTempHome(fn: (home: string) => Promise): Promise { - return withSharedAgentCommandTempHome("openclaw-agent-cli-", fn); -} - -function mockConfig( - home: string, - storePath: string, - agentOverrides?: Parameters[3], -) { - return mockSharedAgentCommandConfig(configSpy, home, storePath, agentOverrides); -} - -function writeSessionStoreSeed( - storePath: string, - sessions: Record>, -) { - fs.mkdirSync(path.dirname(storePath), { recursive: true }); - fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2)); -} - -function readSessionStore(storePath: string): Record { - return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; -} - -async function readSessionMessages(sessionFile: string) { - const raw = await fsp.readFile(sessionFile, "utf-8"); - return raw - .split(/\r?\n/) - .filter(Boolean) - .map((line) => JSON.parse(line) as { type?: string; message?: unknown }) - .filter((entry) => entry.type === "message") - .map( - (entry) => - entry.message as { role?: string; content?: unknown; provider?: string; model?: string }, - ); -} - -function expectLastEmbeddedProviderModel(provider: string, model: string): void { - const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]; - expect(callArgs?.provider).toBe(provider); - expect(callArgs?.model).toBe(model); -} - -beforeEach(() => { - resetSharedAgentCommandRuntimeState(readConfigFileSnapshotForWriteSpy); - runCliAgentSpy.mockResolvedValue(createDefaultAgentCommandResult() as never); -}); - -describe("agentCommand CLI provider handling", () => { - it("rejects explicit CLI overrides that are outside the models allowlist", async () => { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( - (provider) => provider.trim().toLowerCase() === "claude-cli", - ); - try { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - mockConfig(home, store, { - models: { - "openai/gpt-4.1-mini": {}, - }, - }); - - await expect( - agentCommand( - { - message: "use disallowed cli override", - sessionKey: "agent:main:subagent:cli-override-error", - model: "claude-cli/opus", - }, - runtime, - ), - ).rejects.toThrow('Model override "claude-cli/opus" is not allowed for agent "main".'); - }); - } finally { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); - } - }); - - it("clears stored CLI overrides when they fall outside the models allowlist", async () => { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( - (provider) => provider.trim().toLowerCase() === "claude-cli", - ); - try { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - writeSessionStoreSeed(store, { - "agent:main:subagent:clear-cli-overrides": { - sessionId: "session-clear-cli-overrides", - updatedAt: Date.now(), - providerOverride: "claude-cli", - modelOverride: "opus", - }, - }); - - mockConfig(home, store, { - model: { primary: "openai/gpt-4.1-mini" }, - models: { - "openai/gpt-4.1-mini": {}, - }, - }); - - vi.mocked(loadModelCatalog).mockResolvedValueOnce([ - { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" }, - { id: "opus", name: "Opus", provider: "claude-cli" }, - ]); - - await agentCommand( - { - message: "hi", - sessionKey: "agent:main:subagent:clear-cli-overrides", - }, - runtime, - ); - - expectLastEmbeddedProviderModel("openai", "gpt-4.1-mini"); - - const saved = readSessionStore<{ - providerOverride?: string; - modelOverride?: string; - }>(store); - expect(saved["agent:main:subagent:clear-cli-overrides"]?.providerOverride).toBeUndefined(); - expect(saved["agent:main:subagent:clear-cli-overrides"]?.modelOverride).toBeUndefined(); - }); - } finally { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); - } - }); - - it("persists successful google-gemini-cli replies into the session transcript", async () => { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( - (provider) => provider.trim().toLowerCase() === "google-gemini-cli", - ); - try { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - const sessionKey = "agent:main:subagent:gemini-cli-transcript"; - mockConfig(home, store, { - model: { primary: "google-gemini-cli/gemini-3.1-pro-preview", fallbacks: [] }, - models: { "google-gemini-cli/gemini-3.1-pro-preview": {} }, - }); - - runCliAgentSpy.mockResolvedValueOnce({ - payloads: [{ text: "hello from cli" }], - meta: { - durationMs: 5, - finalAssistantVisibleText: "hello from cli", - agentMeta: { - sessionId: "cli-session-123", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - compactionCount: 2, - usage: { - input: 12, - output: 4, - cacheRead: 3, - cacheWrite: 0, - total: 19, - }, - }, - executionTrace: { - winnerProvider: "google-gemini-cli", - winnerModel: "gemini-3.1-pro-preview", - fallbackUsed: false, - runner: "cli", - }, - }, - } as ReturnType); - - await agentCommand({ message: "persist this", sessionKey }, runtime); - - const saved = readSessionStore<{ sessionFile?: string }>(store); - const sessionFile = saved[sessionKey]?.sessionFile; - expect(sessionFile).toBeTruthy(); - expect(saved[sessionKey]).toMatchObject({ - compactionCount: 2, - inputTokens: 12, - outputTokens: 4, - cacheRead: 3, - }); - - const messages = await readSessionMessages(sessionFile!); - expect(messages).toHaveLength(2); - expect(messages[0]).toMatchObject({ - role: "user", - content: "persist this", - }); - expect(messages[1]).toMatchObject({ - role: "assistant", - api: "cli", - provider: "google-gemini-cli", - model: "gemini-3.1-pro-preview", - content: [{ type: "text", text: "hello from cli" }], - }); - }); - } finally { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); - } - }); - - it("clears stale Claude CLI legacy session IDs before retrying after session expiration", async () => { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation( - (provider) => provider.trim().toLowerCase() === "claude-cli", - ); - try { - await withTempHome(async (home) => { - const store = path.join(home, "sessions.json"); - const sessionKey = "agent:main:subagent:cli-expired"; - writeSessionStoreSeed(store, { - [sessionKey]: { - sessionId: "session-cli-123", - updatedAt: Date.now(), - providerOverride: "claude-cli", - modelOverride: "opus", - cliSessionIds: { "claude-cli": "stale-cli-session" }, - claudeCliSessionId: "stale-legacy-session", - }, - }); - - mockConfig(home, store, { - model: { primary: "claude-cli/opus", fallbacks: [] }, - models: { "claude-cli/opus": {} }, - }); - - runCliAgentSpy - .mockRejectedValueOnce( - new FailoverError("session expired", { - reason: "session_expired", - provider: "claude-cli", - model: "opus", - status: 410, - }), - ) - .mockRejectedValue(new Error("retry failed")); - - await expect(agentCommand({ message: "hi", sessionKey }, runtime)).rejects.toThrow( - "retry failed", - ); - - expect(runCliAgentSpy).toHaveBeenCalledTimes(2); - const firstCall = runCliAgentSpy.mock.calls[0]?.[0] as - | { cliSessionId?: string } - | undefined; - const secondCall = runCliAgentSpy.mock.calls[1]?.[0] as - | { cliSessionId?: string } - | undefined; - expect(firstCall?.cliSessionId).toBe("stale-cli-session"); - expect(secondCall?.cliSessionId).toBeUndefined(); - - const saved = readSessionStore<{ - cliSessionIds?: Record; - claudeCliSessionId?: string; - }>(store); - expect(saved[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined(); - expect(saved[sessionKey]?.claudeCliSessionId).toBeUndefined(); - }); - } finally { - vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false); - } - }); -}); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 8418761f0eb..f1754d3125f 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -15,6 +15,10 @@ vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins: () => pluginRegistry.list, })); +vi.mock("../channels/read-only-account-inspect.js", () => ({ + inspectReadOnlyChannelAccount: vi.fn(async () => null), +})); + import { noteSecurityWarnings } from "./doctor-security.js"; describe("noteSecurityWarnings gateway exposure", () => { @@ -172,10 +176,11 @@ describe("noteSecurityWarnings gateway exposure", () => { it("shows explicit dmScope config command for multi-user DMs", async () => { pluginRegistry.list = [ { - id: "whatsapp", - meta: { label: "WhatsApp" }, + id: "test-channel", + meta: { label: "Test Channel" }, config: { listAccountIds: () => ["default"], + inspectAccount: () => ({ enabled: true, configured: true }), resolveAccount: () => ({}), isEnabled: () => true, isConfigured: () => true, diff --git a/src/commands/doctor.matrix-migration.test.ts b/src/commands/doctor.matrix-migration.test.ts deleted file mode 100644 index c9c0e95cf41..00000000000 --- a/src/commands/doctor.matrix-migration.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - createDoctorRuntime, - mockDoctorConfigSnapshot, - runChannelPluginStartupMaintenance, -} from "./doctor.e2e-harness.js"; -import "./doctor.fast-path-mocks.js"; -import { doctorCommand } from "./doctor.js"; - -vi.mock("../plugins/providers.runtime.js", () => ({ - resolvePluginProviders: vi.fn(() => []), -})); - -const DOCTOR_MIGRATION_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000; - -describe("doctor command", () => { - it( - "runs Matrix startup migration during repair flows", - { timeout: DOCTOR_MIGRATION_TIMEOUT_MS }, - async () => { - mockDoctorConfigSnapshot({ - config: { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }, - parsed: { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }, - }); - - await doctorCommand(createDoctorRuntime(), { nonInteractive: true, repair: true }); - - expect(runChannelPluginStartupMaintenance).toHaveBeenCalledTimes(1); - expect(runChannelPluginStartupMaintenance).toHaveBeenCalledWith( - expect.objectContaining({ - cfg: expect.objectContaining({ - channels: { - matrix: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - }, - }, - }), - trigger: "doctor-fix", - logPrefix: "doctor", - log: expect.objectContaining({ - info: expect.any(Function), - warn: expect.any(Function), - }), - }), - ); - }, - ); -}); diff --git a/src/commands/doctor.migrates-slack-discord-dm-policy-aliases.test.ts b/src/commands/doctor.migrates-slack-discord-dm-policy-aliases.test.ts deleted file mode 100644 index 4bacb07d9e3..00000000000 --- a/src/commands/doctor.migrates-slack-discord-dm-policy-aliases.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { readConfigFileSnapshot, writeConfigFile } from "./doctor.e2e-harness.js"; - -const DOCTOR_MIGRATION_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000; -const { doctorCommand } = await import("./doctor.js"); - -describe("doctor command", () => { - it( - "does not rewrite supported Slack/Discord dm.policy aliases", - { timeout: DOCTOR_MIGRATION_TIMEOUT_MS }, - async () => { - readConfigFileSnapshot.mockResolvedValue({ - path: "/tmp/openclaw.json", - exists: true, - raw: "{}", - parsed: { - channels: { - slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, - discord: { - dm: { enabled: true, policy: "allowlist", allowFrom: ["123"] }, - }, - }, - }, - valid: true, - config: { - channels: { - slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } }, - discord: { dm: { enabled: true, policy: "allowlist", allowFrom: ["123"] } }, - }, - }, - issues: [], - legacyIssues: [], - }); - - const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; - writeConfigFile.mockClear(); - - await doctorCommand(runtime, { nonInteractive: true, repair: true }); - - expect(writeConfigFile).not.toHaveBeenCalled(); - }, - ); -}); diff --git a/src/commands/doctor.update-repair-no-restart.test.ts b/src/commands/doctor.update-repair-no-restart.test.ts deleted file mode 100644 index 04c7ff0ab28..00000000000 --- a/src/commands/doctor.update-repair-no-restart.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - auditGatewayServiceConfig, - buildGatewayInstallPlan, - confirm, - createDoctorRuntime, - mockDoctorConfigSnapshot, - serviceReadCommand, - serviceInstall, - serviceIsLoaded, - serviceRestart, - writeConfigFile, -} from "./doctor.e2e-harness.js"; -import { doctorCommand } from "./doctor.js"; -import { healthCommand } from "./health.js"; - -describe("doctor command update-mode repairs", () => { - it("skips gateway installs during non-interactive update repairs", async () => { - mockDoctorConfigSnapshot(); - - vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed")); - - serviceIsLoaded.mockResolvedValueOnce(false); - serviceInstall.mockClear(); - serviceRestart.mockClear(); - confirm.mockClear(); - - await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true }); - - expect(serviceInstall).not.toHaveBeenCalled(); - expect(serviceRestart).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); - }); - - it("skips gateway restarts during non-interactive update repairs", async () => { - mockDoctorConfigSnapshot(); - - vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed")); - - serviceIsLoaded.mockResolvedValueOnce(true); - serviceRestart.mockClear(); - confirm.mockClear(); - - await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true }); - - expect(serviceRestart).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); - }); - - it("skips gateway service-config reinstalls and token persistence during non-interactive update repairs", async () => { - mockDoctorConfigSnapshot({ config: { gateway: {} }, parsed: { gateway: {} } }); - - vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed")); - - serviceIsLoaded.mockResolvedValueOnce(false); - serviceReadCommand.mockResolvedValueOnce({ - programArguments: ["node", "cli", "gateway", "--port", "18789"], - environment: { - OPENCLAW_GATEWAY_TOKEN: "stale-token", - }, - }); - auditGatewayServiceConfig.mockResolvedValueOnce({ - ok: false, - issues: [ - { - code: "gateway-token-mismatch", - message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token", - level: "recommended", - }, - ], - }); - buildGatewayInstallPlan.mockResolvedValue({ - programArguments: ["node", "cli", "gateway", "--port", "18789"], - workingDirectory: "/tmp", - environment: {}, - }); - serviceInstall.mockClear(); - serviceRestart.mockClear(); - writeConfigFile.mockClear(); - confirm.mockClear(); - - await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true }); - - expect(writeConfigFile).not.toHaveBeenCalled(); - expect(serviceInstall).not.toHaveBeenCalled(); - expect(serviceRestart).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); - }); -}); diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index a7ea4935d3f..e3913e75fb3 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -97,29 +97,6 @@ describe("healthCommand", () => { expect(parsed.sessions.count).toBe(1); }); - it("prints text summary when not json", async () => { - callGatewayMock.mockResolvedValueOnce( - createHealthSummary({ - channels: { - whatsapp: { accountId: "default", linked: false, authAgeMs: null }, - telegram: { accountId: "default", configured: false }, - discord: { accountId: "default", configured: false }, - }, - channelOrder: ["whatsapp", "telegram", "discord"], - channelLabels: { - whatsapp: "WhatsApp", - telegram: "Telegram", - discord: "Discord", - }, - }), - ); - - await healthCommand({ json: false, config: {} }, runtime as never); - - expect(runtime.exit).not.toHaveBeenCalled(); - expect(runtime.log).toHaveBeenCalled(); - }); - it("formats per-account probe timings", () => { const summary = createHealthSummary({ channels: { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index e512d0cb4a5..d5864ab01cb 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -7,7 +7,6 @@ import { resolveConfiguredModelRef, resolveHooksGmailModel, } from "../agents/model-selection.js"; -import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js"; import { formatCliCommand } from "../cli/command-format.js"; import { maybeRepairLegacyOAuthProfileIds, @@ -61,6 +60,7 @@ import { buildGatewayConnectionDetails } from "../gateway/call.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; +import { maybeRunDoctorStartupChannelMaintenance } from "./doctor-startup-channel-maintenance.js"; import type { FlowContribution } from "./types.js"; export type DoctorFlowMode = "local" | "remote"; @@ -294,18 +294,11 @@ async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise { - if (!ctx.prompter.shouldRepair) { - return; - } - await runChannelPluginStartupMaintenance({ + await maybeRunDoctorStartupChannelMaintenance({ cfg: ctx.cfg, env: process.env, - log: { - info: (message) => ctx.runtime.log(message), - warn: (message) => ctx.runtime.error(message), - }, - trigger: "doctor-fix", - logPrefix: "doctor", + runtime: ctx.runtime, + shouldRepair: ctx.prompter.shouldRepair, }); } diff --git a/src/flows/doctor-startup-channel-maintenance.test.ts b/src/flows/doctor-startup-channel-maintenance.test.ts new file mode 100644 index 00000000000..a2073f28564 --- /dev/null +++ b/src/flows/doctor-startup-channel-maintenance.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { maybeRunDoctorStartupChannelMaintenance } from "./doctor-startup-channel-maintenance.js"; + +const runChannelPluginStartupMaintenance = vi.hoisted(() => vi.fn()); + +vi.mock("../channels/plugins/lifecycle-startup.js", () => ({ + runChannelPluginStartupMaintenance, +})); + +describe("doctor startup channel maintenance", () => { + beforeEach(() => { + runChannelPluginStartupMaintenance.mockClear(); + }); + + it("runs Matrix startup migration during repair flows", async () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + const runtime = { log: vi.fn(), error: vi.fn() }; + + await maybeRunDoctorStartupChannelMaintenance({ + cfg, + env: { OPENCLAW_TEST: "1" }, + runtime, + shouldRepair: true, + }); + + expect(runChannelPluginStartupMaintenance).toHaveBeenCalledTimes(1); + expect(runChannelPluginStartupMaintenance).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + env: { OPENCLAW_TEST: "1" }, + trigger: "doctor-fix", + logPrefix: "doctor", + log: expect.objectContaining({ + info: expect.any(Function), + warn: expect.any(Function), + }), + }), + ); + }); + + it("skips startup migration outside repair flows", async () => { + await maybeRunDoctorStartupChannelMaintenance({ + cfg: { channels: { matrix: {} } }, + runtime: { log: vi.fn(), error: vi.fn() }, + shouldRepair: false, + }); + + expect(runChannelPluginStartupMaintenance).not.toHaveBeenCalled(); + }); +}); diff --git a/src/flows/doctor-startup-channel-maintenance.ts b/src/flows/doctor-startup-channel-maintenance.ts new file mode 100644 index 00000000000..e9c52c55d5d --- /dev/null +++ b/src/flows/doctor-startup-channel-maintenance.ts @@ -0,0 +1,28 @@ +import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +type DoctorStartupMaintenanceRuntime = { + error: (message: string) => void; + log: (message: string) => void; +}; + +export async function maybeRunDoctorStartupChannelMaintenance(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + runtime: DoctorStartupMaintenanceRuntime; + shouldRepair: boolean; +}): Promise { + if (!params.shouldRepair) { + return; + } + await runChannelPluginStartupMaintenance({ + cfg: params.cfg, + env: params.env ?? process.env, + log: { + info: (message) => params.runtime.log(message), + warn: (message) => params.runtime.error(message), + }, + trigger: "doctor-fix", + logPrefix: "doctor", + }); +}