mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
test: optimize slow test hotspots
This commit is contained in:
41
.agents/skills/optimizetests/SKILL.md
Normal file
41
.agents/skills/optimizetests/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: optimizetests
|
||||
description: Optimize OpenClaw test runtime end to end. Use when the user asks for /optimizetests, slow-test review, import optimization, deduping tests, moving misplaced core coverage to extensions, or reducing CI/test wall time without adding shards or dropping coverage.
|
||||
---
|
||||
|
||||
# Optimize Tests
|
||||
|
||||
Goal: real OpenClaw test/runtime speedups with coverage intact. Do not add shards,
|
||||
skip assertions, weaken gates, or tune runner flags as the main fix.
|
||||
|
||||
## Runbook
|
||||
|
||||
1. Read `docs/help/testing.md`, `docs/ci.md`, and the scoped `AGENTS.md` files
|
||||
for any subtree you will edit.
|
||||
2. Establish evidence before edits:
|
||||
- Full ranking: `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/<name>.json`
|
||||
- Targeted file: `timeout 240 /usr/bin/time -l pnpm test <file> --maxWorkers=1 --reporter=verbose`
|
||||
- Import suspicion: add `OPENCLAW_VITEST_IMPORT_DURATIONS=1 OPENCLAW_VITEST_PRINT_IMPORT_BREAKDOWN=1`
|
||||
3. Attack highest-return hotspots first:
|
||||
- broad barrels or `importActual()` in hot tests
|
||||
- per-test `vi.resetModules()` plus fresh imports
|
||||
- expensive gateway/server/client setup where reset/reuse proves same behavior
|
||||
- core tests asserting extension-owned behavior
|
||||
- duplicated fixture construction or contract assertions
|
||||
4. Prefer production-quality fixes:
|
||||
- narrow runtime seams over broad mocks
|
||||
- pure helpers for static parsing/metadata
|
||||
- injected deps over module resets
|
||||
- extension-owned tests for bundled plugin/provider/channel behavior
|
||||
5. After each change, rerun the same benchmark and the proving test lane. Record
|
||||
before/after wall time, Vitest duration, and max RSS when available.
|
||||
6. Run `pnpm check:changed`; run broader gates (`pnpm check`, `pnpm test`,
|
||||
`pnpm build`) when touched surfaces require them.
|
||||
7. Commit scoped changes with `scripts/committer "<conventional message>" <paths...>`.
|
||||
Push when requested. If CI is red, inspect with `gh run list/view`, fix, push,
|
||||
repeat until current CI is green or a blocker is proven unrelated.
|
||||
|
||||
## Reuse
|
||||
|
||||
For deeper tactics, also use `$openclaw-test-performance`; it contains the
|
||||
hotspot catalog, benchmark commands, and handoff format.
|
||||
6
.agents/skills/optimizetests/agents/openai.yaml
Normal file
6
.agents/skills/optimizetests/agents/openai.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "Optimize Tests"
|
||||
short_description: "Benchmark and speed up OpenClaw tests"
|
||||
default_prompt: "Use $optimizetests to benchmark slow OpenClaw tests, optimize imports and duplicated setup, move misplaced core coverage to extensions, verify gates, commit scoped changes, push, and keep CI green without adding shards or dropping coverage."
|
||||
policy:
|
||||
allow_implicit_invocation: false
|
||||
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
|
||||
- OpenShell: pin host-side sandbox writes under the mounted root so symlink-parent rebinds cannot redirect `writeFile` outside the workspace during local mirror updates. (#69797) Thanks @drobison00.
|
||||
- Ollama/media understanding: register Ollama as an image-capable media-understanding provider so `agents.defaults.imageModel.primary` values like `ollama/qwen2.5vl:7b` route through the Ollama plugin instead of failing as unknown models. (#69816) Thanks @soloclz.
|
||||
- CLI/media understanding: make `openclaw infer image describe --model <provider/model>` execute the explicit image model instead of skipping description when that model supports native vision.
|
||||
- Usage/providers: keep plugin-owned usage auth enabled when manifest-declared provider auth env vars such as `MINIMAX_CODE_PLAN_KEY` are present, so `/usage` can resolve MiniMax billing credentials through the provider plugin.
|
||||
|
||||
## 2026.4.20
|
||||
|
||||
|
||||
41
extensions/anthropic/cli-constants.ts
Normal file
41
extensions/anthropic/cli-constants.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`;
|
||||
export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-haiku-4-5`,
|
||||
] as const;
|
||||
|
||||
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
|
||||
opus: "opus",
|
||||
"opus-4.7": "opus",
|
||||
"opus-4.6": "opus",
|
||||
"opus-4.5": "opus",
|
||||
"opus-4": "opus",
|
||||
"claude-opus-4-7": "opus",
|
||||
"claude-opus-4-6": "opus",
|
||||
"claude-opus-4-5": "opus",
|
||||
"claude-opus-4": "opus",
|
||||
sonnet: "sonnet",
|
||||
"sonnet-4.6": "sonnet",
|
||||
"sonnet-4.5": "sonnet",
|
||||
"sonnet-4.1": "sonnet",
|
||||
"sonnet-4.0": "sonnet",
|
||||
"claude-sonnet-4-6": "sonnet",
|
||||
"claude-sonnet-4-5": "sonnet",
|
||||
"claude-sonnet-4-1": "sonnet",
|
||||
"claude-sonnet-4-0": "sonnet",
|
||||
haiku: "haiku",
|
||||
"haiku-3.5": "haiku",
|
||||
"claude-haiku-3-5": "haiku",
|
||||
};
|
||||
|
||||
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
|
||||
"session_id",
|
||||
"sessionId",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
] as const;
|
||||
@@ -1,47 +1,13 @@
|
||||
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`;
|
||||
export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
|
||||
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-haiku-4-5`,
|
||||
] as const;
|
||||
|
||||
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
|
||||
opus: "opus",
|
||||
"opus-4.7": "opus",
|
||||
"opus-4.6": "opus",
|
||||
"opus-4.5": "opus",
|
||||
"opus-4": "opus",
|
||||
"claude-opus-4-7": "opus",
|
||||
"claude-opus-4-6": "opus",
|
||||
"claude-opus-4-5": "opus",
|
||||
"claude-opus-4": "opus",
|
||||
sonnet: "sonnet",
|
||||
"sonnet-4.6": "sonnet",
|
||||
"sonnet-4.5": "sonnet",
|
||||
"sonnet-4.1": "sonnet",
|
||||
"sonnet-4.0": "sonnet",
|
||||
"claude-sonnet-4-6": "sonnet",
|
||||
"claude-sonnet-4-5": "sonnet",
|
||||
"claude-sonnet-4-1": "sonnet",
|
||||
"claude-sonnet-4-0": "sonnet",
|
||||
haiku: "haiku",
|
||||
"haiku-3.5": "haiku",
|
||||
"claude-haiku-3-5": "haiku",
|
||||
};
|
||||
|
||||
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
|
||||
"session_id",
|
||||
"sessionId",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
] as const;
|
||||
CLAUDE_CLI_MODEL_ALIASES,
|
||||
CLAUDE_CLI_SESSION_ID_FIELDS,
|
||||
} from "./cli-constants.js";
|
||||
|
||||
// Claude Code honors provider-routing, auth, and config-root env before
|
||||
// consulting its local login state, so inherited shell overrides must not
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-constants.js";
|
||||
|
||||
const ANTHROPIC_PROVIDER_API = "anthropic-messages";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeProviderId(provider: string): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(provider);
|
||||
if (normalized === "bedrock" || normalized === "aws-bedrock") {
|
||||
return "amazon-bedrock";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveAnthropicDefaultAuthMode(
|
||||
config: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
|
||||
20
src/agents/pi-embedded-runner/run/attempt-session.ts
Normal file
20
src/agents/pi-embedded-runner/run/attempt-session.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type EmbeddedAgentSessionOptions = {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
authStorage: unknown;
|
||||
modelRegistry: unknown;
|
||||
model: unknown;
|
||||
thinkingLevel: unknown;
|
||||
tools: readonly unknown[];
|
||||
customTools: readonly unknown[];
|
||||
sessionManager: unknown;
|
||||
settingsManager: unknown;
|
||||
resourceLoader: unknown;
|
||||
};
|
||||
|
||||
export async function createEmbeddedAgentSessionWithResourceLoader<Result>(params: {
|
||||
createAgentSession: (options: EmbeddedAgentSessionOptions) => Promise<Result> | Result;
|
||||
options: EmbeddedAgentSessionOptions;
|
||||
}): Promise<Result> {
|
||||
return await params.createAgentSession(params.options);
|
||||
}
|
||||
@@ -1,42 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanupTempPaths,
|
||||
createContextEngineAttemptRunner,
|
||||
getHoisted,
|
||||
resetEmbeddedAttemptHarness,
|
||||
} from "./attempt.spawn-workspace.test-support.js";
|
||||
|
||||
const hoisted = getHoisted();
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js";
|
||||
|
||||
describe("runEmbeddedAttempt resource loader wiring", () => {
|
||||
const tempPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resetEmbeddedAttemptHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTempPaths(tempPaths);
|
||||
});
|
||||
|
||||
it("passes an explicit resourceLoader to createAgentSession even without extension factories", async () => {
|
||||
await createContextEngineAttemptRunner({
|
||||
sessionKey: "agent:main:guildchat:dm:test-resource-loader",
|
||||
tempPaths,
|
||||
contextEngine: {
|
||||
assemble: async ({ messages }) => ({
|
||||
messages,
|
||||
estimatedTokens: 1,
|
||||
}),
|
||||
const resourceLoader = { reload: vi.fn() };
|
||||
const createAgentSession = vi.fn(async () => ({ session: { id: "session" } }));
|
||||
|
||||
await createEmbeddedAgentSessionWithResourceLoader({
|
||||
createAgentSession,
|
||||
options: {
|
||||
cwd: "/tmp/workspace",
|
||||
agentDir: "/tmp/agent",
|
||||
authStorage: {},
|
||||
modelRegistry: {},
|
||||
model: {},
|
||||
thinkingLevel: undefined,
|
||||
tools: [],
|
||||
customTools: [],
|
||||
sessionManager: {},
|
||||
settingsManager: {},
|
||||
resourceLoader,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hoisted.createAgentSessionMock).toHaveBeenCalled();
|
||||
expect(hoisted.createAgentSessionMock).toHaveBeenCalledWith(
|
||||
expect(createAgentSession).toHaveBeenCalledOnce();
|
||||
expect(createAgentSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceLoader: expect.objectContaining({
|
||||
reload: expect.any(Function),
|
||||
}),
|
||||
resourceLoader,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -178,6 +178,7 @@ import {
|
||||
import { splitSdkTools } from "../tool-split.js";
|
||||
import { mapThinkingLevel } from "../utils.js";
|
||||
import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js";
|
||||
import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js";
|
||||
export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js";
|
||||
import {
|
||||
resolveAttemptWorkspaceBootstrapRouting,
|
||||
@@ -1108,18 +1109,22 @@ export async function runEmbeddedAttempt(
|
||||
|
||||
const allCustomTools = [...customTools, ...clientToolDefs];
|
||||
|
||||
({ session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage: params.authStorage,
|
||||
modelRegistry: params.modelRegistry,
|
||||
model: params.model,
|
||||
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
||||
tools: builtInTools,
|
||||
customTools: allCustomTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
({ session } = await createEmbeddedAgentSessionWithResourceLoader({
|
||||
createAgentSession: async (options) =>
|
||||
await createAgentSession(options as Parameters<typeof createAgentSession>[0]),
|
||||
options: {
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage: params.authStorage,
|
||||
modelRegistry: params.modelRegistry,
|
||||
model: params.model,
|
||||
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
||||
tools: builtInTools,
|
||||
customTools: allCustomTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
resourceLoader,
|
||||
},
|
||||
}));
|
||||
applySystemPromptOverrideToSession(session, systemPromptText);
|
||||
if (!session) {
|
||||
|
||||
@@ -30,6 +30,9 @@ const sessionForkMocks = vi.hoisted(() => ({
|
||||
forkSessionFromParent: vi.fn(),
|
||||
nextSessionId: 0,
|
||||
}));
|
||||
const channelSummaryMocks = vi.hoisted(() => ({
|
||||
buildChannelSummary: vi.fn(async () => [] as string[]),
|
||||
}));
|
||||
|
||||
type ForkSessionParamsForTest = {
|
||||
parentEntry: SessionEntry;
|
||||
@@ -51,6 +54,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/channel-summary.js", () => ({
|
||||
buildChannelSummary: channelSummaryMocks.buildChannelSummary,
|
||||
}));
|
||||
|
||||
// Perf: session-store locks are exercised elsewhere; most session tests don't need FS lock files.
|
||||
vi.mock("../../agents/session-write-lock.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../agents/session-write-lock.js")>(
|
||||
@@ -240,6 +247,7 @@ function registerCurrentConversationBindingAdapterForTest(params: {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
channelSummaryMocks.buildChannelSummary.mockReset().mockResolvedValue([]);
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
sessionForkMocks.nextSessionId = 0;
|
||||
sessionForkMocks.forkSessionFromParent
|
||||
@@ -2414,36 +2422,9 @@ describe("drainFormattedSystemEvents", () => {
|
||||
});
|
||||
|
||||
it("keeps channel summary lines prefixed as trusted system output on new main sessions", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
source: "test",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({ id: "whatsapp", label: "WhatsApp" }),
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
defaultAccountId: () => "default",
|
||||
inspectAccount: () => ({
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
name: "line one\nline two",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
name: "line one\nline two",
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
buildChannelSummary: async () => ({ linked: true }),
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
channelSummaryMocks.buildChannelSummary.mockResolvedValue([
|
||||
"WhatsApp: linked\n - default (line one\nline two)",
|
||||
]);
|
||||
|
||||
const result = await drainFormattedSystemEvents({
|
||||
cfg: { channels: {} } as OpenClawConfig,
|
||||
|
||||
@@ -91,6 +91,13 @@ function resolveChannelTargetId(params: {
|
||||
return target;
|
||||
}
|
||||
|
||||
const explicitConversationId = resolveConversationIdFromTargets({
|
||||
targets: [target],
|
||||
});
|
||||
if (explicitConversationId) {
|
||||
return explicitConversationId;
|
||||
}
|
||||
|
||||
const parsed = parseExplicitTargetForChannel(params.channel, target);
|
||||
const parsedTarget = normalizeOptionalString(parsed?.to);
|
||||
if (parsedTarget) {
|
||||
@@ -101,10 +108,7 @@ function resolveChannelTargetId(params: {
|
||||
);
|
||||
}
|
||||
|
||||
const explicitConversationId = resolveConversationIdFromTargets({
|
||||
targets: [target],
|
||||
});
|
||||
return explicitConversationId ?? target;
|
||||
return target;
|
||||
}
|
||||
|
||||
function buildThreadingContext(params: {
|
||||
|
||||
@@ -25,14 +25,9 @@ type TelegramHealthAccount = {
|
||||
};
|
||||
|
||||
async function loadFreshHealthModulesForTest() {
|
||||
vi.doMock("../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => testConfig,
|
||||
};
|
||||
});
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: () => testConfig,
|
||||
}));
|
||||
vi.doMock("../config/sessions.js", () => ({
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveSessionFilePath: vi.fn(() => "/tmp/sessions.json"),
|
||||
@@ -55,6 +50,9 @@ async function loadFreshHealthModulesForTest() {
|
||||
logWebSelfId: vi.fn(),
|
||||
logoutWeb: vi.fn(),
|
||||
}));
|
||||
vi.doMock("../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => [createTelegramHealthPlugin()],
|
||||
}));
|
||||
|
||||
const [pluginsRuntime, channelTestUtils, health] = await Promise.all([
|
||||
import("../plugins/runtime.js"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { createConfigIO } from "./io.js";
|
||||
import type { ConfigFileSnapshot } from "./types.openclaw.js";
|
||||
|
||||
// Mock the plugin manifest registry so we can register a fake channel whose
|
||||
// AJV JSON Schema carries a `default` value. This lets the #56772 regression
|
||||
@@ -241,24 +242,44 @@ describe("config io write", () => {
|
||||
gateway: { mode: "local" },
|
||||
channels: { telegram: { enabled: true, dmPolicy: "pairing" } },
|
||||
agents: { list: [{ id: "main", default: true, workspace: "/tmp/openclaw-main" }] },
|
||||
tools: { profile: "safe" },
|
||||
tools: { profile: "messaging" },
|
||||
commands: { ownerDisplay: "hash" },
|
||||
};
|
||||
await fs.writeFile(configPath, `${JSON.stringify(original, null, 2)}\n`, "utf-8");
|
||||
} satisfies ConfigFileSnapshot["config"];
|
||||
const originalRaw = `${JSON.stringify(original, null, 2)}\n`;
|
||||
await fs.writeFile(configPath, originalRaw, "utf-8");
|
||||
const warn = vi.fn();
|
||||
const io = createConfigIO({
|
||||
env: { VITEST: "true" } as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger: { warn, error: vi.fn() },
|
||||
});
|
||||
const baseSnapshot = {
|
||||
path: configPath,
|
||||
exists: true,
|
||||
raw: originalRaw,
|
||||
parsed: original,
|
||||
sourceConfig: original,
|
||||
resolved: original,
|
||||
valid: true,
|
||||
runtimeConfig: original,
|
||||
config: original,
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
} satisfies ConfigFileSnapshot;
|
||||
|
||||
await expect(io.writeConfigFile({ update: { channel: "beta" } })).rejects.toMatchObject({
|
||||
await expect(
|
||||
io.writeConfigFile(
|
||||
{ update: { channel: "beta" } },
|
||||
{
|
||||
baseSnapshot,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "CONFIG_WRITE_REJECTED",
|
||||
});
|
||||
|
||||
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(
|
||||
`${JSON.stringify(original, null, 2)}\n`,
|
||||
);
|
||||
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(originalRaw);
|
||||
const entries = await fs.readdir(path.dirname(configPath));
|
||||
expect(entries.some((entry) => entry.includes(".rejected."))).toBe(true);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Config write rejected:"));
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { buildChannelSummary } from "./channel-summary.js";
|
||||
|
||||
const isFixtureAccountConfigured = (account: unknown) =>
|
||||
@@ -189,20 +187,11 @@ function makeFallbackSummaryPlugin(params: {
|
||||
}
|
||||
|
||||
describe("buildChannelSummary", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("preserves Slack HTTP signing-secret unavailable state from source config", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "slack", plugin: makeSlackHttpSummaryPlugin(), source: "test" },
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ marker: "resolved", channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
plugins: [makeSlackHttpSummaryPlugin()],
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
});
|
||||
|
||||
@@ -213,44 +202,28 @@ describe("buildChannelSummary", () => {
|
||||
});
|
||||
|
||||
it("shows disabled status without configured account detail lines", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: makeTelegramSummaryPlugin({ enabled: false, configured: false }),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: true,
|
||||
plugins: [makeTelegramSummaryPlugin({ enabled: false, configured: false })],
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["Telegram: disabled +15551234567"]);
|
||||
});
|
||||
|
||||
it("includes linked summary metadata and truncates allow-from details", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
linked: true,
|
||||
authAgeMs: 300_000,
|
||||
allowFrom: ["alice", "bob", "carol"],
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: true,
|
||||
plugins: [
|
||||
makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
linked: true,
|
||||
authAgeMs: 300_000,
|
||||
allowFrom: ["alice", "bob", "carol"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines).toContain("Telegram: linked +15551234567 auth 5m ago");
|
||||
@@ -258,23 +231,16 @@ describe("buildChannelSummary", () => {
|
||||
});
|
||||
|
||||
it("shows not-linked status when linked metadata is explicitly false", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
linked: false,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
plugins: [
|
||||
makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
linked: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines).toContain("Telegram: not linked +15551234567");
|
||||
@@ -282,42 +248,26 @@ describe("buildChannelSummary", () => {
|
||||
});
|
||||
|
||||
it("prefers plugin statusState when provided", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
statusState: "unstable",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
plugins: [
|
||||
makeTelegramSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
statusState: "unstable",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines).toContain("Telegram: auth stabilizing +15551234567");
|
||||
});
|
||||
|
||||
it("renders non-slack account detail fields for configured accounts", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "signal",
|
||||
plugin: makeSignalSummaryPlugin({ enabled: false, configured: true }),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
plugins: [makeSignalSummaryPlugin({ enabled: false, configured: true })],
|
||||
});
|
||||
|
||||
expect(lines).toEqual([
|
||||
@@ -327,47 +277,33 @@ describe("buildChannelSummary", () => {
|
||||
});
|
||||
|
||||
it("uses the channel label and default account id when no accounts exist", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "fallback-plugin",
|
||||
plugin: makeFallbackSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
accountIds: [],
|
||||
defaultAccountId: "fallback-account",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
plugins: [
|
||||
makeFallbackSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: true,
|
||||
accountIds: [],
|
||||
defaultAccountId: "fallback-account",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["Fallback: configured", " - fallback-account"]);
|
||||
});
|
||||
|
||||
it("shows not-configured status when enabled accounts exist without configured ones", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "fallback-plugin",
|
||||
plugin: makeFallbackSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: false,
|
||||
accountIds: ["fallback-account"],
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await buildChannelSummary({ channels: {} } as never, {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
plugins: [
|
||||
makeFallbackSummaryPlugin({
|
||||
enabled: true,
|
||||
configured: false,
|
||||
accountIds: ["fallback-account"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(lines).toEqual(["Fallback: not configured"]);
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
buildChannelAccountSnapshot,
|
||||
formatChannelAllowFrom,
|
||||
} from "../channels/account-summary.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import { formatChannelStatusState } from "../channels/plugins/status-state.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatTimeAgo } from "./format-time/format-relative.ts";
|
||||
@@ -16,10 +15,11 @@ import { formatTimeAgo } from "./format-time/format-relative.ts";
|
||||
export type ChannelSummaryOptions = {
|
||||
colorize?: boolean;
|
||||
includeAllowFrom?: boolean;
|
||||
plugins?: readonly ChannelPlugin[];
|
||||
sourceConfig?: OpenClawConfig;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: Omit<Required<ChannelSummaryOptions>, "sourceConfig"> = {
|
||||
const DEFAULT_OPTIONS: Omit<Required<ChannelSummaryOptions>, "plugins" | "sourceConfig"> = {
|
||||
colorize: false,
|
||||
includeAllowFrom: false,
|
||||
};
|
||||
@@ -43,6 +43,16 @@ const formatAccountLabel = (params: { accountId: string; name?: string }) => {
|
||||
const accountLine = (label: string, details: string[]) =>
|
||||
` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`;
|
||||
|
||||
async function loadChannelSummaryConfig(): Promise<OpenClawConfig> {
|
||||
const { loadConfig } = await import("../config/config.js");
|
||||
return loadConfig();
|
||||
}
|
||||
|
||||
async function listChannelSummaryPlugins(cfg: OpenClawConfig): Promise<ChannelPlugin[]> {
|
||||
const { listReadOnlyChannelPluginsForConfig } = await import("../channels/plugins/read-only.js");
|
||||
return listReadOnlyChannelPluginsForConfig(cfg);
|
||||
}
|
||||
|
||||
const buildAccountDetails = (params: {
|
||||
entry: ChannelAccountEntry;
|
||||
plugin: ChannelPlugin;
|
||||
@@ -106,14 +116,15 @@ export async function buildChannelSummary(
|
||||
cfg?: OpenClawConfig,
|
||||
options?: ChannelSummaryOptions,
|
||||
): Promise<string[]> {
|
||||
const effective = cfg ?? loadConfig();
|
||||
const effective = cfg ?? (await loadChannelSummaryConfig());
|
||||
const lines: string[] = [];
|
||||
const resolved = { ...DEFAULT_OPTIONS, ...options };
|
||||
const tint = (value: string, color?: (input: string) => string) =>
|
||||
resolved.colorize && color ? color(value) : value;
|
||||
const sourceConfig = options?.sourceConfig ?? effective;
|
||||
|
||||
for (const plugin of listReadOnlyChannelPluginsForConfig(effective)) {
|
||||
const plugins = options?.plugins ?? (await listChannelSummaryPlugins(effective));
|
||||
for (const plugin of plugins) {
|
||||
const accountIds = plugin.config.listAccountIds(effective);
|
||||
const defaultAccountId =
|
||||
plugin.config.defaultAccountId?.(effective) ?? accountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type PluginManifestRecord,
|
||||
} from "../plugins/manifest-registry.js";
|
||||
import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js";
|
||||
import { resolveProviderAuthEnvVarCandidates } from "../secrets/provider-env-vars.js";
|
||||
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||
import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js";
|
||||
import type { UsageProviderId } from "./provider-usage.types.js";
|
||||
@@ -76,6 +77,29 @@ function resolveProviderApiKeyFromConfig(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasProviderAuthEnvCredentialSource(params: {
|
||||
state: UsageAuthState;
|
||||
providerIds: string[];
|
||||
}): boolean {
|
||||
const candidates = resolveProviderAuthEnvVarCandidates({
|
||||
config: params.state.cfg,
|
||||
env: {
|
||||
...(process.env.VITEST ? process.env : {}),
|
||||
...params.state.env,
|
||||
},
|
||||
});
|
||||
for (const providerId of normalizeProviderIds(params.providerIds)) {
|
||||
const envVars = Object.hasOwn(candidates, providerId) ? candidates[providerId] : undefined;
|
||||
if (!envVars) {
|
||||
continue;
|
||||
}
|
||||
if (envVars.some((envVar) => Boolean(normalizeSecretInput(params.state.env[envVar])))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveProviderApiKeyFromConfigAndStore(params: {
|
||||
state: UsageAuthState;
|
||||
providerIds: string[];
|
||||
@@ -353,16 +377,22 @@ export async function resolveProviderAuths(params: {
|
||||
const auths: ProviderAuth[] = [];
|
||||
|
||||
for (const provider of params.providers) {
|
||||
const directCredentialState = { ...stateBase, allowAuthProfileStore: false };
|
||||
const credentialProviderIds = resolveUsageCredentialProviderIds({
|
||||
state: { ...stateBase, allowAuthProfileStore: false },
|
||||
state: directCredentialState,
|
||||
provider,
|
||||
});
|
||||
const hasDirectCredentialSource = Boolean(
|
||||
resolveProviderApiKeyFromConfig({
|
||||
state: { ...stateBase, allowAuthProfileStore: false },
|
||||
const hasDirectCredentialSource =
|
||||
Boolean(
|
||||
resolveProviderApiKeyFromConfig({
|
||||
state: directCredentialState,
|
||||
providerIds: credentialProviderIds,
|
||||
}),
|
||||
) ||
|
||||
hasProviderAuthEnvCredentialSource({
|
||||
state: directCredentialState,
|
||||
providerIds: credentialProviderIds,
|
||||
}),
|
||||
);
|
||||
});
|
||||
const allowAuthProfileStore =
|
||||
!params.skipPluginAuthWithoutCredentialSource ||
|
||||
hasDirectCredentialSource ||
|
||||
|
||||
Reference in New Issue
Block a user