diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index 267f025970b..bbee43293e7 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -20,6 +20,19 @@ describe("extractMessagingToolSend", () => { }, source: "test", }, + { + pluginId: "slack", + plugin: { + ...createChannelTestPluginBase({ id: "slack" }), + messaging: { normalizeTarget: (raw: string) => raw.trim().toLowerCase() }, + }, + source: "test", + }, + { + pluginId: "discord", + plugin: createChannelTestPluginBase({ id: "discord" }), + source: "test", + }, ]), ); }); diff --git a/src/agents/pi-tools.before-tool-call.state.ts b/src/agents/pi-tools.before-tool-call.state.ts new file mode 100644 index 00000000000..a624cc4a52c --- /dev/null +++ b/src/agents/pi-tools.before-tool-call.state.ts @@ -0,0 +1,5 @@ +export const adjustedParamsByToolCallId = new Map(); + +export function resetAdjustedParamsByToolCallIdForTests(): void { + adjustedParamsByToolCallId.clear(); +} diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index cf30b79ad58..d810e676a1a 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -26,6 +26,7 @@ import { import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { isPlainObject } from "../utils.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; +import { adjustedParamsByToolCallId } from "./pi-tools.before-tool-call.state.js"; import { normalizeToolName } from "./tool-policy.js"; import type { AnyAgentTool } from "./tools/common.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -67,7 +68,6 @@ const log = createSubsystemLogger("agents/tools"); const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped"); const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON = "Tool call blocked because before_tool_call hook failed"; -const adjustedParamsByToolCallId = new Map(); const MAX_TRACKED_ADJUSTED_PARAMS = 1024; const LOOP_WARNING_BUCKET_SIZE = 10; const MAX_LOOP_WARNING_KEYS = 256; diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index a46526bbfd3..93c8e9a9458 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -9,6 +9,130 @@ const mocks = vi.hoisted(() => ({ applyAuthChoice: vi.fn(), promptModelAllowlist: vi.fn(), promptDefaultModel: vi.fn(), + applyPrimaryModel: vi.fn((cfg: OpenClawConfig, model: string) => ({ + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { primary: model }, + }, + }, + })), + applyModelAllowlist: vi.fn( + (cfg: OpenClawConfig, models: string[], opts: { scopeKeys?: string[] } = {}) => { + const defaults = cfg.agents?.defaults; + const normalized = normalizeTestModelKeys(models); + const scopeKeys = opts.scopeKeys ? normalizeTestModelKeys(opts.scopeKeys) : []; + const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null; + if (normalized.length === 0) { + if (!defaults?.models) { + return cfg; + } + if (scopeKeySet) { + const nextModels = { ...defaults.models }; + for (const key of scopeKeySet) { + delete nextModels[key]; + } + const { models: _ignored, ...restDefaults } = defaults; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: + Object.keys(nextModels).length > 0 + ? { ...defaults, models: nextModels } + : restDefaults, + }, + }; + } + const { models: _ignored, ...restDefaults } = defaults; + return { ...cfg, agents: { ...cfg.agents, defaults: restDefaults } }; + } + const existingModels = defaults?.models ?? {}; + const nextModels = scopeKeySet ? { ...existingModels } : {}; + if (scopeKeySet) { + for (const key of scopeKeySet) { + delete nextModels[key]; + } + } + for (const key of normalized) { + nextModels[key] = existingModels[key] ?? {}; + } + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { ...defaults, models: nextModels }, + }, + }; + }, + ), + applyModelFallbacksFromSelection: vi.fn( + (cfg: OpenClawConfig, selection: string[], opts: { scopeKeys?: string[] } = {}) => { + const defaults = cfg.agents?.defaults; + const existingModel = defaults?.model; + const primary = + typeof existingModel === "string" + ? existingModel + : existingModel && typeof existingModel === "object" + ? existingModel.primary + : undefined; + const normalized = normalizeTestModelKeys(selection); + const scopeKeys = opts.scopeKeys ? normalizeTestModelKeys(opts.scopeKeys) : []; + const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null; + if (!primary || (normalized.length === 0 && !scopeKeySet)) { + return cfg; + } + const aliasIndex = new Map(); + for (const [key, value] of Object.entries(defaults?.models ?? {})) { + const alias = (value as { alias?: unknown }).alias; + if (typeof alias === "string" && alias.trim()) { + aliasIndex.set(alias.trim(), key); + } + } + const existingFallbacks = + existingModel && typeof existingModel === "object" && Array.isArray(existingModel.fallbacks) + ? normalizeTestModelKeys( + existingModel.fallbacks.map((fallback) => aliasIndex.get(fallback) ?? fallback), + ) + : []; + const selectedFallbacks = normalized.filter((key) => key !== primary); + const selected = new Set( + scopeKeySet && !normalized.includes(primary) + ? selectedFallbacks.filter((key) => existingFallbacks.includes(key)) + : selectedFallbacks, + ); + const fallbacks: string[] = []; + for (const fallback of existingFallbacks) { + if (scopeKeySet && !scopeKeySet.has(fallback)) { + fallbacks.push(fallback); + } else if (selected.delete(fallback)) { + fallbacks.push(fallback); + } + } + for (const fallback of selectedFallbacks) { + if (selected.has(fallback)) { + fallbacks.push(fallback); + } + } + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + model: { + ...(existingModel && typeof existingModel === "object" + ? (({ fallbacks: _oldFallbacks, ...rest }) => rest)(existingModel) + : { primary }), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + }, + }, + }, + }; + }, + ), promptCustomApiConfig: vi.fn(), resolvePluginProviders: vi.fn(() => []), resolveProviderPluginChoice: vi.fn<() => unknown>(() => null), @@ -18,6 +142,20 @@ const mocks = vi.hoisted(() => ({ ), })); +function normalizeTestModelKeys(values: string[]): string[] { + const seen = new Set(); + const next: string[] = []; + for (const raw of values) { + const value = raw.trim(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + next.push(value); + } + return next; +} + vi.mock("../agents/auth-profiles.js", () => ({ ensureAuthProfileStore: vi.fn(() => ({ version: 1, @@ -34,14 +172,13 @@ vi.mock("./auth-choice.js", () => ({ resolvePreferredProviderForAuthChoice: mocks.resolvePreferredProviderForAuthChoice, })); -vi.mock("./model-picker.js", async (importActual) => { - const actual = await importActual(); - return { - ...actual, - promptModelAllowlist: mocks.promptModelAllowlist, - promptDefaultModel: mocks.promptDefaultModel, - }; -}); +vi.mock("./model-picker.js", () => ({ + applyModelAllowlist: mocks.applyModelAllowlist, + applyModelFallbacksFromSelection: mocks.applyModelFallbacksFromSelection, + applyPrimaryModel: mocks.applyPrimaryModel, + promptModelAllowlist: mocks.promptModelAllowlist, + promptDefaultModel: mocks.promptDefaultModel, +})); vi.mock("./onboard-custom.js", () => ({ promptCustomApiConfig: mocks.promptCustomApiConfig, diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 0f442db5b7f..4156d210d12 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -15,7 +15,7 @@ import { } from "./model-picker.js"; import { loadStaticManifestCatalogRowsForList } from "./models/list.manifest-catalog.js"; import { promptCustomApiConfig } from "./onboard-custom.js"; -import { randomToken } from "./onboard-helpers.js"; +import { randomToken } from "./random-token.js"; type GatewayAuthChoice = "token" | "password" | "trusted-proxy"; type ProviderChoiceModelPrompt = { diff --git a/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts index 3a172f89111..533833adeff 100644 --- a/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/openclaw-owned-tool-runtime-contract.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { vi } from "vitest"; -import { __testing as beforeToolCallTesting } from "../../../agents/pi-tools.before-tool-call.js"; +import { resetAdjustedParamsByToolCallIdForTests } from "../../../agents/pi-tools.before-tool-call.state.js"; import type { CodexAppServerExtensionFactory, CodexAppServerToolResultEvent, @@ -90,5 +90,5 @@ export function installCodexToolResultMiddleware( export function resetOpenClawOwnedToolHooks(): void { resetGlobalHookRunner(); resetPluginRuntimeStateForTest(); - beforeToolCallTesting.adjustedParamsByToolCallId.clear(); + resetAdjustedParamsByToolCallIdForTests(); }