test: trim CLI and doctor hotspots

This commit is contained in:
Peter Steinberger
2026-04-17 09:23:13 +01:00
parent 199bb1fe05
commit a861da41b5
10 changed files with 280 additions and 522 deletions

View File

@@ -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<string, SessionEntry> = { [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<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[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<string, SessionEntry> = { [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" }],
});
});
});

View File

@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withSharedAgentCommandTempHome("openclaw-agent-cli-", fn);
}
function mockConfig(
home: string,
storePath: string,
agentOverrides?: Parameters<typeof mockSharedAgentCommandConfig>[3],
) {
return mockSharedAgentCommandConfig(configSpy, home, storePath, agentOverrides);
}
function writeSessionStoreSeed(
storePath: string,
sessions: Record<string, Record<string, unknown>>,
) {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, JSON.stringify(sessions, null, 2));
}
function readSessionStore<T>(storePath: string): Record<string, T> {
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
}
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<typeof createDefaultAgentCommandResult>);
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<string, string>;
claudeCliSessionId?: string;
}>(store);
expect(saved[sessionKey]?.cliSessionIds?.["claude-cli"]).toBeUndefined();
expect(saved[sessionKey]?.claudeCliSessionId).toBeUndefined();
});
} finally {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
}
});
});

View File

@@ -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,

View File

@@ -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),
}),
}),
);
},
);
});

View File

@@ -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();
},
);
});

View File

@@ -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();
});
});

View File

@@ -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: {

View File

@@ -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<v
}
async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext): Promise<void> {
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,
});
}

View File

@@ -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();
});
});

View File

@@ -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<void> {
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",
});
}