test: optimize slow test hotspots

This commit is contained in:
Peter Steinberger
2026-04-21 22:39:16 +01:00
parent 49b233caa1
commit d1e3789e15
16 changed files with 315 additions and 254 deletions

View 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.

View 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

View File

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

View 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;

View File

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

View File

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

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:"));

View File

@@ -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"]);

View File

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

View File

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