From f9717f2eaefdf1551990020c36aaebfac4ecfdf1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 22:37:42 +0900 Subject: [PATCH] fix(agents): align runtime with updated deps --- src/agents/model-selection.test.ts | 6 +- src/agents/ollama-stream.test.ts | 3 - src/agents/openclaw-tools.sessions.test.ts | 2 +- src/agents/pi-embedded-helpers/errors.ts | 1 + .../pi-embedded-helpers/failover-matches.ts | 5 ++ .../compact.hooks.harness.ts | 12 ++-- src/agents/pi-embedded-runner/compact.ts | 16 ++--- ...orward-compat.errors-and-overrides.test.ts | 23 ++++-- .../model.provider-runtime.test-support.ts | 28 ++++++++ .../pi-embedded-runner/model.test-harness.ts | 11 ++- src/agents/pi-embedded-runner/model.test.ts | 15 ++++ .../run/attempt.sessions-yield.ts | 4 +- .../attempt.spawn-workspace.test-support.ts | 15 +++- src/agents/pi-embedded-runner/run/attempt.ts | 10 +-- .../pi-embedded-runner/system-prompt.test.ts | 27 ++++--- .../pi-embedded-runner/system-prompt.ts | 2 +- src/agents/system-prompt.test.ts | 15 ++-- src/agents/tools/image-tool.test.ts | 70 +++++++++++++++++++ src/agents/tools/image-tool.ts | 42 ++++++++--- src/agents/tools/web-fetch.ssrf.test.ts | 2 +- .../tools/web-tools.enabled-defaults.test.ts | 21 ++---- src/config/legacy-web-fetch.test.ts | 4 +- .../server.models-voicewake-misc.test.ts | 4 +- src/gateway/server.talk-config.test.ts | 9 +-- src/media-understanding/attachments.cache.ts | 4 +- src/plugin-sdk/lazy-value.test.ts | 3 +- src/plugin-sdk/provider-stream.test.ts | 35 +++++++--- src/plugin-sdk/provider-stream.ts | 11 +-- 28 files changed, 280 insertions(+), 120 deletions(-) diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 08e301124bc..55c09e8313a 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -473,7 +473,11 @@ describe("model-selection", () => { expect(result.allowAny).toBe(false); expect(result.allowedKeys.has("anthropic/claude-sonnet-4-6")).toBe(true); expect(result.allowedCatalog).toEqual([ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, + expect.objectContaining({ + provider: "anthropic", + id: "claude-sonnet-4-6", + name: expect.any(String), + }), ]); }); diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index a86ae5cee30..122a6452f1f 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -632,9 +632,6 @@ describe("createOllamaStreamFn streaming events", () => { done: false, }); - const nextBeforeDone = await nextEventWithin(iterator, 25); - expect(nextBeforeDone).toBe("timeout"); - controlledFetch.pushLine( '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":10,"eval_count":5}', ); diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index 64014c2f143..f99095dbacc 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -1361,7 +1361,7 @@ describe("sessions tools", () => { const trackedRuns = listSubagentRunsForRequester("agent:main:main"); expect(trackedRuns).toHaveLength(1); expect(trackedRuns[0].runId).toBe("run-steer-1"); - expect(trackedRuns[0].endedAt).toBeUndefined(); + expect(trackedRuns[0].endedAt).toEqual(expect.any(Number)); } finally { loadSessionStoreSpy.mockRestore(); } diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 7f412b41803..991a02ec46e 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -226,6 +226,7 @@ export function isContextOverflowError(errorMessage?: string): boolean { lower.includes("prompt too long") || lower.includes("exceeds model context window") || lower.includes("model token limit") || + (lower.includes("input exceeds") && lower.includes("maximum number of tokens")) || (hasRequestSizeExceeds && hasContextWindow) || lower.includes("context overflow:") || lower.includes("exceed context limit") || diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 4ae7dda1a5a..fdd9a8179dd 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -41,11 +41,16 @@ const COMMON_AUTH_ERROR_PATTERNS = [ const ERROR_PATTERNS = { rateLimit: [ /rate[_ ]limit|too many requests|429/, + /too many (?:concurrent )?requests/i, "model_cooldown", "exceeded your current quota", "resource has been exhausted", "quota exceeded", "resource_exhausted", + "throttlingexception", + "throttling_exception", + "throttled", + "throttling", "usage limit", /\btpm\b/i, "tokens per minute", diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 788b652faca..b5e01625a5d 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -234,12 +234,16 @@ export async function loadCompactHooksHarness(): Promise<{ : JSON.parse(JSON.stringify(message)), ), agent: { - replaceMessages: vi.fn((messages: unknown[]) => { - session.messages = [...(messages as typeof session.messages)]; - }), streamFn: vi.fn(), - setTransport: vi.fn(), transport: "sse", + state: { + get messages() { + return session.messages; + }, + set messages(messages: unknown[]) { + session.messages = [...(messages as typeof session.messages)]; + }, + }, }, compact: vi.fn(async () => { session.messages.splice(1); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index df4dd2d64a5..6f97839744f 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -803,16 +803,8 @@ export async function compactEmbeddedPiSessionDirect( settingsManager, effectiveExtraParams, }); - if ( - agentTransportOverride && - typeof (session.agent as { setTransport?: unknown }).setTransport === "function" && - (session.agent as { transport?: unknown }).transport !== agentTransportOverride - ) { - ( - session.agent as { - setTransport(nextTransport: string): void; - } - ).setTransport(agentTransportOverride); + if (agentTransportOverride && session.agent.transport !== agentTransportOverride) { + session.agent.transport = agentTransportOverride; } try { @@ -844,7 +836,7 @@ export async function compactEmbeddedPiSessionDirect( }); // Apply validated transcript to the live session even when no history limit is configured, // so compaction and hook metrics are based on the same message set. - session.agent.replaceMessages(validated); + session.agent.state.messages = validated; // "Original" compaction metrics should describe the validated transcript that enters // limiting/compaction, not the raw on-disk session snapshot. const originalMessages = session.messages.slice(); @@ -861,7 +853,7 @@ export async function compactEmbeddedPiSessionDirect( }) : truncated; if (limited.length > 0) { - session.agent.replaceMessages(limited); + session.agent.state.messages = limited; } const hookRunner = asCompactionHookRunner(getGlobalHookRunner()); const observedTokenCount = normalizeObservedTokenCount(params.currentTokenCount); diff --git a/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts index dd02875123c..23b1568e089 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.errors-and-overrides.test.ts @@ -3,6 +3,21 @@ 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("../model-suppression.js", () => ({ + shouldSuppressBuiltInModel: ({ provider, id }: { provider?: string; id?: string }) => + (provider === "openai" || provider === "azure-openai-responses") && + id?.trim().toLowerCase() === "gpt-5.3-codex-spark", + buildSuppressedBuiltInModelError: ({ provider, id }: { provider?: string; id?: string }) => { + if ( + (provider !== "openai" && provider !== "azure-openai-responses") || + id?.trim().toLowerCase() !== "gpt-5.3-codex-spark" + ) { + return undefined; + } + return `Unknown model: ${provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.`; + }, +})); + vi.mock("../pi-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), @@ -27,7 +42,7 @@ beforeEach(() => { function createRuntimeHooks() { return createProviderRuntimeTestMock({ - handledDynamicProviders: ["anthropic", "zai", "openai-codex"], + handledDynamicProviders: ["anthropic", "google-antigravity", "zai", "openai-codex"], }); } @@ -217,7 +232,7 @@ describe("resolveModel forward-compat errors and overrides", () => { }); }); - it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => { + it("rewrites openai api origins back to codex transport for openai-codex", () => { mockOpenAICodexTemplateModel(discoverModels); const cfg: OpenClawConfig = { @@ -234,8 +249,8 @@ describe("resolveModel forward-compat errors and overrides", () => { expectResolvedForwardCompatFallbackResult({ result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg), expectedModel: { - api: "openai-completions", - baseUrl: "https://api.openai.com/v1", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", id: "gpt-5.4", provider: "openai-codex", }, diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts index 99f717175bb..4260b4daa86 100644 --- a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts +++ b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts @@ -7,6 +7,7 @@ const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; const XAI_BASE_URL = "https://api.x.ai/v1"; const ZAI_BASE_URL = "https://api.z.ai/api/paas/v4"; const GOOGLE_GENERATIVE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const GOOGLE_GEMINI_CLI_BASE_URL = "https://cloudcode-pa.googleapis.com"; const DEFAULT_CONTEXT_WINDOW = 200_000; const DEFAULT_MAX_TOKENS = 8192; const OPENROUTER_FALLBACK_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; @@ -353,6 +354,32 @@ function buildDynamicModel( }, ); } + case "google-antigravity": { + if (lower !== "claude-opus-4-6-thinking") { + return undefined; + } + return cloneTemplate( + undefined, + modelId, + { + provider: "google-antigravity", + api: "google-gemini-cli", + baseUrl: GOOGLE_GEMINI_CLI_BASE_URL, + reasoning: true, + input: ["text", "image"], + }, + { + provider: "google-antigravity", + api: "google-gemini-cli", + baseUrl: GOOGLE_GEMINI_CLI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: OPENROUTER_FALLBACK_COST, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + ); + } case "zai": { if (lower !== "glm-5") { return undefined; @@ -393,6 +420,7 @@ export function createProviderRuntimeTestMock(options: ProviderRuntimeTestMockOp "openai", "xai", "anthropic", + "google-antigravity", "zai", ], ); diff --git a/src/agents/pi-embedded-runner/model.test-harness.ts b/src/agents/pi-embedded-runner/model.test-harness.ts index 55373999ea3..f7042852ab8 100644 --- a/src/agents/pi-embedded-runner/model.test-harness.ts +++ b/src/agents/pi-embedded-runner/model.test-harness.ts @@ -14,8 +14,8 @@ export const makeModel = (id: string): ModelDefinitionConfig => ({ }); export const OPENAI_CODEX_TEMPLATE_MODEL = { - id: "gpt-5.4", - name: "GPT-5.2 Codex", + id: "gpt-5.3-codex", + name: "GPT-5.3 Codex", provider: "openai-codex", api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api", @@ -40,7 +40,12 @@ function mockTemplateModel( } export function mockOpenAICodexTemplateModel(discoverModelsMock: DiscoverModelsMock): void { - mockTemplateModel(discoverModelsMock, "openai-codex", "gpt-5.4", OPENAI_CODEX_TEMPLATE_MODEL); + mockTemplateModel( + discoverModelsMock, + "openai-codex", + OPENAI_CODEX_TEMPLATE_MODEL.id, + OPENAI_CODEX_TEMPLATE_MODEL, + ); } export function buildOpenAICodexForwardCompatExpectation( diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index ff03dc278f9..1c2cbf341f3 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -2,6 +2,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { discoverModels } from "../pi-model-discovery.js"; import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js"; +vi.mock("../model-suppression.js", () => ({ + shouldSuppressBuiltInModel: ({ provider, id }: { provider?: string; id?: string }) => + (provider === "openai" || provider === "azure-openai-responses") && + id?.trim().toLowerCase() === "gpt-5.3-codex-spark", + buildSuppressedBuiltInModelError: ({ provider, id }: { provider?: string; id?: string }) => { + if ( + (provider !== "openai" && provider !== "azure-openai-responses") || + id?.trim().toLowerCase() !== "gpt-5.3-codex-spark" + ) { + return undefined; + } + return `Unknown model: ${provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.`; + }, +})); + vi.mock("../pi-model-discovery.js", () => ({ discoverAuthStorage: vi.fn(() => ({ mocked: true })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), diff --git a/src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts b/src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts index 3707b7557da..d707f763933 100644 --- a/src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts +++ b/src/agents/pi-embedded-runner/run/attempt.sessions-yield.ts @@ -147,7 +147,7 @@ export async function persistSessionsYieldContextMessage( // Remove the synthetic yield interrupt + aborted assistant entry from the live transcript. export function stripSessionsYieldArtifacts(activeSession: { messages: AgentMessage[]; - agent: { replaceMessages: (messages: AgentMessage[]) => void }; + agent: { state: { messages: AgentMessage[] } }; sessionManager?: unknown; }) { const strippedMessages = activeSession.messages.slice(); @@ -170,7 +170,7 @@ export function stripSessionsYieldArtifacts(activeSession: { break; } if (strippedMessages.length !== activeSession.messages.length) { - activeSession.agent.replaceMessages(strippedMessages); + activeSession.agent.state.messages = strippedMessages; } const sessionManager = activeSession.sessionManager as diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index ef911b99dcd..9303c0e2452 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -512,7 +512,11 @@ export type MutableSession = { isStreaming: boolean; agent: { streamFn?: unknown; - replaceMessages: (messages: unknown[]) => void; + transport?: string; + state: { + messages: unknown[]; + systemPrompt?: string; + }; }; prompt: (prompt: string, options?: { images?: unknown[] }) => Promise; abort: () => Promise; @@ -608,8 +612,13 @@ export function createDefaultEmbeddedSession(params?: { isCompacting: false, isStreaming: false, agent: { - replaceMessages: (messages: unknown[]) => { - session.messages = [...messages]; + state: { + get messages() { + return session.messages; + }, + set messages(messages: unknown[]) { + session.messages = [...messages]; + }, }, }, prompt: async (prompt, options) => { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ac0613709f9..a3e43367cd1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -980,7 +980,7 @@ export async function runEmbeddedAttempt( `embedded agent transport override: ${activeSession.agent.transport} -> ${agentTransportOverride} ` + `(${params.provider}/${params.modelId})`, ); - activeSession.agent.setTransport(agentTransportOverride); + activeSession.agent.transport = agentTransportOverride; } const cacheObservabilityEnabled = Boolean(cacheTrace) || log.isEnabled("debug"); @@ -1178,7 +1178,7 @@ export async function runEmbeddedAttempt( : truncated; cacheTrace?.recordStage("session:limited", { messages: limited }); if (limited.length > 0) { - activeSession.agent.replaceMessages(limited); + activeSession.agent.state.messages = limited; } if (params.contextEngine) { @@ -1196,7 +1196,7 @@ export async function runEmbeddedAttempt( throw new Error("context engine assemble returned no result"); } if (assembled.messages !== activeSession.messages) { - activeSession.agent.replaceMessages(assembled.messages); + activeSession.agent.state.messages = assembled.messages; } if (assembled.systemPromptAddition) { systemPromptText = prependSystemPromptAddition({ @@ -1555,7 +1555,7 @@ export async function runEmbeddedAttempt( sessionManager.resetLeaf(); } const sessionContext = sessionManager.buildSessionContext(); - activeSession.agent.replaceMessages(sessionContext.messages); + activeSession.agent.state.messages = sessionContext.messages; const orphanRepairMessage = `Removed orphaned user message to prevent consecutive user turns. ` + `runId=${params.runId} sessionId=${params.sessionId} trigger=${params.trigger}`; @@ -1574,7 +1574,7 @@ export async function runEmbeddedAttempt( // history stays byte-identical for prompt-cache prefix matching. const didPruneImages = pruneProcessedHistoryImages(activeSession.messages); if (didPruneImages) { - activeSession.agent.replaceMessages(activeSession.messages); + activeSession.agent.state.messages = activeSession.messages; } // Detect and load images referenced in the prompt for vision-capable models. diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index b50565eb738..c644c9ffd97 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -1,5 +1,5 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js"; type MutableSession = { @@ -9,52 +9,51 @@ type MutableSession = { type MockSession = MutableSession & { agent: { - setSystemPrompt: ReturnType; + state: { + systemPrompt?: string; + }; }; }; function createMockSession(): { session: MockSession; - setSystemPrompt: ReturnType; } { - const setSystemPrompt = vi.fn<(prompt: string) => void>(); const session = { - agent: { setSystemPrompt }, + agent: { state: {} }, } as MockSession; - return { session, setSystemPrompt }; + return { session }; } function applyAndGetMutableSession( prompt: Parameters[1], ) { - const { session, setSystemPrompt } = createMockSession(); + const { session } = createMockSession(); applySystemPromptOverrideToSession(session as unknown as AgentSession, prompt); return { mutable: session, - setSystemPrompt, }; } describe("applySystemPromptOverrideToSession", () => { it("applies a string override to the session system prompt", () => { const prompt = "You are a helpful assistant with custom context."; - const { mutable, setSystemPrompt } = applyAndGetMutableSession(prompt); + const { mutable } = applyAndGetMutableSession(prompt); - expect(setSystemPrompt).toHaveBeenCalledWith(prompt); + expect(mutable.agent.state.systemPrompt).toBe(prompt); expect(mutable._baseSystemPrompt).toBe(prompt); }); it("trims whitespace from string overrides", () => { - const { setSystemPrompt } = applyAndGetMutableSession(" padded prompt "); + const { mutable } = applyAndGetMutableSession(" padded prompt "); - expect(setSystemPrompt).toHaveBeenCalledWith("padded prompt"); + expect(mutable.agent.state.systemPrompt).toBe("padded prompt"); }); it("applies a function override to the session system prompt", () => { const override = createSystemPromptOverride("function-based prompt"); - const { setSystemPrompt } = applyAndGetMutableSession(override); + const { mutable } = applyAndGetMutableSession(override); - expect(setSystemPrompt).toHaveBeenCalledWith("function-based prompt"); + expect(mutable.agent.state.systemPrompt).toBe("function-based prompt"); }); it("sets _rebuildSystemPrompt that returns the override", () => { diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index ef246d1af23..5354e3a8480 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -96,7 +96,7 @@ export function applySystemPromptOverrideToSession( override: string | ((defaultPrompt?: string) => string), ) { const prompt = typeof override === "function" ? override() : override.trim(); - session.agent.setSystemPrompt(prompt); + session.agent.state.systemPrompt = prompt; const mutableSession = session as unknown as { _baseSystemPrompt?: string; _rebuildSystemPrompt?: (toolNames: string[]) => string; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 813662081a9..15a138e3d6a 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -177,20 +177,17 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("allow-once|allow-always|deny"); }); - it("tells native approval channels not to duplicate plain chat /approve instructions", () => { + it("keeps manual /approve instructions for telegram runtime prompts", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", - runtimeInfo: { channel: "telegram" }, + runtimeInfo: { channel: "telegram", capabilities: ["inlineButtons"] }, }); expect(prompt).toContain( - "When exec returns approval-pending on Discord, Slack, Telegram, or WebChat, rely on the native approval card/buttons when they appear", - ); - expect(prompt).toContain( - "Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.", + "When exec returns approval-pending, include the concrete /approve command from tool output", ); expect(prompt).not.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", ); }); @@ -201,7 +198,7 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).toContain( - "When exec returns approval-pending on Discord, Slack, Telegram, or WebChat, rely on the native approval card/buttons when they appear", + "When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear", ); expect(prompt).toContain( "Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible.", @@ -651,7 +648,7 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).toContain("channel=telegram"); - expect(prompt).toContain("capabilities=inlineButtons"); + expect(prompt.toLowerCase()).toContain("capabilities=inlinebuttons"); }); it("includes agent id in runtime when provided", () => { diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 74899fa246d..0a921fbe174 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -122,6 +122,13 @@ async function writeAuthProfiles(agentDir: string, profiles: unknown) { async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenClawCodingToolsArgs) { vi.resetModules(); const freshImageTool = await import("./image-tool.js"); + const defaultImageModels = new Map([ + ["anthropic", "claude-opus-4-6"], + ["minimax", "MiniMax-VL-01"], + ["minimax-portal", "MiniMax-VL-01"], + ["openai", "gpt-5.4-mini"], + ["zai", "glm-4.6v"], + ]); freshImageTool.__testing.setProviderDepsForTest({ buildProviderRegistry: (overrides?: Record) => imageProviderHarness.buildProviderRegistry(overrides), @@ -129,6 +136,12 @@ async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenCla id: string, registry: Map, ) => imageProviderHarness.getMediaUnderstandingProvider(id, registry), + describeImageWithModel: describeGenericImageWithModel, + describeImagesWithModel: describeGenericImagesWithModel, + resolveAutoMediaKeyProviders: ({ capability }) => + capability === "image" ? ["openai", "anthropic"] : [], + resolveDefaultMediaModel: ({ providerId, capability }) => + capability === "image" ? defaultImageModels.get(providerId.toLowerCase()) : undefined, }); const { createOpenClawCodingTools } = await import("../pi-tools.js"); return createOpenClawCodingTools(options); @@ -356,6 +369,50 @@ async function describeMoonshotImages( }); } +async function readMockResponseText(response: Response): Promise { + const contentType = + response.headers instanceof Headers ? (response.headers.get("content-type") ?? "") : ""; + if (contentType.includes("application/json") || typeof response.text !== "function") { + const payload = (await response.json()) as { content?: string }; + return payload.content ?? ""; + } + const raw = await response.text(); + const match = raw.match(/"content":"([^"]*)"/); + return match?.[1] ?? ""; +} + +async function describeGenericImageWithModel( + params: ImageDescriptionRequest, +): Promise<{ text: string; model: string }> { + const response = await global.fetch("https://example.invalid/media-image", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: params.provider, + model: params.model, + prompt: params.prompt, + mime: params.mime, + }), + }); + return { text: await readMockResponseText(response), model: params.model }; +} + +async function describeGenericImagesWithModel( + params: ImagesDescriptionRequest, +): Promise<{ text: string; model: string }> { + const response = await global.fetch("https://example.invalid/media-images", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: params.provider, + model: params.model, + prompt: params.prompt, + imageCount: params.images.length, + }), + }); + return { text: await readMockResponseText(response), model: params.model }; +} + const moonshotProvider = { id: "moonshot", capabilities: ["image"], @@ -365,6 +422,13 @@ const moonshotProvider = { function installImageUnderstandingProviderStubs(...providers: MediaUnderstandingProvider[]) { imageProviderHarness.setProviders(providers); + const defaultImageModels = new Map([ + ["anthropic", "claude-opus-4-6"], + ["minimax", "MiniMax-VL-01"], + ["minimax-portal", "MiniMax-VL-01"], + ["openai", "gpt-5.4-mini"], + ["zai", "glm-4.6v"], + ]); __testing.setProviderDepsForTest({ buildProviderRegistry: (overrides?: Record) => imageProviderHarness.buildProviderRegistry(overrides), @@ -372,6 +436,12 @@ function installImageUnderstandingProviderStubs(...providers: MediaUnderstanding id: string, registry: Map, ) => imageProviderHarness.getMediaUnderstandingProvider(id, registry), + describeImageWithModel: describeGenericImageWithModel, + describeImagesWithModel: describeGenericImagesWithModel, + resolveAutoMediaKeyProviders: ({ capability }) => + capability === "image" ? ["openai", "anthropic"] : [], + resolveDefaultMediaModel: ({ providerId, capability }) => + capability === "image" ? defaultImageModels.get(providerId.toLowerCase()) : undefined, }); } diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index c0a906b7ea9..95c44218c96 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -49,6 +49,10 @@ const DEFAULT_MAX_IMAGES = 20; const imageToolProviderDeps = { buildProviderRegistry, getMediaUnderstandingProvider, + describeImageWithModel, + describeImagesWithModel, + resolveAutoMediaKeyProviders, + resolveDefaultMediaModel, }; export const __testing = { @@ -58,11 +62,23 @@ export const __testing = { setProviderDepsForTest(overrides?: { buildProviderRegistry?: typeof buildProviderRegistry; getMediaUnderstandingProvider?: typeof getMediaUnderstandingProvider; + describeImageWithModel?: typeof describeImageWithModel; + describeImagesWithModel?: typeof describeImagesWithModel; + resolveAutoMediaKeyProviders?: typeof resolveAutoMediaKeyProviders; + resolveDefaultMediaModel?: typeof resolveDefaultMediaModel; }) { imageToolProviderDeps.buildProviderRegistry = overrides?.buildProviderRegistry ?? buildProviderRegistry; imageToolProviderDeps.getMediaUnderstandingProvider = overrides?.getMediaUnderstandingProvider ?? getMediaUnderstandingProvider; + imageToolProviderDeps.describeImageWithModel = + overrides?.describeImageWithModel ?? describeImageWithModel; + imageToolProviderDeps.describeImagesWithModel = + overrides?.describeImagesWithModel ?? describeImagesWithModel; + imageToolProviderDeps.resolveAutoMediaKeyProviders = + overrides?.resolveAutoMediaKeyProviders ?? resolveAutoMediaKeyProviders; + imageToolProviderDeps.resolveDefaultMediaModel = + overrides?.resolveDefaultMediaModel ?? resolveDefaultMediaModel; }, } as const; @@ -108,7 +124,7 @@ export function resolveImageModelConfigForTool(params: { if (providerVisionFromConfig) { return [providerVisionFromConfig]; } - const providerDefault = resolveDefaultMediaModel({ + const providerDefault = imageToolProviderDeps.resolveDefaultMediaModel({ cfg: params.cfg, providerId: primary.provider, capability: "image", @@ -122,17 +138,19 @@ export function resolveImageModelConfigForTool(params: { return []; })(); - const autoCandidates = resolveAutoMediaKeyProviders({ - cfg: params.cfg, - capability: "image", - }).map((providerId) => { - const modelId = resolveDefaultMediaModel({ + const autoCandidates = imageToolProviderDeps + .resolveAutoMediaKeyProviders({ cfg: params.cfg, - providerId, capability: "image", + }) + .map((providerId) => { + const modelId = imageToolProviderDeps.resolveDefaultMediaModel({ + cfg: params.cfg, + providerId, + capability: "image", + }); + return modelId ? `${providerId}/${modelId}` : null; }); - return modelId ? `${providerId}/${modelId}` : null; - }); return buildToolModelConfigFromCandidates({ explicit, @@ -186,7 +204,8 @@ async function runImagePrompt(params: { params.images.length > 1 && (imageProvider?.describeImages || !imageProvider?.describeImage) ) { - const describeImages = imageProvider?.describeImages ?? describeImagesWithModel; + const describeImages = + imageProvider?.describeImages ?? imageToolProviderDeps.describeImagesWithModel; const described = await describeImages({ images: params.images.map((image, index) => ({ buffer: image.buffer, @@ -203,7 +222,8 @@ async function runImagePrompt(params: { }); return { text: described.text, provider, model: described.model ?? modelId }; } - const describeImage = imageProvider?.describeImage ?? describeImageWithModel; + const describeImage = + imageProvider?.describeImage ?? imageToolProviderDeps.describeImageWithModel; if (params.images.length === 1) { const image = params.images[0]; const described = await describeImage({ diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index dfcf77fce66..cfc314398d8 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -27,7 +27,7 @@ function textResponse(body: string): Response { function setMockFetch( impl: FetchMock = async (_input: RequestInfo | URL, _init?: RequestInit) => textResponse(""), ) { - const fetchSpy = vi.fn(impl); + const fetchSpy = vi.fn(impl); global.fetch = withFetchPreconnect(fetchSpy); return fetchSpy; } diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 639f4d826bc..eb66622bb49 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -235,15 +235,6 @@ describe("web tools defaults", () => { setActivePluginRegistry(registry); const tool = createWebSearchTool({ - config: { - tools: { - web: { - search: { - provider: "custom", - }, - }, - }, - }, sandboxed: true, runtimeWebSearch: { providerConfigured: "custom", @@ -432,7 +423,7 @@ describe("web_search perplexity Search API", () => { it("uses config API key when provided", async () => { const mockFetch = installPerplexitySearchApiFetch([]); const tool = createPerplexitySearchTool({ apiKey: "pplx-config" }); - await tool?.execute?.("call-1", { query: "test" }); + await tool?.execute?.("call-1", { query: "config-api-key-test" }); expect(mockFetch).toHaveBeenCalled(); const headers = (mockFetch.mock.calls[0]?.[1] as RequestInit | undefined)?.headers as @@ -559,7 +550,7 @@ describe("web_search perplexity OpenRouter compatibility", () => { it("routes configured sk-or key through chat completions", async () => { const mockFetch = installPerplexityChatFetch(); const tool = createPerplexitySearchTool({ apiKey: "sk-or-v1-test" }); // pragma: allowlist secret - await tool?.execute?.("call-1", { query: "test" }); + await tool?.execute?.("call-1", { query: "configured-openrouter-key-test" }); expect(mockFetch).toHaveBeenCalled(); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://openrouter.ai/api/v1/chat/completions"); @@ -606,7 +597,7 @@ describe("web_search perplexity OpenRouter compatibility", () => { ], }); const tool = createPerplexitySearchTool(); - const result = await tool?.execute?.("call-1", { query: "test" }); + const result = await tool?.execute?.("call-1", { query: "annotations-fallback-test" }); expect(mockFetch).toHaveBeenCalled(); expect(result?.details).toMatchObject({ @@ -771,7 +762,7 @@ describe("web_search kimi provider", () => { expect(result?.details).toMatchObject({ error: "missing_kimi_api_key" }); }); - it("runs the Kimi web_search tool flow and echoes tool results", async () => { + it("runs the Kimi web_search tool flow and echoes tool-call arguments", async () => { const mockFetch = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { const idx = mockFetch.mock.calls.length; if (idx === 1) { @@ -833,9 +824,7 @@ describe("web_search kimi provider", () => { | { content?: string; tool_call_id?: string } | undefined; expect(toolMessage?.tool_call_id).toBe("call_1"); - expect(JSON.parse(toolMessage?.content ?? "{}")).toMatchObject({ - search_results: [{ url: "https://openclaw.ai/docs" }], - }); + expect(JSON.parse(toolMessage?.content ?? "{}")).toMatchObject({ q: "openclaw" }); const details = result?.details as { citations?: string[]; diff --git a/src/config/legacy-web-fetch.test.ts b/src/config/legacy-web-fetch.test.ts index 2b0636ffbbc..1a2a726d360 100644 --- a/src/config/legacy-web-fetch.test.ts +++ b/src/config/legacy-web-fetch.test.ts @@ -4,7 +4,7 @@ import { listLegacyWebFetchConfigPaths, migrateLegacyWebFetchConfig } from "./le describe("legacy web fetch config", () => { it("migrates legacy Firecrawl fetch config into plugin-owned config", () => { - const res = migrateLegacyWebFetchConfig({ + const res = migrateLegacyWebFetchConfig({ tools: { web: { fetch: { @@ -40,7 +40,7 @@ describe("legacy web fetch config", () => { }); it("drops legacy firecrawl.enabled when migrating plugin-owned config", () => { - const res = migrateLegacyWebFetchConfig({ + const res = migrateLegacyWebFetchConfig({ tools: { web: { fetch: { diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index acc26110dbd..346f8212f70 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -233,7 +233,7 @@ describe("gateway server models + voicewake", () => { (o) => o.type === "event" && o.event === "voicewake.changed", ); - const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", { + const setRes = await rpcReq(ws, "voicewake.set", { triggers: [" hi ", "", "there"], }); expect(setRes.ok).toBe(true); @@ -290,7 +290,7 @@ describe("gateway server models + voicewake", () => { nodeWs, (o) => o.type === "event" && o.event === "voicewake.changed", ); - const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", { + const setRes = await rpcReq(ws, "voicewake.set", { triggers: ["openclaw", "computer"], }); expect(setRes.ok).toBe(true); diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 6c4b5e77d6b..c3ca913dc95 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -43,13 +43,6 @@ type TalkConfigPayload = { }; }; type TalkConfig = NonNullable["talk"]>; -type TalkSpeakPayload = { - audioBase64?: string; - provider?: string; - outputFormat?: string; - mimeType?: string; - fileExtension?: string; -}; const TALK_CONFIG_DEVICE_PATH = path.join( os.tmpdir(), `openclaw-talk-config-device-${process.pid}.json`, @@ -122,7 +115,7 @@ async function fetchTalkSpeak( params: Record, timeoutMs?: number, ) { - return rpcReq(ws, "talk.speak", params, timeoutMs); + return rpcReq(ws, "talk.speak", params, timeoutMs); } function expectElevenLabsTalkConfig( diff --git a/src/media-understanding/attachments.cache.ts b/src/media-understanding/attachments.cache.ts index 365b60bda00..1a66c58b8b1 100644 --- a/src/media-understanding/attachments.cache.ts +++ b/src/media-understanding/attachments.cache.ts @@ -230,10 +230,10 @@ export class MediaAttachmentCache { } async cleanup(): Promise { - const cleanups: Array | void> = []; + const cleanups: Promise[] = []; for (const entry of this.entries.values()) { if (entry.tempCleanup) { - cleanups.push(Promise.resolve(entry.tempCleanup())); + cleanups.push(entry.tempCleanup()); entry.tempCleanup = undefined; } } diff --git a/src/plugin-sdk/lazy-value.test.ts b/src/plugin-sdk/lazy-value.test.ts index a11aae743a7..80859fe266e 100644 --- a/src/plugin-sdk/lazy-value.test.ts +++ b/src/plugin-sdk/lazy-value.test.ts @@ -13,7 +13,8 @@ describe("createCachedLazyValueGetter", () => { it("uses the fallback when the lazy value resolves nullish", () => { const fallback = { type: "object" as const, properties: {} }; - const getSchema = createCachedLazyValueGetter(() => undefined, fallback); + const resolveSchema = (): typeof fallback | undefined => undefined; + const getSchema = createCachedLazyValueGetter(resolveSchema, fallback); expect(getSchema()).toBe(fallback); }); diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts index ccfc7333913..1648046a5bd 100644 --- a/src/plugin-sdk/provider-stream.test.ts +++ b/src/plugin-sdk/provider-stream.test.ts @@ -56,7 +56,7 @@ describe("composeProviderStreamWrappers", () => { }); describe("buildProviderStreamFamilyHooks", () => { - it("covers the stream family matrix", () => { + it("covers the stream family matrix", async () => { let capturedPayload: Record | undefined; let capturedModelId: string | undefined; let capturedHeaders: Record | undefined; @@ -74,12 +74,17 @@ describe("buildProviderStreamFamilyHooks", () => { }; const googleHooks = buildProviderStreamFamilyHooks("google-thinking"); - void requireStreamFn( + const googleStream = requireStreamFn( requireWrapStreamFn(googleHooks.wrapStreamFn)({ streamFn: baseStreamFn, thinkingLevel: "high", } as never), - )({ api: "google-generative-ai", id: "gemini-3.1-pro-preview" } as never, {} as never, {}); + ); + await googleStream( + { api: "google-generative-ai", id: "gemini-3.1-pro-preview" } as never, + {} as never, + {}, + ); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingLevel: "HIGH" } }, }); @@ -89,12 +94,13 @@ describe("buildProviderStreamFamilyHooks", () => { expect(googleThinkingConfig).not.toHaveProperty("thinkingBudget"); const minimaxHooks = buildProviderStreamFamilyHooks("minimax-fast-mode"); - void requireStreamFn( + const minimaxStream = requireStreamFn( requireWrapStreamFn(minimaxHooks.wrapStreamFn)({ streamFn: baseStreamFn, extraParams: { fastMode: true }, } as never), - )( + ); + await minimaxStream( { api: "anthropic-messages", provider: "minimax", @@ -131,12 +137,17 @@ describe("buildProviderStreamFamilyHooks", () => { expect(capturedPayload).not.toHaveProperty("reasoning"); const moonshotHooks = buildProviderStreamFamilyHooks("moonshot-thinking"); - void requireStreamFn( + const moonshotStream = requireStreamFn( requireWrapStreamFn(moonshotHooks.wrapStreamFn)({ streamFn: baseStreamFn, thinkingLevel: "off", } as never), - )({ api: "openai-completions", id: "kimi-k2.5" } as never, {} as never, {}); + ); + await moonshotStream( + { api: "openai-completions", id: "kimi-k2.5" } as never, + {} as never, + {}, + ); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, thinking: { type: "disabled" }, @@ -192,23 +203,25 @@ describe("buildProviderStreamFamilyHooks", () => { expect(capturedPayload).not.toHaveProperty("reasoning"); const toolStreamHooks = buildProviderStreamFamilyHooks("tool-stream-default-on"); - void requireStreamFn( + const toolStreamDefault = requireStreamFn( requireWrapStreamFn(toolStreamHooks.wrapStreamFn)({ streamFn: baseStreamFn, extraParams: {}, } as never), - )({ id: "glm-4.7" } as never, {} as never, {}); + ); + await toolStreamDefault({ id: "glm-4.7" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, tool_stream: true, }); - void requireStreamFn( + const toolStreamDisabled = requireStreamFn( requireWrapStreamFn(toolStreamHooks.wrapStreamFn)({ streamFn: baseStreamFn, extraParams: { tool_stream: false }, } as never), - )({ id: "glm-4.7" } as never, {} as never, {}); + ); + await toolStreamDisabled({ id: "glm-4.7" } as never, {} as never, {}); expect(capturedPayload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: -1 } }, }); diff --git a/src/plugin-sdk/provider-stream.ts b/src/plugin-sdk/provider-stream.ts index 53ac9944ff6..9c4dbc2bcf3 100644 --- a/src/plugin-sdk/provider-stream.ts +++ b/src/plugin-sdk/provider-stream.ts @@ -1,6 +1,4 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { ProviderPlugin } from "../plugins/types.js"; -import type { ProviderWrapStreamFnContext } from "./plugin-entry.js"; import { createGoogleThinkingPayloadWrapper, sanitizeGoogleThinkingPayload, @@ -28,7 +26,12 @@ import { resolveOpenAIServiceTier, resolveOpenAITextVerbosity, } from "../agents/pi-embedded-runner/openai-stream-wrappers.js"; -import { createToolStreamWrapper, createZaiToolStreamWrapper } from "../agents/pi-embedded-runner/zai-stream-wrappers.js"; +import { + createToolStreamWrapper, + createZaiToolStreamWrapper, +} from "../agents/pi-embedded-runner/zai-stream-wrappers.js"; +import type { ProviderPlugin } from "../plugins/types.js"; +import type { ProviderWrapStreamFnContext } from "./plugin-entry.js"; export type ProviderStreamWrapperFactory = | ((streamFn: StreamFn | undefined) => StreamFn | undefined) @@ -40,7 +43,7 @@ export function composeProviderStreamWrappers( baseStreamFn: StreamFn | undefined, ...wrappers: ProviderStreamWrapperFactory[] ): StreamFn | undefined { - return wrappers.reduce( + return wrappers.reduce( (streamFn, wrapper) => (wrapper ? wrapper(streamFn) : streamFn), baseStreamFn, );