Main recovery: restore formatter and contract checks (#49570)

* Extensions: fix oxfmt drift on main

* Plugins: restore runtime barrel exports on main

* Config: restore web search compatibility types

* Telegram: align test harness with reply runtime

* Plugin SDK: fix channel config accessor generics

* CLI: remove redundant search provider casts

* Tests: restore main typecheck coverage

* Lobster: fix test import formatting

* Extensions: route bundled seams through plugin-sdk

* Tests: use extension env helper for xai

* Image generation: fix main oxfmt drift

* Config: restore latest main compatibility checks

* Plugin SDK: align guardrail tests with lint

* Telegram: type native command skill mock
This commit is contained in:
Vincent Koc
2026-03-18 00:30:01 -07:00
committed by GitHub
parent e6c6aaa11b
commit fbd88e2c8f
78 changed files with 476 additions and 327 deletions

View File

@@ -17,7 +17,17 @@ function stubImageGenerationProviders() {
id: "openai",
defaultModel: "gpt-image-1",
models: ["gpt-image-1"],
supportedSizes: ["1024x1024"],
capabilities: {
generate: {
supportsSize: true,
},
edit: {
enabled: false,
},
geometry: {
sizes: ["1024x1024"],
},
},
generateImage: vi.fn(async () => {
throw new Error("not used");
}),

View File

@@ -18,7 +18,7 @@ describe("extra-params: Google thinking payload compatibility", () => {
api: "google-generative-ai",
provider: "google",
id: "gemini-3.1-pro-preview",
} as Model<"openai-completions">,
} as unknown as Model<"openai-completions">,
thinkingLevel: "high",
payload: {
contents: [],

View File

@@ -457,7 +457,7 @@ describe("createOpenClawCodingTools", () => {
it("applies xai model compat for direct Grok tool cleanup", () => {
const xaiTools = createOpenClawCodingTools({
modelProvider: "xai",
modelCompat: applyXaiModelCompat({}).compat,
modelCompat: applyXaiModelCompat({ compat: {} }).compat,
senderIsOwner: true,
});

View File

@@ -18,10 +18,7 @@ function toolNames(tools: AnyAgentTool[]): string[] {
describe("applyModelProviderToolPolicy", () => {
it("keeps web_search for non-xAI models", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
modelProvider: "openai",
modelId: "gpt-4o-mini",
});
const filtered = __testing.applyModelProviderToolPolicy(baseTools);
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
});

View File

@@ -392,10 +392,11 @@ describe("createImageGenerateTool", () => {
throw new Error("expected image_generate tool");
}
await expect(tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" }))
.rejects.toThrow(
"aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9",
);
await expect(
tool.execute("call-bad-aspect", { prompt: "portrait", aspectRatio: "7:5" }),
).rejects.toThrow(
"aspectRatio must be one of 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9, or 21:9",
);
});
it("lists registered provider and model options", async () => {

View File

@@ -230,7 +230,9 @@ function normalizeReferenceImages(args: Record<string, unknown>): string[] {
return normalized;
}
function parseImageGenerationModelRef(raw: string | undefined): { provider: string; model: string } | null {
function parseImageGenerationModelRef(
raw: string | undefined,
): { provider: string; model: string } | null {
const trimmed = raw?.trim();
if (!trimmed) {
return null;
@@ -258,7 +260,8 @@ function resolveSelectedImageGenerationProvider(params: {
}
return listRuntimeImageGenerationProviders({ config: params.config }).find(
(provider) =>
provider.id === selectedRef.provider || (provider.aliases ?? []).includes(selectedRef.provider),
provider.id === selectedRef.provider ||
(provider.aliases ?? []).includes(selectedRef.provider),
);
}
@@ -298,7 +301,9 @@ function validateImageGenerationCapabilities(params: {
if (params.size) {
if (!modeCaps.supportsSize) {
throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`);
throw new ToolInputError(
`${provider.id} ${isEdit ? "edit" : "generate"} does not support size overrides.`,
);
}
if ((geometry?.sizes?.length ?? 0) > 0 && !geometry?.sizes?.includes(params.size)) {
throw new ToolInputError(
@@ -309,7 +314,9 @@ function validateImageGenerationCapabilities(params: {
if (params.aspectRatio) {
if (!modeCaps.supportsAspectRatio) {
throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`);
throw new ToolInputError(
`${provider.id} ${isEdit ? "edit" : "generate"} does not support aspectRatio overrides.`,
);
}
if (
(geometry?.aspectRatios?.length ?? 0) > 0 &&
@@ -323,7 +330,9 @@ function validateImageGenerationCapabilities(params: {
if (params.resolution) {
if (!modeCaps.supportsResolution) {
throw new ToolInputError(`${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`);
throw new ToolInputError(
`${provider.id} ${isEdit ? "edit" : "generate"} does not support resolution overrides.`,
);
}
if (
(geometry?.resolutions?.length ?? 0) > 0 &&

View File

@@ -26,7 +26,7 @@ type AssistantLikeMessage = {
};
function resolveLiveXaiModel() {
return getModel("xai", "grok-4-1-fast-reasoning") ?? getModel("xai", "grok-4");
return getModel("xai", "grok-4");
}
async function collectDoneMessage(

View File

@@ -722,7 +722,14 @@ export function createAccountScopedGroupAccessSection<TResolved>(params: {
};
}
type AccountScopedChannel = "discord" | "slack" | "telegram" | "imessage" | "signal";
type AccountScopedChannel =
| "bluebubbles"
| "discord"
| "imessage"
| "line"
| "signal"
| "slack"
| "telegram";
type LegacyDmChannel = "discord" | "slack";
export function patchLegacyDmChannelConfig(params: {

View File

@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginCompatibilityNotice } from "../plugins/status.js";
const readConfigFileSnapshot = vi.fn();
const buildPluginCompatibilityNotices = vi.fn(() => []);
const buildPluginCompatibilityNotices = vi.fn((): PluginCompatibilityNotice[] => []);
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot,

View File

@@ -184,13 +184,13 @@ async function promptWebToolsConfig(
if (!entry) {
return false;
}
return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry);
return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry);
};
const existingProvider: SP = (() => {
const stored = existingSearch?.provider;
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
return stored as SP;
return stored;
}
return (
SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider
@@ -242,8 +242,8 @@ async function promptWebToolsConfig(
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
const existingKey = resolveExistingKey(nextConfig, providerChoice);
const keyConfigured = hasExistingKey(nextConfig, providerChoice);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
@@ -263,7 +263,7 @@ async function promptWebToolsConfig(
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
const applied = applySearchKey(nextConfig, providerChoice, (key || existingKey)!);
nextSearch = { ...applied.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };

View File

@@ -359,6 +359,8 @@ describe("normalizeCompatibilityConfigValues", () => {
providers: {
google: {
apiKey: "existing-google-key",
baseUrl: "https://generativelanguage.googleapis.com",
models: [],
},
},
},

View File

@@ -474,6 +474,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
};
const normalizeLegacyNanoBananaSkill = () => {
type ModelProviderEntry = Partial<
NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>[string]
>;
type ModelsConfigPatch = Partial<NonNullable<OpenClawConfig["models"]>>;
const rawSkills = next.skills;
if (!isRecord(rawSkills)) {
return;
@@ -544,14 +549,20 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
? structuredClone(rawLegacyEntry.apiKey)
: undefined);
const rawModels = isRecord(next.models) ? structuredClone(next.models) : {};
const rawProviders = isRecord(rawModels.providers) ? { ...rawModels.providers } : {};
const rawGoogle = isRecord(rawProviders.google) ? { ...rawProviders.google } : {};
const rawModels = (
isRecord(next.models) ? structuredClone(next.models) : {}
) as ModelsConfigPatch;
const rawProviders = (
isRecord(rawModels.providers) ? { ...rawModels.providers } : {}
) as Record<string, ModelProviderEntry>;
const rawGoogle = (
isRecord(rawProviders.google) ? { ...rawProviders.google } : {}
) as ModelProviderEntry;
const hasGoogleApiKey = rawGoogle.apiKey !== undefined;
if (!hasGoogleApiKey && legacyApiKey) {
rawGoogle.apiKey = legacyApiKey;
rawProviders.google = rawGoogle;
rawModels.providers = rawProviders;
rawModels.providers = rawProviders as NonNullable<OpenClawConfig["models"]>["providers"];
next = {
...next,
models: rawModels as OpenClawConfig["models"],

View File

@@ -444,6 +444,14 @@ export type MemorySearchConfig = {
};
};
type WebSearchLegacyProviderConfig = {
apiKey?: SecretInput;
baseUrl?: string;
model?: string;
mode?: string;
inlineCitations?: boolean;
};
export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
@@ -465,6 +473,20 @@ export type ToolsConfig = {
timeoutSeconds?: number;
/** Cache TTL in minutes for search results. */
cacheTtlMinutes?: number;
/** @deprecated Legacy Brave credential path. */
apiKey?: SecretInput;
/** @deprecated Legacy Brave scoped config. */
brave?: WebSearchLegacyProviderConfig;
/** @deprecated Legacy Firecrawl scoped config. */
firecrawl?: WebSearchLegacyProviderConfig;
/** @deprecated Legacy Gemini scoped config. */
gemini?: WebSearchLegacyProviderConfig;
/** @deprecated Legacy Grok scoped config. */
grok?: WebSearchLegacyProviderConfig;
/** @deprecated Legacy Kimi scoped config. */
kimi?: WebSearchLegacyProviderConfig;
/** @deprecated Legacy Perplexity scoped config. */
perplexity?: WebSearchLegacyProviderConfig;
};
fetch?: {
/** Enable web fetch tool (default: true). */

View File

@@ -267,6 +267,57 @@ export const ToolsWebSearchSchema = z
maxResults: z.number().int().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
cacheTtlMinutes: z.number().nonnegative().optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
brave: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
mode: z.string().optional(),
})
.strict()
.optional(),
firecrawl: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional(),
gemini: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional(),
grok: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
inlineCitations: z.boolean().optional(),
})
.strict()
.optional(),
kimi: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional(),
perplexity: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional();

View File

@@ -94,14 +94,22 @@ function aspectRatioToEnum(aspectRatio: string | undefined): string | undefined
return undefined;
}
function aspectRatioToDimensions(aspectRatio: string, edge: number): { width: number; height: number } {
function aspectRatioToDimensions(
aspectRatio: string,
edge: number,
): { width: number; height: number } {
const match = /^(\d+):(\d+)$/u.exec(aspectRatio.trim());
if (!match) {
throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`);
}
const widthRatio = Number.parseInt(match[1] ?? "", 10);
const heightRatio = Number.parseInt(match[2] ?? "", 10);
if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) {
if (
!Number.isFinite(widthRatio) ||
!Number.isFinite(heightRatio) ||
widthRatio <= 0 ||
heightRatio <= 0
) {
throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`);
}
if (widthRatio >= heightRatio) {
@@ -140,7 +148,10 @@ function resolveFalImageSize(params: {
return { width: edge, height: edge };
}
if (normalizedAspectRatio) {
return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024);
return (
aspectRatioToEnum(normalizedAspectRatio) ??
aspectRatioToDimensions(normalizedAspectRatio, 1024)
);
}
return undefined;
}

View File

@@ -41,7 +41,7 @@ describe("resolveOutboundSessionRoute", () => {
from?: string;
to?: string;
threadId?: string | number;
chatType?: "direct" | "group";
chatType?: "channel" | "direct" | "group";
};
}> = [
{

View File

@@ -972,7 +972,7 @@ describe("resolveOutboundSessionRoute", () => {
from?: string;
to?: string;
threadId?: string | number;
chatType?: "direct" | "group";
chatType?: "channel" | "direct" | "group";
};
}> = [
{

View File

@@ -1,6 +1,18 @@
// Public ACP runtime helpers for plugins that integrate with ACP control/session state.
export { getAcpSessionManager } from "../acp/control-plane/manager.js";
export { isAcpRuntimeError } from "../acp/runtime/errors.js";
export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js";
export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js";
export type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntimeEnsureInput,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnInput,
AcpSessionUpdateTag,
} from "../acp/runtime/types.js";
export { readAcpSessionEntry } from "../acp/runtime/session-meta.js";
export type { AcpSessionStoreEntry } from "../acp/runtime/session-meta.js";

View File

@@ -41,8 +41,11 @@ export function resolveOptionalConfigString(
}
/** Build the shared allowlist/default target adapter surface for account-scoped channel configs. */
export function createScopedAccountConfigAccessors<ResolvedAccount>(params: {
resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount;
export function createScopedAccountConfigAccessors<
ResolvedAccount,
Config extends OpenClawConfig = OpenClawConfig,
>(params: {
resolveAccount: (params: { cfg: Config; accountId?: string | null }) => ResolvedAccount;
resolveAllowFrom: (account: ResolvedAccount) => Array<string | number> | null | undefined;
formatAllowFrom: (allowFrom: Array<string | number>) => string[];
resolveDefaultTo?: (account: ResolvedAccount) => string | number | null | undefined;
@@ -52,7 +55,9 @@ export function createScopedAccountConfigAccessors<ResolvedAccount>(params: {
> {
const base = {
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
mapAllowFromEntries(params.resolveAllowFrom(params.resolveAccount({ cfg, accountId }))),
mapAllowFromEntries(
params.resolveAllowFrom(params.resolveAccount({ cfg: cfg as Config, accountId })),
),
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
params.formatAllowFrom(allowFrom),
};
@@ -65,7 +70,7 @@ export function createScopedAccountConfigAccessors<ResolvedAccount>(params: {
...base,
resolveDefaultTo: ({ cfg, accountId }) =>
resolveOptionalConfigString(
params.resolveDefaultTo?.(params.resolveAccount({ cfg, accountId })),
params.resolveDefaultTo?.(params.resolveAccount({ cfg: cfg as Config, accountId })),
),
};
}
@@ -160,7 +165,7 @@ export function createScopedChannelConfigAdapter<
clearBaseFields: params.clearBaseFields,
allowTopLevel: params.allowTopLevel,
}),
...createScopedAccountConfigAccessors<AccessorAccount>({
...createScopedAccountConfigAccessors<AccessorAccount, Config>({
resolveAccount: resolveAccessorAccount,
resolveAllowFrom: params.resolveAllowFrom,
formatAllowFrom: params.formatAllowFrom,
@@ -316,7 +321,7 @@ export function createTopLevelChannelConfigAdapter<
deleteMode: params.deleteMode,
clearBaseFields: params.clearBaseFields,
}),
...createScopedAccountConfigAccessors<AccessorAccount>({
...createScopedAccountConfigAccessors<AccessorAccount, Config>({
resolveAccount: resolveAccessorAccount,
resolveAllowFrom: params.resolveAllowFrom,
formatAllowFrom: params.formatAllowFrom,
@@ -438,7 +443,7 @@ export function createHybridChannelConfigAdapter<
clearBaseFields: params.clearBaseFields,
preserveSectionOnDefaultDelete: params.preserveSectionOnDefaultDelete,
}),
...createScopedAccountConfigAccessors<AccessorAccount>({
...createScopedAccountConfigAccessors<AccessorAccount, Config>({
resolveAccount: resolveAccessorAccount,
resolveAllowFrom: params.resolveAllowFrom,
formatAllowFrom: params.formatAllowFrom,

View File

@@ -44,6 +44,7 @@ export type {
ProviderThinkingPolicyContext,
ProviderWrapStreamFnContext,
OpenClawPluginService,
OpenClawPluginServiceContext,
ProviderAuthContext,
ProviderAuthDoctorHintContext,
ProviderAuthMethodNonInteractiveContext,
@@ -51,6 +52,7 @@ export type {
ProviderAuthResult,
OpenClawPluginCommandDefinition,
OpenClawPluginDefinition,
PluginLogger,
PluginInteractiveTelegramHandlerContext,
} from "../plugins/types.js";
export type { OpenClawConfig } from "../config/config.js";

View File

@@ -25,7 +25,7 @@ function collectPluginSdkPackageExports(): string[] {
}
subpaths.push(key.slice("./plugin-sdk/".length));
}
return subpaths.sort();
return subpaths.toSorted();
}
function collectPluginSdkSourceNames(): string[] {
@@ -35,7 +35,7 @@ function collectPluginSdkSourceNames(): string[] {
(entry) => entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts"),
)
.map((entry) => entry.name.slice(0, -".ts".length))
.sort();
.toSorted();
}
function collectTextFiles(rootRelativeDir: string): string[] {
@@ -92,7 +92,7 @@ function collectPluginSdkSubpathReferences() {
describe("plugin-sdk package contract guardrails", () => {
it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => {
expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].sort());
expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].toSorted());
});
it("keeps repo openclaw/plugin-sdk/<name> references on exported built subpaths", () => {
@@ -135,7 +135,7 @@ describe("plugin-sdk package contract guardrails", () => {
failures.push(
`src/plugin-sdk/${sourceName}.ts is referenced as openclaw/plugin-sdk/${sourceName} in ${matchingRefs
.map((reference) => reference.file)
.sort()
.toSorted()
.join(", ")}, but ${sourceName} is not exported as a public plugin-sdk subpath`,
);
}

View File

@@ -26,6 +26,8 @@ export type { StickerMetadata } from "../../extensions/telegram/api.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export { parseTelegramTopicConversation } from "../acp/conversation-id.js";
export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js";
export { resolveTelegramPollVisibility } from "../poll-params.js";
export {
PAIRING_APPROVED_MESSAGE,
@@ -38,9 +40,6 @@ export {
setAccountEnabledInConfigSection,
} from "./channel-plugin-common.js";
export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js";
export { resolveTelegramPollVisibility } from "../poll-params.js";
export {
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,

View File

@@ -99,6 +99,7 @@ describe("plugin shape compatibility matrix", () => {
envVars: ["HYBRID_SEARCH_KEY"],
placeholder: "hsk_...",
signupUrl: "https://example.com/signup",
credentialPath: "tools.web.search.hybrid-search.apiKey",
getCredentialValue: () => "hsk-test",
setCredentialValue(searchConfigTarget, value) {
searchConfigTarget.apiKey = value;

View File

@@ -68,7 +68,10 @@ function createProviderSecretRefConfig(
}
function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown {
return config.plugins?.entries?.[providerPluginId(provider)]?.config?.webSearch?.apiKey;
const pluginConfig = config.plugins?.entries?.[providerPluginId(provider)]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined;
return pluginConfig?.webSearch?.apiKey;
}
function expectInactiveFirecrawlSecretRef(params: {

View File

@@ -21,6 +21,7 @@ describe("web search runtime", () => {
placeholder: "custom-...",
signupUrl: "https://example.com/signup",
autoDetectOrder: 1,
credentialPath: "tools.web.search.custom.apiKey",
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({

View File

@@ -199,5 +199,6 @@ export async function runWebSearch(
export const __testing = {
resolveSearchConfig,
resolveSearchProvider: resolveWebSearchProviderId,
resolveWebSearchProviderId,
};