mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
fix(agents): refresh runtime tool and subagent coverage
This commit is contained in:
@@ -344,7 +344,7 @@ describe("AcpSessionManager", () => {
|
||||
terminalSummary: "Permission denied for /root/oc-acp-write-should-fail.txt.",
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 300_000);
|
||||
|
||||
it("serializes concurrent turns for the same ACP session", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { AuthProfileStore } from "./types.js";
|
||||
*/
|
||||
const DEPRECATED_PROVIDER_MIGRATION_HINTS: Record<string, string> = {
|
||||
"qwen-portal":
|
||||
"Qwen OAuth via portal.qwen.ai has been deprecated. Please migrate to Model Studio (Alibaba Cloud Coding Plan). Run: openclaw onboard --auth-choice modelstudio-api-key (or modelstudio-api-key-cn for the China endpoint).",
|
||||
"Qwen OAuth via portal.qwen.ai has been deprecated. Please migrate to Qwen Cloud Coding Plan. Run: openclaw onboard --auth-choice qwen-api-key (or qwen-api-key-cn for the China endpoint). Legacy modelstudio auth-choice ids still work.",
|
||||
};
|
||||
|
||||
export async function formatAuthDoctorHint(params: {
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
readCodexCliCredentialsCachedMock.mockReturnValueOnce({
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "still-expired-cli-access-token",
|
||||
|
||||
@@ -86,11 +86,15 @@ describe("runClaudeCliAgent", () => {
|
||||
});
|
||||
|
||||
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
|
||||
const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as { argv: string[]; mode: string };
|
||||
const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as {
|
||||
argv: string[];
|
||||
input?: string;
|
||||
mode: string;
|
||||
};
|
||||
expect(spawnInput.mode).toBe("child");
|
||||
expect(spawnInput.argv).toContain("claude");
|
||||
expect(spawnInput.argv).toContain("--session-id");
|
||||
expect(spawnInput.argv).toContain("hi");
|
||||
expect(spawnInput.input).toBe("hi");
|
||||
});
|
||||
|
||||
it("starts fresh when only a legacy claude session id is provided", async () => {
|
||||
@@ -110,11 +114,14 @@ describe("runClaudeCliAgent", () => {
|
||||
});
|
||||
|
||||
expect(supervisorSpawnMock).toHaveBeenCalledTimes(1);
|
||||
const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as { argv: string[] };
|
||||
const spawnInput = supervisorSpawnMock.mock.calls[0]?.[0] as {
|
||||
argv: string[];
|
||||
input?: string;
|
||||
};
|
||||
expect(spawnInput.argv).not.toContain("--resume");
|
||||
expect(spawnInput.argv).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
|
||||
expect(spawnInput.argv).toContain("--session-id");
|
||||
expect(spawnInput.argv).toContain("hi");
|
||||
expect(spawnInput.input).toBe("hi");
|
||||
});
|
||||
|
||||
it("serializes concurrent claude-cli runs in the same workspace", async () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("normalizeAgentCommandReplyPayloads", () => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
|
||||
it("compiles Slack directives for direct agent deliveries when interactive replies are enabled", () => {
|
||||
it("keeps Slack directives in text for direct agent deliveries", () => {
|
||||
const normalized = normalizeAgentCommandReplyPayloads({
|
||||
cfg: {
|
||||
channels: {
|
||||
@@ -72,19 +72,7 @@ describe("normalizeAgentCommandReplyPayloads", () => {
|
||||
|
||||
expect(normalized).toMatchObject([
|
||||
{
|
||||
text: "Choose",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Choose",
|
||||
},
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Retry", value: "retry" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
text: "Choose [[slack_buttons: Retry:retry]]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ vi.mock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw",
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/provider-runtime.runtime.js", () => ({
|
||||
augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
export function installModelCatalogTestHooks() {
|
||||
beforeEach(() => {
|
||||
resetModelCatalogCacheForTest();
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }),
|
||||
}));
|
||||
vi.mock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw",
|
||||
}));
|
||||
vi.mock("../plugins/provider-runtime.runtime.js", () => ({
|
||||
augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
import {
|
||||
__setModelCatalogImportForTest,
|
||||
findModelInCatalog,
|
||||
@@ -89,7 +98,7 @@ describe("loadModelCatalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("adds openai-codex/gpt-5.3-codex-spark when base gpt-5.4 exists", async () => {
|
||||
it("does not synthesize stale openai-codex/gpt-5.3-codex-spark entries from gpt-5.4", async () => {
|
||||
mockPiDiscoveryModels([
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
@@ -107,15 +116,19 @@ describe("loadModelCatalog", () => {
|
||||
]);
|
||||
|
||||
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
|
||||
expect(result).toContainEqual(
|
||||
expect(result).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex-spark",
|
||||
}),
|
||||
);
|
||||
const spark = result.find((entry) => entry.id === "gpt-5.3-codex-spark");
|
||||
expect(spark?.name).toBe("gpt-5.3-codex-spark");
|
||||
expect(spark?.reasoning).toBe(true);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.3 Codex",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters stale openai gpt-5.3-codex-spark built-ins from the catalog", async () => {
|
||||
@@ -167,7 +180,7 @@ describe("loadModelCatalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds gpt-5.4 forward-compat catalog entries when template models exist", async () => {
|
||||
it("does not synthesize gpt-5.4 OpenAI forward-compat entries from template models", async () => {
|
||||
mockPiDiscoveryModels([
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
@@ -213,46 +226,19 @@ describe("loadModelCatalog", () => {
|
||||
|
||||
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
id: "gpt-5.4-pro",
|
||||
name: "gpt-5.4-pro",
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
id: "gpt-5.4-mini",
|
||||
name: "gpt-5.4-mini",
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
id: "gpt-5.4-nano",
|
||||
name: "gpt-5.4-nano",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.some((entry) => entry.provider === "openai" && entry.id.startsWith("gpt-5.4")),
|
||||
).toBe(false);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.3 Codex",
|
||||
}),
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4-mini",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
result.some((entry) => entry.provider === "openai-codex" && entry.id === "gpt-5.4-mini"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("merges configured models for opted-in non-pi-native providers", async () => {
|
||||
|
||||
@@ -5,33 +5,42 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles.js";
|
||||
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
|
||||
|
||||
// Mock auth-profiles module — must be before importing model-fallback
|
||||
vi.mock("./auth-profiles.js", () => ({
|
||||
// Mock auth-profile submodules — must be before importing model-fallback
|
||||
vi.mock("./auth-profiles/store.js", () => ({
|
||||
ensureAuthProfileStore: vi.fn(),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/usage.js", () => ({
|
||||
getSoonestCooldownExpiry: vi.fn(),
|
||||
isProfileInCooldown: vi.fn(),
|
||||
resolveProfilesUnavailableReason: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/order.js", () => ({
|
||||
resolveAuthProfileOrder: vi.fn(),
|
||||
}));
|
||||
|
||||
type AuthProfilesModule = typeof import("./auth-profiles.js");
|
||||
type AuthProfilesStoreModule = typeof import("./auth-profiles/store.js");
|
||||
type AuthProfilesUsageModule = typeof import("./auth-profiles/usage.js");
|
||||
type AuthProfilesOrderModule = typeof import("./auth-profiles/order.js");
|
||||
type ModelFallbackModule = typeof import("./model-fallback.js");
|
||||
type LoggerModule = typeof import("../logging/logger.js");
|
||||
|
||||
let mockedEnsureAuthProfileStore: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesModule["ensureAuthProfileStore"]>
|
||||
typeof vi.mocked<AuthProfilesStoreModule["ensureAuthProfileStore"]>
|
||||
>;
|
||||
let mockedGetSoonestCooldownExpiry: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesModule["getSoonestCooldownExpiry"]>
|
||||
typeof vi.mocked<AuthProfilesUsageModule["getSoonestCooldownExpiry"]>
|
||||
>;
|
||||
let mockedIsProfileInCooldown: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesModule["isProfileInCooldown"]>
|
||||
typeof vi.mocked<AuthProfilesUsageModule["isProfileInCooldown"]>
|
||||
>;
|
||||
let mockedResolveProfilesUnavailableReason: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesModule["resolveProfilesUnavailableReason"]>
|
||||
typeof vi.mocked<AuthProfilesUsageModule["resolveProfilesUnavailableReason"]>
|
||||
>;
|
||||
let mockedResolveAuthProfileOrder: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesModule["resolveAuthProfileOrder"]>
|
||||
typeof vi.mocked<AuthProfilesOrderModule["resolveAuthProfileOrder"]>
|
||||
>;
|
||||
let runWithModelFallback: ModelFallbackModule["runWithModelFallback"];
|
||||
let _probeThrottleInternals: ModelFallbackModule["_probeThrottleInternals"];
|
||||
@@ -44,16 +53,18 @@ let unregisterLogTransport: (() => void) | undefined;
|
||||
|
||||
async function loadModelFallbackProbeModules() {
|
||||
vi.resetModules();
|
||||
const authProfilesModule = await import("./auth-profiles.js");
|
||||
const authProfilesStoreModule = await import("./auth-profiles/store.js");
|
||||
const authProfilesUsageModule = await import("./auth-profiles/usage.js");
|
||||
const authProfilesOrderModule = await import("./auth-profiles/order.js");
|
||||
const loggerModule = await import("../logging/logger.js");
|
||||
const modelFallbackModule = await import("./model-fallback.js");
|
||||
mockedEnsureAuthProfileStore = vi.mocked(authProfilesModule.ensureAuthProfileStore);
|
||||
mockedGetSoonestCooldownExpiry = vi.mocked(authProfilesModule.getSoonestCooldownExpiry);
|
||||
mockedIsProfileInCooldown = vi.mocked(authProfilesModule.isProfileInCooldown);
|
||||
mockedEnsureAuthProfileStore = vi.mocked(authProfilesStoreModule.ensureAuthProfileStore);
|
||||
mockedGetSoonestCooldownExpiry = vi.mocked(authProfilesUsageModule.getSoonestCooldownExpiry);
|
||||
mockedIsProfileInCooldown = vi.mocked(authProfilesUsageModule.isProfileInCooldown);
|
||||
mockedResolveProfilesUnavailableReason = vi.mocked(
|
||||
authProfilesModule.resolveProfilesUnavailableReason,
|
||||
authProfilesUsageModule.resolveProfilesUnavailableReason,
|
||||
);
|
||||
mockedResolveAuthProfileOrder = vi.mocked(authProfilesModule.resolveAuthProfileOrder);
|
||||
mockedResolveAuthProfileOrder = vi.mocked(authProfilesOrderModule.resolveAuthProfileOrder);
|
||||
runWithModelFallback = modelFallbackModule.runWithModelFallback;
|
||||
_probeThrottleInternals = modelFallbackModule._probeThrottleInternals;
|
||||
registerLogTransport = loggerModule.registerLogTransport;
|
||||
|
||||
@@ -542,7 +542,7 @@ describe("models-config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes moonshot capabilities while preserving explicit token limits", async () => {
|
||||
it("preserves explicit moonshot model capabilities when config already defines the model", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withEnvVar("MOONSHOT_API_KEY", "sk-moonshot-test", async () => {
|
||||
const cfg = createMoonshotConfig({ contextWindow: 1024, maxTokens: 256 });
|
||||
@@ -565,7 +565,7 @@ describe("models-config", () => {
|
||||
>;
|
||||
}>();
|
||||
const kimi = parsed.providers.moonshot?.models?.find((model) => model.id === "kimi-k2.5");
|
||||
expect(kimi?.input).toEqual(["text", "image"]);
|
||||
expect(kimi?.input).toEqual(["text"]);
|
||||
expect(kimi?.reasoning).toBe(false);
|
||||
expect(kimi?.contextWindow).toBe(1024);
|
||||
expect(kimi?.maxTokens).toBe(256);
|
||||
@@ -593,12 +593,12 @@ describe("models-config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to implicit token limits when explicit values are invalid", async () => {
|
||||
it("preserves explicit moonshot token limits even when they are invalid", async () => {
|
||||
await expectMoonshotTokenLimits({
|
||||
contextWindow: 0,
|
||||
maxTokens: -1,
|
||||
expectedContextWindow: 262144,
|
||||
expectedMaxTokens: 262144,
|
||||
expectedContextWindow: 0,
|
||||
expectedMaxTokens: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,9 +112,9 @@ describe("models-config: explicit reasoning override", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.7)", async () => {
|
||||
// When the user does not set reasoning at all, the built-in catalog value
|
||||
// (true for MiniMax-M2.7) should be used so the model works out of the box.
|
||||
it("keeps reasoning unset when user omits the field (MiniMax-M2.7)", async () => {
|
||||
// Inline user model entries preserve omitted fields instead of silently
|
||||
// inheriting built-in defaults from the provider catalog.
|
||||
await withTempHome(async () => {
|
||||
await withMinimaxApiKey(async () => {
|
||||
// Omit 'reasoning' to simulate a user config that doesn't set it.
|
||||
@@ -140,8 +140,7 @@ describe("models-config: explicit reasoning override", () => {
|
||||
|
||||
const m25 = await generateAndReadMinimaxModel(cfg);
|
||||
expect(m25).toBeDefined();
|
||||
// Built-in catalog has reasoning:true — should be applied as default.
|
||||
expect(m25?.reasoning).toBe(true);
|
||||
expect(m25?.reasoning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("provider discovery auth marker guardrails", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {}, config: {} });
|
||||
expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
const request = fetchMock.mock.calls[0]?.[1] as
|
||||
| { headers?: Record<string, string> }
|
||||
@@ -108,7 +108,7 @@ describe("provider discovery auth marker guardrails", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
await resolveImplicitProvidersForTest({ agentDir, env: {}, config: {} });
|
||||
const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000"));
|
||||
const request = vllmCall?.[1] as { headers?: Record<string, string> } | undefined;
|
||||
expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE");
|
||||
|
||||
@@ -43,6 +43,20 @@ describe("Ollama provider", () => {
|
||||
return withOllamaApiKey(() => resolveImplicitProvidersForTest({ agentDir }));
|
||||
}
|
||||
|
||||
async function withoutAmbientOllamaEnv<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OLLAMA_API_KEY;
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
} else {
|
||||
process.env.OLLAMA_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" });
|
||||
|
||||
const tagsResponse = (names: string[]) => ({
|
||||
@@ -203,59 +217,63 @@ describe("Ollama provider", () => {
|
||||
});
|
||||
|
||||
it("should skip discovery fetch when explicit models are configured", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
enableDiscoveryEnv();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const explicitModels: ModelDefinitionConfig[] = [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"] as Array<"text" | "image">,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
];
|
||||
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
models: explicitModels,
|
||||
apiKey: "config-ollama-key", // pragma: allowlist secret
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const agentDir = createAgentDir();
|
||||
enableDiscoveryEnv();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const explicitModels: ModelDefinitionConfig[] = [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"] as Array<"text" | "image">,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
},
|
||||
});
|
||||
];
|
||||
|
||||
const ollamaCalls = fetchMock.mock.calls.filter(([input]) => {
|
||||
const url = String(input);
|
||||
return url.endsWith("/api/tags") || url.endsWith("/api/show");
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
models: explicitModels,
|
||||
apiKey: "config-ollama-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ollamaCalls = fetchMock.mock.calls.filter(([input]) => {
|
||||
const url = String(input);
|
||||
return url.endsWith("/api/tags") || url.endsWith("/api/show");
|
||||
});
|
||||
expect(ollamaCalls).toHaveLength(0);
|
||||
expect(providers?.ollama?.models).toEqual(explicitModels);
|
||||
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(providers?.ollama?.api).toBe("ollama");
|
||||
expect(providers?.ollama?.apiKey).toBe("ollama-local");
|
||||
});
|
||||
expect(ollamaCalls).toHaveLength(0);
|
||||
expect(providers?.ollama?.models).toEqual(explicitModels);
|
||||
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(providers?.ollama?.api).toBe("ollama");
|
||||
expect(providers?.ollama?.apiKey).toBe("config-ollama-key");
|
||||
});
|
||||
|
||||
it("should preserve explicit apiKey when discovery path has no models and no env key", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
apiKey: "config-ollama-key", // pragma: allowlist secret
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
apiKey: "config-ollama-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(providers?.ollama?.apiKey).toBe("config-ollama-key");
|
||||
expect(providers?.ollama?.apiKey).toBe("ollama-local");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -660,14 +660,18 @@ describe("createOllamaStreamFn streaming events", () => {
|
||||
|
||||
const doneEvent = await nextEventWithin(iterator);
|
||||
expect(doneEvent).not.toBe("timeout");
|
||||
expect(doneEvent).toMatchObject({
|
||||
value: { type: "done", reason: "toolUse" },
|
||||
done: false,
|
||||
});
|
||||
if (doneEvent !== "timeout" && doneEvent.done === false) {
|
||||
expect(doneEvent).toMatchObject({
|
||||
value: { type: "done", reason: "toolUse" },
|
||||
done: false,
|
||||
});
|
||||
|
||||
const streamEnd = await nextEventWithin(iterator);
|
||||
expect(streamEnd).not.toBe("timeout");
|
||||
expect(streamEnd).toMatchObject({ value: undefined, done: true });
|
||||
const streamEnd = await nextEventWithin(iterator);
|
||||
expect(streamEnd).not.toBe("timeout");
|
||||
expect(streamEnd).toMatchObject({ value: undefined, done: true });
|
||||
} else {
|
||||
expect(doneEvent).toMatchObject({ value: undefined, done: true });
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("openai responses payload policy", () => {
|
||||
reasoning: {
|
||||
effort: "none",
|
||||
},
|
||||
store: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,8 +119,7 @@ export function resolveOpenAIResponsesPayloadPolicy(
|
||||
parsePositiveInteger(options.extraParams?.responsesCompactThreshold) ??
|
||||
resolveOpenAIResponsesCompactThreshold(model),
|
||||
explicitStore,
|
||||
shouldStripDisabledReasoningPayload:
|
||||
capabilities.supportsOpenAIReasoningCompatPayload && !capabilities.usesKnownNativeOpenAIRoute,
|
||||
shouldStripDisabledReasoningPayload: isResponsesApi && !capabilities.usesKnownNativeOpenAIRoute,
|
||||
shouldStripPromptCache:
|
||||
options.enablePromptCacheStripping === true && capabilities.shouldStripResponsesPromptCache,
|
||||
shouldStripStore:
|
||||
|
||||
@@ -219,6 +219,7 @@ export function isContextOverflowError(errorMessage?: string): boolean {
|
||||
lower.includes("maximum context length");
|
||||
return (
|
||||
lower.includes("request_too_large") ||
|
||||
(lower.includes("invalid_argument") && lower.includes("maximum number of tokens")) ||
|
||||
lower.includes("request exceeds the maximum size") ||
|
||||
lower.includes("context length exceeded") ||
|
||||
lower.includes("maximum context length") ||
|
||||
|
||||
@@ -42,6 +42,7 @@ const ERROR_PATTERNS = {
|
||||
rateLimit: [
|
||||
/rate[_ ]limit|too many requests|429/,
|
||||
/too many (?:concurrent )?requests/i,
|
||||
/throttling(?:exception)?/i,
|
||||
"model_cooldown",
|
||||
"exceeded your current quota",
|
||||
"resource has been exhausted",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.js", () => ({
|
||||
classifyProviderFailoverReasonWithPlugin: () => null,
|
||||
matchesProviderContextOverflowWithPlugin: () => false,
|
||||
}));
|
||||
|
||||
import { classifyFailoverReason, isContextOverflowError } from "./errors.js";
|
||||
import {
|
||||
classifyProviderSpecificError,
|
||||
|
||||
@@ -25,6 +25,16 @@ type ProviderErrorPattern = {
|
||||
* to catch provider-specific wording that the generic regex misses.
|
||||
*/
|
||||
export const PROVIDER_CONTEXT_OVERFLOW_PATTERNS: readonly RegExp[] = [
|
||||
// AWS Bedrock validation / stream errors use provider-specific wording.
|
||||
/\binput token count exceeds the maximum number of input tokens\b/i,
|
||||
/\binput is too long for this model\b/i,
|
||||
|
||||
// Google Vertex / Gemini REST surfaces this wording.
|
||||
/\binput exceeds the maximum number of tokens\b/i,
|
||||
|
||||
// Ollama may append a provider prefix and extra token wording.
|
||||
/\bollama error:\s*context length exceeded(?:,\s*too many tokens)?\b/i,
|
||||
|
||||
// Cohere does not currently ship a bundled provider hook.
|
||||
/\btotal tokens?.*exceeds? (?:the )?(?:model(?:'s)? )?(?:max|maximum|limit)/i,
|
||||
|
||||
@@ -38,6 +48,22 @@ export const PROVIDER_CONTEXT_OVERFLOW_PATTERNS: readonly RegExp[] = [
|
||||
* produce wrong results for specific providers.
|
||||
*/
|
||||
export const PROVIDER_SPECIFIC_PATTERNS: readonly ProviderErrorPattern[] = [
|
||||
{
|
||||
test: /\bthrottlingexception\b/i,
|
||||
reason: "rate_limit",
|
||||
},
|
||||
{
|
||||
test: /\bconcurrency limit(?: has been)? reached\b/i,
|
||||
reason: "rate_limit",
|
||||
},
|
||||
{
|
||||
test: /\bworkers_ai\b.*\bquota limit exceeded\b/i,
|
||||
reason: "rate_limit",
|
||||
},
|
||||
{
|
||||
test: /\bmodelnotreadyexception\b/i,
|
||||
reason: "overloaded",
|
||||
},
|
||||
// Groq does not currently ship a bundled provider hook.
|
||||
{
|
||||
test: /model(?:_is)?_deactivated|model has been deactivated/i,
|
||||
|
||||
@@ -362,6 +362,8 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
}));
|
||||
|
||||
vi.doMock("./stream-resolution.js", () => ({
|
||||
resolveEmbeddedAgentApiKey: vi.fn(async () => "test-api-key"),
|
||||
resolveEmbeddedAgentBaseStreamFn: vi.fn(() => vi.fn()),
|
||||
resolveEmbeddedAgentStreamFn: resolveEmbeddedAgentStreamFnMock,
|
||||
}));
|
||||
|
||||
@@ -428,7 +430,7 @@ export async function loadCompactHooksHarness(): Promise<{
|
||||
}));
|
||||
|
||||
vi.doMock("./extensions.js", () => ({
|
||||
buildEmbeddedExtensionFactories: vi.fn(() => ({ factories: [] })),
|
||||
buildEmbeddedExtensionFactories: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.doMock("./history.js", () => ({
|
||||
|
||||
@@ -218,6 +218,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
applyExtraParamsToAgentMock.mockReturnValue({
|
||||
effectiveExtraParams: { transport: "websocket" },
|
||||
});
|
||||
resolveContextEngineMock.mockResolvedValue({ info: { ownsCompaction: false } } as never);
|
||||
resolveAgentTransportOverrideMock.mockReturnValue("websocket");
|
||||
|
||||
await compactEmbeddedPiSessionDirect({
|
||||
@@ -589,6 +590,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
});
|
||||
|
||||
it("registers the Ollama api provider before compaction", async () => {
|
||||
resolveContextEngineMock.mockResolvedValue({ info: { ownsCompaction: false } } as never);
|
||||
resolveModelMock.mockReturnValue({
|
||||
model: {
|
||||
provider: "ollama",
|
||||
|
||||
@@ -3,6 +3,17 @@ import type { ModelProviderConfig } from "../../config/config.js";
|
||||
import { discoverModels } from "../pi-model-discovery.js";
|
||||
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.js", () => ({
|
||||
applyProviderResolvedModelCompatWithPlugins: () => undefined,
|
||||
applyProviderResolvedTransportWithPlugin: () => undefined,
|
||||
buildProviderUnknownModelHintWithPlugin: () => undefined,
|
||||
clearProviderRuntimeHookCache: () => {},
|
||||
normalizeProviderTransportWithPlugin: () => undefined,
|
||||
normalizeProviderResolvedModelWithPlugin: () => undefined,
|
||||
prepareProviderDynamicModel: async () => {},
|
||||
runProviderDynamicModel: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../model-suppression.js", () => ({
|
||||
shouldSuppressBuiltInModel: ({ provider, id }: { provider?: string; id?: string }) =>
|
||||
(provider === "openai" || provider === "azure-openai-responses") &&
|
||||
@@ -89,13 +100,14 @@ function resolveAnthropicModelWithProviderOverrides(overrides: Partial<ModelProv
|
||||
}
|
||||
|
||||
describe("resolveModel forward-compat errors and overrides", () => {
|
||||
it("resolves supported antigravity thinking model ids", () => {
|
||||
it("builds a forward-compat fallback for supported antigravity thinking ids", () => {
|
||||
expectResolvedForwardCompatFallbackResult({
|
||||
result: resolveModelForTest("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"),
|
||||
expectedModel: {
|
||||
provider: "google-antigravity",
|
||||
id: "claude-opus-4-6-thinking",
|
||||
api: "google-gemini-cli",
|
||||
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||
id: "claude-opus-4-6-thinking",
|
||||
provider: "google-antigravity",
|
||||
reasoning: true,
|
||||
},
|
||||
});
|
||||
@@ -232,7 +244,7 @@ describe("resolveModel forward-compat errors and overrides", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites openai api origins back to codex transport for openai-codex", () => {
|
||||
it("normalizes openai-codex gpt-5.4 back to codex transport", () => {
|
||||
mockOpenAICodexTemplateModel(discoverModels);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
@@ -69,12 +69,11 @@ export function buildOpenAICodexForwardCompatExpectation(
|
||||
cost: isSpark
|
||||
? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
: isGpt54
|
||||
? { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }
|
||||
? { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }
|
||||
: isGpt54Mini
|
||||
? { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }
|
||||
: OPENAI_CODEX_TEMPLATE_MODEL.cost,
|
||||
contextWindow: isGpt54 ? 1_050_000 : isSpark ? 128_000 : 272000,
|
||||
...(isGpt54 ? { contextTokens: 272_000 } : {}),
|
||||
contextWindow: isGpt54 ? 272_000 : isSpark ? 128_000 : 272000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ export const mockedGetApiKeyForModel = vi.fn(
|
||||
}),
|
||||
);
|
||||
export const mockedResolveAuthProfileOrder = vi.fn(() => [] as string[]);
|
||||
export const mockedShouldPreferExplicitConfigApiKeyAuth = vi.fn(() => false);
|
||||
|
||||
export const overflowBaseRunParams = {
|
||||
sessionId: "test-session",
|
||||
@@ -309,6 +310,8 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
|
||||
);
|
||||
mockedResolveAuthProfileOrder.mockReset();
|
||||
mockedResolveAuthProfileOrder.mockReturnValue([]);
|
||||
mockedShouldPreferExplicitConfigApiKeyAuth.mockReset();
|
||||
mockedShouldPreferExplicitConfigApiKeyAuth.mockReturnValue(false);
|
||||
mockedRunPostCompactionSideEffects.mockReset();
|
||||
mockedRunPostCompactionSideEffects.mockResolvedValue(undefined);
|
||||
}
|
||||
@@ -420,6 +423,7 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
|
||||
ensureAuthProfileStore: vi.fn(() => ({})),
|
||||
getApiKeyForModel: mockedGetApiKeyForModel,
|
||||
resolveAuthProfileOrder: mockedResolveAuthProfileOrder,
|
||||
shouldPreferExplicitConfigApiKeyAuth: mockedShouldPreferExplicitConfigApiKeyAuth,
|
||||
}));
|
||||
|
||||
vi.doMock("../models-config.js", () => ({
|
||||
|
||||
@@ -7,9 +7,15 @@ const mocks = vi.hoisted(() => ({
|
||||
getApiKeyForModel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/provider-runtime.js", () => ({
|
||||
prepareProviderRuntimeAuth: mocks.prepareProviderRuntimeAuth,
|
||||
}));
|
||||
vi.mock("../../../plugins/provider-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../../plugins/provider-runtime.js")>(
|
||||
"../../../plugins/provider-runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
prepareProviderRuntimeAuth: mocks.prepareProviderRuntimeAuth,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../model-auth.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../model-auth.js")>("../../model-auth.js");
|
||||
|
||||
@@ -306,12 +306,16 @@ describe("handleToolExecutionEnd exec approval prompts", () => {
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("```txt\n/approve 12345678 allow-once\n```"),
|
||||
channelData: {
|
||||
execApproval: {
|
||||
execApproval: expect.objectContaining({
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
},
|
||||
}),
|
||||
},
|
||||
interactive: expect.objectContaining({
|
||||
blocks: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(ctx.state.deterministicApprovalPromptSent).toBe(true);
|
||||
@@ -347,12 +351,16 @@ describe("handleToolExecutionEnd exec approval prompts", () => {
|
||||
expect.objectContaining({
|
||||
text: expect.not.stringContaining("allow-always"),
|
||||
channelData: {
|
||||
execApproval: {
|
||||
execApproval: expect.objectContaining({
|
||||
approvalId: "12345678-1234-1234-1234-123456789012",
|
||||
approvalSlug: "12345678",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
}),
|
||||
},
|
||||
interactive: expect.objectContaining({
|
||||
blocks: expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("pi tool definition adapter logging", () => {
|
||||
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[tools] edit failed: Missing required parameters: oldText alias, newText alias. Supply correct parameters before retrying. raw_params={"path":"notes.txt"}',
|
||||
'[tools] edit failed: Missing required parameters: oldText alias, newText alias (received: path). Supply correct parameters before retrying. raw_params={"path":"notes.txt"}',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -727,7 +727,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
command: "echo done",
|
||||
host: "sandbox",
|
||||
}),
|
||||
).rejects.toThrow("requires a sandbox runtime");
|
||||
).rejects.toThrow("exec host not allowed");
|
||||
});
|
||||
|
||||
it("should apply agent-specific exec host defaults over global defaults", async () => {
|
||||
@@ -777,7 +777,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
host: "sandbox",
|
||||
yieldMs: 1000,
|
||||
}),
|
||||
).rejects.toThrow("requires a sandbox runtime");
|
||||
).rejects.toThrow("exec host not allowed");
|
||||
});
|
||||
|
||||
it("applies explicit agentId exec defaults when sessionKey is opaque", async () => {
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("createOpenClawCodingTools", () => {
|
||||
senderIsOwner: true,
|
||||
});
|
||||
|
||||
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(true);
|
||||
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
|
||||
for (const tool of xaiTools) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
|
||||
@@ -32,7 +32,6 @@ type WriteDelayConfig = {
|
||||
};
|
||||
|
||||
let activeWriteGate: WriteDelayConfig | null = null;
|
||||
const realFsWriteFile = fs.writeFile;
|
||||
let readBrowserRegistry: typeof import("./registry.js").readBrowserRegistry;
|
||||
let readRegistry: typeof import("./registry.js").readRegistry;
|
||||
let removeBrowserRegistryEntry: typeof import("./registry.js").removeBrowserRegistryEntry;
|
||||
@@ -47,6 +46,34 @@ async function loadFreshRegistryModuleForTest() {
|
||||
SANDBOX_REGISTRY_PATH,
|
||||
SANDBOX_BROWSER_REGISTRY_PATH,
|
||||
}));
|
||||
vi.doMock("../../infra/json-files.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/json-files.js")>(
|
||||
"../../infra/json-files.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
writeJsonAtomic: async (
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
options?: Parameters<typeof actual.writeJsonAtomic>[2],
|
||||
) => {
|
||||
const payload = JSON.stringify(value);
|
||||
const gate = activeWriteGate;
|
||||
if (
|
||||
gate &&
|
||||
filePath.includes(gate.targetFile) &&
|
||||
payloadMentionsContainer(payload, gate.containerName)
|
||||
) {
|
||||
if (!gate.started) {
|
||||
gate.started = true;
|
||||
gate.markStarted();
|
||||
}
|
||||
await gate.waitForRelease;
|
||||
}
|
||||
await actual.writeJsonAtomic(filePath, value, options);
|
||||
},
|
||||
};
|
||||
});
|
||||
({
|
||||
readBrowserRegistry,
|
||||
readRegistry,
|
||||
@@ -64,19 +91,6 @@ function payloadMentionsContainer(payload: string, containerName: string): boole
|
||||
);
|
||||
}
|
||||
|
||||
function writeText(content: Parameters<typeof fs.writeFile>[1]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (content instanceof ArrayBuffer) {
|
||||
return Buffer.from(content).toString("utf-8");
|
||||
}
|
||||
if (ArrayBuffer.isView(content)) {
|
||||
return Buffer.from(content.buffer, content.byteOffset, content.byteLength).toString("utf-8");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function seedMalformedContainerRegistry(payload: string) {
|
||||
await fs.writeFile(SANDBOX_REGISTRY_PATH, payload, "utf-8");
|
||||
}
|
||||
@@ -115,27 +129,6 @@ function installWriteGate(
|
||||
|
||||
beforeEach(async () => {
|
||||
activeWriteGate = null;
|
||||
vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => {
|
||||
const [target, content] = args;
|
||||
if (typeof target !== "string") {
|
||||
return realFsWriteFile(...args);
|
||||
}
|
||||
|
||||
const payload = writeText(content);
|
||||
const gate = activeWriteGate;
|
||||
if (
|
||||
gate &&
|
||||
target.includes(gate.targetFile) &&
|
||||
payloadMentionsContainer(payload, gate.containerName)
|
||||
) {
|
||||
if (!gate.started) {
|
||||
gate.started = true;
|
||||
gate.markStarted();
|
||||
}
|
||||
await gate.waitForRelease;
|
||||
}
|
||||
return realFsWriteFile(...args);
|
||||
});
|
||||
await loadFreshRegistryModuleForTest();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSandboxHostPathViaExistingAncestor } from "./host-paths.js";
|
||||
import {
|
||||
getBlockedBindReason,
|
||||
validateBindMounts,
|
||||
@@ -298,7 +299,7 @@ describe("validateBindMounts", () => {
|
||||
});
|
||||
|
||||
function normalizePathForSnapshot(input: string): string {
|
||||
return input.replaceAll("\\", "/");
|
||||
return resolveSandboxHostPathViaExistingAncestor(input).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
describe("validateNetworkMode", () => {
|
||||
|
||||
@@ -114,7 +114,18 @@ export function getBlockedBindReason(bind: string): BlockedBindReason | null {
|
||||
}
|
||||
|
||||
const normalized = normalizeHostPath(sourceRaw);
|
||||
return getBlockedReasonForSourcePath(normalized, getBlockedHostPaths());
|
||||
const blockedHostPaths = getBlockedHostPaths();
|
||||
const directReason = getBlockedReasonForSourcePath(normalized, blockedHostPaths);
|
||||
if (directReason) {
|
||||
return directReason;
|
||||
}
|
||||
|
||||
const canonical = resolveSandboxHostPathViaExistingAncestor(normalized);
|
||||
if (canonical !== normalized) {
|
||||
return getBlockedReasonForSourcePath(canonical, blockedHostPaths);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getBlockedReasonForSourcePath(
|
||||
|
||||
@@ -70,6 +70,71 @@ vi.mock("./subagent-announce-delivery.runtime.js", () =>
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("./subagent-announce-delivery.js", () => ({
|
||||
deliverSubagentAnnouncement: async (params: {
|
||||
targetRequesterSessionKey: string;
|
||||
triggerMessage: string;
|
||||
requesterIsSubagent?: boolean;
|
||||
requesterOrigin?: { channel?: string; to?: string; accountId?: string; threadId?: string };
|
||||
requesterSessionOrigin?: { provider?: string; channel?: string };
|
||||
bestEffortDeliver?: boolean;
|
||||
}) => {
|
||||
const store = loadSessionStoreMock("/tmp/sessions.json") as Record<string, unknown>;
|
||||
const requesterEntry = (store?.[params.targetRequesterSessionKey] ?? {}) as
|
||||
| { sessionId?: string; origin?: { provider?: string; channel?: string } }
|
||||
| undefined;
|
||||
const sessionId = requesterEntry?.sessionId?.trim();
|
||||
const queueChannel =
|
||||
requesterEntry?.origin?.provider ??
|
||||
requesterEntry?.origin?.channel ??
|
||||
params.requesterSessionOrigin?.provider ??
|
||||
params.requesterSessionOrigin?.channel;
|
||||
|
||||
if (sessionId && queueChannel === "discord" && isEmbeddedPiRunActiveMock(sessionId)) {
|
||||
queueEmbeddedPiMessageMock(
|
||||
sessionId,
|
||||
`[Internal task completion event]\n${params.triggerMessage}`,
|
||||
);
|
||||
return { delivered: true, path: "queue" };
|
||||
}
|
||||
|
||||
await callGatewayMock({
|
||||
method: "agent",
|
||||
params: {
|
||||
sessionKey: params.targetRequesterSessionKey,
|
||||
message: params.triggerMessage,
|
||||
deliver: false,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
...(params.requesterIsSubagent
|
||||
? {}
|
||||
: {
|
||||
channel: params.requesterOrigin?.channel,
|
||||
to: params.requesterOrigin?.to,
|
||||
accountId: params.requesterOrigin?.accountId,
|
||||
threadId: params.requesterOrigin?.threadId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return { delivered: true, path: "direct" };
|
||||
},
|
||||
loadRequesterSessionEntry: (sessionKey: string) => {
|
||||
const store = loadSessionStoreMock("/tmp/sessions.json") as Record<string, unknown>;
|
||||
const entry = store?.[sessionKey];
|
||||
return { entry };
|
||||
},
|
||||
loadSessionEntryByKey: (sessionKey: string) => {
|
||||
const store = loadSessionStoreMock("/tmp/sessions.json") as Record<string, unknown>;
|
||||
return store?.[sessionKey] ?? { sessionId: sessionKey };
|
||||
},
|
||||
resolveAnnounceOrigin: (entry: { origin?: unknown } | undefined, requesterOrigin?: unknown) =>
|
||||
requesterOrigin ?? entry?.origin,
|
||||
resolveSubagentCompletionOrigin: async (params: { requesterOrigin?: unknown }) =>
|
||||
params.requesterOrigin,
|
||||
resolveSubagentAnnounceTimeoutMs: () => 10_000,
|
||||
runAnnounceDeliveryWithRetry: async <T>(params: { run: () => Promise<T> }) => await params.run(),
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-announce.registry.runtime.js", () => subagentRegistryRuntimeMock);
|
||||
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
|
||||
|
||||
|
||||
@@ -91,6 +91,83 @@ vi.mock("./subagent-announce-delivery.runtime.js", () =>
|
||||
queueEmbeddedPiMessage: () => false,
|
||||
}),
|
||||
);
|
||||
vi.mock("./subagent-announce-delivery.js", () => ({
|
||||
deliverSubagentAnnouncement: async (params: {
|
||||
targetRequesterSessionKey: string;
|
||||
triggerMessage: string;
|
||||
requesterIsSubagent?: boolean;
|
||||
requesterOrigin?: { channel?: string; to?: string; accountId?: string; threadId?: string };
|
||||
requesterSessionOrigin?: { provider?: string; channel?: string };
|
||||
bestEffortDeliver?: boolean;
|
||||
directIdempotencyKey?: string;
|
||||
internalEvents?: unknown;
|
||||
}) => {
|
||||
const buildRequest = () => ({
|
||||
method: "agent",
|
||||
expectFinal: true,
|
||||
timeoutMs,
|
||||
params: {
|
||||
sessionKey: params.targetRequesterSessionKey,
|
||||
message: params.triggerMessage,
|
||||
deliver: !params.requesterIsSubagent,
|
||||
bestEffortDeliver: params.bestEffortDeliver,
|
||||
internalEvents: params.internalEvents,
|
||||
...(params.requesterIsSubagent
|
||||
? {}
|
||||
: {
|
||||
channel: params.requesterOrigin?.channel,
|
||||
to: params.requesterOrigin?.to,
|
||||
accountId: params.requesterOrigin?.accountId,
|
||||
threadId: params.requesterOrigin?.threadId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const timeoutMs =
|
||||
typeof configOverride.agents?.defaults?.subagents?.announceTimeoutMs === "number" &&
|
||||
Number.isFinite(configOverride.agents.defaults.subagents.announceTimeoutMs)
|
||||
? Math.min(
|
||||
Math.max(1, Math.floor(configOverride.agents.defaults.subagents.announceTimeoutMs)),
|
||||
2_147_000_000,
|
||||
)
|
||||
: 90_000;
|
||||
const retryDelaysMs =
|
||||
process.env.OPENCLAW_TEST_FAST === "1" ? [8, 16, 32] : [5_000, 10_000, 20_000];
|
||||
let retryIndex = 0;
|
||||
for (;;) {
|
||||
const request = buildRequest();
|
||||
gatewayCalls.push(request);
|
||||
try {
|
||||
await callGatewayImpl(request);
|
||||
return { delivered: true, path: "direct" };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const delayMs = retryDelaysMs[retryIndex];
|
||||
if (!/gateway timeout/i.test(message) || delayMs == null) {
|
||||
return { delivered: false, path: "direct", error: message };
|
||||
}
|
||||
retryIndex += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
loadRequesterSessionEntry: (sessionKey: string) => ({
|
||||
cfg: configOverride,
|
||||
canonicalKey: sessionKey,
|
||||
entry: sessionStore[sessionKey],
|
||||
}),
|
||||
loadSessionEntryByKey: (sessionKey: string) => sessionStore[sessionKey],
|
||||
resolveAnnounceOrigin: (entry: { origin?: unknown } | undefined, requesterOrigin?: unknown) =>
|
||||
requesterOrigin ?? entry?.origin,
|
||||
resolveSubagentCompletionOrigin: async (params: { requesterOrigin?: unknown }) =>
|
||||
params.requesterOrigin,
|
||||
resolveSubagentAnnounceTimeoutMs: (cfg: typeof configOverride) => {
|
||||
const configured = cfg.agents?.defaults?.subagents?.announceTimeoutMs;
|
||||
if (typeof configured !== "number" || !Number.isFinite(configured)) {
|
||||
return 90_000;
|
||||
}
|
||||
return Math.min(Math.max(1, Math.floor(configured)), 2_147_000_000);
|
||||
},
|
||||
runAnnounceDeliveryWithRetry: async <T>(params: { run: () => Promise<T> }) => await params.run(),
|
||||
}));
|
||||
vi.mock("./subagent-announce.runtime.js", () => ({
|
||||
callGateway: createGatewayCallModuleMock().callGateway,
|
||||
loadConfig: () => configOverride,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
const sharedMocks = vi.hoisted(() => ({
|
||||
callGateway: vi.fn(async () => ({
|
||||
status: "ok",
|
||||
status: "ok" as const,
|
||||
startedAt: 111,
|
||||
endedAt: 222,
|
||||
})),
|
||||
onAgentEvent: vi.fn(() => noop),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: sharedMocks.callGateway,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/agent-events.js", () => ({
|
||||
onAgentEvent: vi.fn(() => noop),
|
||||
onAgentEvent: sharedMocks.onAgentEvent,
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./subagent-registry.mocks.shared.js";
|
||||
import {
|
||||
clearSessionStoreCacheForTest,
|
||||
drainSessionStoreLockQueuesForTest,
|
||||
} from "../config/sessions/store.js";
|
||||
import { captureEnv, withEnv } from "../test-utils/env.js";
|
||||
|
||||
const { announceSpy } = vi.hoisted(() => ({
|
||||
@@ -180,13 +184,25 @@ describe("subagent registry persistence", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await loadSubagentRegistryModules();
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
const { onAgentEvent } = await import("../infra/agent-events.js");
|
||||
vi.mocked(callGateway).mockReset();
|
||||
vi.mocked(callGateway).mockResolvedValue({
|
||||
status: "ok",
|
||||
startedAt: 111,
|
||||
endedAt: 222,
|
||||
});
|
||||
vi.mocked(onAgentEvent).mockReset();
|
||||
vi.mocked(onAgentEvent).mockReturnValue(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
announceSpy.mockClear();
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
await drainSessionStoreLockQueuesForTest();
|
||||
clearSessionStoreCacheForTest();
|
||||
if (tempStateDir) {
|
||||
await fs.rm(tempStateDir, { recursive: true, force: true });
|
||||
await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
tempStateDir = null;
|
||||
}
|
||||
envSnapshot.restore();
|
||||
@@ -245,7 +261,7 @@ describe("subagent registry persistence", () => {
|
||||
expect(run?.requesterOrigin?.accountId).toBe("acct-main");
|
||||
|
||||
// Simulate a process restart: module re-import should load persisted runs
|
||||
// and trigger the announce flow once the run resolves.
|
||||
// and preserve requester origin for non-completion-message runs.
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
initSubagentRegistry();
|
||||
releaseInitialWait?.({
|
||||
@@ -257,57 +273,42 @@ describe("subagent registry persistence", () => {
|
||||
// allow queued async wait/cleanup to execute
|
||||
await flushQueuedRegistryWork();
|
||||
|
||||
expect(announceSpy).toHaveBeenCalled();
|
||||
expect(announceSpy).not.toHaveBeenCalled();
|
||||
|
||||
type AnnounceParams = {
|
||||
childSessionKey: string;
|
||||
childRunId: string;
|
||||
requesterSessionKey: string;
|
||||
requesterOrigin?: { channel?: string; accountId?: string };
|
||||
task: string;
|
||||
cleanup: string;
|
||||
label?: string;
|
||||
};
|
||||
const first = (announceSpy.mock.calls as unknown as Array<[unknown]>)[0]?.[0] as
|
||||
| AnnounceParams
|
||||
| undefined;
|
||||
if (!first) {
|
||||
throw new Error("expected announce call");
|
||||
}
|
||||
expect(first.childSessionKey).toBe("agent:main:subagent:test");
|
||||
expect(first.requesterOrigin?.channel).toBe("whatsapp");
|
||||
expect(first.requesterOrigin?.accountId).toBe("acct-main");
|
||||
const restored = listSubagentRunsForRequester("agent:main:main")[0];
|
||||
expect(restored?.childSessionKey).toBe("agent:main:subagent:test");
|
||||
expect(restored?.requesterOrigin?.channel).toBe("whatsapp");
|
||||
expect(restored?.requesterOrigin?.accountId).toBe("acct-main");
|
||||
});
|
||||
|
||||
it("persists completed subagent timing into the child session entry", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
|
||||
const { callGateway } = await import("../gateway/call.js");
|
||||
const { persistSubagentSessionTiming } = await import("./subagent-registry-helpers.js");
|
||||
const now = Date.now();
|
||||
const startedAt = now;
|
||||
const endedAt = now + 500;
|
||||
vi.mocked(callGateway).mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
startedAt,
|
||||
endedAt,
|
||||
});
|
||||
|
||||
const storePath = await writeChildSessionEntry({
|
||||
sessionKey: "agent:main:subagent:timing",
|
||||
sessionId: "sess-timing",
|
||||
updatedAt: startedAt - 1,
|
||||
});
|
||||
registerSubagentRun({
|
||||
await persistSubagentSessionTiming({
|
||||
runId: "run-session-timing",
|
||||
childSessionKey: "agent:main:subagent:timing",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "persist timing",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
await flushQueuedRegistryWork();
|
||||
createdAt: startedAt,
|
||||
startedAt,
|
||||
sessionStartedAt: startedAt,
|
||||
accumulatedRuntimeMs: 0,
|
||||
endedAt,
|
||||
outcome: { status: "ok" },
|
||||
} as never);
|
||||
|
||||
const store = await readSessionStore(storePath);
|
||||
const persisted = store["agent:main:subagent:timing"];
|
||||
|
||||
@@ -388,15 +388,7 @@ describe("subagent registry steer restarts", () => {
|
||||
emitLifecycleEnd("run-terminal-state-new");
|
||||
|
||||
await flushAnnounce();
|
||||
expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1);
|
||||
expect(runSubagentEndedHookMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "run-terminal-state-new",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runId: "run-terminal-state-new",
|
||||
}),
|
||||
);
|
||||
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
|
||||
expect(emitSessionLifecycleEventMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:subagent:terminal-state",
|
||||
@@ -544,24 +536,7 @@ describe("subagent registry steer restarts", () => {
|
||||
expect(run?.outcome).toEqual({ status: "error", error: "manual kill" });
|
||||
expect(run?.cleanupHandled).toBe(true);
|
||||
expect(typeof run?.cleanupCompletedAt).toBe("number");
|
||||
expect(runSubagentEndedHookMock).toHaveBeenCalledWith(
|
||||
{
|
||||
targetSessionKey: childSessionKey,
|
||||
targetKind: "subagent",
|
||||
reason: "subagent-killed",
|
||||
sendFarewell: true,
|
||||
accountId: undefined,
|
||||
runId: "run-killed",
|
||||
endedAt: expect.any(Number),
|
||||
outcome: "killed",
|
||||
error: "manual kill",
|
||||
},
|
||||
{
|
||||
runId: "run-killed",
|
||||
childSessionKey,
|
||||
requesterSessionKey: MAIN_REQUESTER_SESSION_KEY,
|
||||
},
|
||||
);
|
||||
expect(runSubagentEndedHookMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a child session as inactive when only a stale older row is still unended", async () => {
|
||||
|
||||
@@ -180,17 +180,20 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).not.toContain("allow-once|allow-always|deny");
|
||||
});
|
||||
|
||||
it("keeps manual /approve instructions for telegram runtime prompts", () => {
|
||||
it("tells native approval channels not to duplicate plain chat /approve instructions", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/openclaw",
|
||||
runtimeInfo: { channel: "telegram", capabilities: ["inlineButtons"] },
|
||||
});
|
||||
|
||||
expect(prompt).toContain(
|
||||
"When exec returns approval-pending, include the concrete /approve command from tool output",
|
||||
"When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.",
|
||||
);
|
||||
expect(prompt).not.toContain(
|
||||
"When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear",
|
||||
"When exec returns approval-pending, include the concrete /approve command from tool output",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -654,7 +657,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("channel=telegram");
|
||||
expect(prompt.toLowerCase()).toContain("capabilities=inlinebuttons");
|
||||
expect(prompt).toContain("capabilities=inlinebuttons");
|
||||
});
|
||||
|
||||
it("includes agent id in runtime when provided", () => {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type, type TSchema } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function createWrappedTestTool(params: {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}): AgentTool<TSchema, unknown> {
|
||||
return {
|
||||
name: params.name,
|
||||
label: params.label,
|
||||
description: params.description,
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
execute: async (): Promise<AgentToolResult<unknown>> => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
}),
|
||||
} as AgentTool<TSchema, unknown>;
|
||||
}
|
||||
|
||||
describe("resolveEffectiveToolInventory integration", () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("preserves plugin and channel classification through the real tool wrapper pipeline", async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("./tools-effective-inventory.js");
|
||||
vi.doUnmock("./pi-tools.js");
|
||||
vi.doUnmock("./agent-scope.js");
|
||||
vi.doUnmock("./channel-tools.js");
|
||||
vi.doUnmock("../plugins/registry-empty.js");
|
||||
vi.doUnmock("../plugins/runtime.js");
|
||||
vi.doUnmock("../plugins/tools.js");
|
||||
vi.doUnmock("../test-utils/channel-plugins.js");
|
||||
|
||||
const { createEmptyPluginRegistry } = await import("../plugins/registry-empty.js");
|
||||
const { resetPluginRuntimeStateForTest, setActivePluginRegistry } =
|
||||
await import("../plugins/runtime.js");
|
||||
const { createChannelTestPluginBase } = await import("../test-utils/channel-plugins.js");
|
||||
const { resolveEffectiveToolInventory } = await import("./tools-effective-inventory.js");
|
||||
|
||||
const pluginTool = createWrappedTestTool({
|
||||
name: "docs_lookup",
|
||||
label: "Docs Lookup",
|
||||
description: "Search docs",
|
||||
});
|
||||
const channelTool = createWrappedTestTool({
|
||||
name: "channel_action",
|
||||
label: "Channel Action",
|
||||
description: "Act in channel",
|
||||
});
|
||||
|
||||
const channelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
}),
|
||||
agentTools: [channelTool],
|
||||
};
|
||||
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.tools.push({
|
||||
pluginId: "docs",
|
||||
pluginName: "Docs",
|
||||
factory: () => pluginTool,
|
||||
names: ["docs_lookup"],
|
||||
optional: false,
|
||||
source: "test",
|
||||
});
|
||||
registry.channels.push({
|
||||
pluginId: "telegram",
|
||||
pluginName: "Telegram",
|
||||
plugin: channelPlugin,
|
||||
source: "test",
|
||||
});
|
||||
registry.channelSetups.push({
|
||||
pluginId: "telegram",
|
||||
pluginName: "Telegram",
|
||||
plugin: channelPlugin,
|
||||
source: "test",
|
||||
enabled: true,
|
||||
});
|
||||
setActivePluginRegistry(registry, "tools-effective-integration");
|
||||
|
||||
const result = resolveEffectiveToolInventory({ cfg: { plugins: { enabled: true } } });
|
||||
|
||||
const pluginGroup = result.groups.find((group) => group.source === "plugin");
|
||||
const channelGroup = result.groups.find((group) => group.source === "channel");
|
||||
const coreGroup = result.groups.find((group) => group.source === "core");
|
||||
|
||||
expect(pluginGroup?.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "docs_lookup",
|
||||
source: "plugin",
|
||||
pluginId: "docs",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(channelGroup?.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "channel_action",
|
||||
source: "channel",
|
||||
channelId: "telegram",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(coreGroup?.tools.some((tool) => tool.id === "docs_lookup")).toBe(false);
|
||||
expect(coreGroup?.tools.some((tool) => tool.id === "channel_action")).toBe(false);
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ function parseGatewayConfigMutationRaw(
|
||||
return parsedRes.parsed;
|
||||
}
|
||||
|
||||
function getValueAtPath(config: Record<string, unknown>, path: string): unknown {
|
||||
function getValueAtCanonicalPath(config: Record<string, unknown>, path: string): unknown {
|
||||
let current: unknown = config;
|
||||
for (const part of path.split(".")) {
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
||||
@@ -74,6 +74,17 @@ function getValueAtPath(config: Record<string, unknown>, path: string): unknown
|
||||
return current;
|
||||
}
|
||||
|
||||
function getValueAtPath(config: Record<string, unknown>, path: string): unknown {
|
||||
const direct = getValueAtCanonicalPath(config, path);
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
if (!path.startsWith("tools.exec.")) {
|
||||
return undefined;
|
||||
}
|
||||
return getValueAtCanonicalPath(config, path.replace(/^tools\.exec\./, "tools.bash."));
|
||||
}
|
||||
|
||||
function assertGatewayConfigMutationAllowed(params: {
|
||||
action: "config.apply" | "config.patch";
|
||||
currentConfig: Record<string, unknown>;
|
||||
|
||||
@@ -536,7 +536,12 @@ describe("image tool implicit imageModel config", () => {
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"GEMINI_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MODELSTUDIO_API_KEY",
|
||||
"QWEN_API_KEY",
|
||||
"DASHSCOPE_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"Z_AI_API_KEY",
|
||||
// Avoid implicit Copilot provider discovery hitting the network in tests.
|
||||
@@ -572,9 +577,14 @@ describe("image tool implicit imageModel config", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } },
|
||||
};
|
||||
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual(
|
||||
createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"),
|
||||
);
|
||||
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
|
||||
...createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"),
|
||||
fallbacks: [
|
||||
"openai/gpt-5.4-mini",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"minimax-portal/MiniMax-VL-01",
|
||||
],
|
||||
});
|
||||
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,10 @@ export function resolvePdfModelConfigForTool(params: {
|
||||
providerId: primary.provider,
|
||||
capability: "image",
|
||||
});
|
||||
const primarySupportsNativePdf = providerSupportsNativePdfDocument({
|
||||
cfg: params.cfg,
|
||||
providerId: primary.provider,
|
||||
});
|
||||
const nativePdfCandidates = resolveAutoMediaKeyProviders({
|
||||
cfg: params.cfg,
|
||||
capability: "image",
|
||||
@@ -131,9 +135,9 @@ export function resolvePdfModelConfigForTool(params: {
|
||||
})
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
if (primary.provider === "google" && googleOk && providerVision) {
|
||||
if (primary.provider === "google" && googleOk && providerVision && primarySupportsNativePdf) {
|
||||
preferred = providerVision;
|
||||
} else if (providerOk && (providerVision || providerDefault)) {
|
||||
} else if (providerOk && primarySupportsNativePdf && (providerVision || providerDefault)) {
|
||||
preferred = providerVision ?? `${primary.provider}/${providerDefault}`;
|
||||
} else {
|
||||
preferred = nativePdfCandidates[0] ?? genericImageCandidates[0] ?? null;
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../subagent-spawn.js", () => ({
|
||||
vi.mock("../acp-spawn.js", () => ({
|
||||
ACP_SPAWN_MODES: ["run", "session"],
|
||||
ACP_SPAWN_STREAM_TARGETS: ["parent"],
|
||||
isSpawnAcpAcceptedResult: (result: { status?: string }) => result?.status === "accepted",
|
||||
spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args),
|
||||
}));
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("web_fetch SSRF protection", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("FIRECRAWL_API_KEY", "");
|
||||
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
|
||||
resolvePinnedHostname(hostname, lookupMock),
|
||||
);
|
||||
@@ -81,6 +82,7 @@ describe("web_fetch SSRF protection", () => {
|
||||
afterEach(() => {
|
||||
global.fetch = priorFetch;
|
||||
lookupMock.mockClear();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -389,13 +389,13 @@ describe("web_search perplexity Search API", () => {
|
||||
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
|
||||
const mockFetch = installPerplexitySearchApiFetch();
|
||||
const tool = createPerplexitySearchTool();
|
||||
const result = await tool?.execute?.("call-1", { query: "test" });
|
||||
const result = await tool?.execute?.("call-1", { query: "annotations-test" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/search");
|
||||
expect((mockFetch.mock.calls[0]?.[1] as RequestInit | undefined)?.method).toBe("POST");
|
||||
const body = parseFirstRequestBody(mockFetch);
|
||||
expect(body.query).toBe("test");
|
||||
expect(body.query).toBe("annotations-test");
|
||||
expect(result?.details).toMatchObject({
|
||||
provider: "perplexity",
|
||||
externalContent: { untrusted: true, source: "web_search", wrapped: true },
|
||||
@@ -534,7 +534,7 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
|
||||
const mockFetch = installPerplexityChatFetch();
|
||||
const tool = createPerplexitySearchTool();
|
||||
const result = await tool?.execute?.("call-1", { query: "test" });
|
||||
const result = await tool?.execute?.("call-1", { query: "annotations-test" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions");
|
||||
@@ -573,7 +573,7 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
|
||||
it("falls back to message annotations when top-level citations are missing", async () => {
|
||||
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
|
||||
const mockFetch = installPerplexityChatFetch({
|
||||
installPerplexityChatFetch({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
@@ -599,7 +599,6 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
const tool = createPerplexitySearchTool();
|
||||
const result = await tool?.execute?.("call-1", { query: "annotations-fallback-test" });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(result?.details).toMatchObject({
|
||||
provider: "perplexity",
|
||||
citations: ["https://example.com/a", "https://example.com/b"],
|
||||
|
||||
@@ -123,6 +123,10 @@ function defaultFirecrawlApiKey() {
|
||||
return "firecrawl-test"; // pragma: allowlist secret
|
||||
}
|
||||
|
||||
function withoutAmbientFirecrawlEnv() {
|
||||
vi.stubEnv("FIRECRAWL_API_KEY", "");
|
||||
}
|
||||
|
||||
async function executeFetch(
|
||||
tool: ReturnType<typeof createFetchTool>,
|
||||
params: { url: string; extractMode?: "text" | "markdown" },
|
||||
@@ -146,6 +150,7 @@ describe("web_fetch extraction fallbacks", () => {
|
||||
const priorFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
withoutAmbientFirecrawlEnv();
|
||||
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||
const addresses = ["93.184.216.34", "93.184.216.35"];
|
||||
@@ -349,7 +354,7 @@ describe("web_fetch extraction fallbacks", () => {
|
||||
expect(firecrawlCall).toBeTruthy();
|
||||
const requestInit = firecrawlCall?.[1] as (RequestInit & { dispatcher?: unknown }) | undefined;
|
||||
expect(requestInit?.dispatcher).toBeDefined();
|
||||
expect(requestInit?.dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(requestInit?.dispatcher).toHaveProperty("dispatch");
|
||||
});
|
||||
|
||||
it("throws when readability is disabled and firecrawl is unavailable", async () => {
|
||||
@@ -526,7 +531,7 @@ describe("web_fetch extraction fallbacks", () => {
|
||||
url: "https://example.com/firecrawl-error",
|
||||
});
|
||||
|
||||
expect(message).toContain("Firecrawl fetch failed (403):");
|
||||
expect(message).toContain("Firecrawl API error (403):");
|
||||
expect(message).toMatch(/<<<EXTERNAL_UNTRUSTED_CONTENT id="[a-f0-9]{16}">>>/);
|
||||
expect(message).toContain("blocked");
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
realtimeVoiceProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
videoGenerationProviders: [],
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
|
||||
Reference in New Issue
Block a user