mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
test: trim CLI and doctor hotspots
This commit is contained in:
183
src/agents/command/attempt-execution.cli.test.ts
Normal file
183
src/agents/command/attempt-execution.cli.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
58
src/flows/doctor-startup-channel-maintenance.test.ts
Normal file
58
src/flows/doctor-startup-channel-maintenance.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
src/flows/doctor-startup-channel-maintenance.ts
Normal file
28
src/flows/doctor-startup-channel-maintenance.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user