diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7266469c4a2..9316afb6d09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,7 +273,9 @@ jobs: use-sticky-disk: "false" - name: Run changed extension tests - run: pnpm test:extension ${{ matrix.extension }} + env: + EXTENSION_ID: ${{ matrix.extension }} + run: pnpm test:extension "$EXTENSION_ID" # Types, lint, and format check. check: @@ -458,30 +460,30 @@ jobs: run: pre-commit run --all-files detect-private-key - name: Audit changed GitHub workflows with zizmor + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} run: | set -euo pipefail - BASE="$( - python - <<'PY' - import json - import os + if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then + echo "No usable base SHA detected; skipping zizmor." + exit 0 + fi - with open(os.environ["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as fh: - event = json.load(fh) + if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor." + exit 0 + fi - if os.environ["GITHUB_EVENT_NAME"] == "push": - print(event["before"]) - else: - print(event["pull_request"]["base"]["sha"]) - PY - )" - - mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + mapfile -t workflow_files < <( + git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml' + ) if [ "${#workflow_files[@]}" -eq 0 ]; then echo "No workflow changes detected; skipping zizmor." exit 0 fi + printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}" pre-commit run zizmor --files "${workflow_files[@]}" - name: Audit production dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb9de629f9..2b33d3f4947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,11 +34,13 @@ Docs: https://docs.openclaw.ai - Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese. - Plugins/bundles: make enabled bundle MCP servers expose runnable tools in embedded Pi, and default relative bundle MCP launches to the bundle root so marketplace bundles like Context7 work through Pi instead of stopping at config import. - Scope message SecretRef resolution and harden doctor/status paths. (#48728) Thanks @joshavant. +- Plugins/testing: add a public `openclaw/plugin-sdk/testing` seam for plugin-author test helpers, and move bundled-extension-only test bridges out of `extensions/` into private repo test helpers. ### Breaking - Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc. - Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root. +- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly. ### Fixes @@ -119,6 +121,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. +- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. ## 2026.3.13 diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 9767f2db674..5627f93395d 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -434,7 +434,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. nodeManager: "npm", }, entries: { - "nano-banana-pro": { + "image-lab": { enabled: true, apiKey: "GEMINI_KEY_HERE", env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 9085c9c35f5..0913040949b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2371,7 +2371,7 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio nodeManager: "npm", // npm | pnpm | yarn }, entries: { - "nano-banana-pro": { + "image-lab": { apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { GEMINI_API_KEY: "GEMINI_KEY_HERE" }, }, diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3ead49f6817..d15efb3384b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -597,11 +597,11 @@ Rules: }, skills: { entries: { - "nano-banana-pro": { + "image-lab": { apiKey: { source: "file", provider: "filemain", - id: "/skills/entries/nano-banana-pro/apiKey", + id: "/skills/entries/image-lab/apiKey", }, }, }, diff --git a/docs/help/testing.md b/docs/help/testing.md index f3315fa6faa..2055db4373f 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -360,14 +360,29 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local - Enable: `BYTEPLUS_API_KEY=... BYTEPLUS_LIVE_TEST=1 pnpm test:live src/agents/byteplus.live.test.ts` - Optional model override: `BYTEPLUS_CODING_MODEL=ark-code-latest` -## Google image generation live +## Image generation live -- Test: `src/image-generation/providers/google.live.test.ts` -- Enable: `GOOGLE_LIVE_TEST=1 pnpm test:live src/image-generation/providers/google.live.test.ts` -- Key source: `GEMINI_API_KEY` or `GOOGLE_API_KEY` -- Optional overrides: - - `GOOGLE_IMAGE_GENERATION_MODEL=gemini-3.1-flash-image-preview` - - `GOOGLE_IMAGE_BASE_URL=https://generativelanguage.googleapis.com/v1beta` +- Test: `src/image-generation/runtime.live.test.ts` +- Command: `pnpm test:live src/image-generation/runtime.live.test.ts` +- Scope: + - Enumerates every registered image-generation provider plugin + - Loads missing provider env vars from your login shell (`~/.profile`) before probing + - Uses live/env API keys ahead of stored auth profiles by default, so stale test keys in `auth-profiles.json` do not mask real shell credentials + - Skips providers with no usable auth/profile/model + - Runs the stock image-generation variants through the shared runtime capability: + - `google:flash-generate` + - `google:pro-generate` + - `google:pro-edit` + - `openai:default-generate` +- Current bundled providers covered: + - `openai` + - `google` +- Optional narrowing: + - `OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS="openai,google"` + - `OPENCLAW_LIVE_IMAGE_GENERATION_MODELS="openai/gpt-image-1,google/gemini-3.1-flash-image-preview"` + - `OPENCLAW_LIVE_IMAGE_GENERATION_CASES="google:flash-generate,google:pro-edit"` +- Optional auth behavior: + - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to force profile-store auth and ignore env-only overrides ## Docker runners (optional “works in Linux” checks) diff --git a/docs/tools/index.md b/docs/tools/index.md index deb42b0d76a..1dfe2b87703 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -400,6 +400,30 @@ Notes: - Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing). - Uses the image model directly (independent of the main chat model). +### `image_generate` + +Generate one or more images with the configured image-generation model. + +Core parameters: + +- `action` (optional: `generate` or `list`; default `generate`) +- `prompt` (required) +- `image` or `images` (optional reference image path/URL for edit mode) +- `model` (optional provider/model override) +- `size` (optional size hint) +- `resolution` (optional `1K|2K|4K` hint) +- `count` (optional, `1-4`, default `1`) + +Notes: + +- Only available when `agents.defaults.imageGenerationModel` is configured. +- Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support. +- Returns local `MEDIA:` lines so channels can deliver the generated files directly. +- Uses the image-generation model directly (independent of the main chat model). +- Google-backed flows support reference-image edits plus explicit `1K|2K|4K` resolution hints. +- When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size. +- This is the built-in replacement for the old sample `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation. + ### `pdf` Analyze one or more PDF documents. diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 589d464bb13..697cb46dad6 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -24,7 +24,7 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j nodeManager: "npm", // npm | pnpm | yarn | bun (Gateway runtime still Node; bun not recommended) }, entries: { - "nano-banana-pro": { + "image-lab": { enabled: true, apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { @@ -38,6 +38,10 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j } ``` +For built-in image generation/editing, prefer `agents.defaults.imageGenerationModel` +plus the core `image_generate` tool. `skills.entries.*` is only for custom or +third-party skill workflows. + ## Fields - `allowBundled`: optional allowlist for **bundled** skills only. When set, only diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 05369677b89..5b91d79af59 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -81,8 +81,8 @@ that up as `/skills` on the next session. ```markdown --- -name: nano-banana-pro -description: Generate or edit images via Gemini 3 Pro Image +name: image-lab +description: Generate or edit images via a provider-backed image workflow --- ``` @@ -109,8 +109,8 @@ OpenClaw **filters skills at load time** using `metadata` (single-line JSON): ```markdown --- -name: nano-banana-pro -description: Generate or edit images via Gemini 3 Pro Image +name: image-lab +description: Generate or edit images via a provider-backed image workflow metadata: { "openclaw": @@ -194,7 +194,7 @@ Bundled/managed skills can be toggled and supplied with env values: { skills: { entries: { - "nano-banana-pro": { + "image-lab": { enabled: true, apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string env: { @@ -214,6 +214,10 @@ Bundled/managed skills can be toggled and supplied with env values: Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys). +If you want stock image generation/editing inside OpenClaw itself, use the core +`image_generate` tool with `agents.defaults.imageGenerationModel` instead of a +bundled skill. Skill examples here are for custom or third-party workflows. + Config keys match the **skill name** by default. If a skill defines `metadata.openclaw.skillKey`, use that key under `skills.entries`. diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 61b33a0bc68..4afa67e3501 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { registerSingleProviderPlugin } from "../test-utils/plugin-registration.js"; +import { registerSingleProviderPlugin } from "../../test/helpers/extensions/plugin-registration.js"; import amazonBedrockPlugin from "./index.js"; describe("amazon-bedrock provider plugin", () => { diff --git a/extensions/bluebubbles/src/actions.runtime.ts b/extensions/bluebubbles/src/actions.runtime.ts index 00d0bc00efd..6b4112547d1 100644 --- a/extensions/bluebubbles/src/actions.runtime.ts +++ b/extensions/bluebubbles/src/actions.runtime.ts @@ -15,87 +15,17 @@ import { sendMessageBlueBubbles as sendMessageBlueBubblesImpl, } from "./send.js"; -type SendBlueBubblesAttachment = typeof import("./attachments.js").sendBlueBubblesAttachment; -type AddBlueBubblesParticipant = typeof import("./chat.js").addBlueBubblesParticipant; -type EditBlueBubblesMessage = typeof import("./chat.js").editBlueBubblesMessage; -type LeaveBlueBubblesChat = typeof import("./chat.js").leaveBlueBubblesChat; -type RemoveBlueBubblesParticipant = typeof import("./chat.js").removeBlueBubblesParticipant; -type RenameBlueBubblesChat = typeof import("./chat.js").renameBlueBubblesChat; -type SetGroupIconBlueBubbles = typeof import("./chat.js").setGroupIconBlueBubbles; -type UnsendBlueBubblesMessage = typeof import("./chat.js").unsendBlueBubblesMessage; -type ResolveBlueBubblesMessageId = typeof import("./monitor.js").resolveBlueBubblesMessageId; -type SendBlueBubblesReaction = typeof import("./reactions.js").sendBlueBubblesReaction; -type ResolveChatGuidForTarget = typeof import("./send.js").resolveChatGuidForTarget; -type SendMessageBlueBubbles = typeof import("./send.js").sendMessageBlueBubbles; - -export function sendBlueBubblesAttachment( - ...args: Parameters -): ReturnType { - return sendBlueBubblesAttachmentImpl(...args); -} - -export function addBlueBubblesParticipant( - ...args: Parameters -): ReturnType { - return addBlueBubblesParticipantImpl(...args); -} - -export function editBlueBubblesMessage( - ...args: Parameters -): ReturnType { - return editBlueBubblesMessageImpl(...args); -} - -export function leaveBlueBubblesChat( - ...args: Parameters -): ReturnType { - return leaveBlueBubblesChatImpl(...args); -} - -export function removeBlueBubblesParticipant( - ...args: Parameters -): ReturnType { - return removeBlueBubblesParticipantImpl(...args); -} - -export function renameBlueBubblesChat( - ...args: Parameters -): ReturnType { - return renameBlueBubblesChatImpl(...args); -} - -export function setGroupIconBlueBubbles( - ...args: Parameters -): ReturnType { - return setGroupIconBlueBubblesImpl(...args); -} - -export function unsendBlueBubblesMessage( - ...args: Parameters -): ReturnType { - return unsendBlueBubblesMessageImpl(...args); -} - -export function resolveBlueBubblesMessageId( - ...args: Parameters -): ReturnType { - return resolveBlueBubblesMessageIdImpl(...args); -} - -export function sendBlueBubblesReaction( - ...args: Parameters -): ReturnType { - return sendBlueBubblesReactionImpl(...args); -} - -export function resolveChatGuidForTarget( - ...args: Parameters -): ReturnType { - return resolveChatGuidForTargetImpl(...args); -} - -export function sendMessageBlueBubbles( - ...args: Parameters -): ReturnType { - return sendMessageBlueBubblesImpl(...args); -} +export const blueBubblesActionsRuntime = { + sendBlueBubblesAttachment: sendBlueBubblesAttachmentImpl, + addBlueBubblesParticipant: addBlueBubblesParticipantImpl, + editBlueBubblesMessage: editBlueBubblesMessageImpl, + leaveBlueBubblesChat: leaveBlueBubblesChatImpl, + removeBlueBubblesParticipant: removeBlueBubblesParticipantImpl, + renameBlueBubblesChat: renameBlueBubblesChatImpl, + setGroupIconBlueBubbles: setGroupIconBlueBubblesImpl, + unsendBlueBubblesMessage: unsendBlueBubblesMessageImpl, + resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdImpl, + sendBlueBubblesReaction: sendBlueBubblesReactionImpl, + resolveChatGuidForTarget: resolveChatGuidForTargetImpl, + sendMessageBlueBubbles: sendMessageBlueBubblesImpl, +}; diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 4e6476afa3f..47eedf97511 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -11,18 +11,19 @@ import { type ChannelMessageActionAdapter, type ChannelMessageActionName, } from "openclaw/plugin-sdk/bluebubbles"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; import type { BlueBubblesSendTarget } from "./types.js"; -let actionsRuntimePromise: Promise | null = null; +type BlueBubblesActionsRuntime = typeof import("./actions.runtime.js").blueBubblesActionsRuntime; -function loadBlueBubblesActionsRuntime() { - actionsRuntimePromise ??= import("./actions.runtime.js"); - return actionsRuntimePromise; -} +const loadBlueBubblesActionsRuntime = createLazyRuntimeSurface( + () => import("./actions.runtime.js"), + ({ blueBubblesActionsRuntime }) => blueBubblesActionsRuntime, +); const providerId = "bluebubbles"; diff --git a/extensions/bluebubbles/src/channel.runtime.ts b/extensions/bluebubbles/src/channel.runtime.ts index d318943d3f2..b8b4066c4cd 100644 --- a/extensions/bluebubbles/src/channel.runtime.ts +++ b/extensions/bluebubbles/src/channel.runtime.ts @@ -6,52 +6,14 @@ import { } from "./monitor.js"; import { probeBlueBubbles as probeBlueBubblesImpl } from "./probe.js"; import { sendMessageBlueBubbles as sendMessageBlueBubblesImpl } from "./send.js"; -import { blueBubblesSetupWizard as blueBubblesSetupWizardImpl } from "./setup-surface.js"; export type { BlueBubblesProbe } from "./probe.js"; -type SendBlueBubblesMedia = typeof import("./media-send.js").sendBlueBubblesMedia; -type ResolveBlueBubblesMessageId = typeof import("./monitor.js").resolveBlueBubblesMessageId; -type MonitorBlueBubblesProvider = typeof import("./monitor.js").monitorBlueBubblesProvider; -type ResolveWebhookPathFromConfig = typeof import("./monitor.js").resolveWebhookPathFromConfig; -type ProbeBlueBubbles = typeof import("./probe.js").probeBlueBubbles; -type SendMessageBlueBubbles = typeof import("./send.js").sendMessageBlueBubbles; -type BlueBubblesSetupWizard = typeof import("./setup-surface.js").blueBubblesSetupWizard; - -export function sendBlueBubblesMedia( - ...args: Parameters -): ReturnType { - return sendBlueBubblesMediaImpl(...args); -} - -export function resolveBlueBubblesMessageId( - ...args: Parameters -): ReturnType { - return resolveBlueBubblesMessageIdImpl(...args); -} - -export function monitorBlueBubblesProvider( - ...args: Parameters -): ReturnType { - return monitorBlueBubblesProviderImpl(...args); -} - -export function resolveWebhookPathFromConfig( - ...args: Parameters -): ReturnType { - return resolveWebhookPathFromConfigImpl(...args); -} - -export function probeBlueBubbles( - ...args: Parameters -): ReturnType { - return probeBlueBubblesImpl(...args); -} - -export function sendMessageBlueBubbles( - ...args: Parameters -): ReturnType { - return sendMessageBlueBubblesImpl(...args); -} - -export const blueBubblesSetupWizard: BlueBubblesSetupWizard = { ...blueBubblesSetupWizardImpl }; +export const blueBubblesChannelRuntime = { + sendBlueBubblesMedia: sendBlueBubblesMediaImpl, + resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdImpl, + monitorBlueBubblesProvider: monitorBlueBubblesProviderImpl, + resolveWebhookPathFromConfig: resolveWebhookPathFromConfigImpl, + probeBlueBubbles: probeBlueBubblesImpl, + sendMessageBlueBubbles: sendMessageBlueBubblesImpl, +}; diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 9550c1166ed..f3f3cdd7eb3 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -18,6 +18,7 @@ import { buildAccountScopedDmSecurityPolicy, collectOpenGroupPolicyRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { listBlueBubblesAccountIds, type ResolvedBlueBubblesAccount, @@ -37,12 +38,12 @@ import { parseBlueBubblesTarget, } from "./targets.js"; -let blueBubblesChannelRuntimePromise: Promise | null = null; +type BlueBubblesChannelRuntime = typeof import("./channel.runtime.js").blueBubblesChannelRuntime; -function loadBlueBubblesChannelRuntime() { - blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js"); - return blueBubblesChannelRuntimePromise; -} +const loadBlueBubblesChannelRuntime = createLazyRuntimeSurface( + () => import("./channel.runtime.js"), + ({ blueBubblesChannelRuntime }) => blueBubblesChannelRuntime, +); const meta = { id: "bluebubbles", diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 1ba2e27f0b6..17467465d82 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js"; diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index f6826ac510b..8d98b0c45eb 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index b1ade0c6a09..3e7fd3c474b 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,8 +1,8 @@ import type { IncomingMessage } from "node:http"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { describe, expect, it, vi } from "vitest"; -import { createMockServerResponse } from "../test-utils/mock-http-response.js"; -import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index eed9abd77d8..e35d847597b 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -1,6 +1,6 @@ import type { IncomingMessage } from "node:http"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createMockServerResponse } from "../../test-utils/mock-http-response.js"; +import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; import { createDiffsHttpHandler } from "./http.js"; import { DiffArtifactStore } from "./store.js"; import { createDiffStoreHarness } from "./test-helpers.js"; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 2f845727274..b0e019f33e2 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createTestPluginApi } from "../../test-utils/plugin-api.js"; +import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; diff --git a/extensions/discord/src/api.test.ts b/extensions/discord/src/api.test.ts index 09e0863e137..11d15d5f59f 100644 --- a/extensions/discord/src/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { fetchDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; diff --git a/extensions/discord/src/chunk.test.ts b/extensions/discord/src/chunk.test.ts index 69f5ec856ec..228871fe5d6 100644 --- a/extensions/discord/src/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { countLines, hasBalancedFences } from "../../test-utils/chunk-test-helpers.js"; +import { + countLines, + hasBalancedFences, +} from "../../../test/helpers/extensions/chunk-test-helpers.js"; import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; describe("chunkDiscordText", () => { diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index b3af666c35f..9836984d555 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -1,6 +1,6 @@ import { ChannelType, type Guild } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { typedCases } from "../../test-utils/typed-cases.js"; +import { typedCases } from "../../../test/helpers/extensions/typed-cases.js"; import { allowListMatches, buildDiscordMediaPayload, diff --git a/extensions/discord/src/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts index fd4f67b0890..6d0405d756c 100644 --- a/extensions/discord/src/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; export const sendMock: MockFn = vi.fn(); diff --git a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts index adeaf7953e7..72327dfc608 100644 --- a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; export const preflightDiscordMessageMock: MockFn = vi.fn(); diff --git a/extensions/discord/src/monitor/provider.registry.test.ts b/extensions/discord/src/monitor/provider.registry.test.ts index 2187c851f69..5e092445065 100644 --- a/extensions/discord/src/monitor/provider.registry.test.ts +++ b/extensions/discord/src/monitor/provider.registry.test.ts @@ -5,7 +5,7 @@ import { baseRuntime, getProviderMonitorTestMocks, resetDiscordProviderMonitorMocks, -} from "./provider.test-support.js"; +} from "../../../../test/helpers/extensions/discord-provider.test-support.js"; const { createDiscordNativeCommandMock, clientHandleDeployRequestMock, monitorLifecycleMock } = getProviderMonitorTestMocks(); diff --git a/extensions/discord/src/monitor/provider.test-support.ts b/extensions/discord/src/monitor/provider.test-support.ts index 23ffb7da2f2..360210e3604 100644 --- a/extensions/discord/src/monitor/provider.test-support.ts +++ b/extensions/discord/src/monitor/provider.test-support.ts @@ -1,459 +1 @@ -import type { Mock } from "vitest"; -import { expect, vi } from "vitest"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; -import type { RuntimeEnv } from "../../../../src/runtime.js"; - -export type NativeCommandSpecMock = { - name: string; - description: string; - acceptsArgs: boolean; -}; - -export type PluginCommandSpecMock = { - name: string; - description: string; - acceptsArgs: boolean; -}; - -type ProviderMonitorTestMocks = { - clientHandleDeployRequestMock: Mock<() => Promise>; - clientFetchUserMock: Mock<(target: string) => Promise<{ id: string }>>; - clientGetPluginMock: Mock<(name: string) => unknown>; - clientConstructorOptionsMock: Mock<(options?: unknown) => void>; - createDiscordAutoPresenceControllerMock: Mock<() => unknown>; - createDiscordNativeCommandMock: Mock<(params?: { command?: { name?: string } }) => unknown>; - createDiscordMessageHandlerMock: Mock<() => unknown>; - createNoopThreadBindingManagerMock: Mock<() => { stop: ReturnType }>; - createThreadBindingManagerMock: Mock<() => { stop: ReturnType }>; - reconcileAcpThreadBindingsOnStartupMock: Mock<() => unknown>; - createdBindingManagers: Array<{ stop: ReturnType }>; - getAcpSessionStatusMock: Mock< - (params: { - cfg: OpenClawConfig; - sessionKey: string; - signal?: AbortSignal; - }) => Promise<{ state: string }> - >; - getPluginCommandSpecsMock: Mock<() => PluginCommandSpecMock[]>; - listNativeCommandSpecsForConfigMock: Mock<() => NativeCommandSpecMock[]>; - listSkillCommandsForAgentsMock: Mock<() => unknown[]>; - monitorLifecycleMock: Mock<(params: { threadBindings: { stop: () => void } }) => Promise>; - resolveDiscordAccountMock: Mock<() => unknown>; - resolveDiscordAllowlistConfigMock: Mock<() => Promise>; - resolveNativeCommandsEnabledMock: Mock<() => boolean>; - resolveNativeSkillsEnabledMock: Mock<() => boolean>; - isVerboseMock: Mock<() => boolean>; - shouldLogVerboseMock: Mock<() => boolean>; - voiceRuntimeModuleLoadedMock: Mock<() => void>; -}; - -export function baseDiscordAccountConfig() { - return { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - }; -} - -const providerMonitorTestMocks: ProviderMonitorTestMocks = vi.hoisted(() => { - const createdBindingManagers: Array<{ stop: ReturnType }> = []; - const isVerboseMock = vi.fn(() => false); - const shouldLogVerboseMock = vi.fn(() => false); - - return { - clientHandleDeployRequestMock: vi.fn(async () => undefined), - clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })), - clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined), - clientConstructorOptionsMock: vi.fn(), - createDiscordAutoPresenceControllerMock: vi.fn(() => ({ - enabled: false, - start: vi.fn(), - stop: vi.fn(), - refresh: vi.fn(), - runNow: vi.fn(), - })), - createDiscordNativeCommandMock: vi.fn((params?: { command?: { name?: string } }) => ({ - name: params?.command?.name ?? "mock-command", - })), - createDiscordMessageHandlerMock: vi.fn(() => - Object.assign( - vi.fn(async () => undefined), - { - deactivate: vi.fn(), - }, - ), - ), - createNoopThreadBindingManagerMock: vi.fn(() => { - const manager = { stop: vi.fn() }; - createdBindingManagers.push(manager); - return manager; - }), - createThreadBindingManagerMock: vi.fn(() => { - const manager = { stop: vi.fn() }; - createdBindingManagers.push(manager); - return manager; - }), - reconcileAcpThreadBindingsOnStartupMock: vi.fn(() => ({ - checked: 0, - removed: 0, - staleSessionKeys: [], - })), - createdBindingManagers, - getAcpSessionStatusMock: vi.fn( - async (_params: { cfg: OpenClawConfig; sessionKey: string; signal?: AbortSignal }) => ({ - state: "idle", - }), - ), - getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []), - listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [ - { name: "cmd", description: "built-in", acceptsArgs: false }, - ]), - listSkillCommandsForAgentsMock: vi.fn(() => []), - monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => { - params.threadBindings.stop(); - }), - resolveDiscordAccountMock: vi.fn(() => ({ - accountId: "default", - token: "cfg-token", - config: baseDiscordAccountConfig(), - })), - resolveDiscordAllowlistConfigMock: vi.fn(async () => ({ - guildEntries: undefined, - allowFrom: undefined, - })), - resolveNativeCommandsEnabledMock: vi.fn(() => true), - resolveNativeSkillsEnabledMock: vi.fn(() => false), - isVerboseMock, - shouldLogVerboseMock, - voiceRuntimeModuleLoadedMock: vi.fn(), - }; -}); - -const { - clientHandleDeployRequestMock, - clientFetchUserMock, - clientGetPluginMock, - clientConstructorOptionsMock, - createDiscordAutoPresenceControllerMock, - createDiscordNativeCommandMock, - createDiscordMessageHandlerMock, - createNoopThreadBindingManagerMock, - createThreadBindingManagerMock, - reconcileAcpThreadBindingsOnStartupMock, - createdBindingManagers, - getAcpSessionStatusMock, - getPluginCommandSpecsMock, - listNativeCommandSpecsForConfigMock, - listSkillCommandsForAgentsMock, - monitorLifecycleMock, - resolveDiscordAccountMock, - resolveDiscordAllowlistConfigMock, - resolveNativeCommandsEnabledMock, - resolveNativeSkillsEnabledMock, - isVerboseMock, - shouldLogVerboseMock, - voiceRuntimeModuleLoadedMock, -} = providerMonitorTestMocks; - -export function getProviderMonitorTestMocks(): typeof providerMonitorTestMocks { - return providerMonitorTestMocks; -} - -export function mockResolvedDiscordAccountConfig(overrides: Record) { - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - ...baseDiscordAccountConfig(), - ...overrides, - }, - })); -} - -export function getFirstDiscordMessageHandlerParams() { - expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); - const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as [T] | undefined; - return firstCall?.[0]; -} - -export function resetDiscordProviderMonitorMocks(params?: { - nativeCommands?: NativeCommandSpecMock[]; -}) { - clientHandleDeployRequestMock.mockClear().mockResolvedValue(undefined); - clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" }); - clientGetPluginMock.mockClear().mockReturnValue(undefined); - clientConstructorOptionsMock.mockClear(); - createDiscordAutoPresenceControllerMock.mockClear().mockImplementation(() => ({ - enabled: false, - start: vi.fn(), - stop: vi.fn(), - refresh: vi.fn(), - runNow: vi.fn(), - })); - createDiscordNativeCommandMock.mockClear().mockImplementation((input) => ({ - name: input?.command?.name ?? "mock-command", - })); - createDiscordMessageHandlerMock.mockClear().mockImplementation(() => - Object.assign( - vi.fn(async () => undefined), - { - deactivate: vi.fn(), - }, - ), - ); - createNoopThreadBindingManagerMock.mockClear(); - createThreadBindingManagerMock.mockClear(); - reconcileAcpThreadBindingsOnStartupMock.mockClear().mockReturnValue({ - checked: 0, - removed: 0, - staleSessionKeys: [], - }); - createdBindingManagers.length = 0; - getAcpSessionStatusMock.mockClear().mockResolvedValue({ state: "idle" }); - getPluginCommandSpecsMock.mockClear().mockReturnValue([]); - listNativeCommandSpecsForConfigMock - .mockClear() - .mockReturnValue( - params?.nativeCommands ?? [{ name: "cmd", description: "built-in", acceptsArgs: false }], - ); - listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]); - monitorLifecycleMock.mockClear().mockImplementation(async (monitorParams) => { - monitorParams.threadBindings.stop(); - }); - resolveDiscordAccountMock.mockClear().mockReturnValue({ - accountId: "default", - token: "cfg-token", - config: baseDiscordAccountConfig(), - }); - resolveDiscordAllowlistConfigMock.mockClear().mockResolvedValue({ - guildEntries: undefined, - allowFrom: undefined, - }); - resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); - resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); - isVerboseMock.mockClear().mockReturnValue(false); - shouldLogVerboseMock.mockClear().mockReturnValue(false); - voiceRuntimeModuleLoadedMock.mockClear(); -} - -export const baseRuntime = (): RuntimeEnv => ({ - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), -}); - -export const baseConfig = (): OpenClawConfig => - ({ - channels: { - discord: { - accounts: { - default: {}, - }, - }, - }, - }) as OpenClawConfig; - -vi.mock("@buape/carbon", () => { - class ReadyListener {} - class RateLimitError extends Error { - status = 429; - discordCode?: number; - retryAfter: number; - scope: string | null; - bucket: string | null; - constructor( - response: Response, - body: { message: string; retry_after: number; global: boolean }, - ) { - super(body.message); - this.retryAfter = body.retry_after; - this.scope = body.global ? "global" : response.headers.get("X-RateLimit-Scope"); - this.bucket = response.headers.get("X-RateLimit-Bucket"); - } - } - class Client { - listeners: unknown[]; - rest: { put: ReturnType }; - options: unknown; - constructor(options: unknown, handlers: { listeners?: unknown[] }) { - this.options = options; - this.listeners = handlers.listeners ?? []; - this.rest = { put: vi.fn(async () => undefined) }; - clientConstructorOptionsMock(options); - } - async handleDeployRequest() { - return await clientHandleDeployRequestMock(); - } - async fetchUser(target: string) { - return await clientFetchUserMock(target); - } - getPlugin(name: string) { - return clientGetPluginMock(name); - } - } - return { Client, RateLimitError, ReadyListener }; -}); - -vi.mock("@buape/carbon/gateway", () => ({ - GatewayCloseCodes: { DisallowedIntents: 4014 }, -})); - -vi.mock("@buape/carbon/voice", () => ({ - VoicePlugin: class VoicePlugin {}, -})); - -vi.mock("../../../../src/acp/control-plane/manager.js", () => ({ - getAcpSessionManager: () => ({ - getSessionStatus: getAcpSessionStatusMock, - }), -})); - -vi.mock("../../../../src/auto-reply/chunk.js", () => ({ - resolveTextChunkLimit: () => 2000, -})); - -vi.mock("../../../../src/auto-reply/commands-registry.js", () => ({ - listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, -})); - -vi.mock("../../../../src/auto-reply/skill-commands.js", () => ({ - listSkillCommandsForAgents: listSkillCommandsForAgentsMock, -})); - -vi.mock("../../../../src/config/commands.js", () => ({ - isNativeCommandsExplicitlyDisabled: () => false, - resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, - resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, -})); - -vi.mock("../../../../src/config/config.js", () => ({ - loadConfig: () => ({}), -})); - -vi.mock("../../../../src/globals.js", () => ({ - danger: (value: string) => value, - isVerbose: isVerboseMock, - logVerbose: vi.fn(), - shouldLogVerbose: shouldLogVerboseMock, - warn: (value: string) => value, -})); - -vi.mock("../../../../src/infra/errors.js", () => ({ - formatErrorMessage: (error: unknown) => String(error), -})); - -vi.mock("../../../../src/infra/retry-policy.js", () => ({ - createDiscordRetryRunner: () => async (run: () => Promise) => run(), -})); - -vi.mock("../../../../src/logging/subsystem.js", () => ({ - createSubsystemLogger: () => { - const logger = { - child: vi.fn(() => logger), - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }; - return logger; - }, -})); - -vi.mock("../../../../src/runtime.js", () => ({ - createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }), -})); - -vi.mock("../accounts.js", () => ({ - resolveDiscordAccount: resolveDiscordAccountMock, -})); - -vi.mock("../probe.js", () => ({ - fetchDiscordApplicationId: async () => "app-1", -})); - -vi.mock("../token.js", () => ({ - normalizeDiscordToken: (value?: string) => value, -})); - -vi.mock("../voice/command.js", () => ({ - createDiscordVoiceCommand: () => ({ name: "voice-command" }), -})); - -vi.mock("./agent-components.js", () => ({ - createAgentComponentButton: () => ({ id: "btn" }), - createAgentSelectMenu: () => ({ id: "menu" }), - createDiscordComponentButton: () => ({ id: "btn2" }), - createDiscordComponentChannelSelect: () => ({ id: "channel" }), - createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }), - createDiscordComponentModal: () => ({ id: "modal" }), - createDiscordComponentRoleSelect: () => ({ id: "role" }), - createDiscordComponentStringSelect: () => ({ id: "string" }), - createDiscordComponentUserSelect: () => ({ id: "user" }), -})); - -vi.mock("./auto-presence.js", () => ({ - createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, -})); - -vi.mock("./commands.js", () => ({ - resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }), -})); - -vi.mock("./exec-approvals.js", () => ({ - createExecApprovalButton: () => ({ id: "exec-approval" }), - DiscordExecApprovalHandler: class DiscordExecApprovalHandler { - async start() { - return undefined; - } - async stop() { - return undefined; - } - }, -})); - -vi.mock("./gateway-plugin.js", () => ({ - createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), -})); - -vi.mock("./listeners.js", () => ({ - DiscordMessageListener: class DiscordMessageListener {}, - DiscordPresenceListener: class DiscordPresenceListener {}, - DiscordReactionListener: class DiscordReactionListener {}, - DiscordReactionRemoveListener: class DiscordReactionRemoveListener {}, - DiscordThreadUpdateListener: class DiscordThreadUpdateListener {}, - registerDiscordListener: vi.fn(), -})); - -vi.mock("./message-handler.js", () => ({ - createDiscordMessageHandler: createDiscordMessageHandlerMock, -})); - -vi.mock("./native-command.js", () => ({ - createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }), - createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }), - createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }), - createDiscordNativeCommand: createDiscordNativeCommandMock, -})); - -vi.mock("./presence.js", () => ({ - resolveDiscordPresenceUpdate: () => undefined, -})); - -vi.mock("./provider.allowlist.js", () => ({ - resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock, -})); - -vi.mock("./provider.lifecycle.js", () => ({ - runDiscordGatewayLifecycle: monitorLifecycleMock, -})); - -vi.mock("./rest-fetch.js", () => ({ - resolveDiscordRestFetch: () => async () => undefined, -})); - -vi.mock("./thread-bindings.js", () => ({ - createNoopThreadBindingManager: createNoopThreadBindingManagerMock, - createThreadBindingManager: createThreadBindingManagerMock, - reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, -})); +export * from "../../../../test/helpers/extensions/discord-provider.test-support.js"; diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 14177aec001..8cda7cc90b3 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -9,7 +9,7 @@ import { getProviderMonitorTestMocks, mockResolvedDiscordAccountConfig, resetDiscordProviderMonitorMocks, -} from "./provider.test-support.js"; +} from "../../../../test/helpers/extensions/discord-provider.test-support.js"; const { clientConstructorOptionsMock, @@ -37,9 +37,15 @@ const { voiceRuntimeModuleLoadedMock, } = getProviderMonitorTestMocks(); -vi.mock("../../../../src/plugins/commands.js", () => ({ - getPluginCommandSpecs: getPluginCommandSpecsMock, -})); +vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/plugin-runtime", + ); + return { + ...actual, + getPluginCommandSpecs: getPluginCommandSpecsMock, + }; +}); vi.mock("../voice/manager.runtime.js", () => { voiceRuntimeModuleLoadedMock(); diff --git a/extensions/discord/src/resolve-channels.test.ts b/extensions/discord/src/resolve-channels.test.ts index f053fb97888..8fd06593923 100644 --- a/extensions/discord/src/resolve-channels.test.ts +++ b/extensions/discord/src/resolve-channels.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { resolveDiscordChannelAllowlist } from "./resolve-channels.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; diff --git a/extensions/discord/src/resolve-users.test.ts b/extensions/discord/src/resolve-users.test.ts index f67b7289a59..080c312b856 100644 --- a/extensions/discord/src/resolve-users.test.ts +++ b/extensions/discord/src/resolve-users.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { jsonResponse, urlToString } from "./test-http-helpers.js"; diff --git a/extensions/discord/src/send.test-harness.ts b/extensions/discord/src/send.test-harness.ts index 8a2058772fc..c0069f99770 100644 --- a/extensions/discord/src/send.test-harness.ts +++ b/extensions/discord/src/send.test-harness.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; type DiscordWebMediaMockFactoryResult = { diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 4b807f10a65..f7722a35af5 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -2,6 +2,7 @@ import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, + formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -16,7 +17,6 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 66f7f8bbf4b..801f7bf7838 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,11 +1,11 @@ import { + formatDocsLink, type OpenClawConfig, promptLegacyChannelAllowFrom, resolveSetupAccountId, type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 92e248066af..03174404bdb 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -3,10 +3,12 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { DiscordConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index 6d22ea1ff54..a05db63043a 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRequiredHookHandler, registerHookHandlersForTest, -} from "../../test-utils/subagent-hooks.js"; +} from "../../../test/helpers/extensions/subagent-hooks.js"; import { registerDiscordSubagentHooks } from "./subagent-hooks.js"; type ThreadBindingRecord = { diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index df787b0106a..4594f09fd59 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { FeishuMessageEvent } from "./bot.js"; import { buildBroadcastSessionKey, diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts index c8a742942ea..ef13b721a4e 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -26,104 +26,22 @@ import { sendMessageFeishu as sendMessageFeishuImpl, } from "./send.js"; -type ListFeishuDirectoryGroupsLive = typeof import("./directory.js").listFeishuDirectoryGroupsLive; -type ListFeishuDirectoryPeersLive = typeof import("./directory.js").listFeishuDirectoryPeersLive; -type FeishuOutbound = typeof import("./outbound.js").feishuOutbound; -type CreatePinFeishu = typeof import("./pins.js").createPinFeishu; -type ListPinsFeishu = typeof import("./pins.js").listPinsFeishu; -type RemovePinFeishu = typeof import("./pins.js").removePinFeishu; -type ProbeFeishu = typeof import("./probe.js").probeFeishu; -type AddReactionFeishu = typeof import("./reactions.js").addReactionFeishu; -type ListReactionsFeishu = typeof import("./reactions.js").listReactionsFeishu; -type RemoveReactionFeishu = typeof import("./reactions.js").removeReactionFeishu; -type GetChatInfo = typeof import("./chat.js").getChatInfo; -type GetChatMembers = typeof import("./chat.js").getChatMembers; -type GetFeishuMemberInfo = typeof import("./chat.js").getFeishuMemberInfo; -type EditMessageFeishu = typeof import("./send.js").editMessageFeishu; -type GetMessageFeishu = typeof import("./send.js").getMessageFeishu; -type SendCardFeishu = typeof import("./send.js").sendCardFeishu; -type SendMessageFeishu = typeof import("./send.js").sendMessageFeishu; - -export function listFeishuDirectoryGroupsLive( - ...args: Parameters -): ReturnType { - return listFeishuDirectoryGroupsLiveImpl(...args); -} - -export function listFeishuDirectoryPeersLive( - ...args: Parameters -): ReturnType { - return listFeishuDirectoryPeersLiveImpl(...args); -} - -export const feishuOutbound: FeishuOutbound = { ...feishuOutboundImpl }; - -export function createPinFeishu(...args: Parameters): ReturnType { - return createPinFeishuImpl(...args); -} - -export function listPinsFeishu(...args: Parameters): ReturnType { - return listPinsFeishuImpl(...args); -} - -export function removePinFeishu(...args: Parameters): ReturnType { - return removePinFeishuImpl(...args); -} - -export function probeFeishu(...args: Parameters): ReturnType { - return probeFeishuImpl(...args); -} - -export function addReactionFeishu( - ...args: Parameters -): ReturnType { - return addReactionFeishuImpl(...args); -} - -export function listReactionsFeishu( - ...args: Parameters -): ReturnType { - return listReactionsFeishuImpl(...args); -} - -export function removeReactionFeishu( - ...args: Parameters -): ReturnType { - return removeReactionFeishuImpl(...args); -} - -export function getChatInfo(...args: Parameters): ReturnType { - return getChatInfoImpl(...args); -} - -export function getChatMembers(...args: Parameters): ReturnType { - return getChatMembersImpl(...args); -} - -export function getFeishuMemberInfo( - ...args: Parameters -): ReturnType { - return getFeishuMemberInfoImpl(...args); -} - -export function editMessageFeishu( - ...args: Parameters -): ReturnType { - return editMessageFeishuImpl(...args); -} - -export function getMessageFeishu( - ...args: Parameters -): ReturnType { - return getMessageFeishuImpl(...args); -} - -export function sendCardFeishu(...args: Parameters): ReturnType { - return sendCardFeishuImpl(...args); -} - -export function sendMessageFeishu( - ...args: Parameters -): ReturnType { - return sendMessageFeishuImpl(...args); -} +export const feishuChannelRuntime = { + listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveImpl, + listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveImpl, + feishuOutbound: { ...feishuOutboundImpl }, + createPinFeishu: createPinFeishuImpl, + listPinsFeishu: listPinsFeishuImpl, + removePinFeishu: removePinFeishuImpl, + probeFeishu: probeFeishuImpl, + addReactionFeishu: addReactionFeishuImpl, + listReactionsFeishu: listReactionsFeishuImpl, + removeReactionFeishu: removeReactionFeishuImpl, + getChatInfo: getChatInfoImpl, + getChatMembers: getChatMembersImpl, + getFeishuMemberInfo: getFeishuMemberInfoImpl, + editMessageFeishu: editMessageFeishuImpl, + getMessageFeishu: getMessageFeishuImpl, + sendCardFeishu: sendCardFeishuImpl, + sendMessageFeishu: sendMessageFeishuImpl, +}; diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 826ca1c26fb..7c4ae5d877a 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -28,22 +28,28 @@ vi.mock("./client.js", () => ({ })); vi.mock("./channel.runtime.js", () => ({ - addReactionFeishu: addReactionFeishuMock, - createPinFeishu: createPinFeishuMock, - editMessageFeishu: editMessageFeishuMock, - getChatInfo: getChatInfoMock, - getChatMembers: getChatMembersMock, - getFeishuMemberInfo: getFeishuMemberInfoMock, - getMessageFeishu: getMessageFeishuMock, - listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock, - listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock, - listPinsFeishu: listPinsFeishuMock, - listReactionsFeishu: listReactionsFeishuMock, - probeFeishu: probeFeishuMock, - removePinFeishu: removePinFeishuMock, - removeReactionFeishu: removeReactionFeishuMock, - sendCardFeishu: sendCardFeishuMock, - sendMessageFeishu: sendMessageFeishuMock, + feishuChannelRuntime: { + addReactionFeishu: addReactionFeishuMock, + createPinFeishu: createPinFeishuMock, + editMessageFeishu: editMessageFeishuMock, + getChatInfo: getChatInfoMock, + getChatMembers: getChatMembersMock, + getFeishuMemberInfo: getFeishuMemberInfoMock, + getMessageFeishu: getMessageFeishuMock, + listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock, + listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveMock, + listPinsFeishu: listPinsFeishuMock, + listReactionsFeishu: listReactionsFeishuMock, + probeFeishu: probeFeishuMock, + removePinFeishu: removePinFeishuMock, + removeReactionFeishu: removeReactionFeishuMock, + sendCardFeishu: sendCardFeishuMock, + sendMessageFeishu: sendMessageFeishuMock, + feishuOutbound: { + sendText: vi.fn(), + sendMedia: vi.fn(), + }, + }, })); import { feishuPlugin } from "./channel.js"; diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 1964331e7e0..c2df79e0028 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -12,6 +12,7 @@ import { PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/feishu"; import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { resolveFeishuAccount, resolveFeishuCredentials, @@ -41,9 +42,12 @@ const meta: ChannelMeta = { order: 70, }; -async function loadFeishuChannelRuntime() { - return await import("./channel.runtime.js"); -} +type FeishuChannelRuntime = typeof import("./channel.runtime.js").feishuChannelRuntime; + +const loadFeishuChannelRuntime = createLazyRuntimeSurface( + () => import("./channel.runtime.js"), + ({ feishuChannelRuntime }) => feishuChannelRuntime, +); function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts index cecb0b0512c..988e04d80ca 100644 --- a/extensions/feishu/src/monitor.bot-menu.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -5,7 +5,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; import type { ResolvedFeishuAccount } from "./types.js"; diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index 001b8140f80..048aed2247e 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -5,7 +5,7 @@ import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js"; import * as dedup from "./dedup.js"; import { monitorSingleAccount } from "./monitor.account.js"; diff --git a/extensions/feishu/src/subagent-hooks.test.ts b/extensions/feishu/src/subagent-hooks.test.ts index df2c276ad95..87450b10265 100644 --- a/extensions/feishu/src/subagent-hooks.test.ts +++ b/extensions/feishu/src/subagent-hooks.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getRequiredHookHandler, registerHookHandlersForTest, -} from "../../test-utils/subagent-hooks.js"; +} from "../../../test/helpers/extensions/subagent-hooks.js"; import { registerFeishuSubagentHooks } from "./subagent-hooks.js"; import { __testing as threadBindingTesting, diff --git a/extensions/github-copilot/usage.test.ts b/extensions/github-copilot/usage.test.ts index 0bc97974d70..f0687c33b0a 100644 --- a/extensions/github-copilot/usage.test.ts +++ b/extensions/github-copilot/usage.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { + createProviderUsageFetch, + makeResponse, +} from "../../test/helpers/extensions/provider-usage-fetch.js"; import { fetchCopilotUsage } from "./usage.js"; describe("fetchCopilotUsage", () => { diff --git a/extensions/googlechat/src/channel.runtime.ts b/extensions/googlechat/src/channel.runtime.ts index 1e41376c8f5..81f000f95e7 100644 --- a/extensions/googlechat/src/channel.runtime.ts +++ b/extensions/googlechat/src/channel.runtime.ts @@ -8,36 +8,10 @@ import { startGoogleChatMonitor as startGoogleChatMonitorImpl, } from "./monitor.js"; -type ProbeGoogleChat = typeof import("./api.js").probeGoogleChat; -type SendGoogleChatMessage = typeof import("./api.js").sendGoogleChatMessage; -type UploadGoogleChatAttachment = typeof import("./api.js").uploadGoogleChatAttachment; -type ResolveGoogleChatWebhookPath = typeof import("./monitor.js").resolveGoogleChatWebhookPath; -type StartGoogleChatMonitor = typeof import("./monitor.js").startGoogleChatMonitor; - -export function probeGoogleChat(...args: Parameters): ReturnType { - return probeGoogleChatImpl(...args); -} - -export function sendGoogleChatMessage( - ...args: Parameters -): ReturnType { - return sendGoogleChatMessageImpl(...args); -} - -export function uploadGoogleChatAttachment( - ...args: Parameters -): ReturnType { - return uploadGoogleChatAttachmentImpl(...args); -} - -export function resolveGoogleChatWebhookPath( - ...args: Parameters -): ReturnType { - return resolveGoogleChatWebhookPathImpl(...args); -} - -export function startGoogleChatMonitor( - ...args: Parameters -): ReturnType { - return startGoogleChatMonitorImpl(...args); -} +export const googleChatChannelRuntime = { + probeGoogleChat: probeGoogleChatImpl, + sendGoogleChatMessage: sendGoogleChatMessageImpl, + uploadGoogleChatAttachment: uploadGoogleChatAttachmentImpl, + resolveGoogleChatWebhookPath: resolveGoogleChatWebhookPathImpl, + startGoogleChatMonitor: startGoogleChatMonitorImpl, +}; diff --git a/extensions/googlechat/src/channel.startup.test.ts b/extensions/googlechat/src/channel.startup.test.ts index 11c46aa663a..e65aa444314 100644 --- a/extensions/googlechat/src/channel.startup.test.ts +++ b/extensions/googlechat/src/channel.startup.test.ts @@ -4,7 +4,7 @@ import { abortStartedAccount, expectPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index faa1b4e4930..84715321ce8 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -27,6 +27,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/googlechat"; import { GoogleChatConfigSchema } from "openclaw/plugin-sdk/googlechat"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listGoogleChatAccountIds, @@ -47,9 +48,12 @@ import { const meta = getChatChannelMeta("googlechat"); -async function loadGoogleChatChannelRuntime() { - return await import("./channel.runtime.js"); -} +type GoogleChatChannelRuntime = typeof import("./channel.runtime.js").googleChatChannelRuntime; + +const loadGoogleChatChannelRuntime = createLazyRuntimeSurface( + () => import("./channel.runtime.js"), + ({ googleChatChannelRuntime }) => googleChatChannelRuntime, +); const formatAllowFromEntry = (entry: string) => entry diff --git a/extensions/googlechat/src/monitor.webhook-routing.test.ts b/extensions/googlechat/src/monitor.webhook-routing.test.ts index 2258d154449..f5e7c69ef8a 100644 --- a/extensions/googlechat/src/monitor.webhook-routing.test.ts +++ b/extensions/googlechat/src/monitor.webhook-routing.test.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/googlech import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; -import { createMockServerResponse } from "../../test-utils/mock-http-response.js"; +import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { verifyGoogleChatRequest } from "./auth.js"; import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js"; diff --git a/extensions/googlechat/src/setup-surface.test.ts b/extensions/googlechat/src/setup-surface.test.ts index 8ecae3855cc..15d77a46605 100644 --- a/extensions/googlechat/src/setup-surface.test.ts +++ b/extensions/googlechat/src/setup-surface.test.ts @@ -1,8 +1,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { googlechatPlugin } from "./channel.js"; const googlechatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index 17f1b7487d3..9da4e99b1ef 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,5 +1,6 @@ import { createPatchedAccountSetupAdapter, + formatDocsLink, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, @@ -13,7 +14,6 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index 27e8b256ada..54511d284c4 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,5 +1,8 @@ -import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { detectBinary } from "../../../src/plugins/setup-binary.js"; +import { + detectBinary, + setSetupChannelEnabled, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js"; import { createIMessageCliPathTextInput, diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 1ede2ad412d..935546721da 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -3,19 +3,17 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../../../src/channels/plugins/config-helpers.js"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { IMessageConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, -} from "../../../src/plugin-sdk/channel-config-helpers.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk/imessage"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, diff --git a/extensions/irc/src/channel.startup.test.ts b/extensions/irc/src/channel.startup.test.ts index 7b4416d1892..de3526a32d2 100644 --- a/extensions/irc/src/channel.startup.test.ts +++ b/extensions/irc/src/channel.startup.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { expectStopPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedIrcAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 8fbe58e7f22..7dc064930be 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -3,7 +3,7 @@ import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, expectRuntimeCfgFallback, -} from "../../test-utils/send-config.js"; +} from "../../../test/helpers/extensions/send-config.js"; import type { IrcClient } from "./client.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/irc/src/setup-surface.test.ts b/extensions/irc/src/setup-surface.test.ts index 6ac3fb268cc..5741a90ad96 100644 --- a/extensions/irc/src/setup-surface.test.ts +++ b/extensions/irc/src/setup-surface.test.ts @@ -1,8 +1,11 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { ircPlugin } from "./channel.js"; import type { CoreConfig } from "./types.js"; diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index b10d484fbb1..4f474032dc9 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "openclaw/plugin-sdk/line"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; diff --git a/extensions/line/src/channel.startup.test.ts b/extensions/line/src/channel.startup.test.ts index e4de0f38e3b..9f1e10cd6fc 100644 --- a/extensions/line/src/channel.startup.test.ts +++ b/extensions/line/src/channel.startup.test.ts @@ -6,7 +6,7 @@ import type { ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { linePlugin } from "./channel.js"; import { setLineRuntime } from "./runtime.js"; diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts index bf4e560e0df..3c2e6bc05e4 100644 --- a/extensions/line/src/setup-surface.test.ts +++ b/extensions/line/src/setup-surface.test.ts @@ -6,8 +6,11 @@ import { resolveDefaultLineAccountId, resolveLineAccount, } from "../../../src/line/accounts.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; const lineConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 2c5bc9533f3..ced16d90638 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,6 +1,6 @@ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { matrixPlugin } from "./channel.js"; import { setMatrixRuntime } from "./runtime.js"; import { createMatrixBotSdkMock } from "./test-mocks.js"; diff --git a/extensions/matrix/src/channel.runtime.ts b/extensions/matrix/src/channel.runtime.ts index df56d07ff2c..475d53629e1 100644 --- a/extensions/matrix/src/channel.runtime.ts +++ b/extensions/matrix/src/channel.runtime.ts @@ -7,49 +7,12 @@ import { probeMatrix as probeMatrixImpl } from "./matrix/probe.js"; import { sendMessageMatrix as sendMessageMatrixImpl } from "./matrix/send.js"; import { matrixOutbound as matrixOutboundImpl } from "./outbound.js"; import { resolveMatrixTargets as resolveMatrixTargetsImpl } from "./resolve-targets.js"; - -type ListMatrixDirectoryGroupsLive = - typeof import("./directory-live.js").listMatrixDirectoryGroupsLive; -type ListMatrixDirectoryPeersLive = - typeof import("./directory-live.js").listMatrixDirectoryPeersLive; -type ResolveMatrixAuth = typeof import("./matrix/client.js").resolveMatrixAuth; -type ProbeMatrix = typeof import("./matrix/probe.js").probeMatrix; -type SendMessageMatrix = typeof import("./matrix/send.js").sendMessageMatrix; -type ResolveMatrixTargets = typeof import("./resolve-targets.js").resolveMatrixTargets; -type MatrixOutbound = typeof import("./outbound.js").matrixOutbound; - -export function listMatrixDirectoryGroupsLive( - ...args: Parameters -): ReturnType { - return listMatrixDirectoryGroupsLiveImpl(...args); -} - -export function listMatrixDirectoryPeersLive( - ...args: Parameters -): ReturnType { - return listMatrixDirectoryPeersLiveImpl(...args); -} - -export function resolveMatrixAuth( - ...args: Parameters -): ReturnType { - return resolveMatrixAuthImpl(...args); -} - -export function probeMatrix(...args: Parameters): ReturnType { - return probeMatrixImpl(...args); -} - -export function sendMessageMatrix( - ...args: Parameters -): ReturnType { - return sendMessageMatrixImpl(...args); -} - -export function resolveMatrixTargets( - ...args: Parameters -): ReturnType { - return resolveMatrixTargetsImpl(...args); -} - -export const matrixOutbound: MatrixOutbound = { ...matrixOutboundImpl }; +export const matrixChannelRuntime = { + listMatrixDirectoryGroupsLive: listMatrixDirectoryGroupsLiveImpl, + listMatrixDirectoryPeersLive: listMatrixDirectoryPeersLiveImpl, + resolveMatrixAuth: resolveMatrixAuthImpl, + probeMatrix: probeMatrixImpl, + sendMessageMatrix: sendMessageMatrixImpl, + resolveMatrixTargets: resolveMatrixTargetsImpl, + matrixOutbound: { ...matrixOutboundImpl }, +}; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index d82d3eb2bdb..03007151d18 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -15,6 +15,7 @@ import { PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; @@ -38,9 +39,12 @@ import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); -async function loadMatrixChannelRuntime() { - return await import("./channel.runtime.js"); -} +type MatrixChannelRuntime = typeof import("./channel.runtime.js").matrixChannelRuntime; + +const loadMatrixChannelRuntime = createLazyRuntimeSurface( + () => import("./channel.runtime.js"), + ({ matrixChannelRuntime }) => matrixChannelRuntime, +); const meta = { id: "matrix", diff --git a/extensions/mattermost/index.test.ts b/extensions/mattermost/index.test.ts index b2ef565c4d2..d21403111cb 100644 --- a/extensions/mattermost/index.test.ts +++ b/extensions/mattermost/index.test.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/mattermost"; import { describe, expect, it, vi } from "vitest"; -import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import plugin from "./index.js"; function createApi( diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 774f40f99fa..15cf05eb541 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { expectProvidedCfgSkipsRuntimeLoad, expectRuntimeCfgFallback, -} from "../../../test-utils/send-config.js"; +} from "../../../../test/helpers/extensions/send-config.js"; import { parseMattermostTarget, sendMessageMattermost } from "./send.js"; import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js"; diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 790dc8bd33f..fa119a2b44a 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -1,6 +1,6 @@ import type { PluginRuntime, SsrFPolicy } from "openclaw/plugin-sdk/msteams"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { buildMSTeamsAttachmentPlaceholder, buildMSTeamsGraphMessageUrls, diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts index be95e6103ea..df3547d012a 100644 --- a/extensions/msteams/src/channel.directory.test.ts +++ b/extensions/msteams/src/channel.directory.test.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams"; import { describe, expect, it } from "vitest"; -import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.js"; import { msteamsPlugin } from "./channel.js"; describe("msteams directory", () => { diff --git a/extensions/msteams/src/channel.runtime.ts b/extensions/msteams/src/channel.runtime.ts index c55d0fc626a..bc6c36a101b 100644 --- a/extensions/msteams/src/channel.runtime.ts +++ b/extensions/msteams/src/channel.runtime.ts @@ -8,42 +8,11 @@ import { sendAdaptiveCardMSTeams as sendAdaptiveCardMSTeamsImpl, sendMessageMSTeams as sendMessageMSTeamsImpl, } from "./send.js"; - -type ListMSTeamsDirectoryGroupsLive = - typeof import("./directory-live.js").listMSTeamsDirectoryGroupsLive; -type ListMSTeamsDirectoryPeersLive = - typeof import("./directory-live.js").listMSTeamsDirectoryPeersLive; -type MSTeamsOutbound = typeof import("./outbound.js").msteamsOutbound; -type ProbeMSTeams = typeof import("./probe.js").probeMSTeams; -type SendAdaptiveCardMSTeams = typeof import("./send.js").sendAdaptiveCardMSTeams; -type SendMessageMSTeams = typeof import("./send.js").sendMessageMSTeams; - -export function listMSTeamsDirectoryGroupsLive( - ...args: Parameters -): ReturnType { - return listMSTeamsDirectoryGroupsLiveImpl(...args); -} - -export function listMSTeamsDirectoryPeersLive( - ...args: Parameters -): ReturnType { - return listMSTeamsDirectoryPeersLiveImpl(...args); -} - -export const msteamsOutbound: MSTeamsOutbound = { ...msteamsOutboundImpl }; - -export function probeMSTeams(...args: Parameters): ReturnType { - return probeMSTeamsImpl(...args); -} - -export function sendAdaptiveCardMSTeams( - ...args: Parameters -): ReturnType { - return sendAdaptiveCardMSTeamsImpl(...args); -} - -export function sendMessageMSTeams( - ...args: Parameters -): ReturnType { - return sendMessageMSTeamsImpl(...args); -} +export const msTeamsChannelRuntime = { + listMSTeamsDirectoryGroupsLive: listMSTeamsDirectoryGroupsLiveImpl, + listMSTeamsDirectoryPeersLive: listMSTeamsDirectoryPeersLiveImpl, + msteamsOutbound: { ...msteamsOutboundImpl }, + probeMSTeams: probeMSTeamsImpl, + sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsImpl, + sendMessageMSTeams: sendMessageMSTeamsImpl, +}; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index d61a377dd4d..e337566e483 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -14,6 +14,7 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import type { ProbeMSTeamsResult } from "./probe.js"; import { @@ -56,9 +57,12 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; -async function loadMSTeamsChannelRuntime() { - return await import("./channel.runtime.js"); -} +type MSTeamsChannelRuntime = typeof import("./channel.runtime.js").msTeamsChannelRuntime; + +const loadMSTeamsChannelRuntime = createLazyRuntimeSurface( + () => import("./channel.runtime.js"), + ({ msTeamsChannelRuntime }) => msTeamsChannelRuntime, +); export const msteamsPlugin: ChannelPlugin = { id: "msteams", diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts index 90a9da1d352..a41147840ec 100644 --- a/extensions/msteams/src/graph-upload.test.ts +++ b/extensions/msteams/src/graph-upload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js"; describe("graph upload helpers", () => { diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index cc4cf2fb6f0..e67017ed8fc 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk/msteams"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { StoredConversationReference } from "./conversation-store.js"; const graphUploadMockState = vi.hoisted(() => ({ uploadAndShareOneDrive: vi.fn(), diff --git a/extensions/nextcloud-talk/src/channel.startup.test.ts b/extensions/nextcloud-talk/src/channel.startup.test.ts index 5fd0607e753..e0117936f51 100644 --- a/extensions/nextcloud-talk/src/channel.startup.test.ts +++ b/extensions/nextcloud-talk/src/channel.startup.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; import { expectStopPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/nextcloud-talk/src/send.test.ts b/extensions/nextcloud-talk/src/send.test.ts index 3ee178b815d..b82ac1c4309 100644 --- a/extensions/nextcloud-talk/src/send.test.ts +++ b/extensions/nextcloud-talk/src/send.test.ts @@ -3,7 +3,7 @@ import { createSendCfgThreadingRuntime, expectProvidedCfgSkipsRuntimeLoad, expectRuntimeCfgFallback, -} from "../../test-utils/send-config.js"; +} from "../../../test/helpers/extensions/send-config.js"; const hoisted = vi.hoisted(() => ({ loadConfig: vi.fn(), diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 0aa63485951..0bbe7f880bf 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -1,6 +1,6 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/nostr"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStartAccountContext } from "../../test-utils/start-account-context.js"; +import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js"; import { nostrPlugin } from "./channel.js"; import { setNostrRuntime } from "./runtime.js"; diff --git a/extensions/nostr/src/setup-surface.test.ts b/extensions/nostr/src/setup-surface.test.ts index 2985ff3e513..98e479842c5 100644 --- a/extensions/nostr/src/setup-surface.test.ts +++ b/extensions/nostr/src/setup-surface.test.ts @@ -1,8 +1,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/nostr"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { nostrPlugin } from "./channel.js"; const nostrConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 5964919e9d7..e5fe260463b 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -7,7 +7,7 @@ import type { PluginCommandContext, } from "openclaw/plugin-sdk/phone-control"; import { describe, expect, it, vi } from "vitest"; -import { createTestPluginApi } from "../test-utils/plugin-api.js"; +import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import registerPhoneControl from "./index.js"; function createApi(params: { diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 10cf32b383a..bcca049f4d7 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -1,6 +1,6 @@ import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime"; import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js"; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index a1433a34f13..e0c4d5ec0a3 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,5 +1,7 @@ import { createPatchedAccountSetupAdapter, + formatCliCommand, + formatDocsLink, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -14,8 +16,6 @@ import type { ChannelSetupWizard, ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index edcea39d6b1..01ded866785 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,6 +1,9 @@ -import { setSetupChannelEnabled, type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { detectBinary } from "../../../src/plugins/setup-binary.js"; -import { installSignalCli } from "../../../src/plugins/signal-cli-install.js"; +import { + detectBinary, + installSignalCli, + setSetupChannelEnabled, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; import { createSignalCliPathTextInput, diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 60dfd0ed010..3de5af7d57a 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -4,15 +4,15 @@ import { collectAllowlistProviderRestrictSendersWarnings, } from "openclaw/plugin-sdk/channel-policy"; import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, + getChatChannelMeta, + normalizeE164, setAccountEnabledInConfigSection, -} from "../../../src/channels/plugins/config-helpers.js"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { SignalConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { normalizeE164 } from "../../../src/utils.js"; + SignalConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/signal"; import { listSignalAccountIds, resolveDefaultSignalAccountId, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index bafc5fc8c91..5e25f0187b1 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -22,11 +22,11 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, + createSlackActions, type ChannelPlugin, type OpenClawConfig, + type SlackActionContext, } from "openclaw/plugin-sdk/slack"; -import type { SlackActionContext } from "../../../src/agents/tools/slack-actions.js"; -import { createSlackActions } from "../../../src/channels/plugins/slack.actions.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index 9d5114e2961..9ac0bc0eeb1 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -4,7 +4,10 @@ import * as mediaFetch from "../../../../src/media/fetch.js"; import type { SavedMedia } from "../../../../src/media/store.js"; import * as mediaStore from "../../../../src/media/store.js"; import { mockPinnedHostnameResolution } from "../../../../src/test-helpers/ssrf.js"; -import { type FetchMock, withFetchPreconnect } from "../../../test-utils/fetch-mock.js"; +import { + type FetchMock, + withFetchPreconnect, +} from "../../../../test/helpers/extensions/fetch-mock.js"; import { fetchWithSlackAuth, resolveSlackAttachmentContent, diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index af71e5edc52..2a3aad980fa 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -2,6 +2,7 @@ import { createAllowlistSetupWizardProxy, DEFAULT_ACCOUNT_ID, createEnvPatchedAccountSetupAdapter, + formatDocsLink, hasConfiguredSecretInput, type OpenClawConfig, noteChannelLookupFailure, @@ -18,7 +19,6 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index f7a52a72888..d103a329c50 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,4 +1,5 @@ import { + formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, @@ -11,7 +12,6 @@ import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 4471e851097..58dfae35c90 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -3,14 +3,18 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import { patchChannelConfigForAccount } from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { SlackConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + formatDocsLink, + hasConfiguredSecretInput, + patchChannelConfigForAccount, +} from "openclaw/plugin-sdk/setup"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + SlackConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/slack"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/setup-surface.test.ts index 96c17300e0f..5b30c747813 100644 --- a/extensions/synology-chat/src/setup-surface.test.ts +++ b/extensions/synology-chat/src/setup-surface.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { synologyChatPlugin } from "./channel.js"; import { synologyChatSetupWizard } from "./setup-surface.js"; diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index 5b246c94bf1..487df4a2d7a 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawPluginCommandDefinition } from "../test-utils/plugin-command.js"; -import { createPluginRuntimeMock } from "../test-utils/plugin-runtime-mock.js"; +import type { OpenClawPluginCommandDefinition } from "../../test/helpers/extensions/plugin-command.js"; +import { createPluginRuntimeMock } from "../../test/helpers/extensions/plugin-runtime-mock.js"; import register from "./index.js"; function createHarness(config: Record) { diff --git a/extensions/telegram/src/account-inspect.test.ts b/extensions/telegram/src/account-inspect.test.ts index 54915edb61c..735cf4e53bb 100644 --- a/extensions/telegram/src/account-inspect.test.ts +++ b/extensions/telegram/src/account-inspect.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { withEnv } from "../../test-utils/env.js"; +import { withEnv } from "../../../test/helpers/extensions/env.js"; import { inspectTelegramAccount } from "./account-inspect.js"; describe("inspectTelegramAccount SecretRef resolution", () => { diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index 6155b89d0af..ae8a56c66cf 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import * as subsystemModule from "../../../src/logging/subsystem.js"; -import { withEnv } from "../../test-utils/env.js"; +import { withEnv } from "../../../test/helpers/extensions/env.js"; import { listTelegramAccountIds, resetMissingDefaultWarnFlag, diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index 43059cd9b61..3afeb63fbb2 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime"; import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; import { createNativeCommandTestParams, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 9f3eea03954..9d015e770a5 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; type AnyMock = MockFn<(...args: unknown[]) => unknown>; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 3390aa3ff24..1cb0fd98512 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -3,8 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { withEnvAsync } from "../../test-utils/env.js"; -import { useFrozenTime, useRealTime } from "../../test-utils/frozen-time.js"; +import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; +import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js"; import { answerCallbackQuerySpy, botCtorSpy, diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index 48d16361b1a..bac2de59f0b 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -5,7 +5,7 @@ import type { PluginRuntime, } from "openclaw/plugin-sdk/telegram"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; import * as auditModule from "./audit.js"; import { telegramPlugin } from "./channel.js"; diff --git a/extensions/telegram/src/probe.test.ts b/extensions/telegram/src/probe.test.ts index 970e2559540..da6f86f9b80 100644 --- a/extensions/telegram/src/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -1,5 +1,5 @@ import { afterEach, type Mock, describe, expect, it, vi } from "vitest"; -import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js"; import { probeTelegram, resetTelegramProbeFetcherCacheForTests } from "./probe.js"; const resolveTelegramFetch = vi.hoisted(() => vi.fn()); diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index c12a571c642..f313141dab0 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -1,4 +1,4 @@ -import type { MockFn } from "openclaw/plugin-sdk/test-utils"; +import type { MockFn } from "openclaw/plugin-sdk/testing"; import { beforeEach, vi } from "vitest"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 542fffc0500..7e73898f8b1 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,6 +1,8 @@ import { createEnvPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID, + formatCliCommand, + formatDocsLink, patchChannelConfigForAccount, promptResolvedAllowFrom, splitSetupEntries, @@ -8,8 +10,6 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 335213adead..644869dbc60 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -3,12 +3,14 @@ import { createScopedAccountConfigAccessors, createScopedChannelConfigBase, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { TelegramConfigSchema } from "../../../src/config/zod-schema.providers-core.js"; -import { normalizeAccountId } from "../../../src/routing/session-key.js"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + normalizeAccountId, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/telegram"; import { inspectTelegramAccount } from "./account-inspect.js"; import { listTelegramAccountIds, diff --git a/extensions/test-utils/chunk-test-helpers.ts b/extensions/test-utils/chunk-test-helpers.ts deleted file mode 100644 index 643e28e5c24..00000000000 --- a/extensions/test-utils/chunk-test-helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export { countLines, hasBalancedFences } from "../../src/test-utils/chunk-test-helpers.js"; diff --git a/extensions/test-utils/env.ts b/extensions/test-utils/env.ts deleted file mode 100644 index b171aa55a6c..00000000000 --- a/extensions/test-utils/env.ts +++ /dev/null @@ -1 +0,0 @@ -export { captureEnv, withEnv, withEnvAsync } from "../../src/test-utils/env.js"; diff --git a/extensions/test-utils/fetch-mock.ts b/extensions/test-utils/fetch-mock.ts deleted file mode 100644 index 2cd6b65e680..00000000000 --- a/extensions/test-utils/fetch-mock.ts +++ /dev/null @@ -1 +0,0 @@ -export { withFetchPreconnect, type FetchMock } from "../../src/test-utils/fetch-mock.js"; diff --git a/extensions/test-utils/frozen-time.ts b/extensions/test-utils/frozen-time.ts deleted file mode 100644 index ec31962fb76..00000000000 --- a/extensions/test-utils/frozen-time.ts +++ /dev/null @@ -1 +0,0 @@ -export { useFrozenTime, useRealTime } from "../../src/test-utils/frozen-time.js"; diff --git a/extensions/test-utils/mock-http-response.ts b/extensions/test-utils/mock-http-response.ts deleted file mode 100644 index bf0d8bef20c..00000000000 --- a/extensions/test-utils/mock-http-response.ts +++ /dev/null @@ -1 +0,0 @@ -export { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; diff --git a/extensions/test-utils/plugin-registration.ts b/extensions/test-utils/plugin-registration.ts deleted file mode 100644 index 7a7da8ecdad..00000000000 --- a/extensions/test-utils/plugin-registration.ts +++ /dev/null @@ -1 +0,0 @@ -export { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js"; diff --git a/extensions/test-utils/provider-usage-fetch.ts b/extensions/test-utils/provider-usage-fetch.ts deleted file mode 100644 index d70a6e1657a..00000000000 --- a/extensions/test-utils/provider-usage-fetch.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - createProviderUsageFetch, - makeResponse, -} from "../../src/test-utils/provider-usage-fetch.js"; diff --git a/extensions/test-utils/temp-dir.ts b/extensions/test-utils/temp-dir.ts deleted file mode 100644 index 3bd69bcc7b9..00000000000 --- a/extensions/test-utils/temp-dir.ts +++ /dev/null @@ -1 +0,0 @@ -export { withTempDir } from "../../src/test-utils/temp-dir.js"; diff --git a/extensions/test-utils/typed-cases.ts b/extensions/test-utils/typed-cases.ts deleted file mode 100644 index 4b6bd35b1ec..00000000000 --- a/extensions/test-utils/typed-cases.ts +++ /dev/null @@ -1 +0,0 @@ -export { typedCases } from "../../src/test-utils/typed-cases.js"; diff --git a/extensions/tlon/src/setup-surface.test.ts b/extensions/tlon/src/setup-surface.test.ts index f2b53f0df72..e88fd15a89e 100644 --- a/extensions/tlon/src/setup-surface.test.ts +++ b/extensions/tlon/src/setup-surface.test.ts @@ -1,8 +1,11 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/tlon"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { tlonPlugin } from "./channel.js"; const tlonConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts index 43d1739e13f..9926b3c5324 100644 --- a/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts +++ b/extensions/whatsapp/src/accounts.whatsapp-auth.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { captureEnv } from "../../test-utils/env.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; import { hasAnyWhatsAppAuth, listWhatsAppAuthDirs } from "./accounts.js"; describe("hasAnyWhatsAppAuth", () => { diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 6a5184fc059..235942663a8 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -5,7 +5,7 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { setLoggerOverride } from "../../../src/logging.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; -import { withEnvAsync } from "../../test-utils/env.js"; +import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; import { createMockWebListener, createWebListenerFactoryCapture, diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index d1011f5c7f8..eb733d14e0e 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { saveSessionStore } from "../../../../src/config/sessions.js"; -import { withTempDir } from "../../../test-utils/temp-dir.js"; +import { withTempDir } from "../../../../test/helpers/extensions/temp-dir.js"; import { debugMention, isBotMentionedFromTargets, diff --git a/extensions/whatsapp/src/media.test.ts b/extensions/whatsapp/src/media.test.ts index 45f3fbae309..ce3e98c549c 100644 --- a/extensions/whatsapp/src/media.test.ts +++ b/extensions/whatsapp/src/media.test.ts @@ -7,8 +7,8 @@ import { resolveStateDir } from "../../../src/config/paths.js"; import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js"; import { optimizeImageToPng } from "../../../src/media/image-ops.js"; import { mockPinnedHostnameResolution } from "../../../src/test-helpers/ssrf.js"; +import { captureEnv } from "../../../test/helpers/extensions/env.js"; import { sendVoiceMessageDiscord } from "../../discord/src/send.js"; -import { captureEnv } from "../../test-utils/env.js"; import { LocalMediaAccessError, loadWebMedia, diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 7bee33c2ef4..4a87ce4d0f8 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { DEFAULT_ACCOUNT_ID, + formatCliCommand, + formatDocsLink, normalizeAccountId, normalizeAllowFromEntries, normalizeE164, @@ -11,8 +13,6 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js"; import { loginWeb } from "./login.js"; import { whatsappSetupAdapter } from "./setup-core.js"; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 575954a516c..6616ac6911f 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -3,22 +3,20 @@ import { collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, } from "openclaw/plugin-sdk/channel-policy"; -import { buildChannelConfigSchema } from "../../../src/channels/plugins/config-schema.js"; -import { - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, -} from "../../../src/channels/plugins/group-mentions.js"; -import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js"; -import { resolveWhatsAppGroupIntroHint } from "../../../src/channels/plugins/whatsapp-shared.js"; -import { getChatChannelMeta } from "../../../src/channels/registry.js"; -import { WhatsAppConfigSchema } from "../../../src/config/zod-schema.providers-whatsapp.js"; import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigDefaultTo, -} from "../../../src/plugin-sdk/channel-config-helpers.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { normalizeE164 } from "../../../src/utils.js"; + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; import { listWhatsAppAccountIds, resolveDefaultWhatsAppAccountId, diff --git a/extensions/zalo/src/actions.runtime.ts b/extensions/zalo/src/actions.runtime.ts index d463edc5b24..0fd0a2c6f58 100644 --- a/extensions/zalo/src/actions.runtime.ts +++ b/extensions/zalo/src/actions.runtime.ts @@ -1,7 +1,5 @@ import { sendMessageZalo as sendMessageZaloImpl } from "./send.js"; -type SendMessageZalo = typeof import("./send.js").sendMessageZalo; - -export function sendMessageZalo(...args: Parameters): ReturnType { - return sendMessageZaloImpl(...args); -} +export const zaloActionsRuntime = { + sendMessageZalo: sendMessageZaloImpl, +}; diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 6f8572b01cd..b492a57a6dc 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -4,14 +4,15 @@ import type { OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; +import { createLazyRuntimeSurface } from "../../../src/shared/lazy-runtime.js"; import { listEnabledZaloAccounts } from "./accounts.js"; -let zaloActionsRuntimePromise: Promise | null = null; +type ZaloActionsRuntime = typeof import("./actions.runtime.js").zaloActionsRuntime; -async function loadZaloActionsRuntime() { - zaloActionsRuntimePromise ??= import("./actions.runtime.js"); - return zaloActionsRuntimePromise; -} +const loadZaloActionsRuntime = createLazyRuntimeSurface( + () => import("./actions.runtime.js"), + ({ zaloActionsRuntime }) => zaloActionsRuntime, +); const providerId = "zalo"; diff --git a/extensions/zalo/src/channel.directory.test.ts b/extensions/zalo/src/channel.directory.test.ts index 8a303e72a97..ac079109736 100644 --- a/extensions/zalo/src/channel.directory.test.ts +++ b/extensions/zalo/src/channel.directory.test.ts @@ -1,6 +1,9 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { createDirectoryTestRuntime, expectDirectorySurface } from "../../test-utils/directory.js"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.js"; import { zaloPlugin } from "./channel.js"; describe("zalo directory", () => { diff --git a/extensions/zalo/src/channel.startup.test.ts b/extensions/zalo/src/channel.startup.test.ts index ea0718d29a2..d99f2397438 100644 --- a/extensions/zalo/src/channel.startup.test.ts +++ b/extensions/zalo/src/channel.startup.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { expectPendingUntilAbort, startAccountAndTrackLifecycle, -} from "../../test-utils/start-account-lifecycle.js"; +} from "../../../test/helpers/extensions/start-account-lifecycle.js"; import type { ResolvedZaloAccount } from "./accounts.js"; const hoisted = vi.hoisted(() => ({ diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index a6e278b6f69..8470a3bce66 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -1,8 +1,11 @@ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter, type WizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { + createTestWizardPrompter, + type WizardPrompter, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { zaloPlugin } from "./channel.js"; const zaloConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/zalo/src/status-issues.test.ts b/extensions/zalo/src/status-issues.test.ts index 581a0dfe916..1187d45a298 100644 --- a/extensions/zalo/src/status-issues.test.ts +++ b/extensions/zalo/src/status-issues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js"; +import { expectOpenDmPolicyConfigIssue } from "../../../test/helpers/extensions/status-issues.js"; import { collectZaloStatusIssues } from "./status-issues.js"; describe("collectZaloStatusIssues", () => { diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index 1aa8dd93bd0..af95c35465b 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -1,8 +1,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalouser"; import { describe, expect, it, vi } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { createTestWizardPrompter } from "../../test-utils/setup-wizard.js"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { createTestWizardPrompter } from "../../../test/helpers/extensions/setup-wizard.js"; vi.mock("./zalo-js.js", async (importOriginal) => { const actual = await importOriginal(); diff --git a/extensions/zalouser/src/status-issues.test.ts b/extensions/zalouser/src/status-issues.test.ts index c1e142c88e8..bd1ae4d4cd4 100644 --- a/extensions/zalouser/src/status-issues.test.ts +++ b/extensions/zalouser/src/status-issues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { expectOpenDmPolicyConfigIssue } from "../../test-utils/status-issues.js"; +import { expectOpenDmPolicyConfigIssue } from "../../../test/helpers/extensions/status-issues.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; describe("collectZalouserStatusIssues", () => { diff --git a/package.json b/package.json index e1e9379c1a8..c6ed4cab402 100644 --- a/package.json +++ b/package.json @@ -278,6 +278,10 @@ "types": "./dist/plugin-sdk/talk-voice.d.ts", "default": "./dist/plugin-sdk/talk-voice.js" }, + "./plugin-sdk/testing": { + "types": "./dist/plugin-sdk/testing.d.ts", + "default": "./dist/plugin-sdk/testing.js" + }, "./plugin-sdk/test-utils": { "types": "./dist/plugin-sdk/test-utils.d.ts", "default": "./dist/plugin-sdk/test-utils.js" diff --git a/scripts/check-no-extension-test-core-imports.ts b/scripts/check-no-extension-test-core-imports.ts index b8e3b1bc764..01d6639df1e 100644 --- a/scripts/check-no-extension-test-core-imports.ts +++ b/scripts/check-no-extension-test-core-imports.ts @@ -6,17 +6,25 @@ const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; hint: string }> = [ pattern: /["']openclaw\/plugin-sdk["']/, hint: "Use openclaw/plugin-sdk/ instead of the monolithic root entry.", }, + { + pattern: /["']openclaw\/plugin-sdk\/test-utils["']/, + hint: "Use openclaw/plugin-sdk/testing for the public extension test seam.", + }, { pattern: /["']openclaw\/plugin-sdk\/compat["']/, hint: "Use a focused public plugin-sdk subpath instead of compat.", }, + { + pattern: /["'](?:\.\.\/)+(?:test-utils\/)[^"']+["']/, + hint: "Use test/helpers/extensions/* for repo-only bundled extension test helpers.", + }, { pattern: /["'](?:\.\.\/)+(?:src\/test-utils\/)[^"']+["']/, - hint: "Use extensions/test-utils/* bridges for shared extension test helpers.", + hint: "Use test/helpers/extensions/* for repo-only helpers, or openclaw/plugin-sdk/testing for public seams.", }, { pattern: /["'](?:\.\.\/)+(?:src\/plugins\/types\.js)["']/, - hint: "Use public plugin-sdk/core types or extensions/test-utils bridges instead.", + hint: "Use public plugin-sdk/core types or test/helpers/extensions/* instead.", }, ]; diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 801cebcd462..6e41a759867 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -59,6 +59,7 @@ "qwen-portal-auth", "synology-chat", "talk-voice", + "testing", "test-utils", "thread-ownership", "tlon", diff --git a/src/agents/auth-profiles.runtime.ts b/src/agents/auth-profiles.runtime.ts index 5c25bb97c84..7e2da31c058 100644 --- a/src/agents/auth-profiles.runtime.ts +++ b/src/agents/auth-profiles.runtime.ts @@ -1 +1,9 @@ -export { ensureAuthProfileStore } from "./auth-profiles.js"; +import { ensureAuthProfileStore as ensureAuthProfileStoreImpl } from "./auth-profiles.js"; + +type EnsureAuthProfileStore = typeof import("./auth-profiles.js").ensureAuthProfileStore; + +export function ensureAuthProfileStore( + ...args: Parameters +): ReturnType { + return ensureAuthProfileStoreImpl(...args); +} diff --git a/src/agents/command-poll-backoff.runtime.ts b/src/agents/command-poll-backoff.runtime.ts index 1667abba083..87494f4013f 100644 --- a/src/agents/command-poll-backoff.runtime.ts +++ b/src/agents/command-poll-backoff.runtime.ts @@ -1 +1,9 @@ -export { pruneStaleCommandPolls } from "./command-poll-backoff.js"; +import { pruneStaleCommandPolls as pruneStaleCommandPollsImpl } from "./command-poll-backoff.js"; + +type PruneStaleCommandPolls = typeof import("./command-poll-backoff.js").pruneStaleCommandPolls; + +export function pruneStaleCommandPolls( + ...args: Parameters +): ReturnType { + return pruneStaleCommandPollsImpl(...args); +} diff --git a/src/agents/model-suppression.runtime.ts b/src/agents/model-suppression.runtime.ts index 472a662b810..7d39bf2b8a3 100644 --- a/src/agents/model-suppression.runtime.ts +++ b/src/agents/model-suppression.runtime.ts @@ -1 +1,10 @@ -export { shouldSuppressBuiltInModel } from "./model-suppression.js"; +import { shouldSuppressBuiltInModel as shouldSuppressBuiltInModelImpl } from "./model-suppression.js"; + +type ShouldSuppressBuiltInModel = + typeof import("./model-suppression.js").shouldSuppressBuiltInModel; + +export function shouldSuppressBuiltInModel( + ...args: Parameters +): ReturnType { + return shouldSuppressBuiltInModelImpl(...args); +} diff --git a/src/agents/openclaw-tools.image-generation.test.ts b/src/agents/openclaw-tools.image-generation.test.ts new file mode 100644 index 00000000000..dd237115ab7 --- /dev/null +++ b/src/agents/openclaw-tools.image-generation.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createOpenClawTools } from "./openclaw-tools.js"; + +vi.mock("../plugins/tools.js", () => ({ + resolvePluginTools: () => [], +})); + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +describe("openclaw tools image generation registration", () => { + it("registers image_generate when image-generation config is present", () => { + const tools = createOpenClawTools({ + config: asConfig({ + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, + }, + }, + }), + agentDir: "/tmp/openclaw-agent-main", + }); + + expect(tools.map((tool) => tool.name)).toContain("image_generate"); + }); + + it("omits image_generate when image-generation config is absent", () => { + const tools = createOpenClawTools({ + config: asConfig({}), + agentDir: "/tmp/openclaw-agent-main", + }); + + expect(tools.map((tool) => tool.name)).not.toContain("image_generate"); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 32bd92f4207..6f4929d288a 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -12,6 +12,7 @@ import { createCanvasTool } from "./tools/canvas-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool } from "./tools/cron-tool.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; +import { createImageGenerateTool } from "./tools/image-generate-tool.js"; import { createImageTool } from "./tools/image-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; @@ -103,6 +104,13 @@ export function createOpenClawTools( modelHasVision: options?.modelHasVision, }) : null; + const imageGenerateTool = createImageGenerateTool({ + config: options?.config, + agentDir: options?.agentDir, + workspaceDir, + sandbox, + fsPolicy: options?.fsPolicy, + }); const pdfTool = options?.agentDir?.trim() ? createPdfTool({ config: options?.config, @@ -163,6 +171,7 @@ export function createOpenClawTools( agentChannel: options?.agentChannel, config: options?.config, }), + ...(imageGenerateTool ? [imageGenerateTool] : []), createGatewayTool({ agentSessionKey: options?.agentSessionKey, config: options?.config, diff --git a/src/agents/pi-embedded-runner/compact.runtime.ts b/src/agents/pi-embedded-runner/compact.runtime.ts index 33c4ed7066a..f6230265bac 100644 --- a/src/agents/pi-embedded-runner/compact.runtime.ts +++ b/src/agents/pi-embedded-runner/compact.runtime.ts @@ -1 +1,9 @@ -export { compactEmbeddedPiSessionDirect } from "./compact.js"; +import { compactEmbeddedPiSessionDirect as compactEmbeddedPiSessionDirectImpl } from "./compact.js"; + +type CompactEmbeddedPiSessionDirect = typeof import("./compact.js").compactEmbeddedPiSessionDirect; + +export function compactEmbeddedPiSessionDirect( + ...args: Parameters +): ReturnType { + return compactEmbeddedPiSessionDirectImpl(...args); +} diff --git a/src/agents/pi-embedded-runner/extensions.test.ts b/src/agents/pi-embedded-runner/extensions.test.ts index 948d96bd9df..e3f412cafd0 100644 --- a/src/agents/pi-embedded-runner/extensions.test.ts +++ b/src/agents/pi-embedded-runner/extensions.test.ts @@ -24,6 +24,16 @@ function buildSafeguardFactories(cfg: OpenClawConfig) { return { factories, sessionManager }; } +function expectSafeguardRuntime( + cfg: OpenClawConfig, + expectedRuntime: { qualityGuardEnabled: boolean; qualityGuardMaxRetries?: number }, +) { + const { factories, sessionManager } = buildSafeguardFactories(cfg); + + expect(factories).toContain(compactionSafeguardExtension); + expect(getCompactionSafeguardRuntime(sessionManager)).toMatchObject(expectedRuntime); +} + describe("buildEmbeddedExtensionFactories", () => { it("does not opt safeguard mode into quality-guard retries", () => { const cfg = { @@ -35,10 +45,7 @@ describe("buildEmbeddedExtensionFactories", () => { }, }, } as OpenClawConfig; - const { factories, sessionManager } = buildSafeguardFactories(cfg); - - expect(factories).toContain(compactionSafeguardExtension); - expect(getCompactionSafeguardRuntime(sessionManager)).toMatchObject({ + expectSafeguardRuntime(cfg, { qualityGuardEnabled: false, }); }); @@ -57,10 +64,7 @@ describe("buildEmbeddedExtensionFactories", () => { }, }, } as OpenClawConfig; - const { factories, sessionManager } = buildSafeguardFactories(cfg); - - expect(factories).toContain(compactionSafeguardExtension); - expect(getCompactionSafeguardRuntime(sessionManager)).toMatchObject({ + expectSafeguardRuntime(cfg, { qualityGuardEnabled: true, qualityGuardMaxRetries: 2, }); diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts index 2f2d7d0260c..7b7ce460826 100644 --- a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts @@ -25,14 +25,18 @@ function expectClearedTimeoutState(onTimeout: ReturnType, timedOut function buildAggregateTimeoutParams( overrides: Partial & Pick, -): AggregateTimeoutParams & { onTimeout: ReturnType } { - const onTimeout = overrides.onTimeout ?? vi.fn(); +): { params: AggregateTimeoutParams; onTimeoutSpy: ReturnType } { + const onTimeoutSpy = vi.fn(); + const onTimeout = overrides.onTimeout ?? (() => onTimeoutSpy()); return { - waitForCompactionRetry: overrides.waitForCompactionRetry, - abortable: overrides.abortable ?? (async (promise) => await promise), - aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, - isCompactionStillInFlight: overrides.isCompactionStillInFlight, - onTimeout, + params: { + waitForCompactionRetry: overrides.waitForCompactionRetry, + abortable: overrides.abortable ?? (async (promise) => await promise), + aggregateTimeoutMs: overrides.aggregateTimeoutMs ?? 60_000, + isCompactionStillInFlight: overrides.isCompactionStillInFlight, + onTimeout, + }, + onTimeoutSpy, }; } @@ -40,7 +44,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { it("times out and fires callback when compaction retry never resolves", async () => { await withFakeTimers(async () => { const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); - const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); + const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry }); const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); @@ -48,7 +52,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(params.onTimeout, true); + expectClearedTimeoutState(onTimeoutSpy, true); }); }); @@ -68,14 +72,15 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { waitForCompactionRetry, isCompactionStillInFlight: () => compactionInFlight, }); + const { params: aggregateTimeoutParams, onTimeoutSpy } = params; - const resultPromise = waitForCompactionRetryWithAggregateTimeout(params); + const resultPromise = waitForCompactionRetryWithAggregateTimeout(aggregateTimeoutParams); await vi.advanceTimersByTimeAsync(170_000); const result = await resultPromise; expect(result.timedOut).toBe(false); - expectClearedTimeoutState(params.onTimeout, false); + expectClearedTimeoutState(onTimeoutSpy, false); }); }); @@ -86,7 +91,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { setTimeout(() => { compactionInFlight = false; }, 90_000); - const params = buildAggregateTimeoutParams({ + const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry, isCompactionStillInFlight: () => compactionInFlight, }); @@ -97,19 +102,19 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const result = await resultPromise; expect(result.timedOut).toBe(true); - expectClearedTimeoutState(params.onTimeout, true); + expectClearedTimeoutState(onTimeoutSpy, true); }); }); it("does not time out when compaction retry resolves", async () => { await withFakeTimers(async () => { const waitForCompactionRetry = vi.fn(async () => {}); - const params = buildAggregateTimeoutParams({ waitForCompactionRetry }); + const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry }); const result = await waitForCompactionRetryWithAggregateTimeout(params); expect(result.timedOut).toBe(false); - expectClearedTimeoutState(params.onTimeout, false); + expectClearedTimeoutState(onTimeoutSpy, false); }); }); @@ -118,7 +123,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { const abortError = new Error("aborted"); abortError.name = "AbortError"; const waitForCompactionRetry = vi.fn(async () => await new Promise(() => {})); - const params = buildAggregateTimeoutParams({ + const { params, onTimeoutSpy } = buildAggregateTimeoutParams({ waitForCompactionRetry, abortable: async () => { throw abortError; @@ -127,7 +132,7 @@ describe("waitForCompactionRetryWithAggregateTimeout", () => { await expect(waitForCompactionRetryWithAggregateTimeout(params)).rejects.toThrow("aborted"); - expectClearedTimeoutState(params.onTimeout, false); + expectClearedTimeoutState(onTimeoutSpy, false); }); }); }); diff --git a/src/agents/pi-embedded-runner/run/failover-observation.test.ts b/src/agents/pi-embedded-runner/run/failover-observation.test.ts index 763540f9ca7..71363915b46 100644 --- a/src/agents/pi-embedded-runner/run/failover-observation.test.ts +++ b/src/agents/pi-embedded-runner/run/failover-observation.test.ts @@ -1,21 +1,31 @@ import { describe, expect, it } from "vitest"; import { normalizeFailoverDecisionObservationBase } from "./failover-observation.js"; +function normalizeObservation( + overrides: Partial[0]>, +) { + return normalizeFailoverDecisionObservationBase({ + stage: "assistant", + runId: "run:base", + rawError: "", + failoverReason: null, + profileFailureReason: null, + provider: "openai", + model: "mock-1", + profileId: "openai:p1", + fallbackConfigured: false, + timedOut: false, + aborted: false, + ...overrides, + }); +} + describe("normalizeFailoverDecisionObservationBase", () => { it("fills timeout observation reasons for deadline timeouts without provider error text", () => { expect( - normalizeFailoverDecisionObservationBase({ - stage: "assistant", + normalizeObservation({ runId: "run:timeout", - rawError: "", - failoverReason: null, - profileFailureReason: null, - provider: "openai", - model: "mock-1", - profileId: "openai:p1", - fallbackConfigured: false, timedOut: true, - aborted: false, }), ).toMatchObject({ failoverReason: "timeout", @@ -26,18 +36,13 @@ describe("normalizeFailoverDecisionObservationBase", () => { it("preserves explicit failover reasons", () => { expect( - normalizeFailoverDecisionObservationBase({ - stage: "assistant", + normalizeObservation({ runId: "run:overloaded", rawError: '{"error":{"type":"overloaded_error"}}', failoverReason: "overloaded", profileFailureReason: "overloaded", - provider: "openai", - model: "mock-1", - profileId: "openai:p1", fallbackConfigured: true, timedOut: true, - aborted: false, }), ).toMatchObject({ failoverReason: "overloaded", diff --git a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts index dbed0335435..03e532eda2e 100644 --- a/src/agents/pi-embedded-runner/run/history-image-prune.test.ts +++ b/src/agents/pi-embedded-runner/run/history-image-prune.test.ts @@ -4,6 +4,28 @@ import { describe, expect, it } from "vitest"; import { castAgentMessage } from "../../test-helpers/agent-message-fixtures.js"; import { PRUNED_HISTORY_IMAGE_MARKER, pruneProcessedHistoryImages } from "./history-image-prune.js"; +function expectArrayMessageContent( + message: AgentMessage | undefined, + errorMessage: string, +): Array<{ type: string; text?: string; data?: string }> { + if (!message || !("content" in message) || !Array.isArray(message.content)) { + throw new Error(errorMessage); + } + return message.content as Array<{ type: string; text?: string; data?: string }>; +} + +function expectPrunedImageMessage( + messages: AgentMessage[], + errorMessage: string, +): Array<{ type: string; text?: string; data?: string }> { + const didMutate = pruneProcessedHistoryImages(messages); + expect(didMutate).toBe(true); + const content = expectArrayMessageContent(messages[0], errorMessage); + expect(content).toHaveLength(2); + expect(content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); + return content; +} + describe("pruneProcessedHistoryImages", () => { const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" }; @@ -19,15 +41,8 @@ describe("pruneProcessedHistoryImages", () => { }), ]; - const didMutate = pruneProcessedHistoryImages(messages); - - expect(didMutate).toBe(true); - const firstUser = messages[0] as Extract | undefined; - expect(Array.isArray(firstUser?.content)).toBe(true); - const content = firstUser?.content as Array<{ type: string; text?: string; data?: string }>; - expect(content).toHaveLength(2); + const content = expectPrunedImageMessage(messages, "expected user array content"); expect(content[0]?.type).toBe("text"); - expect(content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); }); it("does not prune latest user message when no assistant response exists yet", () => { @@ -41,12 +56,9 @@ describe("pruneProcessedHistoryImages", () => { const didMutate = pruneProcessedHistoryImages(messages); expect(didMutate).toBe(false); - const first = messages[0] as Extract | undefined; - if (!first || !Array.isArray(first.content)) { - throw new Error("expected array content"); - } - expect(first.content).toHaveLength(2); - expect(first.content[1]).toMatchObject({ type: "image", data: "abc" }); + const content = expectArrayMessageContent(messages[0], "expected user array content"); + expect(content).toHaveLength(2); + expect(content[1]).toMatchObject({ type: "image", data: "abc" }); }); it("prunes image blocks from toolResult messages that already have assistant replies", () => { @@ -62,15 +74,7 @@ describe("pruneProcessedHistoryImages", () => { }), ]; - const didMutate = pruneProcessedHistoryImages(messages); - - expect(didMutate).toBe(true); - const firstTool = messages[0] as Extract | undefined; - if (!firstTool || !Array.isArray(firstTool.content)) { - throw new Error("expected toolResult array content"); - } - expect(firstTool.content).toHaveLength(2); - expect(firstTool.content[1]).toMatchObject({ type: "text", text: PRUNED_HISTORY_IMAGE_MARKER }); + expectPrunedImageMessage(messages, "expected toolResult array content"); }); it("does not change messages when no assistant turn exists", () => { diff --git a/src/agents/pi-embedded-runner/run/images.test.ts b/src/agents/pi-embedded-runner/run/images.test.ts index 305290df323..59b3673e90f 100644 --- a/src/agents/pi-embedded-runner/run/images.test.ts +++ b/src/agents/pi-embedded-runner/run/images.test.ts @@ -16,13 +16,29 @@ function expectNoPromptImages(result: { detectedRefs: unknown[]; images: unknown expect(result.images).toHaveLength(0); } +function expectNoImageReferences(prompt: string) { + const refs = detectImageReferences(prompt); + expect(refs).toHaveLength(0); +} + +function expectImageReferenceCount(prompt: string, count: number) { + const refs = detectImageReferences(prompt); + expect(refs).toHaveLength(count); + return refs; +} + +function expectSingleImageReference(prompt: string) { + const refs = expectImageReferenceCount(prompt, 1); + return refs[0]; +} + describe("detectImageReferences", () => { it("detects absolute file paths with common extensions", () => { - const prompt = "Check this image /path/to/screenshot.png and tell me what you see"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference( + "Check this image /path/to/screenshot.png and tell me what you see", + ); - expect(refs).toHaveLength(1); - expect(refs[0]).toEqual({ + expect(ref).toEqual({ raw: "/path/to/screenshot.png", type: "path", resolved: "/path/to/screenshot.png", @@ -30,43 +46,38 @@ describe("detectImageReferences", () => { }); it("detects relative paths starting with ./", () => { - const prompt = "Look at ./images/photo.jpg"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("Look at ./images/photo.jpg"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("./images/photo.jpg"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("./images/photo.jpg"); + expect(ref?.type).toBe("path"); }); it("detects relative paths starting with ../", () => { - const prompt = "The file is at ../screenshots/test.jpeg"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("The file is at ../screenshots/test.jpeg"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("../screenshots/test.jpeg"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("../screenshots/test.jpeg"); + expect(ref?.type).toBe("path"); }); it("detects home directory paths starting with ~/", () => { - const prompt = "My photo is at ~/Pictures/vacation.png"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("My photo is at ~/Pictures/vacation.png"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("~/Pictures/vacation.png"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("~/Pictures/vacation.png"); + expect(ref?.type).toBe("path"); // Resolved path should expand ~ - expect(refs[0]?.resolved?.startsWith("~")).toBe(false); + expect(ref?.resolved?.startsWith("~")).toBe(false); }); it("detects multiple image references in a prompt", () => { - const prompt = ` + const refs = expectImageReferenceCount( + ` Compare these two images: 1. /home/user/photo1.png 2. https://mysite.com/photo2.jpg - `; - const refs = detectImageReferences(prompt); + `, + 1, + ); - expect(refs).toHaveLength(1); expect(refs.some((r) => r.type === "path")).toBe(true); }); @@ -81,121 +92,103 @@ describe("detectImageReferences", () => { }); it("deduplicates repeated image references", () => { - const prompt = "Look at /path/image.png and also /path/image.png again"; - const refs = detectImageReferences(prompt); - - expect(refs).toHaveLength(1); + expectImageReferenceCount("Look at /path/image.png and also /path/image.png again", 1); }); it("dedupe casing follows host filesystem conventions", () => { - const prompt = "Look at /tmp/Image.png and /tmp/image.png"; - const refs = detectImageReferences(prompt); - if (process.platform === "win32") { - expect(refs).toHaveLength(1); + expectImageReferenceCount("Look at /tmp/Image.png and /tmp/image.png", 1); return; } - expect(refs).toHaveLength(2); + expectImageReferenceCount("Look at /tmp/Image.png and /tmp/image.png", 2); }); it("returns empty array when no images found", () => { - const prompt = "Just some text without any image references"; - const refs = detectImageReferences(prompt); - - expect(refs).toHaveLength(0); + expectNoImageReferences("Just some text without any image references"); }); it("ignores non-image file extensions", () => { - const prompt = "Check /path/to/document.pdf and /code/file.ts"; - const refs = detectImageReferences(prompt); - - expect(refs).toHaveLength(0); + expectNoImageReferences("Check /path/to/document.pdf and /code/file.ts"); }); it("handles paths inside quotes (without spaces)", () => { - const prompt = 'The file is at "/path/to/image.png"'; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference('The file is at "/path/to/image.png"'); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("/path/to/image.png"); + expect(ref?.raw).toBe("/path/to/image.png"); }); it("handles paths in parentheses", () => { - const prompt = "See the image (./screenshot.png) for details"; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference("See the image (./screenshot.png) for details"); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("./screenshot.png"); + expect(ref?.raw).toBe("./screenshot.png"); }); it("detects [Image: source: ...] format from messaging systems", () => { - const prompt = `What does this image show? -[Image: source: /Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg]`; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference(`What does this image show? +[Image: source: /Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg]`); - expect(refs).toHaveLength(1); - expect(refs[0]?.raw).toBe("/Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg"); - expect(refs[0]?.type).toBe("path"); + expect(ref?.raw).toBe("/Users/tyleryust/Library/Messages/Attachments/IMG_0043.jpeg"); + expect(ref?.type).toBe("path"); }); it("handles complex message attachment paths", () => { - const prompt = `[Image: source: /Users/tyleryust/Library/Messages/Attachments/23/03/AA4726EA-DB27-4269-BA56-1436936CC134/5E3E286A-F585-4E5E-9043-5BC2AFAFD81BIMG_0043.jpeg]`; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference( + "[Image: source: /Users/tyleryust/Library/Messages/Attachments/23/03/AA4726EA-DB27-4269-BA56-1436936CC134/5E3E286A-F585-4E5E-9043-5BC2AFAFD81BIMG_0043.jpeg]", + ); - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("IMG_0043.jpeg"); + expect(ref?.resolved).toContain("IMG_0043.jpeg"); }); it("detects multiple images in [media attached: ...] format", () => { // Multi-file format uses separate brackets on separate lines - const prompt = `[media attached: 2 files] + const refs = expectImageReferenceCount( + `[media attached: 2 files] [media attached 1/2: /Users/tyleryust/.openclaw/media/IMG_6430.jpeg (image/jpeg)] [media attached 2/2: /Users/tyleryust/.openclaw/media/IMG_6431.jpeg (image/jpeg)] -what about these images?`; - const refs = detectImageReferences(prompt); +what about these images?`, + 2, + ); - expect(refs).toHaveLength(2); expect(refs[0]?.resolved).toContain("IMG_6430.jpeg"); expect(refs[1]?.resolved).toContain("IMG_6431.jpeg"); }); it("does not double-count path and url in same bracket", () => { // Single file with URL (| separates path from url, not multiple files) - const prompt = `[media attached: /cache/IMG_6430.jpeg (image/jpeg) | /cache/IMG_6430.jpeg]`; - const refs = detectImageReferences(prompt); + const ref = expectSingleImageReference( + "[media attached: /cache/IMG_6430.jpeg (image/jpeg) | /cache/IMG_6430.jpeg]", + ); - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("IMG_6430.jpeg"); + expect(ref?.resolved).toContain("IMG_6430.jpeg"); }); it("ignores remote URLs entirely (local-only)", () => { - const prompt = `To send an image: MEDIA:https://example.com/image.jpg + const refs = expectImageReferenceCount( + `To send an image: MEDIA:https://example.com/image.jpg Here is my actual image: /path/to/real.png -Also https://cdn.mysite.com/img.jpg`; - const refs = detectImageReferences(prompt); +Also https://cdn.mysite.com/img.jpg`, + 1, + ); - expect(refs).toHaveLength(1); expect(refs[0]?.raw).toBe("/path/to/real.png"); }); it("handles single file format with URL (no index)", () => { - const prompt = `[media attached: /cache/photo.jpeg (image/jpeg) | https://example.com/url] -what is this?`; - const refs = detectImageReferences(prompt); + const ref = + expectSingleImageReference(`[media attached: /cache/photo.jpeg (image/jpeg) | https://example.com/url] +what is this?`); - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("photo.jpeg"); + expect(ref?.resolved).toContain("photo.jpeg"); }); it("handles paths with spaces in filename", () => { // URL after | is https, not a local path, so only the local path should be detected - const prompt = `[media attached: /Users/test/.openclaw/media/ChatGPT Image Apr 21, 2025.png (image/png) | https://example.com/same.png] -what is this?`; - const refs = detectImageReferences(prompt); + const ref = + expectSingleImageReference(`[media attached: /Users/test/.openclaw/media/ChatGPT Image Apr 21, 2025.png (image/png) | https://example.com/same.png] +what is this?`); // Only 1 ref - the local path (example.com URLs are skipped) - expect(refs).toHaveLength(1); - expect(refs[0]?.resolved).toContain("ChatGPT Image Apr 21, 2025.png"); + expect(ref?.resolved).toContain("ChatGPT Image Apr 21, 2025.png"); }); }); diff --git a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts index 61cb7da7891..5aa8dfe7fd6 100644 --- a/src/agents/pi-embedded-runner/run/payloads.errors.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.errors.test.ts @@ -40,6 +40,18 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads[0]?.text).toBe(OVERLOADED_FALLBACK_TEXT); }; + function expectSinglePayloadSummary( + payloads: ReturnType, + expected: { text: string; isError?: boolean }, + ) { + expectSinglePayloadText(payloads, expected.text); + if (expected.isError === undefined) { + expect(payloads[0]?.isError).toBeUndefined(); + return; + } + expect(payloads[0]?.isError).toBe(expected.isError); + } + function expectNoPayloads(params: Parameters[0]) { const payloads = buildPayloads(params); expect(payloads).toHaveLength(0); @@ -100,9 +112,10 @@ describe("buildEmbeddedRunPayloads", () => { model: "claude-3-5-sonnet", }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe(formatBillingErrorMessage("Anthropic", "claude-3-5-sonnet")); - expect(payloads[0]?.isError).toBe(true); + expectSinglePayloadSummary(payloads, { + text: formatBillingErrorMessage("Anthropic", "claude-3-5-sonnet"), + isError: true, + }); }); it("does not emit a synthetic billing error for successful turns with stale errorMessage", () => { @@ -242,52 +255,32 @@ describe("buildEmbeddedRunPayloads", () => { lastToolError: { toolName: "browser", error: "connection timeout" }, }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.isError).toBeUndefined(); - expect(payloads[0]?.text).toContain("recovered"); - }); - - it("suppresses recoverable tool errors containing 'required' for non-mutating tools", () => { - const payloads = buildPayloads({ - lastToolError: { toolName: "browser", error: "url required" }, + expectSinglePayloadSummary(payloads, { + text: "Checked the page and recovered with final answer.", }); - - // Recoverable errors should not be sent to the user - expect(payloads).toHaveLength(0); }); - it("suppresses recoverable tool errors containing 'missing' for non-mutating tools", () => { - const payloads = buildPayloads({ - lastToolError: { toolName: "browser", error: "url missing" }, - }); - - expect(payloads).toHaveLength(0); - }); - - it("suppresses recoverable tool errors containing 'invalid' for non-mutating tools", () => { - const payloads = buildPayloads({ - lastToolError: { toolName: "browser", error: "invalid parameter: url" }, - }); - - expect(payloads).toHaveLength(0); - }); + it.each(["url required", "url missing", "invalid parameter: url"])( + "suppresses recoverable non-mutating tool error: %s", + (error) => { + expectNoPayloads({ + lastToolError: { toolName: "browser", error }, + }); + }, + ); it("suppresses non-mutating non-recoverable tool errors when messages.suppressToolErrors is enabled", () => { - const payloads = buildPayloads({ + expectNoPayloads({ lastToolError: { toolName: "browser", error: "connection timeout" }, config: { messages: { suppressToolErrors: true } }, }); - - expect(payloads).toHaveLength(0); }); it("suppresses mutating tool errors when suppressToolErrorWarnings is enabled", () => { - const payloads = buildPayloads({ + expectNoPayloads({ lastToolError: { toolName: "exec", error: "command not found" }, suppressToolErrorWarnings: true, }); - - expect(payloads).toHaveLength(0); }); it.each([ @@ -346,8 +339,7 @@ describe("buildEmbeddedRunPayloads", () => { }, }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe("Status loaded."); + expectSinglePayloadSummary(payloads, { text: "Status loaded." }); }); it("dedupes identical tool warning text already present in assistant output", () => { @@ -371,8 +363,7 @@ describe("buildEmbeddedRunPayloads", () => { }, }); - expect(payloads).toHaveLength(1); - expect(payloads[0]?.text).toBe(warningText); + expectSinglePayloadSummary(payloads, { text: warningText ?? "" }); }); it("includes non-recoverable tool error details when verbose mode is on", () => { diff --git a/src/agents/pi-embedded-runner/run/payloads.test.ts b/src/agents/pi-embedded-runner/run/payloads.test.ts index 52a88368c50..5fa54d5f57c 100644 --- a/src/agents/pi-embedded-runner/run/payloads.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.test.ts @@ -64,20 +64,22 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => { }); }); - it("suppresses sessions_send errors to avoid leaking transient relay failures", () => { - expectNoPayloads({ + it.each([ + { + name: "default relay failure", lastToolError: { toolName: "sessions_send", error: "delivery timeout" }, - verboseLevel: "on", - }); - }); - - it("suppresses sessions_send errors even when marked mutating", () => { - expectNoPayloads({ + }, + { + name: "mutating relay failure", lastToolError: { toolName: "sessions_send", error: "delivery timeout", mutatingAction: true, }, + }, + ])("suppresses sessions_send errors for $name", ({ lastToolError }) => { + expectNoPayloads({ + lastToolError, verboseLevel: "on", }); }); diff --git a/src/agents/pi-embedded-runner/runs.test.ts b/src/agents/pi-embedded-runner/runs.test.ts index 3a4eb6d3743..82baac1ca1e 100644 --- a/src/agents/pi-embedded-runner/runs.test.ts +++ b/src/agents/pi-embedded-runner/runs.test.ts @@ -10,6 +10,20 @@ import { waitForActiveEmbeddedRuns, } from "./runs.js"; +type RunHandle = Parameters[1]; + +function createRunHandle( + overrides: { isCompacting?: boolean; abort?: () => void } = {}, +): RunHandle { + const abort = overrides.abort ?? (() => {}); + return { + queueMessage: async () => {}, + isStreaming: () => true, + isCompacting: () => overrides.isCompacting ?? false, + abort, + }; +} + describe("pi-embedded runner run registry", () => { afterEach(() => { __testing.resetActiveEmbeddedRuns(); @@ -20,19 +34,12 @@ describe("pi-embedded runner run registry", () => { const abortCompacting = vi.fn(); const abortNormal = vi.fn(); - setActiveEmbeddedRun("session-compacting", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => true, - abort: abortCompacting, - }); + setActiveEmbeddedRun( + "session-compacting", + createRunHandle({ isCompacting: true, abort: abortCompacting }), + ); - setActiveEmbeddedRun("session-normal", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: abortNormal, - }); + setActiveEmbeddedRun("session-normal", createRunHandle({ abort: abortNormal })); const aborted = abortEmbeddedPiRun(undefined, { mode: "compacting" }); expect(aborted).toBe(true); @@ -44,19 +51,9 @@ describe("pi-embedded runner run registry", () => { const abortA = vi.fn(); const abortB = vi.fn(); - setActiveEmbeddedRun("session-a", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => true, - abort: abortA, - }); + setActiveEmbeddedRun("session-a", createRunHandle({ isCompacting: true, abort: abortA })); - setActiveEmbeddedRun("session-b", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: abortB, - }); + setActiveEmbeddedRun("session-b", createRunHandle({ abort: abortB })); const aborted = abortEmbeddedPiRun(undefined, { mode: "all" }); expect(aborted).toBe(true); @@ -67,12 +64,7 @@ describe("pi-embedded runner run registry", () => { it("waits for active runs to drain", async () => { vi.useFakeTimers(); try { - const handle = { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }; + const handle = createRunHandle(); setActiveEmbeddedRun("session-a", handle); setTimeout(() => { clearActiveEmbeddedRun("session-a", handle); @@ -92,12 +84,7 @@ describe("pi-embedded runner run registry", () => { it("returns drained=false when timeout elapses", async () => { vi.useFakeTimers(); try { - setActiveEmbeddedRun("session-a", { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }); + setActiveEmbeddedRun("session-a", createRunHandle()); const waitPromise = waitForActiveEmbeddedRuns(1_000, { pollMs: 100 }); await vi.advanceTimersByTimeAsync(1_000); @@ -118,12 +105,7 @@ describe("pi-embedded runner run registry", () => { import.meta.url, "./runs.js?scope=shared-b", ); - const handle = { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }; + const handle = createRunHandle(); runsA.__testing.resetActiveEmbeddedRuns(); runsB.__testing.resetActiveEmbeddedRuns(); @@ -141,12 +123,7 @@ describe("pi-embedded runner run registry", () => { }); it("tracks and clears per-session transcript snapshots for active runs", () => { - const handle = { - queueMessage: async () => {}, - isStreaming: () => true, - isCompacting: () => false, - abort: vi.fn(), - }; + const handle = createRunHandle(); setActiveEmbeddedRun("session-snapshot", handle); updateActiveEmbeddedRunSnapshot("session-snapshot", { diff --git a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts index 8d42b061b81..437b021cdd7 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.integration.test.ts @@ -31,6 +31,14 @@ async function setupBundledDiffsPlugin() { return { bundledPluginsDir, workspaceDir }; } +async function resolveBundledDiffsSkillEntries(config?: OpenClawConfig) { + const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; + clearPluginManifestRegistryCache(); + + return resolveEmbeddedRunSkillEntries({ workspaceDir, ...(config ? { config } : {}) }); +} + afterEach(async () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledDir; clearPluginManifestRegistryCache(); @@ -41,10 +49,6 @@ afterEach(async () => { describe("resolveEmbeddedRunSkillEntries (integration)", () => { it("loads bundled diffs skill when explicitly enabled in config", async () => { - const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin(); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; - clearPluginManifestRegistryCache(); - const config: OpenClawConfig = { plugins: { entries: { @@ -53,23 +57,14 @@ describe("resolveEmbeddedRunSkillEntries (integration)", () => { }, }; - const result = resolveEmbeddedRunSkillEntries({ - workspaceDir, - config, - }); + const result = await resolveBundledDiffsSkillEntries(config); expect(result.shouldLoadSkillEntries).toBe(true); expect(result.skillEntries.map((entry) => entry.skill.name)).toContain("diffs"); }); it("skips bundled diffs skill when config is missing", async () => { - const { bundledPluginsDir, workspaceDir } = await setupBundledDiffsPlugin(); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; - clearPluginManifestRegistryCache(); - - const result = resolveEmbeddedRunSkillEntries({ - workspaceDir, - }); + const result = await resolveBundledDiffsSkillEntries(); expect(result.shouldLoadSkillEntries).toBe(true); expect(result.skillEntries.map((entry) => entry.skill.name)).not.toContain("diffs"); diff --git a/src/agents/pi-embedded-runner/system-prompt.test.ts b/src/agents/pi-embedded-runner/system-prompt.test.ts index 8e20a95bda7..0ba4ee66d0f 100644 --- a/src/agents/pi-embedded-runner/system-prompt.test.ts +++ b/src/agents/pi-embedded-runner/system-prompt.test.ts @@ -2,7 +2,7 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { applySystemPromptOverrideToSession, createSystemPromptOverride } from "./system-prompt.js"; -type MutableSession = AgentSession & { +type MutableSystemPromptFields = { _baseSystemPrompt?: string; _rebuildSystemPrompt?: (toolNames: string[]) => string; }; @@ -21,7 +21,7 @@ function applyAndGetMutableSession( const { session, setSystemPrompt } = createMockSession(); applySystemPromptOverrideToSession(session, prompt); return { - mutable: session as MutableSession, + mutable: session as unknown as MutableSystemPromptFields, setSystemPrompt, }; } diff --git a/src/agents/pi-embedded-subscribe.tools.media.test.ts b/src/agents/pi-embedded-subscribe.tools.media.test.ts index a07ed71473d..7cf51bb7c1c 100644 --- a/src/agents/pi-embedded-subscribe.tools.media.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.media.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { extractToolResultMediaPaths } from "./pi-embedded-subscribe.tools.js"; +import { + extractToolResultMediaPaths, + isToolResultMediaTrusted, +} from "./pi-embedded-subscribe.tools.js"; describe("extractToolResultMediaPaths", () => { it("returns empty array for null/undefined", () => { @@ -229,4 +232,8 @@ describe("extractToolResultMediaPaths", () => { }; expect(extractToolResultMediaPaths(result)).toEqual(["/tmp/page1.png", "/tmp/page2.png"]); }); + + it("trusts image_generate local MEDIA paths", () => { + expect(isToolResultMediaTrusted("image_generate")).toBe(true); + }); }); diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 08a5e5f80c4..925f56fa6ee 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -142,6 +142,7 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([ "exec", "gateway", "image", + "image_generate", "memory_get", "memory_search", "message", diff --git a/src/agents/pi-tools.before-tool-call.runtime.ts b/src/agents/pi-tools.before-tool-call.runtime.ts index b78a58231a2..95126670e31 100644 --- a/src/agents/pi-tools.before-tool-call.runtime.ts +++ b/src/agents/pi-tools.before-tool-call.runtime.ts @@ -1,7 +1,15 @@ -export { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; -export { logToolLoopAction } from "../logging/diagnostic.js"; -export { +import { getDiagnosticSessionState } from "../logging/diagnostic-session-state.js"; +import { logToolLoopAction } from "../logging/diagnostic.js"; +import { detectToolCallLoop, recordToolCall, recordToolCallOutcome, } from "./tool-loop-detection.js"; + +export const beforeToolCallRuntime = { + getDiagnosticSessionState, + logToolLoopAction, + detectToolCallLoop, + recordToolCall, + recordToolCallOutcome, +}; diff --git a/src/agents/pi-tools.before-tool-call.ts b/src/agents/pi-tools.before-tool-call.ts index 99a470e8bd0..62bf0e0fb59 100644 --- a/src/agents/pi-tools.before-tool-call.ts +++ b/src/agents/pi-tools.before-tool-call.ts @@ -2,6 +2,7 @@ import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import type { SessionState } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { isPlainObject } from "../utils.js"; import { normalizeToolName } from "./tool-policy.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -23,14 +24,11 @@ const adjustedParamsByToolCallId = new Map(); const MAX_TRACKED_ADJUSTED_PARAMS = 1024; const LOOP_WARNING_BUCKET_SIZE = 10; const MAX_LOOP_WARNING_KEYS = 256; -let beforeToolCallRuntimePromise: Promise< - typeof import("./pi-tools.before-tool-call.runtime.js") -> | null = null; -function loadBeforeToolCallRuntime() { - beforeToolCallRuntimePromise ??= import("./pi-tools.before-tool-call.runtime.js"); - return beforeToolCallRuntimePromise; -} +const loadBeforeToolCallRuntime = createLazyRuntimeSurface( + () => import("./pi-tools.before-tool-call.runtime.js"), + ({ beforeToolCallRuntime }) => beforeToolCallRuntime, +); function buildAdjustedParamsKey(params: { runId?: string; toolCallId: string }): string { if (params.runId && params.runId.trim()) { diff --git a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts index 1f4da5163e1..b09571f540f 100644 --- a/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts +++ b/src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts @@ -200,30 +200,30 @@ describe("buildWorkspaceSkillsPrompt", () => { }); it("filters skills based on env/config gates", async () => { const workspaceDir = await createCaseDir("workspace"); - const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro"); + const skillDir = path.join(workspaceDir, "skills", "image-lab"); await writeSkill({ dir: skillDir, - name: "nano-banana-pro", + name: "image-lab", description: "Generates images", metadata: '{"openclaw":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}', - body: "# Nano Banana\n", + body: "# Image Lab\n", }); withEnv({ GEMINI_API_KEY: undefined }, () => { const missingPrompt = buildPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), - config: { skills: { entries: { "nano-banana-pro": { apiKey: "" } } } }, + config: { skills: { entries: { "image-lab": { apiKey: "" } } } }, }); - expect(missingPrompt).not.toContain("nano-banana-pro"); + expect(missingPrompt).not.toContain("image-lab"); const enabledPrompt = buildPrompt(workspaceDir, { managedSkillsDir: path.join(workspaceDir, ".managed"), config: { - skills: { entries: { "nano-banana-pro": { apiKey: "test-key" } } }, // pragma: allowlist secret + skills: { entries: { "image-lab": { apiKey: "test-key" } } }, // pragma: allowlist secret }, }); - expect(enabledPrompt).toContain("nano-banana-pro"); + expect(enabledPrompt).toContain("image-lab"); }); }); it("applies skill filters, including empty lists", async () => { diff --git a/src/agents/skills/env-overrides.runtime.ts b/src/agents/skills/env-overrides.runtime.ts index ab8c4b305fb..6f5ebf3947a 100644 --- a/src/agents/skills/env-overrides.runtime.ts +++ b/src/agents/skills/env-overrides.runtime.ts @@ -1 +1,9 @@ -export { getActiveSkillEnvKeys } from "./env-overrides.js"; +import { getActiveSkillEnvKeys as getActiveSkillEnvKeysImpl } from "./env-overrides.js"; + +type GetActiveSkillEnvKeys = typeof import("./env-overrides.js").getActiveSkillEnvKeys; + +export function getActiveSkillEnvKeys( + ...args: Parameters +): ReturnType { + return getActiveSkillEnvKeysImpl(...args); +} diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 5f4ee932bd7..3ee438db2d4 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -268,6 +268,7 @@ export function buildAgentSystemPrompt(params: { session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override", image: "Analyze an image with the configured image model", + image_generate: "Generate images with the configured image-generation model", }; const toolOrder = [ @@ -295,6 +296,7 @@ export function buildAgentSystemPrompt(params: { "subagents", "session_status", "image", + "image_generate", ]; const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 120a744432c..2f7fa0fc5d6 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -7,5 +7,6 @@ describe("tool-catalog", () => { expect(policy).toBeDefined(); expect(policy!.allow).toContain("web_search"); expect(policy!.allow).toContain("web_fetch"); + expect(policy!.allow).toContain("image_generate"); }); }); diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 445cdc5f10b..0d58c066928 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -233,6 +233,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ profiles: ["coding"], includeInOpenClawGroup: true, }, + { + id: "image_generate", + label: "image_generate", + description: "Image generation", + sectionId: "media", + profiles: ["coding"], + includeInOpenClawGroup: true, + }, { id: "tts", label: "tts", diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts new file mode 100644 index 00000000000..97f324921e3 --- /dev/null +++ b/src/agents/tools/image-generate-tool.test.ts @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as imageGenerationRuntime from "../../image-generation/runtime.js"; +import * as imageOps from "../../media/image-ops.js"; +import * as mediaStore from "../../media/store.js"; +import * as webMedia from "../../plugin-sdk/web-media.js"; +import { createImageGenerateTool } from "./image-generate-tool.js"; + +describe("createImageGenerateTool", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when image-generation model is not configured", () => { + expect(createImageGenerateTool({ config: {} })).toBeNull(); + }); + + it("generates images and returns MEDIA paths", async () => { + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ + provider: "openai", + model: "gpt-image-1", + attempts: [], + images: [ + { + buffer: Buffer.from("png-1"), + mimeType: "image/png", + fileName: "cat-one.png", + }, + { + buffer: Buffer.from("png-2"), + mimeType: "image/png", + fileName: "cat-two.png", + revisedPrompt: "A more cinematic cat", + }, + ], + }); + const saveMediaBuffer = vi.spyOn(mediaStore, "saveMediaBuffer"); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/generated-1.png", + id: "generated-1.png", + size: 5, + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/generated-2.png", + id: "generated-2.png", + size: 5, + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, + }, + }, + }, + agentDir: "/tmp/agent", + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + const result = await tool.execute("call-1", { + prompt: "A cat wearing sunglasses", + model: "openai/gpt-image-1", + count: 2, + size: "1024x1024", + }); + + expect(generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: { + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + }, + }, + }, + }, + prompt: "A cat wearing sunglasses", + agentDir: "/tmp/agent", + modelOverride: "openai/gpt-image-1", + size: "1024x1024", + count: 2, + inputImages: [], + }), + ); + expect(saveMediaBuffer).toHaveBeenNthCalledWith( + 1, + Buffer.from("png-1"), + "image/png", + "tool-image-generation", + undefined, + "cat-one.png", + ); + expect(saveMediaBuffer).toHaveBeenNthCalledWith( + 2, + Buffer.from("png-2"), + "image/png", + "tool-image-generation", + undefined, + "cat-two.png", + ); + expect(result).toMatchObject({ + content: [ + { + type: "text", + text: expect.stringContaining("Generated 2 images with openai/gpt-image-1."), + }, + ], + details: { + provider: "openai", + model: "gpt-image-1", + count: 2, + paths: ["/tmp/generated-1.png", "/tmp/generated-2.png"], + revisedPrompts: ["A more cinematic cat"], + }, + }); + const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + expect(text).toContain("MEDIA:/tmp/generated-1.png"); + expect(text).toContain("MEDIA:/tmp/generated-2.png"); + }); + + it("rejects counts outside the supported range", async () => { + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, + }, + }, + }, + }); + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await expect(tool.execute("call-2", { prompt: "too many cats", count: 5 })).rejects.toThrow( + "count must be between 1 and 4", + ); + }); + + it("forwards reference images and inferred resolution for edit mode", async () => { + const generateImage = vi.spyOn(imageGenerationRuntime, "generateImage").mockResolvedValue({ + provider: "google", + model: "gemini-3-pro-image-preview", + attempts: [], + images: [ + { + buffer: Buffer.from("png-out"), + mimeType: "image/png", + fileName: "edited.png", + }, + ], + }); + vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({ + kind: "image", + buffer: Buffer.from("input-image"), + contentType: "image/png", + }); + vi.spyOn(imageOps, "getImageMetadata").mockResolvedValue({ + width: 3200, + height: 1800, + }); + vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValue({ + path: "/tmp/edited.png", + id: "edited.png", + size: 7, + contentType: "image/png", + }); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3-pro-image-preview", + }, + }, + }, + }, + workspaceDir: process.cwd(), + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + await tool.execute("call-edit", { + prompt: "Add a dramatic stormy sky but keep everything else identical.", + image: "./fixtures/reference.png", + }); + + expect(generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + resolution: "4K", + inputImages: [ + expect.objectContaining({ + buffer: Buffer.from("input-image"), + mimeType: "image/png", + }), + ], + }), + ); + }); + + it("lists registered provider and model options", async () => { + vi.spyOn(imageGenerationRuntime, "listRuntimeImageGenerationProviders").mockReturnValue([ + { + id: "google", + defaultModel: "gemini-3.1-flash-image-preview", + models: ["gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"], + supportedResolutions: ["1K", "2K", "4K"], + supportsImageEditing: true, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + { + id: "openai", + defaultModel: "gpt-image-1", + models: ["gpt-image-1"], + supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], + supportsImageEditing: false, + generateImage: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); + + const tool = createImageGenerateTool({ + config: { + agents: { + defaults: { + imageGenerationModel: { + primary: "google/gemini-3.1-flash-image-preview", + }, + }, + }, + }, + }); + + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image_generate tool"); + } + + const result = await tool.execute("call-list", { action: "list" }); + const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + + expect(text).toContain("google (default gemini-3.1-flash-image-preview)"); + expect(text).toContain("gemini-3.1-flash-image-preview"); + expect(text).toContain("gemini-3-pro-image-preview"); + expect(text).toContain("editing"); + expect(result).toMatchObject({ + details: { + providers: expect.arrayContaining([ + expect.objectContaining({ + id: "google", + defaultModel: "gemini-3.1-flash-image-preview", + models: expect.arrayContaining([ + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image-preview", + ]), + }), + ]), + }, + }); + }); +}); diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts new file mode 100644 index 00000000000..810bfe3ba6f --- /dev/null +++ b/src/agents/tools/image-generate-tool.ts @@ -0,0 +1,424 @@ +import { Type } from "@sinclair/typebox"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/config.js"; +import { + generateImage, + listRuntimeImageGenerationProviders, +} from "../../image-generation/runtime.js"; +import type { + ImageGenerationResolution, + ImageGenerationSourceImage, +} from "../../image-generation/types.js"; +import { getImageMetadata } from "../../media/image-ops.js"; +import { saveMediaBuffer } from "../../media/store.js"; +import { loadWebMedia } from "../../plugin-sdk/web-media.js"; +import { resolveUserPath } from "../../utils.js"; +import { ToolInputError, readNumberParam, readStringParam } from "./common.js"; +import { decodeDataUrl } from "./image-tool.helpers.js"; +import { resolveMediaToolLocalRoots } from "./media-tool-shared.js"; +import { + createSandboxBridgeReadFile, + resolveSandboxedBridgeMediaPath, + type AnyAgentTool, + type SandboxFsBridge, + type ToolFsPolicy, +} from "./tool-runtime.helpers.js"; + +const DEFAULT_COUNT = 1; +const MAX_COUNT = 4; +const MAX_INPUT_IMAGES = 4; +const DEFAULT_RESOLUTION: ImageGenerationResolution = "1K"; + +const ImageGenerateToolSchema = Type.Object({ + action: Type.Optional( + Type.String({ + description: + 'Optional action: "generate" (default) or "list" to inspect available providers/models.', + }), + ), + prompt: Type.Optional(Type.String({ description: "Image generation prompt." })), + image: Type.Optional( + Type.String({ + description: "Optional reference image path or URL for edit mode.", + }), + ), + images: Type.Optional( + Type.Array(Type.String(), { + description: `Optional reference images for edit mode (up to ${MAX_INPUT_IMAGES}).`, + }), + ), + model: Type.Optional( + Type.String({ description: "Optional provider/model override, e.g. openai/gpt-image-1." }), + ), + size: Type.Optional( + Type.String({ + description: + "Optional size hint like 1024x1024, 1536x1024, 1024x1536, 1024x1792, or 1792x1024.", + }), + ), + resolution: Type.Optional( + Type.String({ + description: + "Optional resolution hint: 1K, 2K, or 4K. Useful for Google edit/generation flows.", + }), + ), + count: Type.Optional( + Type.Number({ + description: `Optional number of images to request (1-${MAX_COUNT}).`, + minimum: 1, + maximum: MAX_COUNT, + }), + ), +}); + +function hasConfiguredImageGenerationModel(cfg: OpenClawConfig): boolean { + const configured = cfg.agents?.defaults?.imageGenerationModel; + if (typeof configured === "string") { + return configured.trim().length > 0; + } + if (configured?.primary?.trim()) { + return true; + } + return (configured?.fallbacks ?? []).some((entry) => entry.trim().length > 0); +} + +function resolveAction(args: Record): "generate" | "list" { + const raw = readStringParam(args, "action"); + if (!raw) { + return "generate"; + } + const normalized = raw.trim().toLowerCase(); + if (normalized === "generate" || normalized === "list") { + return normalized; + } + throw new ToolInputError('action must be "generate" or "list"'); +} + +function resolveRequestedCount(args: Record): number { + const count = readNumberParam(args, "count", { integer: true }); + if (count === undefined) { + return DEFAULT_COUNT; + } + if (count < 1 || count > MAX_COUNT) { + throw new ToolInputError(`count must be between 1 and ${MAX_COUNT}`); + } + return count; +} + +function normalizeResolution(raw: string | undefined): ImageGenerationResolution | undefined { + const normalized = raw?.trim().toUpperCase(); + if (!normalized) { + return undefined; + } + if (normalized === "1K" || normalized === "2K" || normalized === "4K") { + return normalized; + } + throw new ToolInputError("resolution must be one of 1K, 2K, or 4K"); +} + +function normalizeReferenceImages(args: Record): string[] { + const imageCandidates: string[] = []; + if (typeof args.image === "string") { + imageCandidates.push(args.image); + } + if (Array.isArray(args.images)) { + imageCandidates.push( + ...args.images.filter((value): value is string => typeof value === "string"), + ); + } + + const seen = new Set(); + const normalized: string[] = []; + for (const candidate of imageCandidates) { + const trimmed = candidate.trim(); + const dedupe = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!dedupe || seen.has(dedupe)) { + continue; + } + seen.add(dedupe); + normalized.push(trimmed); + } + if (normalized.length > MAX_INPUT_IMAGES) { + throw new ToolInputError( + `Too many reference images: ${normalized.length} provided, maximum is ${MAX_INPUT_IMAGES}.`, + ); + } + return normalized; +} + +type ImageGenerateSandboxConfig = { + root: string; + bridge: SandboxFsBridge; +}; + +async function loadReferenceImages(params: { + imageInputs: string[]; + maxBytes?: number; + localRoots: string[]; + sandboxConfig: { root: string; bridge: SandboxFsBridge; workspaceOnly: boolean } | null; +}): Promise< + Array<{ + sourceImage: ImageGenerationSourceImage; + resolvedImage: string; + rewrittenFrom?: string; + }> +> { + const loaded: Array<{ + sourceImage: ImageGenerationSourceImage; + resolvedImage: string; + rewrittenFrom?: string; + }> = []; + + for (const imageRawInput of params.imageInputs) { + const trimmed = imageRawInput.trim(); + const imageRaw = trimmed.startsWith("@") ? trimmed.slice(1).trim() : trimmed; + if (!imageRaw) { + throw new ToolInputError("image required (empty string in array)"); + } + const looksLikeWindowsDrivePath = /^[a-zA-Z]:[\\/]/.test(imageRaw); + const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(imageRaw); + const isFileUrl = /^file:/i.test(imageRaw); + const isHttpUrl = /^https?:\/\//i.test(imageRaw); + const isDataUrl = /^data:/i.test(imageRaw); + if (hasScheme && !looksLikeWindowsDrivePath && !isFileUrl && !isHttpUrl && !isDataUrl) { + throw new ToolInputError( + `Unsupported image reference: ${imageRawInput}. Use a file path, a file:// URL, a data: URL, or an http(s) URL.`, + ); + } + if (params.sandboxConfig && isHttpUrl) { + throw new ToolInputError("Sandboxed image_generate does not allow remote URLs."); + } + + const resolvedImage = (() => { + if (params.sandboxConfig) { + return imageRaw; + } + if (imageRaw.startsWith("~")) { + return resolveUserPath(imageRaw); + } + return imageRaw; + })(); + + const resolvedPathInfo: { resolved: string; rewrittenFrom?: string } = isDataUrl + ? { resolved: "" } + : params.sandboxConfig + ? await resolveSandboxedBridgeMediaPath({ + sandbox: params.sandboxConfig, + mediaPath: resolvedImage, + inboundFallbackDir: "media/inbound", + }) + : { + resolved: resolvedImage.startsWith("file://") + ? resolvedImage.slice("file://".length) + : resolvedImage, + }; + const resolvedPath = isDataUrl ? null : resolvedPathInfo.resolved; + + const media = isDataUrl + ? decodeDataUrl(resolvedImage) + : params.sandboxConfig + ? await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes: params.maxBytes, + sandboxValidated: true, + readFile: createSandboxBridgeReadFile({ sandbox: params.sandboxConfig }), + }) + : await loadWebMedia(resolvedPath ?? resolvedImage, { + maxBytes: params.maxBytes, + localRoots: params.localRoots, + }); + if (media.kind !== "image") { + throw new ToolInputError(`Unsupported media type: ${media.kind}`); + } + + const mimeType = + ("contentType" in media && media.contentType) || + ("mimeType" in media && media.mimeType) || + "image/png"; + + loaded.push({ + sourceImage: { + buffer: media.buffer, + mimeType, + }, + resolvedImage, + ...(resolvedPathInfo.rewrittenFrom ? { rewrittenFrom: resolvedPathInfo.rewrittenFrom } : {}), + }); + } + + return loaded; +} + +async function inferResolutionFromInputImages( + images: ImageGenerationSourceImage[], +): Promise { + let maxDimension = 0; + for (const image of images) { + const meta = await getImageMetadata(image.buffer); + const dimension = Math.max(meta?.width ?? 0, meta?.height ?? 0); + maxDimension = Math.max(maxDimension, dimension); + } + if (maxDimension >= 3000) { + return "4K"; + } + if (maxDimension >= 1500) { + return "2K"; + } + return DEFAULT_RESOLUTION; +} + +export function createImageGenerateTool(options?: { + config?: OpenClawConfig; + agentDir?: string; + workspaceDir?: string; + sandbox?: ImageGenerateSandboxConfig; + fsPolicy?: ToolFsPolicy; +}): AnyAgentTool | null { + const cfg = options?.config ?? loadConfig(); + if (!hasConfiguredImageGenerationModel(cfg)) { + return null; + } + const localRoots = resolveMediaToolLocalRoots(options?.workspaceDir, { + workspaceOnly: options?.fsPolicy?.workspaceOnly === true, + }); + const sandboxConfig = + options?.sandbox && options.sandbox.root.trim() + ? { + root: options.sandbox.root.trim(), + bridge: options.sandbox.bridge, + workspaceOnly: options.fsPolicy?.workspaceOnly === true, + } + : null; + + return { + label: "Image Generation", + name: "image_generate", + description: + 'Generate new images or edit reference images with the configured image-generation model. Use action="list" to inspect available providers/models. Generated images are delivered automatically from the tool result as MEDIA paths.', + parameters: ImageGenerateToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const action = resolveAction(params); + if (action === "list") { + const providers = listRuntimeImageGenerationProviders({ config: cfg }).map((provider) => ({ + id: provider.id, + ...(provider.label ? { label: provider.label } : {}), + ...(provider.defaultModel ? { defaultModel: provider.defaultModel } : {}), + models: provider.models ?? (provider.defaultModel ? [provider.defaultModel] : []), + ...(provider.supportedSizes ? { supportedSizes: [...provider.supportedSizes] } : {}), + ...(provider.supportedResolutions + ? { supportedResolutions: [...provider.supportedResolutions] } + : {}), + ...(typeof provider.supportsImageEditing === "boolean" + ? { supportsImageEditing: provider.supportsImageEditing } + : {}), + })); + const lines = providers.flatMap((provider) => { + const caps: string[] = []; + if (provider.supportsImageEditing) { + caps.push("editing"); + } + if ((provider.supportedResolutions?.length ?? 0) > 0) { + caps.push(`resolutions ${provider.supportedResolutions?.join("/")}`); + } + if ((provider.supportedSizes?.length ?? 0) > 0) { + caps.push(`sizes ${provider.supportedSizes?.join(", ")}`); + } + const modelLine = + provider.models.length > 0 + ? `models: ${provider.models.join(", ")}` + : "models: unknown"; + return [ + `${provider.id}${provider.defaultModel ? ` (default ${provider.defaultModel})` : ""}`, + ` ${modelLine}`, + ...(caps.length > 0 ? [` capabilities: ${caps.join("; ")}`] : []), + ]; + }); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { providers }, + }; + } + + const prompt = readStringParam(params, "prompt", { required: true }); + const imageInputs = normalizeReferenceImages(params); + const model = readStringParam(params, "model"); + const size = readStringParam(params, "size"); + const explicitResolution = normalizeResolution(readStringParam(params, "resolution")); + const count = resolveRequestedCount(params); + const loadedReferenceImages = await loadReferenceImages({ + imageInputs, + localRoots, + sandboxConfig, + }); + const inputImages = loadedReferenceImages.map((entry) => entry.sourceImage); + const resolution = + explicitResolution ?? + (size + ? undefined + : inputImages.length > 0 + ? await inferResolutionFromInputImages(inputImages) + : undefined); + + const result = await generateImage({ + cfg, + prompt, + agentDir: options?.agentDir, + modelOverride: model, + size, + resolution, + count, + inputImages, + }); + + const savedImages = await Promise.all( + result.images.map((image) => + saveMediaBuffer( + image.buffer, + image.mimeType, + "tool-image-generation", + undefined, + image.fileName, + ), + ), + ); + + const revisedPrompts = result.images + .map((image) => image.revisedPrompt?.trim()) + .filter((entry): entry is string => Boolean(entry)); + const lines = [ + `Generated ${savedImages.length} image${savedImages.length === 1 ? "" : "s"} with ${result.provider}/${result.model}.`, + ...savedImages.map((image) => `MEDIA:${image.path}`), + ]; + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { + provider: result.provider, + model: result.model, + count: savedImages.length, + paths: savedImages.map((image) => image.path), + ...(imageInputs.length === 1 + ? { + image: loadedReferenceImages[0]?.resolvedImage, + ...(loadedReferenceImages[0]?.rewrittenFrom + ? { rewrittenFrom: loadedReferenceImages[0].rewrittenFrom } + : {}), + } + : imageInputs.length > 1 + ? { + images: loadedReferenceImages.map((entry) => ({ + image: entry.resolvedImage, + ...(entry.rewrittenFrom ? { rewrittenFrom: entry.rewrittenFrom } : {}), + })), + } + : {}), + ...(resolution ? { resolution } : {}), + ...(size ? { size } : {}), + attempts: result.attempts, + metadata: result.metadata, + ...(revisedPrompts.length > 0 ? { revisedPrompts } : {}), + }, + }; + }, + }; +} diff --git a/src/channels/plugins/setup-wizard-helpers.runtime.ts b/src/channels/plugins/setup-wizard-helpers.runtime.ts index 8c1808f5d40..9fcdf661643 100644 --- a/src/channels/plugins/setup-wizard-helpers.runtime.ts +++ b/src/channels/plugins/setup-wizard-helpers.runtime.ts @@ -1 +1,8 @@ -export { promptResolvedAllowFrom } from "./setup-wizard-helpers.js"; +import type { promptResolvedAllowFrom as promptResolvedAllowFromType } from "./setup-wizard-helpers.js"; + +export async function promptResolvedAllowFrom( + ...args: Parameters +): ReturnType { + const runtime = await import("./setup-wizard-helpers.js"); + return runtime.promptResolvedAllowFrom(...args); +} diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 9996c155288..1d9d6885fe2 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,4 +1,5 @@ import type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; /** @@ -24,10 +25,11 @@ function createLazySender( channelId: string, loader: () => Promise, ): (...args: unknown[]) => Promise { + const loadRuntimeSend = createLazyRuntimeSurface(loader, ({ runtimeSend }) => runtimeSend); return async (...args: unknown[]) => { let cached = senderCache.get(channelId); if (!cached) { - cached = loader().then(({ runtimeSend }) => runtimeSend); + cached = loadRuntimeSend(); senderCache.set(channelId, cached); } const runtimeSend = await cached; diff --git a/src/commands/model-picker.runtime.ts b/src/commands/model-picker.runtime.ts index 3d033fa3e80..f527f0c5cf8 100644 --- a/src/commands/model-picker.runtime.ts +++ b/src/commands/model-picker.runtime.ts @@ -1,7 +1,15 @@ -export { +import { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js"; +import { resolveProviderModelPickerEntries, resolveProviderPluginChoice, runProviderModelSelectedHook, } from "../plugins/provider-wizard.js"; -export { resolvePluginProviders } from "../plugins/providers.js"; -export { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js"; +import { resolvePluginProviders } from "../plugins/providers.js"; + +export const modelPickerRuntime = { + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + resolvePluginProviders, + runProviderPluginAuthMethod, +}; diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index a4eb89e066c..fc09d5a7f3c 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -40,11 +40,13 @@ const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); const runProviderPluginAuthMethod = vi.hoisted(() => vi.fn()); vi.mock("./model-picker.runtime.js", () => ({ - resolveProviderModelPickerEntries, - resolveProviderPluginChoice, - runProviderModelSelectedHook, - resolvePluginProviders, - runProviderPluginAuthMethod, + modelPickerRuntime: { + resolveProviderModelPickerEntries, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + resolvePluginProviders, + runProviderPluginAuthMethod, + }, })); const OPENROUTER_CATALOG = [ diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index c0b67ea7d7c..cea263f7e58 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -13,6 +13,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; import type { ProviderPlugin } from "../plugins/types.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { formatTokenK } from "./models/shared.js"; @@ -49,6 +50,11 @@ async function loadModelPickerRuntime() { return import("./model-picker.runtime.js"); } +const loadResolvedModelPickerRuntime = createLazyRuntimeSurface( + loadModelPickerRuntime, + ({ modelPickerRuntime }) => modelPickerRuntime, +); + function hasAuthForProvider( provider: string, cfg: OpenClawConfig, @@ -284,7 +290,7 @@ export async function promptDefaultModel( options.push({ value: MANUAL_VALUE, label: "Enter model manually" }); } if (includeProviderPluginSetups && agentDir) { - const { resolveProviderModelPickerEntries } = await loadModelPickerRuntime(); + const { resolveProviderModelPickerEntries } = await loadResolvedModelPickerRuntime(); options.push( ...resolveProviderModelPickerEntries({ config: cfg, @@ -343,7 +349,7 @@ export async function promptDefaultModel( if (selection.startsWith("provider-plugin:")) { pluginResolution = selection; } else if (!selection.includes("/")) { - const { resolvePluginProviders } = await loadModelPickerRuntime(); + const { resolvePluginProviders } = await loadResolvedModelPickerRuntime(); pluginProviders = resolvePluginProviders({ config: cfg, workspaceDir: params.workspaceDir, @@ -368,7 +374,7 @@ export async function promptDefaultModel( resolveProviderPluginChoice, runProviderModelSelectedHook, runProviderPluginAuthMethod, - } = await loadModelPickerRuntime(); + } = await loadResolvedModelPickerRuntime(); if (pluginProviders.length === 0) { pluginProviders = resolvePluginProviders({ config: cfg, @@ -404,7 +410,7 @@ export async function promptDefaultModel( return { model: applied.defaultModel, config: applied.config }; } const model = String(selection); - const { runProviderModelSelectedHook } = await loadModelPickerRuntime(); + const { runProviderModelSelectedHook } = await loadResolvedModelPickerRuntime(); await runProviderModelSelectedHook({ config: cfg, model, diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts index a02dd2f2ee2..05422a839fb 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.runtime.ts @@ -1,5 +1,11 @@ -export { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; -export { +import { resolveProviderPluginChoice } from "../../../plugins/provider-wizard.js"; +import { resolveOwningPluginIdsForProvider, resolvePluginProviders, } from "../../../plugins/providers.js"; + +export const authChoicePluginProvidersRuntime = { + resolveOwningPluginIdsForProvider, + resolveProviderPluginChoice, + resolvePluginProviders, +}; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index 3ccee9bbfd3..bea20a66764 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -11,10 +11,11 @@ const resolveOwningPluginIdsForProvider = vi.hoisted(() => vi.fn(() => undefined const resolveProviderPluginChoice = vi.hoisted(() => vi.fn()); const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("./auth-choice.plugin-providers.runtime.js", () => ({ - resolveOwningPluginIdsForProvider, - resolveProviderPluginChoice, - resolvePluginProviders, - PROVIDER_PLUGIN_CHOICE_PREFIX: "provider-plugin:", + authChoicePluginProvidersRuntime: { + resolveOwningPluginIdsForProvider, + resolveProviderPluginChoice, + resolvePluginProviders, + }, })); beforeEach(() => { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index b7a369e4674..ad6cb853955 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -14,6 +14,7 @@ import type { ProviderResolveNonInteractiveApiKeyParams, } from "../../../plugins/types.js"; import type { RuntimeEnv } from "../../../runtime.js"; +import { createLazyRuntimeSurface } from "../../../shared/lazy-runtime.js"; import type { OnboardOptions } from "../../onboard-types.js"; const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; @@ -22,6 +23,11 @@ async function loadPluginProviderRuntime() { return import("./auth-choice.plugin-providers.runtime.js"); } +const loadAuthChoicePluginProvidersRuntime = createLazyRuntimeSurface( + loadPluginProviderRuntime, + ({ authChoicePluginProvidersRuntime }) => authChoicePluginProvidersRuntime, +); + function buildIsolatedProviderResolutionConfig( cfg: OpenClawConfig, providerId: string | undefined, @@ -81,7 +87,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { preferredProviderId, ); const { resolveOwningPluginIdsForProvider, resolveProviderPluginChoice, resolvePluginProviders } = - await loadPluginProviderRuntime(); + await loadAuthChoicePluginProvidersRuntime(); const owningPluginIds = preferredProviderId ? resolveOwningPluginIdsForProvider({ provider: preferredProviderId, diff --git a/src/commands/status.scan.runtime.ts b/src/commands/status.scan.runtime.ts index 372b31f4803..a783d0a94d6 100644 --- a/src/commands/status.scan.runtime.ts +++ b/src/commands/status.scan.runtime.ts @@ -1,2 +1,7 @@ -export { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; -export { buildChannelsTable } from "./status-all/channels.js"; +import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; +import { buildChannelsTable } from "./status-all/channels.js"; + +export const statusScanRuntime = { + collectChannelStatusIssues, + buildChannelsTable, +}; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 168c2f55017..899aea2b267 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -42,8 +42,10 @@ vi.mock("./status-all/channels.js", () => ({ })); vi.mock("./status.scan.runtime.js", () => ({ - buildChannelsTable: mocks.buildChannelsTable, - collectChannelStatusIssues: vi.fn(() => []), + statusScanRuntime: { + buildChannelsTable: mocks.buildChannelsTable, + collectChannelStatusIssues: vi.fn(() => []), + }, })); vi.mock("./status.update.js", () => ({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 3eb6fc8ed3d..e7d05542743 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -6,14 +6,13 @@ import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; import { readBestEffortConfig } from "../config/config.js"; import { callGateway } from "../gateway/call.js"; +import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js"; import { resolveOsSummary } from "../infra/os-summary.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; +import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; -import type { - buildChannelsTable as buildChannelsTableFn, - collectChannelStatusIssues as collectChannelStatusIssuesFn, -} from "./status.scan.runtime.js"; import { buildTailscaleHttpsUrl, pickGatewaySelfPresence, @@ -30,7 +29,6 @@ import { getUpdateCheckResult } from "./status.update.js"; type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; let pluginRegistryModulePromise: Promise | undefined; -let statusScanRuntimeModulePromise: Promise | undefined; let statusScanDepsRuntimeModulePromise: | Promise | undefined; @@ -40,10 +38,10 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } -function loadStatusScanRuntimeModule() { - statusScanRuntimeModulePromise ??= import("./status.scan.runtime.js"); - return statusScanRuntimeModulePromise; -} +const loadStatusScanRuntimeModule = createLazyRuntimeSurface( + () => import("./status.scan.runtime.js"), + ({ statusScanRuntime }) => statusScanRuntime, +); function loadStatusScanDepsRuntimeModule() { statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); diff --git a/src/commands/status.summary.runtime.ts b/src/commands/status.summary.runtime.ts index df1ae881d4f..e4b08a49856 100644 --- a/src/commands/status.summary.runtime.ts +++ b/src/commands/status.summary.runtime.ts @@ -1,2 +1,8 @@ -export { resolveContextTokensForModel } from "../agents/context.js"; -export { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; +import { resolveContextTokensForModel } from "../agents/context.js"; +import { classifySessionKey, resolveSessionModelRef } from "../gateway/session-utils.js"; + +export const statusSummaryRuntime = { + resolveContextTokensForModel, + classifySessionKey, + resolveSessionModelRef, +}; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index c5c3f174547..c235765b406 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -10,14 +10,12 @@ import { listGatewayAgentsBasic } from "../gateway/agent-list.js"; import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js"; let channelSummaryModulePromise: Promise | undefined; let linkChannelModulePromise: Promise | undefined; -let statusSummaryRuntimeModulePromise: - | Promise - | undefined; let configIoModulePromise: Promise | undefined; function loadChannelSummaryModule() { @@ -30,10 +28,10 @@ function loadLinkChannelModule() { return linkChannelModulePromise; } -function loadStatusSummaryRuntimeModule() { - statusSummaryRuntimeModulePromise ??= import("./status.summary.runtime.js"); - return statusSummaryRuntimeModulePromise; -} +const loadStatusSummaryRuntimeModule = createLazyRuntimeSurface( + () => import("./status.summary.runtime.js"), + ({ statusSummaryRuntime }) => statusSummaryRuntime, +); function loadConfigIoModule() { configIoModulePromise ??= import("../config/io.js"); diff --git a/src/image-generation/live-test-helpers.test.ts b/src/image-generation/live-test-helpers.test.ts new file mode 100644 index 00000000000..3a7058569cf --- /dev/null +++ b/src/image-generation/live-test-helpers.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + parseCaseFilter, + parseCsvFilter, + parseProviderModelMap, + redactLiveApiKey, + resolveConfiguredLiveImageModels, + resolveLiveImageAuthStore, +} from "./live-test-helpers.js"; + +describe("image-generation live-test helpers", () => { + it("parses provider filters and treats empty/all as unfiltered", () => { + expect(parseCsvFilter()).toBeNull(); + expect(parseCsvFilter("all")).toBeNull(); + expect(parseCsvFilter(" openai , google ")).toEqual(new Set(["openai", "google"])); + }); + + it("parses live case filters and treats empty/all as unfiltered", () => { + expect(parseCaseFilter()).toBeNull(); + expect(parseCaseFilter("all")).toBeNull(); + expect(parseCaseFilter(" google:flash , openai:default ")).toEqual( + new Set(["google:flash", "openai:default"]), + ); + }); + + it("parses provider model overrides by provider id", () => { + expect( + parseProviderModelMap("openai/gpt-image-1, google/gemini-3.1-flash-image-preview, invalid"), + ).toEqual( + new Map([ + ["openai", "openai/gpt-image-1"], + ["google", "google/gemini-3.1-flash-image-preview"], + ]), + ); + }); + + it("collects configured models from primary and fallbacks", () => { + const cfg = { + agents: { + defaults: { + imageGenerationModel: { + primary: "openai/gpt-image-1", + fallbacks: ["google/gemini-3.1-flash-image-preview", "invalid"], + }, + }, + }, + } as OpenClawConfig; + + expect(resolveConfiguredLiveImageModels(cfg)).toEqual( + new Map([ + ["openai", "openai/gpt-image-1"], + ["google", "google/gemini-3.1-flash-image-preview"], + ]), + ); + }); + + it("uses an empty auth store when live env keys should override stale profiles", () => { + expect( + resolveLiveImageAuthStore({ + requireProfileKeys: false, + hasLiveKeys: true, + }), + ).toEqual({ + version: 1, + profiles: {}, + }); + }); + + it("keeps profile-store mode when requested or when no live keys exist", () => { + expect( + resolveLiveImageAuthStore({ + requireProfileKeys: true, + hasLiveKeys: true, + }), + ).toBeUndefined(); + expect( + resolveLiveImageAuthStore({ + requireProfileKeys: false, + hasLiveKeys: false, + }), + ).toBeUndefined(); + }); + + it("redacts live API keys for diagnostics", () => { + expect(redactLiveApiKey(undefined)).toBe("none"); + expect(redactLiveApiKey("short-key")).toBe("short-key"); + expect(redactLiveApiKey("sk-proj-1234567890")).toBe("sk-proj-...7890"); + }); +}); diff --git a/src/image-generation/live-test-helpers.ts b/src/image-generation/live-test-helpers.ts new file mode 100644 index 00000000000..0063bab89fa --- /dev/null +++ b/src/image-generation/live-test-helpers.ts @@ -0,0 +1,96 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import type { OpenClawConfig } from "../config/config.js"; + +export const DEFAULT_LIVE_IMAGE_MODELS: Record = { + google: "google/gemini-3.1-flash-image-preview", + openai: "openai/gpt-image-1", +}; + +export function parseCaseFilter(raw?: string): Set | null { + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "all") { + return null; + } + const values = trimmed + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + return values.length > 0 ? new Set(values) : null; +} + +export function redactLiveApiKey(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + return "none"; + } + if (trimmed.length <= 12) { + return trimmed; + } + return `${trimmed.slice(0, 8)}...${trimmed.slice(-4)}`; +} + +export function parseCsvFilter(raw?: string): Set | null { + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "all") { + return null; + } + const values = trimmed + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + return values.length > 0 ? new Set(values) : null; +} + +export function parseProviderModelMap(raw?: string): Map { + const entries = new Map(); + for (const token of raw?.split(",") ?? []) { + const trimmed = token.trim(); + if (!trimmed) { + continue; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) { + continue; + } + entries.set(trimmed.slice(0, slash).trim().toLowerCase(), trimmed); + } + return entries; +} + +export function resolveConfiguredLiveImageModels(cfg: OpenClawConfig): Map { + const resolved = new Map(); + const configured = cfg.agents?.defaults?.imageGenerationModel; + const add = (value: string | undefined) => { + const trimmed = value?.trim(); + if (!trimmed) { + return; + } + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) { + return; + } + resolved.set(trimmed.slice(0, slash).trim().toLowerCase(), trimmed); + }; + if (typeof configured === "string") { + add(configured); + return resolved; + } + add(configured?.primary); + for (const fallback of configured?.fallbacks ?? []) { + add(fallback); + } + return resolved; +} + +export function resolveLiveImageAuthStore(params: { + requireProfileKeys: boolean; + hasLiveKeys: boolean; +}): AuthProfileStore | undefined { + if (params.requireProfileKeys || !params.hasLiveKeys) { + return undefined; + } + return { + version: 1, + profiles: {}, + }; +} diff --git a/src/image-generation/providers/google.live.test.ts b/src/image-generation/providers/google.live.test.ts deleted file mode 100644 index dcf2ddd1108..00000000000 --- a/src/image-generation/providers/google.live.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isTruthyEnvValue } from "../../infra/env.js"; -import { buildGoogleImageGenerationProvider } from "./google.js"; - -const LIVE = - isTruthyEnvValue(process.env.GOOGLE_LIVE_TEST) || - isTruthyEnvValue(process.env.LIVE) || - isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); -const HAS_KEY = Boolean(process.env.GEMINI_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim()); -const MODEL = - process.env.GOOGLE_IMAGE_GENERATION_MODEL?.trim() || - process.env.GEMINI_IMAGE_GENERATION_MODEL?.trim() || - "gemini-3.1-flash-image-preview"; -const BASE_URL = process.env.GOOGLE_IMAGE_BASE_URL?.trim(); - -const describeLive = LIVE && HAS_KEY ? describe : describe.skip; - -function buildLiveConfig(): OpenClawConfig { - if (!BASE_URL) { - return {}; - } - return { - models: { - providers: { - google: { - baseUrl: BASE_URL, - }, - }, - }, - } as unknown as OpenClawConfig; -} - -describeLive("google image-generation live", () => { - it("generates a real image", async () => { - const provider = buildGoogleImageGenerationProvider(); - const result = await provider.generateImage({ - provider: "google", - model: MODEL, - prompt: - "Create a minimal flat illustration of an orange cat face sticker on a white background.", - cfg: buildLiveConfig(), - size: "1024x1024", - }); - - expect(result.model).toBeTruthy(); - expect(result.images.length).toBeGreaterThan(0); - expect(result.images[0]?.mimeType.startsWith("image/")).toBe(true); - expect(result.images[0]?.buffer.byteLength).toBeGreaterThan(512); - }, 120_000); -}); diff --git a/src/image-generation/providers/google.test.ts b/src/image-generation/providers/google.test.ts index 83f7e565a80..224779f3429 100644 --- a/src/image-generation/providers/google.test.ts +++ b/src/image-generation/providers/google.test.ts @@ -131,4 +131,78 @@ describe("Google image-generation provider", () => { model: "gemini-3.1-flash-image-preview", }); }); + + it("sends reference images and explicit resolution for edit flows", async () => { + vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-test-key", + source: "env", + mode: "api-key", + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("png-data").toString("base64"), + }, + }, + ], + }, + }, + ], + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const provider = buildGoogleImageGenerationProvider(); + await provider.generateImage({ + provider: "google", + model: "gemini-3-pro-image-preview", + prompt: "Change only the sky to a sunset.", + cfg: {}, + resolution: "4K", + inputImages: [ + { + buffer: Buffer.from("reference-bytes"), + mimeType: "image/png", + fileName: "reference.png", + }, + ], + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [ + { + inlineData: { + mimeType: "image/png", + data: Buffer.from("reference-bytes").toString("base64"), + }, + }, + { text: "Change only the sky to a sunset." }, + ], + }, + ], + generationConfig: { + responseModalities: ["TEXT", "IMAGE"], + imageConfig: { + aspectRatio: "1:1", + imageSize: "4K", + }, + }, + }), + }), + ); + }); }); diff --git a/src/image-generation/providers/google.ts b/src/image-generation/providers/google.ts index 0519aef7bc3..f7469b147fa 100644 --- a/src/image-generation/providers/google.ts +++ b/src/image-generation/providers/google.ts @@ -79,11 +79,16 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu return { id: "google", label: "Google", + defaultModel: DEFAULT_GOOGLE_IMAGE_MODEL, + models: [DEFAULT_GOOGLE_IMAGE_MODEL, "gemini-3-pro-image-preview"], + supportedResolutions: ["1K", "2K", "4K"], + supportsImageEditing: true, async generateImage(req) { const auth = await resolveApiKeyForProvider({ provider: "google", cfg: req.cfg, agentDir: req.agentDir, + store: req.authStore, }); if (!auth.apiKey) { throw new Error("Google API key missing"); @@ -98,6 +103,16 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu const authHeaders = parseGeminiAuth(auth.apiKey); const headers = new Headers(authHeaders.headers); const imageConfig = mapSizeToImageConfig(req.size); + const inputParts = (req.inputImages ?? []).map((image) => ({ + inlineData: { + mimeType: image.mimeType, + data: image.buffer.toString("base64"), + }, + })); + const resolvedImageConfig = { + ...imageConfig, + ...(req.resolution ? { imageSize: req.resolution } : {}), + }; const { response: res, release } = await postJsonRequest({ url: `${baseUrl}/models/${model}:generateContent`, @@ -106,12 +121,14 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProviderPlu contents: [ { role: "user", - parts: [{ text: req.prompt }], + parts: [...inputParts, { text: req.prompt }], }, ], generationConfig: { responseModalities: ["TEXT", "IMAGE"], - ...(imageConfig ? { imageConfig } : {}), + ...(Object.keys(resolvedImageConfig).length > 0 + ? { imageConfig: resolvedImageConfig } + : {}), }, }, timeoutMs: 60_000, diff --git a/src/image-generation/providers/openai.test.ts b/src/image-generation/providers/openai.test.ts index a55e6107d3b..a128d6c6e04 100644 --- a/src/image-generation/providers/openai.test.ts +++ b/src/image-generation/providers/openai.test.ts @@ -8,7 +8,7 @@ describe("OpenAI image-generation provider", () => { }); it("generates PNG buffers from the OpenAI Images API", async () => { - vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ + const resolveApiKeySpy = vi.spyOn(modelAuth, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "sk-test", source: "env", mode: "api-key", @@ -27,17 +27,31 @@ describe("OpenAI image-generation provider", () => { vi.stubGlobal("fetch", fetchMock); const provider = buildOpenAIImageGenerationProvider(); + const authStore = { version: 1, profiles: {} }; const result = await provider.generateImage({ provider: "openai", model: "gpt-image-1", prompt: "draw a cat", cfg: {}, + authStore, }); + expect(resolveApiKeySpy).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + store: authStore, + }), + ); expect(fetchMock).toHaveBeenCalledWith( "https://api.openai.com/v1/images/generations", expect.objectContaining({ method: "POST", + body: JSON.stringify({ + model: "gpt-image-1", + prompt: "draw a cat", + n: 1, + size: "1024x1024", + }), }), ); expect(result).toEqual({ @@ -52,4 +66,18 @@ describe("OpenAI image-generation provider", () => { model: "gpt-image-1", }); }); + + it("rejects reference-image edits for now", async () => { + const provider = buildOpenAIImageGenerationProvider(); + + await expect( + provider.generateImage({ + provider: "openai", + model: "gpt-image-1", + prompt: "Edit this image", + cfg: {}, + inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], + }), + ).rejects.toThrow("does not support reference-image edits"); + }); }); diff --git a/src/image-generation/providers/openai.ts b/src/image-generation/providers/openai.ts index 0c7788fb5d5..1a0afe1f67d 100644 --- a/src/image-generation/providers/openai.ts +++ b/src/image-generation/providers/openai.ts @@ -22,12 +22,18 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu return { id: "openai", label: "OpenAI", + defaultModel: DEFAULT_OPENAI_IMAGE_MODEL, + models: [DEFAULT_OPENAI_IMAGE_MODEL], supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], async generateImage(req) { + if ((req.inputImages?.length ?? 0) > 0) { + throw new Error("OpenAI image generation provider does not support reference-image edits"); + } const auth = await resolveApiKeyForProvider({ provider: "openai", cfg: req.cfg, agentDir: req.agentDir, + store: req.authStore, }); if (!auth.apiKey) { throw new Error("OpenAI API key missing"); @@ -44,7 +50,6 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProviderPlu prompt: req.prompt, n: req.count ?? 1, size: req.size ?? DEFAULT_SIZE, - response_format: "b64_json", }), }); diff --git a/src/image-generation/runtime.live.test.ts b/src/image-generation/runtime.live.test.ts new file mode 100644 index 00000000000..f0132414a6c --- /dev/null +++ b/src/image-generation/runtime.live.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { collectProviderApiKeys } from "../agents/live-auth-keys.js"; +import { resolveApiKeyForProvider } from "../agents/model-auth.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; +import { getShellEnvAppliedKeys, loadShellEnvFallback } from "../infra/shell-env.js"; +import { encodePngRgba, fillPixel } from "../media/png-encode.js"; +import { + imageGenerationProviderContractRegistry, + providerContractRegistry, +} from "../plugins/contracts/registry.js"; +import { + DEFAULT_LIVE_IMAGE_MODELS, + parseCaseFilter, + parseCsvFilter, + parseProviderModelMap, + redactLiveApiKey, + resolveConfiguredLiveImageModels, + resolveLiveImageAuthStore, +} from "./live-test-helpers.js"; +import { generateImage } from "./runtime.js"; + +const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); +const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS); +const describeLive = LIVE ? describe : describe.skip; + +type LiveImageCase = { + id: string; + providerId: string; + modelRef: string; + prompt: string; + size?: string; + resolution?: "1K" | "2K" | "4K"; + inputImages?: Array<{ buffer: Buffer; mimeType: string; fileName?: string }>; +}; + +function createEditReferencePng(): Buffer { + const width = 192; + const height = 192; + const buf = Buffer.alloc(width * height * 4, 255); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + fillPixel(buf, x, y, width, 245, 248, 255, 255); + } + } + + for (let y = 24; y < 168; y += 1) { + for (let x = 24; x < 168; x += 1) { + fillPixel(buf, x, y, width, 255, 189, 89, 255); + } + } + + for (let y = 48; y < 144; y += 1) { + for (let x = 48; x < 144; x += 1) { + fillPixel(buf, x, y, width, 41, 47, 54, 255); + } + } + + return encodePngRgba(buf, width, height); +} + +function withPluginsEnabled(cfg: OpenClawConfig): OpenClawConfig { + return { + ...cfg, + plugins: { + ...cfg.plugins, + enabled: true, + }, + }; +} + +function resolveProviderEnvVars(providerId: string): string[] { + const entry = providerContractRegistry.find((candidate) => candidate.provider.id === providerId); + return entry?.provider.envVars ?? []; +} + +function maybeLoadShellEnvForImageProviders(providerIds: string[]): void { + const expectedKeys = [ + ...new Set(providerIds.flatMap((providerId) => resolveProviderEnvVars(providerId))), + ]; + if (expectedKeys.length === 0) { + return; + } + loadShellEnvFallback({ + enabled: true, + env: process.env, + expectedKeys, + logger: { warn: (message: string) => console.warn(message) }, + }); +} + +async function resolveLiveAuthForProvider( + provider: string, + cfg: ReturnType, + agentDir: string, +) { + const authStore = resolveLiveImageAuthStore({ + requireProfileKeys: REQUIRE_PROFILE_KEYS, + hasLiveKeys: collectProviderApiKeys(provider).length > 0, + }); + try { + const auth = await resolveApiKeyForProvider({ provider, cfg, agentDir, store: authStore }); + return { auth, authStore }; + } catch { + return null; + } +} + +describeLive("image generation live (provider sweep)", () => { + it("generates images for every configured image-generation variant with available auth", async () => { + const cfg = withPluginsEnabled(loadConfig()); + const agentDir = resolveOpenClawAgentDir(); + const providerFilter = parseCsvFilter(process.env.OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS); + const caseFilter = parseCaseFilter(process.env.OPENCLAW_LIVE_IMAGE_GENERATION_CASES); + const envModelMap = parseProviderModelMap(process.env.OPENCLAW_LIVE_IMAGE_GENERATION_MODELS); + const configuredModels = resolveConfiguredLiveImageModels(cfg); + const availableProviders = imageGenerationProviderContractRegistry + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)) + .filter((providerId) => (providerFilter ? providerFilter.has(providerId) : true)); + const liveCases: LiveImageCase[] = []; + + if (availableProviders.includes("google")) { + liveCases.push( + { + id: "google:flash-generate", + providerId: "google", + modelRef: + envModelMap.get("google") ?? + configuredModels.get("google") ?? + DEFAULT_LIVE_IMAGE_MODELS.google, + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + size: "1024x1024", + }, + { + id: "google:pro-generate", + providerId: "google", + modelRef: "google/gemini-3-pro-image-preview", + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + size: "1024x1024", + }, + { + id: "google:pro-edit", + providerId: "google", + modelRef: "google/gemini-3-pro-image-preview", + prompt: + "Change ONLY the background to a pale blue gradient. Keep the subject, framing, and style identical.", + resolution: "2K", + inputImages: [ + { + buffer: createEditReferencePng(), + mimeType: "image/png", + fileName: "reference.png", + }, + ], + }, + ); + } + if (availableProviders.includes("openai")) { + liveCases.push({ + id: "openai:default-generate", + providerId: "openai", + modelRef: + envModelMap.get("openai") ?? + configuredModels.get("openai") ?? + DEFAULT_LIVE_IMAGE_MODELS.openai, + prompt: + "Create a minimal flat illustration of an orange cat face sticker on a white background.", + size: "1024x1024", + }); + } + + const selectedCases = liveCases.filter((entry) => + caseFilter ? caseFilter.has(entry.id.toLowerCase()) : true, + ); + + maybeLoadShellEnvForImageProviders(availableProviders); + + const attempted: string[] = []; + const skipped: string[] = []; + const failures: string[] = []; + + for (const testCase of selectedCases) { + if (!testCase.modelRef) { + skipped.push(`${testCase.id}: no model configured`); + continue; + } + const resolvedAuth = await resolveLiveAuthForProvider(testCase.providerId, cfg, agentDir); + if (!resolvedAuth) { + skipped.push(`${testCase.id}: no auth`); + continue; + } + + try { + const result = await generateImage({ + cfg, + agentDir, + authStore: resolvedAuth.authStore, + modelOverride: testCase.modelRef, + prompt: testCase.prompt, + size: testCase.size, + resolution: testCase.resolution, + inputImages: testCase.inputImages, + }); + + attempted.push( + `${testCase.id}:${result.model} (${resolvedAuth.auth.source} ${redactLiveApiKey(resolvedAuth.auth.apiKey)})`, + ); + expect(result.provider).toBe(testCase.providerId); + expect(result.images.length).toBeGreaterThan(0); + expect(result.images[0]?.mimeType.startsWith("image/")).toBe(true); + expect(result.images[0]?.buffer.byteLength).toBeGreaterThan(512); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push( + `${testCase.id} (${resolvedAuth.auth.source} ${redactLiveApiKey(resolvedAuth.auth.apiKey)}): ${message}`, + ); + } + } + + console.log( + `[live:image-generation] attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`, + ); + + if (attempted.length === 0) { + console.warn("[live:image-generation] no provider had usable auth; skipping assertions"); + return; + } + expect(failures).toEqual([]); + expect(attempted.length).toBeGreaterThan(0); + }, 180_000); +}); diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index 4ef478b3349..b044c899c60 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -11,13 +11,16 @@ describe("image-generation runtime helpers", () => { it("generates images through the active image-generation registry", async () => { const pluginRegistry = createEmptyPluginRegistry(); + const authStore = { version: 1, profiles: {} } as const; + let seenAuthStore: unknown; pluginRegistry.imageGenerationProviders.push({ pluginId: "image-plugin", pluginName: "Image Plugin", source: "test", provider: { id: "image-plugin", - async generateImage() { + async generateImage(req) { + seenAuthStore = req.authStore; return { images: [ { @@ -47,11 +50,13 @@ describe("image-generation runtime helpers", () => { cfg, prompt: "draw a cat", agentDir: "/tmp/agent", + authStore, }); expect(result.provider).toBe("image-plugin"); expect(result.model).toBe("img-v1"); expect(result.attempts).toEqual([]); + expect(seenAuthStore).toEqual(authStore); expect(result.images).toEqual([ { buffer: Buffer.from("png-bytes"), @@ -69,6 +74,9 @@ describe("image-generation runtime helpers", () => { source: "test", provider: { id: "image-plugin", + defaultModel: "img-v1", + models: ["img-v1", "img-v2"], + supportedResolutions: ["1K", "2K"], generateImage: async () => ({ images: [{ buffer: Buffer.from("x"), mimeType: "image/png" }], }), @@ -76,6 +84,13 @@ describe("image-generation runtime helpers", () => { }); setActivePluginRegistry(pluginRegistry); - expect(listRuntimeImageGenerationProviders()).toMatchObject([{ id: "image-plugin" }]); + expect(listRuntimeImageGenerationProviders()).toMatchObject([ + { + id: "image-plugin", + defaultModel: "img-v1", + models: ["img-v1", "img-v2"], + supportedResolutions: ["1K", "2K"], + }, + ]); }); }); diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 8c9104edd5d..f25048cd0b1 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -1,3 +1,4 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; import { describeFailoverError, isFailoverError } from "../agents/failover-error.js"; import type { FallbackAttempt } from "../agents/model-fallback.types.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -7,7 +8,12 @@ import { } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js"; -import type { GeneratedImageAsset, ImageGenerationResult } from "./types.js"; +import type { + GeneratedImageAsset, + ImageGenerationResolution, + ImageGenerationResult, + ImageGenerationSourceImage, +} from "./types.js"; const log = createSubsystemLogger("image-generation"); @@ -15,9 +21,12 @@ export type GenerateImageParams = { cfg: OpenClawConfig; prompt: string; agentDir?: string; + authStore?: AuthProfileStore; modelOverride?: string; count?: number; size?: string; + resolution?: ImageGenerationResolution; + inputImages?: ImageGenerationSourceImage[]; }; export type GenerateImageRuntimeResult = { @@ -130,8 +139,11 @@ export async function generateImage( prompt: params.prompt, cfg: params.cfg, agentDir: params.agentDir, + authStore: params.authStore, count: params.count, size: params.size, + resolution: params.resolution, + inputImages: params.inputImages, }); if (!Array.isArray(result.images) || result.images.length === 0) { throw new Error("Image generation provider returned no images."); diff --git a/src/image-generation/types.ts b/src/image-generation/types.ts index ff33d6079ee..7ea530ac2b9 100644 --- a/src/image-generation/types.ts +++ b/src/image-generation/types.ts @@ -1,3 +1,4 @@ +import type { AuthProfileStore } from "../agents/auth-profiles.js"; import type { OpenClawConfig } from "../config/config.js"; export type GeneratedImageAsset = { @@ -8,14 +9,26 @@ export type GeneratedImageAsset = { metadata?: Record; }; +export type ImageGenerationResolution = "1K" | "2K" | "4K"; + +export type ImageGenerationSourceImage = { + buffer: Buffer; + mimeType: string; + fileName?: string; + metadata?: Record; +}; + export type ImageGenerationRequest = { provider: string; model: string; prompt: string; cfg: OpenClawConfig; agentDir?: string; + authStore?: AuthProfileStore; count?: number; size?: string; + resolution?: ImageGenerationResolution; + inputImages?: ImageGenerationSourceImage[]; }; export type ImageGenerationResult = { @@ -28,6 +41,10 @@ export type ImageGenerationProvider = { id: string; aliases?: string[]; label?: string; + defaultModel?: string; + models?: string[]; supportedSizes?: string[]; + supportedResolutions?: ImageGenerationResolution[]; + supportsImageEditing?: boolean; generateImage: (req: ImageGenerationRequest) => Promise; }; diff --git a/src/plugin-sdk/image-generation.ts b/src/plugin-sdk/image-generation.ts index d9afa8b3a3d..25fde2e9d2b 100644 --- a/src/plugin-sdk/image-generation.ts +++ b/src/plugin-sdk/image-generation.ts @@ -3,8 +3,10 @@ export type { GeneratedImageAsset, ImageGenerationProvider, + ImageGenerationResolution, ImageGenerationRequest, ImageGenerationResult, + ImageGenerationSourceImage, } from "../image-generation/types.js"; export { buildGoogleImageGenerationProvider } from "../image-generation/providers/google.js"; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 45465f2f68e..16720cf8961 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -40,13 +40,13 @@ export type { export type { OpenClawConfig } from "../config/config.js"; /** @deprecated Use OpenClawConfig instead */ export type { OpenClawConfig as ClawdbotConfig } from "../config/config.js"; -export { registerContextEngine } from "../context-engine/index.js"; export * from "./image-generation.js"; export type { SecretInput, SecretRef } from "../config/types.secrets.js"; export type { RuntimeEnv } from "../runtime.js"; export type { HookEntry } from "../hooks/types.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export type { ContextEngineFactory } from "../context-engine/index.js"; +export type { ContextEngineFactory } from "../context-engine/registry.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export { registerContextEngine } from "../context-engine/registry.js"; diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 8e6793543af..0e8ce16aef2 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -81,3 +81,5 @@ export { } from "../../extensions/slack/src/actions.js"; export { recordSlackThreadParticipation } from "../../extensions/slack/src/sent-thread-cache.js"; export { handleSlackMessageAction } from "./slack-message-actions.js"; +export { createSlackActions } from "../channels/plugins/slack.actions.js"; +export type { SlackActionContext } from "../agents/tools/slack-actions.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index a3cc2b3ba1f..7a43a159b73 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -20,6 +20,7 @@ import * as setupSdk from "openclaw/plugin-sdk/setup"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; +import * as testingSdk from "openclaw/plugin-sdk/testing"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; @@ -114,6 +115,11 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); + it("exports the public testing seam", () => { + expect(typeof testingSdk.removeAckReactionAfterReply).toBe("function"); + expect(typeof testingSdk.shouldAckReaction).toBe("function"); + }); + it("keeps core shared types aligned with the channel prelude", () => { expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); diff --git a/src/plugin-sdk/test-utils.ts b/src/plugin-sdk/test-utils.ts index 5d825813d0e..26cddc72854 100644 --- a/src/plugin-sdk/test-utils.ts +++ b/src/plugin-sdk/test-utils.ts @@ -1,9 +1,4 @@ -// Narrow plugin-sdk surface for the bundled test-utils plugin. -// Keep this list additive and scoped to symbols used under extensions/test-utils. +// Deprecated compatibility alias. +// Prefer openclaw/plugin-sdk/testing for public test helpers. -export { removeAckReactionAfterReply, shouldAckReaction } from "../channels/ack-reactions.js"; -export type { ChannelAccountSnapshot, ChannelGatewayContext } from "../channels/plugins/types.js"; -export type { OpenClawConfig } from "../config/config.js"; -export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { RuntimeEnv } from "../runtime.js"; -export type { MockFn } from "../test-utils/vitest-mock-fn.js"; +export * from "./testing.js"; diff --git a/src/plugin-sdk/testing.ts b/src/plugin-sdk/testing.ts new file mode 100644 index 00000000000..e8a7e89f646 --- /dev/null +++ b/src/plugin-sdk/testing.ts @@ -0,0 +1,9 @@ +// Narrow public testing surface for plugin authors. +// Keep this list additive and limited to helpers we are willing to support. + +export { removeAckReactionAfterReply, shouldAckReaction } from "../channels/ack-reactions.js"; +export type { ChannelAccountSnapshot, ChannelGatewayContext } from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; +export type { PluginRuntime } from "../plugins/runtime/types.js"; +export type { RuntimeEnv } from "../runtime.js"; +export type { MockFn } from "../test-utils/vitest-mock-fn.js"; diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index ad37b986b91..40404f512af 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -6,7 +6,7 @@ import { } from "./provider-auth-input.js"; import { applyPrimaryModel } from "./provider-model-primary.js"; -export { +export const providerApiKeyAuthRuntime = { applyAuthProfileConfig, applyPrimaryModel, buildApiKeyCredential, diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index aa3805aea8f..183c8c4f5f0 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -1,6 +1,7 @@ import { upsertAuthProfile } from "../agents/auth-profiles/profiles.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SecretInput } from "../config/types.secrets.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { ProviderAuthMethod, @@ -29,14 +30,10 @@ type ProviderApiKeyAuthMethodOptions = { applyConfig?: (cfg: OpenClawConfig) => OpenClawConfig; }; -let providerApiKeyAuthRuntimePromise: - | Promise - | undefined; - -function loadProviderApiKeyAuthRuntime() { - providerApiKeyAuthRuntimePromise ??= import("./provider-api-key-auth.runtime.js"); - return providerApiKeyAuthRuntimePromise; -} +const loadProviderApiKeyAuthRuntime = createLazyRuntimeSurface( + () => import("./provider-api-key-auth.runtime.js"), + ({ providerApiKeyAuthRuntime }) => providerApiKeyAuthRuntime, +); function resolveStringOption(opts: Record | undefined, optionKey: string) { return normalizeOptionalSecretInput(opts?.[optionKey]); diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 033c1631828..4878bff3d81 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -9,123 +9,121 @@ import { setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "../../../extensions/discord/src/monitor/thread-bindings.js"; +import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; import { createDiscordTypingLease } from "./runtime-discord-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeDiscordOps = typeof import("./runtime-discord-ops.runtime.js").runtimeDiscordOps; -let runtimeDiscordOpsPromise: Promise | null = null; +const loadRuntimeDiscordOps = createLazyRuntimeSurface( + () => import("./runtime-discord-ops.runtime.js"), + ({ runtimeDiscordOps }) => runtimeDiscordOps, +); -function loadRuntimeDiscordOps() { - runtimeDiscordOpsPromise ??= import("./runtime-discord-ops.runtime.js").then( - ({ runtimeDiscordOps }) => runtimeDiscordOps, - ); - return runtimeDiscordOpsPromise; -} +const auditChannelPermissionsLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.auditChannelPermissions); -const auditChannelPermissionsLazy: PluginRuntimeChannel["discord"]["auditChannelPermissions"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.auditChannelPermissions(...args); - }; +const listDirectoryGroupsLiveLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryGroupsLive); -const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryGroupsLive"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.listDirectoryGroupsLive(...args); - }; +const listDirectoryPeersLiveLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.listDirectoryPeersLive); -const listDirectoryPeersLiveLazy: PluginRuntimeChannel["discord"]["listDirectoryPeersLive"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.listDirectoryPeersLive(...args); - }; +const probeDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.probeDiscord); -const probeDiscordLazy: PluginRuntimeChannel["discord"]["probeDiscord"] = async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.probeDiscord(...args); -}; +const resolveChannelAllowlistLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.resolveChannelAllowlist); -const resolveChannelAllowlistLazy: PluginRuntimeChannel["discord"]["resolveChannelAllowlist"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.resolveChannelAllowlist(...args); - }; +const resolveUserAllowlistLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.resolveUserAllowlist); -const resolveUserAllowlistLazy: PluginRuntimeChannel["discord"]["resolveUserAllowlist"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.resolveUserAllowlist(...args); -}; +const sendComponentMessageLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendComponentMessage); -const sendComponentMessageLazy: PluginRuntimeChannel["discord"]["sendComponentMessage"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.sendComponentMessage(...args); -}; +const sendMessageDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendMessageDiscord); -const sendMessageDiscordLazy: PluginRuntimeChannel["discord"]["sendMessageDiscord"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.sendMessageDiscord(...args); -}; +const sendPollDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.sendPollDiscord); -const sendPollDiscordLazy: PluginRuntimeChannel["discord"]["sendPollDiscord"] = async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.sendPollDiscord(...args); -}; +const monitorDiscordProviderLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.monitorDiscordProvider); -const monitorDiscordProviderLazy: PluginRuntimeChannel["discord"]["monitorDiscordProvider"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.monitorDiscordProvider(...args); - }; +const sendTypingDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.typing.pulse); -const sendTypingDiscordLazy: PluginRuntimeChannel["discord"]["typing"]["pulse"] = async ( - ...args -) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.typing.pulse(...args); -}; +const editMessageDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editMessage); -const editMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.editMessage(...args); - }; +const deleteMessageDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>( + loadRuntimeDiscordOps, + (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.deleteMessage, +); -const deleteMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["deleteMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.deleteMessage(...args); - }; +const pinMessageDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.pinMessage); -const pinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["pinMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.pinMessage(...args); - }; +const unpinMessageDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.unpinMessage); -const unpinMessageDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["unpinMessage"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.unpinMessage(...args); - }; +const createThreadDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.createThread); -const createThreadDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["createThread"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.createThread(...args); - }; - -const editChannelDiscordLazy: PluginRuntimeChannel["discord"]["conversationActions"]["editChannel"] = - async (...args) => { - const runtimeDiscordOps = await loadRuntimeDiscordOps(); - return runtimeDiscordOps.conversationActions.editChannel(...args); - }; +const editChannelDiscordLazy = createLazyRuntimeMethod< + RuntimeDiscordOps, + Parameters, + ReturnType +>(loadRuntimeDiscordOps, (runtimeDiscordOps) => runtimeDiscordOps.conversationActions.editChannel); export function createRuntimeDiscord(): PluginRuntimeChannel["discord"] { return { diff --git a/src/plugins/runtime/runtime-slack.ts b/src/plugins/runtime/runtime-slack.ts index 23d34a7e5f4..30742195ad6 100644 --- a/src/plugins/runtime/runtime-slack.ts +++ b/src/plugins/runtime/runtime-slack.ts @@ -1,65 +1,60 @@ +import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = typeof import("./runtime-slack-ops.runtime.js").runtimeSlackOps; -let runtimeSlackOpsPromise: Promise | null = null; +const loadRuntimeSlackOps = createLazyRuntimeSurface( + () => import("./runtime-slack-ops.runtime.js"), + ({ runtimeSlackOps }) => runtimeSlackOps, +); -function loadRuntimeSlackOps() { - runtimeSlackOpsPromise ??= import("./runtime-slack-ops.runtime.js").then( - ({ runtimeSlackOps }) => runtimeSlackOps, - ); - return runtimeSlackOpsPromise; -} +const listDirectoryGroupsLiveLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.listDirectoryGroupsLive); -const listDirectoryGroupsLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryGroupsLive"] = - async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.listDirectoryGroupsLive(...args); - }; +const listDirectoryPeersLiveLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.listDirectoryPeersLive); -const listDirectoryPeersLiveLazy: PluginRuntimeChannel["slack"]["listDirectoryPeersLive"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.listDirectoryPeersLive(...args); -}; +const probeSlackLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.probeSlack); -const probeSlackLazy: PluginRuntimeChannel["slack"]["probeSlack"] = async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.probeSlack(...args); -}; +const resolveChannelAllowlistLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.resolveChannelAllowlist); -const resolveChannelAllowlistLazy: PluginRuntimeChannel["slack"]["resolveChannelAllowlist"] = - async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.resolveChannelAllowlist(...args); - }; +const resolveUserAllowlistLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.resolveUserAllowlist); -const resolveUserAllowlistLazy: PluginRuntimeChannel["slack"]["resolveUserAllowlist"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.resolveUserAllowlist(...args); -}; +const sendMessageSlackLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.sendMessageSlack); -const sendMessageSlackLazy: PluginRuntimeChannel["slack"]["sendMessageSlack"] = async (...args) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.sendMessageSlack(...args); -}; +const monitorSlackProviderLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.monitorSlackProvider); -const monitorSlackProviderLazy: PluginRuntimeChannel["slack"]["monitorSlackProvider"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.monitorSlackProvider(...args); -}; - -const handleSlackActionLazy: PluginRuntimeChannel["slack"]["handleSlackAction"] = async ( - ...args -) => { - const runtimeSlackOps = await loadRuntimeSlackOps(); - return runtimeSlackOps.handleSlackAction(...args); -}; +const handleSlackActionLazy = createLazyRuntimeMethod< + RuntimeSlackOps, + Parameters, + ReturnType +>(loadRuntimeSlackOps, (runtimeSlackOps) => runtimeSlackOps.handleSlackAction); export function createRuntimeSlack(): PluginRuntimeChannel["slack"] { return { diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index d0d71d08c4e..b83df21670f 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -5,94 +5,106 @@ import { setTelegramThreadBindingMaxAgeBySessionKey, } from "../../../extensions/telegram/src/thread-bindings.js"; import { resolveTelegramToken } from "../../../extensions/telegram/src/token.js"; +import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; import { createTelegramTypingLease } from "./runtime-telegram-typing.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeTelegramOps = typeof import("./runtime-telegram-ops.runtime.js").runtimeTelegramOps; -let runtimeTelegramOpsPromise: Promise | null = null; +const loadRuntimeTelegramOps = createLazyRuntimeSurface( + () => import("./runtime-telegram-ops.runtime.js"), + ({ runtimeTelegramOps }) => runtimeTelegramOps, +); -function loadRuntimeTelegramOps() { - runtimeTelegramOpsPromise ??= import("./runtime-telegram-ops.runtime.js").then( - ({ runtimeTelegramOps }) => runtimeTelegramOps, - ); - return runtimeTelegramOpsPromise; -} +const auditGroupMembershipLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.auditGroupMembership); -const auditGroupMembershipLazy: PluginRuntimeChannel["telegram"]["auditGroupMembership"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.auditGroupMembership(...args); -}; +const probeTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.probeTelegram); -const probeTelegramLazy: PluginRuntimeChannel["telegram"]["probeTelegram"] = async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.probeTelegram(...args); -}; +const sendMessageTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.sendMessageTelegram); -const sendMessageTelegramLazy: PluginRuntimeChannel["telegram"]["sendMessageTelegram"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.sendMessageTelegram(...args); -}; +const sendPollTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.sendPollTelegram); -const sendPollTelegramLazy: PluginRuntimeChannel["telegram"]["sendPollTelegram"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.sendPollTelegram(...args); -}; +const monitorTelegramProviderLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.monitorTelegramProvider); -const monitorTelegramProviderLazy: PluginRuntimeChannel["telegram"]["monitorTelegramProvider"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.monitorTelegramProvider(...args); - }; +const sendTypingTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>(loadRuntimeTelegramOps, (runtimeTelegramOps) => runtimeTelegramOps.typing.pulse); -const sendTypingTelegramLazy: PluginRuntimeChannel["telegram"]["typing"]["pulse"] = async ( - ...args -) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.typing.pulse(...args); -}; +const editMessageTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>( + loadRuntimeTelegramOps, + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editMessage, +); -const editMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.editMessage(...args); - }; +const editMessageReplyMarkupTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>( + loadRuntimeTelegramOps, + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.editReplyMarkup, +); -const editMessageReplyMarkupTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["editReplyMarkup"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.editReplyMarkup(...args); - }; +const deleteMessageTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>( + loadRuntimeTelegramOps, + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.deleteMessage, +); -const deleteMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["deleteMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.deleteMessage(...args); - }; +const renameForumTopicTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>( + loadRuntimeTelegramOps, + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.renameTopic, +); -const renameForumTopicTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["renameTopic"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.renameTopic(...args); - }; +const pinMessageTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>( + loadRuntimeTelegramOps, + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.pinMessage, +); -const pinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["pinMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.pinMessage(...args); - }; - -const unpinMessageTelegramLazy: PluginRuntimeChannel["telegram"]["conversationActions"]["unpinMessage"] = - async (...args) => { - const runtimeTelegramOps = await loadRuntimeTelegramOps(); - return runtimeTelegramOps.conversationActions.unpinMessage(...args); - }; +const unpinMessageTelegramLazy = createLazyRuntimeMethod< + RuntimeTelegramOps, + Parameters, + ReturnType +>( + loadRuntimeTelegramOps, + (runtimeTelegramOps) => runtimeTelegramOps.conversationActions.unpinMessage, +); export function createRuntimeTelegram(): PluginRuntimeChannel["telegram"] { return { diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 10f8e9e6a94..63871bc08f8 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -6,6 +6,7 @@ import { readWebSelfId, webAuthExists, } from "../../../extensions/whatsapp/src/auth-store.js"; +import { createLazyRuntimeMethod, createLazyRuntimeSurface } from "../../shared/lazy-runtime.js"; import { createRuntimeWhatsAppLoginTool } from "./runtime-whatsapp-login-tool.js"; import type { PluginRuntime } from "./types.js"; @@ -14,24 +15,33 @@ type RuntimeWhatsAppOutbound = type RuntimeWhatsAppLogin = typeof import("./runtime-whatsapp-login.runtime.js").runtimeWhatsAppLogin; -const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async ( - ...args -) => { - const runtimeWhatsAppOutbound = await loadWebOutbound(); - return runtimeWhatsAppOutbound.sendMessageWhatsApp(...args); -}; +const loadWebOutbound = createLazyRuntimeSurface( + () => import("./runtime-whatsapp-outbound.runtime.js"), + ({ runtimeWhatsAppOutbound }) => runtimeWhatsAppOutbound, +); -const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async ( - ...args -) => { - const runtimeWhatsAppOutbound = await loadWebOutbound(); - return runtimeWhatsAppOutbound.sendPollWhatsApp(...args); -}; +const loadWebLogin = createLazyRuntimeSurface( + () => import("./runtime-whatsapp-login.runtime.js"), + ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, +); -const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => { - const runtimeWhatsAppLogin = await loadWebLogin(); - return runtimeWhatsAppLogin.loginWeb(...args); -}; +const sendMessageWhatsAppLazy = createLazyRuntimeMethod< + RuntimeWhatsAppOutbound, + Parameters, + ReturnType +>(loadWebOutbound, (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendMessageWhatsApp); + +const sendPollWhatsAppLazy = createLazyRuntimeMethod< + RuntimeWhatsAppOutbound, + Parameters, + ReturnType +>(loadWebOutbound, (runtimeWhatsAppOutbound) => runtimeWhatsAppOutbound.sendPollWhatsApp); + +const loginWebLazy = createLazyRuntimeMethod< + RuntimeWhatsAppLogin, + Parameters, + ReturnType +>(loadWebLogin, (runtimeWhatsAppLogin) => runtimeWhatsAppLogin.loginWeb); const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async ( ...args @@ -64,26 +74,10 @@ let webLoginQrPromise: Promise< typeof import("../../../extensions/whatsapp/src/login-qr.js") > | null = null; let webChannelPromise: Promise | null = null; -let webOutboundPromise: Promise | null = null; -let webLoginPromise: Promise | null = null; let whatsappActionsPromise: Promise< typeof import("../../agents/tools/whatsapp-actions.js") > | null = null; -function loadWebOutbound() { - webOutboundPromise ??= import("./runtime-whatsapp-outbound.runtime.js").then( - ({ runtimeWhatsAppOutbound }) => runtimeWhatsAppOutbound, - ); - return webOutboundPromise; -} - -function loadWebLogin() { - webLoginPromise ??= import("./runtime-whatsapp-login.runtime.js").then( - ({ runtimeWhatsAppLogin }) => runtimeWhatsAppLogin, - ); - return webLoginPromise; -} - function loadWebLoginQr() { webLoginQrPromise ??= import("../../../extensions/whatsapp/src/login-qr.js"); return webLoginQrPromise; diff --git a/src/security/audit-channel.collect.runtime.ts b/src/security/audit-channel.collect.runtime.ts index 6a33ff6a93a..bed24a7f73e 100644 --- a/src/security/audit-channel.collect.runtime.ts +++ b/src/security/audit-channel.collect.runtime.ts @@ -1 +1,10 @@ -export { collectChannelSecurityFindings } from "./audit-channel.js"; +import { collectChannelSecurityFindings as collectChannelSecurityFindingsImpl } from "./audit-channel.js"; + +type CollectChannelSecurityFindings = + typeof import("./audit-channel.js").collectChannelSecurityFindings; + +export function collectChannelSecurityFindings( + ...args: Parameters +): ReturnType { + return collectChannelSecurityFindingsImpl(...args); +} diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index 867f0a91162..de2d666cb87 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,9 +1,17 @@ -export { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -export { - isDiscordMutableAllowEntry, - isZalouserMutableGroupEntry, -} from "./mutable-allowlist-detectors.js"; -export { +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; +import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, } from "../plugin-sdk/telegram.js"; +import { + isDiscordMutableAllowEntry, + isZalouserMutableGroupEntry, +} from "./mutable-allowlist-detectors.js"; + +export const auditChannelRuntime = { + readChannelAllowFromStore, + isDiscordMutableAllowEntry, + isZalouserMutableGroupEntry, + isNumericTelegramUserId, + normalizeTelegramAllowFromEntry, +}; diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 44b83c28cc3..dd920e77818 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -11,18 +11,15 @@ import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../con import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; import { resolveDmAllowState } from "./dm-policy-shared.js"; -let auditChannelRuntimeModulePromise: - | Promise - | undefined; - -function loadAuditChannelRuntimeModule() { - auditChannelRuntimeModulePromise ??= import("./audit-channel.runtime.js"); - return auditChannelRuntimeModulePromise; -} +const loadAuditChannelRuntimeModule = createLazyRuntimeSurface( + () => import("./audit-channel.runtime.js"), + ({ auditChannelRuntime }) => auditChannelRuntime, +); function normalizeAllowFromList(list: Array | undefined | null): string[] { return normalizeStringEntries(Array.isArray(list) ? list : undefined); diff --git a/src/security/audit.runtime.ts b/src/security/audit.runtime.ts index 349d2f26fe5..f36d23de14d 100644 --- a/src/security/audit.runtime.ts +++ b/src/security/audit.runtime.ts @@ -1 +1,9 @@ -export { runSecurityAudit } from "./audit.js"; +import { runSecurityAudit as runSecurityAuditImpl } from "./audit.js"; + +type RunSecurityAudit = typeof import("./audit.js").runSecurityAudit; + +export function runSecurityAudit( + ...args: Parameters +): ReturnType { + return runSecurityAuditImpl(...args); +} diff --git a/src/shared/lazy-runtime.ts b/src/shared/lazy-runtime.ts new file mode 100644 index 00000000000..3edaa865f50 --- /dev/null +++ b/src/shared/lazy-runtime.ts @@ -0,0 +1,21 @@ +export function createLazyRuntimeSurface( + importer: () => Promise, + select: (module: TModule) => TSurface, +): () => Promise { + let cached: Promise | null = null; + return () => { + cached ??= importer().then(select); + return cached; + }; +} + +export function createLazyRuntimeMethod( + load: () => Promise, + select: (surface: TSurface) => (...args: TArgs) => TResult, +): (...args: TArgs) => Promise> { + const invoke = async (...args: TArgs): Promise> => { + const method = select(await load()); + return await method(...args); + }; + return invoke; +} diff --git a/test/helpers/extensions/chunk-test-helpers.ts b/test/helpers/extensions/chunk-test-helpers.ts new file mode 100644 index 00000000000..c6589284fd3 --- /dev/null +++ b/test/helpers/extensions/chunk-test-helpers.ts @@ -0,0 +1 @@ +export { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js"; diff --git a/extensions/test-utils/directory.ts b/test/helpers/extensions/directory.ts similarity index 100% rename from extensions/test-utils/directory.ts rename to test/helpers/extensions/directory.ts diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts new file mode 100644 index 00000000000..ca1d1fd0894 --- /dev/null +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -0,0 +1,472 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/discord"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { Mock } from "vitest"; +import { expect, vi } from "vitest"; + +export type NativeCommandSpecMock = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +export type PluginCommandSpecMock = { + name: string; + description: string; + acceptsArgs: boolean; +}; + +type ProviderMonitorTestMocks = { + clientHandleDeployRequestMock: Mock<() => Promise>; + clientFetchUserMock: Mock<(target: string) => Promise<{ id: string }>>; + clientGetPluginMock: Mock<(name: string) => unknown>; + clientConstructorOptionsMock: Mock<(options?: unknown) => void>; + createDiscordAutoPresenceControllerMock: Mock<() => unknown>; + createDiscordNativeCommandMock: Mock<(params?: { command?: { name?: string } }) => unknown>; + createDiscordMessageHandlerMock: Mock<() => unknown>; + createNoopThreadBindingManagerMock: Mock<() => { stop: ReturnType }>; + createThreadBindingManagerMock: Mock<() => { stop: ReturnType }>; + reconcileAcpThreadBindingsOnStartupMock: Mock<() => unknown>; + createdBindingManagers: Array<{ stop: ReturnType }>; + getAcpSessionStatusMock: Mock< + (params: { + cfg: OpenClawConfig; + sessionKey: string; + signal?: AbortSignal; + }) => Promise<{ state: string }> + >; + getPluginCommandSpecsMock: Mock<() => PluginCommandSpecMock[]>; + listNativeCommandSpecsForConfigMock: Mock<() => NativeCommandSpecMock[]>; + listSkillCommandsForAgentsMock: Mock<() => unknown[]>; + monitorLifecycleMock: Mock<(params: { threadBindings: { stop: () => void } }) => Promise>; + resolveDiscordAccountMock: Mock<() => unknown>; + resolveDiscordAllowlistConfigMock: Mock<() => Promise>; + resolveNativeCommandsEnabledMock: Mock<() => boolean>; + resolveNativeSkillsEnabledMock: Mock<() => boolean>; + isVerboseMock: Mock<() => boolean>; + shouldLogVerboseMock: Mock<() => boolean>; + voiceRuntimeModuleLoadedMock: Mock<() => void>; +}; + +export function baseDiscordAccountConfig() { + return { + commands: { native: true, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + }; +} + +const providerMonitorTestMocks: ProviderMonitorTestMocks = vi.hoisted(() => { + const createdBindingManagers: Array<{ stop: ReturnType }> = []; + const isVerboseMock = vi.fn(() => false); + const shouldLogVerboseMock = vi.fn(() => false); + + return { + clientHandleDeployRequestMock: vi.fn(async () => undefined), + clientFetchUserMock: vi.fn(async (_target: string) => ({ id: "bot-1" })), + clientGetPluginMock: vi.fn<(_name: string) => unknown>(() => undefined), + clientConstructorOptionsMock: vi.fn(), + createDiscordAutoPresenceControllerMock: vi.fn(() => ({ + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + })), + createDiscordNativeCommandMock: vi.fn((params?: { command?: { name?: string } }) => ({ + name: params?.command?.name ?? "mock-command", + })), + createDiscordMessageHandlerMock: vi.fn(() => + Object.assign( + vi.fn(async () => undefined), + { + deactivate: vi.fn(), + }, + ), + ), + createNoopThreadBindingManagerMock: vi.fn(() => { + const manager = { stop: vi.fn() }; + createdBindingManagers.push(manager); + return manager; + }), + createThreadBindingManagerMock: vi.fn(() => { + const manager = { stop: vi.fn() }; + createdBindingManagers.push(manager); + return manager; + }), + reconcileAcpThreadBindingsOnStartupMock: vi.fn(() => ({ + checked: 0, + removed: 0, + staleSessionKeys: [], + })), + createdBindingManagers, + getAcpSessionStatusMock: vi.fn( + async (_params: { cfg: OpenClawConfig; sessionKey: string; signal?: AbortSignal }) => ({ + state: "idle", + }), + ), + getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []), + listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [ + { name: "cmd", description: "built-in", acceptsArgs: false }, + ]), + listSkillCommandsForAgentsMock: vi.fn(() => []), + monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => { + params.threadBindings.stop(); + }), + resolveDiscordAccountMock: vi.fn(() => ({ + accountId: "default", + token: "cfg-token", + config: baseDiscordAccountConfig(), + })), + resolveDiscordAllowlistConfigMock: vi.fn(async () => ({ + guildEntries: undefined, + allowFrom: undefined, + })), + resolveNativeCommandsEnabledMock: vi.fn(() => true), + resolveNativeSkillsEnabledMock: vi.fn(() => false), + isVerboseMock, + shouldLogVerboseMock, + voiceRuntimeModuleLoadedMock: vi.fn(), + }; +}); + +const { + clientHandleDeployRequestMock, + clientFetchUserMock, + clientGetPluginMock, + clientConstructorOptionsMock, + createDiscordAutoPresenceControllerMock, + createDiscordNativeCommandMock, + createDiscordMessageHandlerMock, + createNoopThreadBindingManagerMock, + createThreadBindingManagerMock, + reconcileAcpThreadBindingsOnStartupMock, + createdBindingManagers, + getAcpSessionStatusMock, + getPluginCommandSpecsMock, + listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgentsMock, + monitorLifecycleMock, + resolveDiscordAccountMock, + resolveDiscordAllowlistConfigMock, + resolveNativeCommandsEnabledMock, + resolveNativeSkillsEnabledMock, + isVerboseMock, + shouldLogVerboseMock, + voiceRuntimeModuleLoadedMock, +} = providerMonitorTestMocks; + +export function getProviderMonitorTestMocks(): typeof providerMonitorTestMocks { + return providerMonitorTestMocks; +} + +export function mockResolvedDiscordAccountConfig(overrides: Record) { + resolveDiscordAccountMock.mockImplementation(() => ({ + accountId: "default", + token: "cfg-token", + config: { + ...baseDiscordAccountConfig(), + ...overrides, + }, + })); +} + +export function getFirstDiscordMessageHandlerParams() { + expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); + const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as [T] | undefined; + return firstCall?.[0]; +} + +export function resetDiscordProviderMonitorMocks(params?: { + nativeCommands?: NativeCommandSpecMock[]; +}) { + clientHandleDeployRequestMock.mockClear().mockResolvedValue(undefined); + clientFetchUserMock.mockClear().mockResolvedValue({ id: "bot-1" }); + clientGetPluginMock.mockClear().mockReturnValue(undefined); + clientConstructorOptionsMock.mockClear(); + createDiscordAutoPresenceControllerMock.mockClear().mockImplementation(() => ({ + enabled: false, + start: vi.fn(), + stop: vi.fn(), + refresh: vi.fn(), + runNow: vi.fn(), + })); + createDiscordNativeCommandMock.mockClear().mockImplementation((input) => ({ + name: input?.command?.name ?? "mock-command", + })); + createDiscordMessageHandlerMock.mockClear().mockImplementation(() => + Object.assign( + vi.fn(async () => undefined), + { + deactivate: vi.fn(), + }, + ), + ); + createNoopThreadBindingManagerMock.mockClear(); + createThreadBindingManagerMock.mockClear(); + reconcileAcpThreadBindingsOnStartupMock.mockClear().mockReturnValue({ + checked: 0, + removed: 0, + staleSessionKeys: [], + }); + createdBindingManagers.length = 0; + getAcpSessionStatusMock.mockClear().mockResolvedValue({ state: "idle" }); + getPluginCommandSpecsMock.mockClear().mockReturnValue([]); + listNativeCommandSpecsForConfigMock + .mockClear() + .mockReturnValue( + params?.nativeCommands ?? [{ name: "cmd", description: "built-in", acceptsArgs: false }], + ); + listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]); + monitorLifecycleMock.mockClear().mockImplementation(async (monitorParams) => { + monitorParams.threadBindings.stop(); + }); + resolveDiscordAccountMock.mockClear().mockReturnValue({ + accountId: "default", + token: "cfg-token", + config: baseDiscordAccountConfig(), + }); + resolveDiscordAllowlistConfigMock.mockClear().mockResolvedValue({ + guildEntries: undefined, + allowFrom: undefined, + }); + resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); + resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); + isVerboseMock.mockClear().mockReturnValue(false); + shouldLogVerboseMock.mockClear().mockReturnValue(false); + voiceRuntimeModuleLoadedMock.mockClear(); +} + +export const baseRuntime = (): RuntimeEnv => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}); + +export const baseConfig = (): OpenClawConfig => + ({ + channels: { + discord: { + accounts: { + default: {}, + }, + }, + }, + }) as OpenClawConfig; + +vi.mock("@buape/carbon", () => { + class ReadyListener {} + class RateLimitError extends Error { + status = 429; + discordCode?: number; + retryAfter: number; + scope: string | null; + bucket: string | null; + constructor( + response: Response, + body: { message: string; retry_after: number; global: boolean }, + ) { + super(body.message); + this.retryAfter = body.retry_after; + this.scope = body.global ? "global" : response.headers.get("X-RateLimit-Scope"); + this.bucket = response.headers.get("X-RateLimit-Bucket"); + } + } + class Client { + listeners: unknown[]; + rest: { put: ReturnType }; + options: unknown; + constructor(options: unknown, handlers: { listeners?: unknown[] }) { + this.options = options; + this.listeners = handlers.listeners ?? []; + this.rest = { put: vi.fn(async () => undefined) }; + clientConstructorOptionsMock(options); + } + async handleDeployRequest() { + return await clientHandleDeployRequestMock(); + } + async fetchUser(target: string) { + return await clientFetchUserMock(target); + } + getPlugin(name: string) { + return clientGetPluginMock(name); + } + } + return { Client, RateLimitError, ReadyListener }; +}); + +vi.mock("@buape/carbon/gateway", () => ({ + GatewayCloseCodes: { DisallowedIntents: 4014 }, +})); + +vi.mock("@buape/carbon/voice", () => ({ + VoicePlugin: class VoicePlugin {}, +})); + +vi.mock("openclaw/plugin-sdk/acp-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/acp-runtime", + ); + return { + ...actual, + getAcpSessionManager: () => ({ + getSessionStatus: getAcpSessionStatusMock, + }), + isAcpRuntimeError: (error: unknown): error is { code: string } => + error instanceof Error && "code" in error, + }; +}); + +vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/reply-runtime", + ); + return { + ...actual, + resolveTextChunkLimit: () => 2000, + listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock, + listSkillCommandsForAgents: listSkillCommandsForAgentsMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/config-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/config-runtime", + ); + return { + ...actual, + isNativeCommandsExplicitlyDisabled: () => false, + loadConfig: () => ({}), + resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, + resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/runtime-env", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/runtime-env", + ); + return { + ...actual, + danger: (value: string) => value, + isVerbose: isVerboseMock, + logVerbose: vi.fn(), + shouldLogVerbose: shouldLogVerboseMock, + warn: (value: string) => value, + createSubsystemLogger: () => { + const logger = { + child: vi.fn(() => logger), + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + return logger; + }, + createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }), + }; +}); + +vi.mock("openclaw/plugin-sdk/infra-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/infra-runtime", + ); + return { + ...actual, + formatErrorMessage: (error: unknown) => String(error), + }; +}); + +vi.mock("../discord/src/accounts.js", () => ({ + resolveDiscordAccount: resolveDiscordAccountMock, +})); + +vi.mock("../discord/src/probe.js", () => ({ + fetchDiscordApplicationId: async () => "app-1", +})); + +vi.mock("../discord/src/token.js", () => ({ + normalizeDiscordToken: (value?: string) => value, +})); + +vi.mock("../discord/src/voice/command.js", () => ({ + createDiscordVoiceCommand: () => ({ name: "voice-command" }), +})); + +vi.mock("../discord/src/monitor/agent-components.js", () => ({ + createAgentComponentButton: () => ({ id: "btn" }), + createAgentSelectMenu: () => ({ id: "menu" }), + createDiscordComponentButton: () => ({ id: "btn2" }), + createDiscordComponentChannelSelect: () => ({ id: "channel" }), + createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }), + createDiscordComponentModal: () => ({ id: "modal" }), + createDiscordComponentRoleSelect: () => ({ id: "role" }), + createDiscordComponentStringSelect: () => ({ id: "string" }), + createDiscordComponentUserSelect: () => ({ id: "user" }), +})); + +vi.mock("../discord/src/monitor/auto-presence.js", () => ({ + createDiscordAutoPresenceController: createDiscordAutoPresenceControllerMock, +})); + +vi.mock("../discord/src/monitor/commands.js", () => ({ + resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }), +})); + +vi.mock("../discord/src/monitor/exec-approvals.js", () => ({ + createExecApprovalButton: () => ({ id: "exec-approval" }), + DiscordExecApprovalHandler: class DiscordExecApprovalHandler { + async start() { + return undefined; + } + async stop() { + return undefined; + } + }, +})); + +vi.mock("../discord/src/monitor/gateway-plugin.js", () => ({ + createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }), +})); + +vi.mock("../discord/src/monitor/listeners.js", () => ({ + DiscordMessageListener: class DiscordMessageListener {}, + DiscordPresenceListener: class DiscordPresenceListener {}, + DiscordReactionListener: class DiscordReactionListener {}, + DiscordReactionRemoveListener: class DiscordReactionRemoveListener {}, + DiscordThreadUpdateListener: class DiscordThreadUpdateListener {}, + registerDiscordListener: vi.fn(), +})); + +vi.mock("../discord/src/monitor/message-handler.js", () => ({ + createDiscordMessageHandler: createDiscordMessageHandlerMock, +})); + +vi.mock("../discord/src/monitor/native-command.js", () => ({ + createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }), + createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }), + createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }), + createDiscordNativeCommand: createDiscordNativeCommandMock, +})); + +vi.mock("../discord/src/monitor/presence.js", () => ({ + resolveDiscordPresenceUpdate: () => undefined, +})); + +vi.mock("../discord/src/monitor/provider.allowlist.js", () => ({ + resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock, +})); + +vi.mock("../discord/src/monitor/provider.lifecycle.js", () => ({ + runDiscordGatewayLifecycle: monitorLifecycleMock, +})); + +vi.mock("../discord/src/monitor/rest-fetch.js", () => ({ + resolveDiscordRestFetch: () => async () => undefined, +})); + +vi.mock("../discord/src/monitor/thread-bindings.js", () => ({ + createNoopThreadBindingManager: createNoopThreadBindingManagerMock, + createThreadBindingManager: createThreadBindingManagerMock, + reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, +})); diff --git a/test/helpers/extensions/env.ts b/test/helpers/extensions/env.ts new file mode 100644 index 00000000000..bc48bfd3d10 --- /dev/null +++ b/test/helpers/extensions/env.ts @@ -0,0 +1 @@ +export { captureEnv, withEnv, withEnvAsync } from "../../../src/test-utils/env.js"; diff --git a/test/helpers/extensions/fetch-mock.ts b/test/helpers/extensions/fetch-mock.ts new file mode 100644 index 00000000000..e1774b46463 --- /dev/null +++ b/test/helpers/extensions/fetch-mock.ts @@ -0,0 +1 @@ +export { withFetchPreconnect, type FetchMock } from "../../../src/test-utils/fetch-mock.js"; diff --git a/test/helpers/extensions/frozen-time.ts b/test/helpers/extensions/frozen-time.ts new file mode 100644 index 00000000000..69f188f09ca --- /dev/null +++ b/test/helpers/extensions/frozen-time.ts @@ -0,0 +1 @@ +export { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js"; diff --git a/test/helpers/extensions/mock-http-response.ts b/test/helpers/extensions/mock-http-response.ts new file mode 100644 index 00000000000..3bbed0372a8 --- /dev/null +++ b/test/helpers/extensions/mock-http-response.ts @@ -0,0 +1 @@ +export { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js"; diff --git a/extensions/test-utils/plugin-api.ts b/test/helpers/extensions/plugin-api.ts similarity index 100% rename from extensions/test-utils/plugin-api.ts rename to test/helpers/extensions/plugin-api.ts diff --git a/extensions/test-utils/plugin-command.ts b/test/helpers/extensions/plugin-command.ts similarity index 100% rename from extensions/test-utils/plugin-command.ts rename to test/helpers/extensions/plugin-command.ts diff --git a/test/helpers/extensions/plugin-registration.ts b/test/helpers/extensions/plugin-registration.ts new file mode 100644 index 00000000000..bd20510800e --- /dev/null +++ b/test/helpers/extensions/plugin-registration.ts @@ -0,0 +1 @@ +export { registerSingleProviderPlugin } from "../../../src/test-utils/plugin-registration.js"; diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts similarity index 99% rename from extensions/test-utils/plugin-runtime-mock.ts rename to test/helpers/extensions/plugin-runtime-mock.ts index fbc9bcdc7fd..d71eeb2d584 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -1,6 +1,6 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "openclaw/plugin-sdk/agent-runtime"; -import type { PluginRuntime } from "openclaw/plugin-sdk/test-utils"; -import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk/test-utils"; +import type { PluginRuntime } from "openclaw/plugin-sdk/testing"; +import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; type DeepPartial = { diff --git a/test/helpers/extensions/provider-usage-fetch.ts b/test/helpers/extensions/provider-usage-fetch.ts new file mode 100644 index 00000000000..fe54174732e --- /dev/null +++ b/test/helpers/extensions/provider-usage-fetch.ts @@ -0,0 +1,4 @@ +export { + createProviderUsageFetch, + makeResponse, +} from "../../../src/test-utils/provider-usage-fetch.js"; diff --git a/extensions/test-utils/runtime-env.ts b/test/helpers/extensions/runtime-env.ts similarity index 77% rename from extensions/test-utils/runtime-env.ts rename to test/helpers/extensions/runtime-env.ts index a5e52665b0e..b197619e43e 100644 --- a/extensions/test-utils/runtime-env.ts +++ b/test/helpers/extensions/runtime-env.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/test-utils"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; export function createRuntimeEnv(): RuntimeEnv { diff --git a/extensions/test-utils/send-config.ts b/test/helpers/extensions/send-config.ts similarity index 100% rename from extensions/test-utils/send-config.ts rename to test/helpers/extensions/send-config.ts diff --git a/extensions/test-utils/setup-wizard.ts b/test/helpers/extensions/setup-wizard.ts similarity index 84% rename from extensions/test-utils/setup-wizard.ts rename to test/helpers/extensions/setup-wizard.ts index aab15a4aecc..109394ee886 100644 --- a/extensions/test-utils/setup-wizard.ts +++ b/test/helpers/extensions/setup-wizard.ts @@ -1,7 +1,7 @@ import { vi } from "vitest"; -import type { WizardPrompter } from "../../src/wizard/prompts.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -export type { WizardPrompter } from "../../src/wizard/prompts.js"; +export type { WizardPrompter } from "../../../src/wizard/prompts.js"; export async function selectFirstWizardOption(params: { options: Array<{ value: T }>; diff --git a/extensions/test-utils/start-account-context.ts b/test/helpers/extensions/start-account-context.ts similarity index 95% rename from extensions/test-utils/start-account-context.ts rename to test/helpers/extensions/start-account-context.ts index a878b3dbfd9..56a66a9ca56 100644 --- a/extensions/test-utils/start-account-context.ts +++ b/test/helpers/extensions/start-account-context.ts @@ -2,7 +2,7 @@ import type { ChannelAccountSnapshot, ChannelGatewayContext, OpenClawConfig, -} from "openclaw/plugin-sdk/test-utils"; +} from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; import { createRuntimeEnv } from "./runtime-env.js"; diff --git a/extensions/test-utils/start-account-lifecycle.ts b/test/helpers/extensions/start-account-lifecycle.ts similarity index 98% rename from extensions/test-utils/start-account-lifecycle.ts rename to test/helpers/extensions/start-account-lifecycle.ts index 6ce1c734736..ea76fe857d5 100644 --- a/extensions/test-utils/start-account-lifecycle.ts +++ b/test/helpers/extensions/start-account-lifecycle.ts @@ -1,4 +1,4 @@ -import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/test-utils"; +import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing"; import { expect, vi } from "vitest"; import { createStartAccountContext } from "./start-account-context.js"; diff --git a/extensions/test-utils/status-issues.ts b/test/helpers/extensions/status-issues.ts similarity index 100% rename from extensions/test-utils/status-issues.ts rename to test/helpers/extensions/status-issues.ts diff --git a/extensions/test-utils/subagent-hooks.ts b/test/helpers/extensions/subagent-hooks.ts similarity index 100% rename from extensions/test-utils/subagent-hooks.ts rename to test/helpers/extensions/subagent-hooks.ts diff --git a/test/helpers/extensions/temp-dir.ts b/test/helpers/extensions/temp-dir.ts new file mode 100644 index 00000000000..08ec26218ec --- /dev/null +++ b/test/helpers/extensions/temp-dir.ts @@ -0,0 +1 @@ +export { withTempDir } from "../../../src/test-utils/temp-dir.js"; diff --git a/test/helpers/extensions/typed-cases.ts b/test/helpers/extensions/typed-cases.ts new file mode 100644 index 00000000000..45be30b08c3 --- /dev/null +++ b/test/helpers/extensions/typed-cases.ts @@ -0,0 +1 @@ +export { typedCases } from "../../../src/test-utils/typed-cases.js";