fix(agents): keep missing external channel providers in agents list

This commit is contained in:
NIO
2026-06-29 10:19:58 +08:00
committed by GitHub
parent 63fe5c7402
commit 245257238b
3 changed files with 233 additions and 7 deletions

View File

@@ -178,6 +178,7 @@ describe("provider attribution", () => {
OPENCLAW_VERSION: "2026.3.22",
});
expect(policy).toBeDefined();
expect(policy).toEqual({
provider: "nvidia",
enabledByDefault: true,

View File

@@ -1,7 +1,13 @@
// Agents provider tests cover provider status index construction for configured agents.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildProviderStatusIndex } from "./agents.providers.js";
import type { OfficialExternalPluginRepairHint } from "../plugins/official-external-plugin-repair-hints.js";
import {
buildProviderStatusIndex,
buildProviderSummaryMetadataIndex,
listProvidersForAgent,
summarizeBindings,
} from "./agents.providers.js";
const mocks = vi.hoisted(() => ({
listReadOnlyChannelPluginsForConfig: vi.fn(),
@@ -11,6 +17,10 @@ const mocks = vi.hoisted(() => ({
),
resolveChannelDefaultAccountId: vi.fn(() => "default"),
isChannelVisibleInConfiguredLists: vi.fn(() => true),
listExplicitConfiguredChannelIdsForConfig: vi.fn(() => [] as string[]),
resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn<
() => OfficialExternalPluginRepairHint | null
>(() => null),
}));
vi.mock("../channels/plugins/index.js", () => ({
@@ -38,9 +48,20 @@ vi.mock("../channels/plugins/exposure.js", () => ({
) => mocks.isChannelVisibleInConfiguredLists(...args),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
listExplicitConfiguredChannelIdsForConfig: mocks.listExplicitConfiguredChannelIdsForConfig,
}));
vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({
resolveMissingOfficialExternalChannelPluginRepairHint:
mocks.resolveMissingOfficialExternalChannelPluginRepairHint,
}));
describe("buildProviderStatusIndex", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.listExplicitConfiguredChannelIdsForConfig.mockReturnValue([]);
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null);
});
it("prefers inspectAccount for read-only status surfaces", async () => {
@@ -129,4 +150,134 @@ describe("buildProviderStatusIndex", () => {
await expect(buildProviderStatusIndex({} as OpenClawConfig)).rejects.toThrow("plugin crash");
});
it("keeps configured missing external channels in provider metadata", () => {
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
mocks.listExplicitConfiguredChannelIdsForConfig.mockReturnValue(["feishu"]);
mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue({
channelId: "feishu",
pluginId: "feishu",
label: "Feishu",
installSpec: "@openclaw/feishu",
installCommand: "openclaw plugins install @openclaw/feishu",
doctorFixCommand: "openclaw doctor --fix",
repairHint:
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
});
expect(
buildProviderSummaryMetadataIndex({ channels: { feishu: { appId: "cli_xxx" } } } as never),
).toEqual(
new Map([
[
"feishu",
{
label: "Feishu",
defaultAccountId: "default",
visibleInConfiguredLists: true,
repairHint:
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
},
],
]),
);
});
it("uses repair hints instead of unknown for bound missing external channels", () => {
const lines = listProvidersForAgent({
summaryIsDefault: false,
cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never,
bindings: [{ match: { channel: "feishu" } }] as never,
providerStatus: new Map(),
providerMetadata: new Map([
[
"feishu",
{
label: "Feishu",
defaultAccountId: "default",
visibleInConfiguredLists: true,
repairHint:
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
},
],
]),
});
expect(lines).toEqual([
"Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
]);
});
it("keeps bound missing external channels when runtime registry normalization is unavailable", () => {
mocks.normalizeChannelId.mockReturnValueOnce(null);
const lines = listProvidersForAgent({
summaryIsDefault: false,
cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never,
bindings: [{ match: { channel: "feishu" } }] as never,
providerStatus: new Map(),
providerMetadata: new Map([
[
"feishu",
{
label: "Feishu",
defaultAccountId: "default",
visibleInConfiguredLists: true,
repairHint:
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
},
],
]),
});
expect(lines).toEqual([
"Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
]);
});
it("shows missing external plugin repair hints for default agent summaries", () => {
const lines = listProvidersForAgent({
summaryIsDefault: true,
cfg: { channels: { feishu: { appId: "cli_xxx" } } } as never,
bindings: [],
providerStatus: new Map(),
providerMetadata: new Map([
[
"feishu",
{
label: "Feishu",
defaultAccountId: "default",
visibleInConfiguredLists: true,
repairHint:
"Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
},
],
]),
});
expect(lines).toEqual([
"Feishu default: missing plugin - Install the official external plugin with: openclaw plugins install @openclaw/feishu, or run: openclaw doctor --fix.",
]);
});
it("keeps route summaries when runtime registry normalization is unavailable", () => {
mocks.normalizeChannelId.mockReturnValueOnce(null);
expect(
summarizeBindings(
{ channels: { feishu: { appId: "cli_xxx" } } } as never,
[{ match: { channel: "feishu" } }] as never,
new Map([
[
"feishu",
{
label: "Feishu",
defaultAccountId: "default",
visibleInConfiguredLists: true,
},
],
]),
),
).toEqual(["Feishu default"]);
});
});

View File

@@ -1,4 +1,5 @@
// Provider/account summary helpers for `openclaw agents list`.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { isChannelVisibleInConfiguredLists } from "../channels/plugins/exposure.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
@@ -7,6 +8,8 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import type { AgentBinding } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { listExplicitConfiguredChannelIdsForConfig } from "../plugins/channel-plugin-ids.js";
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../plugins/official-external-plugin-repair-hints.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
type ProviderAccountStatus = {
@@ -24,17 +27,33 @@ type ProviderSummaryMetadata = {
label: string;
defaultAccountId: string;
visibleInConfiguredLists: boolean;
repairHint?: string;
};
function providerAccountKey(provider: ChannelId, accountId?: string) {
return `${provider}:${accountId ?? DEFAULT_ACCOUNT_ID}`;
}
function resolveProviderChannelId(params: {
rawChannelId: string | null | undefined;
metadataByProvider: ReadonlyMap<ChannelId, ProviderSummaryMetadata>;
}): ChannelId | null {
const resolved = normalizeChannelId(params.rawChannelId);
if (resolved) {
return resolved;
}
const fallback = normalizeOptionalLowercaseString(params.rawChannelId);
if (!fallback) {
return null;
}
return params.metadataByProvider.has(fallback as ChannelId) ? (fallback as ChannelId) : null;
}
/** Build stable provider labels/default accounts without resolving live account state. */
export function buildProviderSummaryMetadataIndex(
cfg: OpenClawConfig,
): Map<ChannelId, ProviderSummaryMetadata> {
return new Map(
const metadata = new Map<ChannelId, ProviderSummaryMetadata>(
listReadOnlyChannelPluginsForConfig(cfg, {
includeSetupFallbackPlugins: false,
}).map((plugin) => [
@@ -50,6 +69,25 @@ export function buildProviderSummaryMetadataIndex(
},
]),
);
for (const channelId of listExplicitConfiguredChannelIdsForConfig(cfg)) {
if (metadata.has(channelId)) {
continue;
}
const hint = resolveMissingOfficialExternalChannelPluginRepairHint({
config: cfg,
channelId,
});
if (!hint) {
continue;
}
metadata.set(channelId as ChannelId, {
label: hint.label,
defaultAccountId: DEFAULT_ACCOUNT_ID,
visibleInConfiguredLists: true,
repairHint: hint.repairHint,
});
}
return metadata;
}
function isUnresolvedSecretRefResolutionError(error: unknown): boolean {
@@ -195,6 +233,22 @@ function formatProviderEntry(entry: ProviderAccountStatus): string {
return `${label}: ${formatProviderState(entry)}`;
}
function formatMissingProviderEntry(params: {
provider: ChannelId;
accountId: string;
metadata?: ProviderSummaryMetadata;
}): string {
const label = formatChannelAccountLabel({
provider: params.provider,
providerLabel: params.metadata?.label,
accountId: params.accountId,
});
if (params.metadata?.repairHint) {
return `${label}: missing plugin - ${params.metadata.repairHint}`;
}
return `${label}: unknown`;
}
/** Render the provider/account routes implied by an agent's route bindings. */
export function summarizeBindings(
cfg: OpenClawConfig,
@@ -206,7 +260,10 @@ export function summarizeBindings(
}
const seen = new Map<string, string>();
for (const binding of bindings) {
const channel = normalizeChannelId(binding.match.channel);
const channel = resolveProviderChannelId({
rawChannelId: binding.match.channel,
metadataByProvider,
});
if (!channel) {
continue;
}
@@ -240,7 +297,10 @@ export function listProvidersForAgent(params: {
if (params.bindings.length > 0) {
const seen = new Set<string>();
for (const binding of params.bindings) {
const channel = normalizeChannelId(binding.match.channel);
const channel = resolveProviderChannelId({
rawChannelId: binding.match.channel,
metadataByProvider,
});
if (!channel) {
continue;
}
@@ -256,11 +316,11 @@ export function listProvidersForAgent(params: {
providerLines.push(formatProviderEntry(status));
} else {
providerLines.push(
`${formatChannelAccountLabel({
formatMissingProviderEntry({
provider: channel,
providerLabel: metadataByProvider.get(channel)?.label,
accountId,
})}: unknown`,
metadata: metadataByProvider.get(channel),
}),
);
}
}
@@ -268,11 +328,25 @@ export function listProvidersForAgent(params: {
}
if (params.summaryIsDefault) {
const seenProviders = new Set<ChannelId>();
for (const entry of allProviderEntries) {
if (shouldShowProviderEntry({ entry, cfg: params.cfg, metadataByProvider })) {
providerLines.push(formatProviderEntry(entry));
seenProviders.add(entry.provider);
}
}
for (const [provider, metadata] of metadataByProvider.entries()) {
if (!metadata.repairHint || seenProviders.has(provider)) {
continue;
}
providerLines.push(
formatMissingProviderEntry({
provider,
accountId: metadata.defaultAccountId,
metadata,
}),
);
}
}
return providerLines;