mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 10:34:44 +00:00
fix(agents): keep missing external channel providers in agents list
This commit is contained in:
@@ -178,6 +178,7 @@ describe("provider attribution", () => {
|
||||
OPENCLAW_VERSION: "2026.3.22",
|
||||
});
|
||||
|
||||
expect(policy).toBeDefined();
|
||||
expect(policy).toEqual({
|
||||
provider: "nvidia",
|
||||
enabledByDefault: true,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user