From 245257238bbbe5ab8a2ef55722acde138a6084e6 Mon Sep 17 00:00:00 2001 From: NIO Date: Mon, 29 Jun 2026 10:19:58 +0800 Subject: [PATCH] fix(agents): keep missing external channel providers in agents list --- src/agents/provider-attribution.test.ts | 1 + src/commands/agents.providers.test.ts | 153 +++++++++++++++++++++++- src/commands/agents.providers.ts | 86 ++++++++++++- 3 files changed, 233 insertions(+), 7 deletions(-) diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index ff7eb1aec256..a4e0fb807684 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -178,6 +178,7 @@ describe("provider attribution", () => { OPENCLAW_VERSION: "2026.3.22", }); + expect(policy).toBeDefined(); expect(policy).toEqual({ provider: "nvidia", enabledByDefault: true, diff --git a/src/commands/agents.providers.test.ts b/src/commands/agents.providers.test.ts index 3e307807de8e..716439d5312b 100644 --- a/src/commands/agents.providers.test.ts +++ b/src/commands/agents.providers.test.ts @@ -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"]); + }); }); diff --git a/src/commands/agents.providers.ts b/src/commands/agents.providers.ts index 2109363164d5..44f292d443eb 100644 --- a/src/commands/agents.providers.ts +++ b/src/commands/agents.providers.ts @@ -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 | 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 { - return new Map( + const metadata = new Map( 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(); 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(); 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(); 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;