From 7b943667a0b6b55e1d611afa508107659cc6643f Mon Sep 17 00:00:00 2001 From: Pinghuachiu <9033138+Pinghuachiu@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:22:22 +0800 Subject: [PATCH] fix: expose image edit geometry flags in capability cli Expose image edit geometry flags in the capability CLI and document the new infer options.\n\nThanks @Pinghuachiu. --- CHANGELOG.md | 6 +- docs/cli/infer.md | 3 + scripts/test-projects.test-support.mjs | 6 +- src/cli/capability-cli.test.ts | 89 +++++++++++++++++++ src/cli/capability-cli.ts | 21 ++++- .../workspace-shadow-bypass.test.ts | 23 +++++ src/config/config.plugin-validation.test.ts | 14 +-- src/config/runtime-schema.test.ts | 5 ++ src/plugin-activation-boundary.test.ts | 10 +++ src/plugins/bundle-commands.test.ts | 4 + src/scripts/test-projects.test.ts | 6 +- test/scripts/test-projects.test.ts | 42 +-------- 12 files changed, 166 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 246602c76f9..4c5d6c7c287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Diagnostics/OTEL: treat normal early model stream cleanup as a completed model call instead of exporting a misleading `StreamAbandoned` error span. Thanks @vincentkoc. - Gateway/pairing: stop corrupt or unreadable device/node pairing stores from being treated as empty state, preserving `paired.json` for repair instead of overwriting approved pairings. Fixes #71873. Thanks @iret77. - ACP: keep `/acp` management commands, plus local `/status` and `/unfocus`, on the Gateway path inside ACP-bound threads so they are not consumed as ACP prompt text. Fixes #66298. Thanks @kindomLee. +- CLI/image edit: accept `--size`, `--aspect-ratio`, and `--resolution` on `openclaw infer image edit` and report all supported edit flags from `capability inspect image.edit`. Thanks @Pinghuachiu. - ACP: wait for the configured runtime backend to become healthy before startup identity reconciliation, avoiding transient acpx warnings during Gateway boot. Fixes #40566. - Channels/ACP bindings: time out configured binding readiness checks instead of letting Discord preflight hang forever when an ACP target never settles. Fixes #68776. - Control UI: hide the chat loading skeleton during background history reloads when existing messages or active stream content are already visible, avoiding reload flashes on high-latency local gateways. Fixes #71844. Thanks @WolvenRA. @@ -101,10 +102,7 @@ Docs: https://docs.openclaw.ai and `media://inbound/...` markers from pruned model replay context so stale media refs are not rehydrated as fresh prompt images. Fixes #71868. Thanks @jmeadlock. -- CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` - and show daemon state separately when available, so `gateway.tailscale.mode: -"off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. - Thanks @pesvobodak. +- CLI/status: label the OpenClaw Serve/Funnel setting as `Tailscale exposure` and show daemon state separately when available, so `gateway.tailscale.mode: "off"` no longer reads like the Tailscale daemon is stopped. Fixes #71790. Thanks @pesvobodak. - Plugins/Bonjour: stop ciao mDNS watchdog failures from looping forever when the advertiser stays stuck in `probing` or `announcing`; Bonjour now disables itself for the current Gateway process after repeated failed restarts while the Gateway keeps running. Fixes #69011. Thanks @siddharthaagarwalofficial-ux, @FiredMosquito831, and @spikefcz. - Gateway/Fly.io: seed Control UI allowed origins from the actual runtime bind and port so CLI-driven non-loopback starts do not crash before config exists. Fixes #71823. - Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv. diff --git a/docs/cli/infer.md b/docs/cli/infer.md index aabcff8dc47..8ab8faa01ed 100644 --- a/docs/cli/infer.md +++ b/docs/cli/infer.md @@ -159,6 +159,7 @@ openclaw infer image generate --prompt "cinematic product photo of headphones" - openclaw infer image generate --model openai/gpt-image-1.5 --output-format png --background transparent --prompt "simple red circle sticker on a transparent background" --json openclaw infer image generate --prompt "slow image backend" --timeout-ms 180000 --json openclaw infer image edit --file ./logo.png --model openai/gpt-image-1.5 --output-format png --background transparent --prompt "keep the logo, remove the background" --json +openclaw infer image edit --file ./poster.png --prompt "make this a vertical story ad" --size 2160x3840 --aspect-ratio 9:16 --resolution 4K --json openclaw infer image describe --file ./photo.jpg --json openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --json @@ -167,6 +168,8 @@ openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --j Notes: - Use `image edit` when starting from existing input files. +- Use `--size`, `--aspect-ratio`, or `--resolution` with `image edit` for + providers/models that support geometry hints on reference-image edits. - Use `--output-format png --background transparent` with `--model openai/gpt-image-1.5` for transparent-background OpenAI PNG output; `--openai-background` remains available as an OpenAI-specific alias. Providers diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index a17c6835aa5..825d68edd00 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -97,7 +97,6 @@ const EXTENSION_VOICE_CALL_VITEST_CONFIG = "test/vitest/vitest.extension-voice-c const EXTENSION_WHATSAPP_VITEST_CONFIG = "test/vitest/vitest.extension-whatsapp.config.ts"; const EXTENSION_ZALO_VITEST_CONFIG = "test/vitest/vitest.extension-zalo.config.ts"; const EXTENSIONS_VITEST_CONFIG = "test/vitest/vitest.extensions.config.ts"; -const FULL_AGENTIC_VITEST_CONFIG = "test/vitest/vitest.full-agentic.config.ts"; const FULL_EXTENSIONS_VITEST_CONFIG = "test/vitest/vitest.full-extensions.config.ts"; const GATEWAY_CLIENT_VITEST_CONFIG = "test/vitest/vitest.gateway-client.config.ts"; const GATEWAY_CORE_VITEST_CONFIG = "test/vitest/vitest.gateway-core.config.ts"; @@ -1062,10 +1061,7 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) { ) { return []; } - const expandShard = - expandToProjectConfigs || - shard.config === FULL_AGENTIC_VITEST_CONFIG || - shard.config === FULL_EXTENSIONS_VITEST_CONFIG; + const expandShard = expandToProjectConfigs; const configs = expandShard ? shard.projects : [shard.config]; return configs.map((config) => ({ config, diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index d6f16e12a71..ddf6ba3b23e 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -713,6 +713,95 @@ describe("capability cli", () => { ); }); + it("forwards size, aspect ratio, and resolution overrides for image edit", async () => { + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII="; + mocks.generateImage.mockResolvedValue({ + provider: "openai", + model: "gpt-image-2", + attempts: [], + images: [ + { + buffer: Buffer.from(pngBase64, "base64"), + mimeType: "image/png", + fileName: "provider-output.png", + }, + ], + }); + + const tempInput = path.join(os.tmpdir(), `openclaw-image-edit-input-${Date.now()}.png`); + const tempOutput = path.join(os.tmpdir(), `openclaw-image-edit-output-${Date.now()}.png`); + await fs.writeFile(tempInput, Buffer.from(pngBase64, "base64")); + await fs.rm(tempOutput, { force: true }); + + await runRegisteredCli({ + register: registerCapabilityCli as (program: Command) => void, + argv: [ + "capability", + "image", + "edit", + "--file", + tempInput, + "--prompt", + "remove the background object", + "--model", + "openai/gpt-image-2", + "--size", + "2160x3840", + "--aspect-ratio", + "9:16", + "--resolution", + "4K", + "--output", + tempOutput, + "--json", + ], + }); + + expect(mocks.generateImage).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "remove the background object", + modelOverride: "openai/gpt-image-2", + size: "2160x3840", + aspectRatio: "9:16", + resolution: "4K", + inputImages: [ + expect.objectContaining({ + fileName: path.basename(tempInput), + mimeType: "image/png", + }), + ], + }), + ); + }); + + it("reports the expanded image.edit flags in capability inspect", async () => { + await runRegisteredCli({ + register: registerCapabilityCli as (program: Command) => void, + argv: ["capability", "inspect", "--name", "image.edit", "--json"], + }); + + expect(mocks.runtime.writeJson).toHaveBeenCalledWith( + expect.objectContaining({ + id: "image.edit", + flags: [ + "--file", + "--prompt", + "--model", + "--size", + "--aspect-ratio", + "--resolution", + "--output-format", + "--background", + "--openai-background", + "--timeout-ms", + "--output", + "--json", + ], + }), + ); + }); + it("streams url-only generated videos to --output paths", async () => { mocks.generateVideo.mockResolvedValue({ provider: "vydra", diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index a9242d81402..acc84c89d3e 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -175,7 +175,20 @@ const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "image.edit", description: "Generate edited images from one or more input files.", transports: ["local"], - flags: ["--file", "--prompt", "--model", "--output", "--json"], + flags: [ + "--file", + "--prompt", + "--model", + "--size", + "--aspect-ratio", + "--resolution", + "--output-format", + "--background", + "--openai-background", + "--timeout-ms", + "--output", + "--json", + ], resultShape: "saved image files plus attempts", }, { @@ -1519,6 +1532,9 @@ export function registerCapabilityCli(program: Command) { .requiredOption("--file ", "Input file", collectOption, []) .requiredOption("--prompt ", "Prompt text") .option("--model ", "Model override") + .option("--size ", "Size hint like 1024x1024") + .option("--aspect-ratio ", "Aspect ratio hint like 16:9") + .option("--resolution ", "Resolution hint: 1K, 2K, or 4K") .option("--output-format ", "Output format hint: png, jpeg, or webp") .option("--background ", "Background hint: transparent, opaque, or auto") .option("--openai-background ", "OpenAI background hint: transparent, opaque, or auto") @@ -1532,6 +1548,9 @@ export function registerCapabilityCli(program: Command) { capability: "image.edit", prompt: String(opts.prompt), model: opts.model as string | undefined, + size: opts.size as string | undefined, + aspectRatio: opts.aspectRatio as string | undefined, + resolution: opts.resolution as "1K" | "2K" | "4K" | undefined, file: files, outputFormat: normalizeImageOutputFormat(opts.outputFormat as string | undefined), background: normalizeImageBackground(opts.background as string | undefined), diff --git a/src/commands/channel-setup/workspace-shadow-bypass.test.ts b/src/commands/channel-setup/workspace-shadow-bypass.test.ts index c3b080ee6b7..69c78073ecc 100644 --- a/src/commands/channel-setup/workspace-shadow-bypass.test.ts +++ b/src/commands/channel-setup/workspace-shadow-bypass.test.ts @@ -16,6 +16,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_opts?: unknown): unknown[] => [])); const listChatChannels = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); +const loadPluginRegistrySnapshot = vi.hoisted(() => vi.fn()); +const listPluginContributionIds = vi.hoisted(() => vi.fn((_params?: unknown): string[] => [])); const applyPluginAutoEnable = vi.hoisted(() => vi.fn(({ config }: { config: unknown }) => ({ config: config as never, @@ -36,6 +38,12 @@ vi.mock("../../channels/registry.js", () => ({ vi.mock("../../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry: (...a: unknown[]) => loadPluginManifestRegistry(...a), })); +vi.mock("../../plugins/plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: (...args: unknown[]) => + loadPluginManifestRegistry(...args), + loadPluginRegistrySnapshot: (...args: unknown[]) => loadPluginRegistrySnapshot(...args), + listPluginContributionIds: (...args: unknown[]) => listPluginContributionIds(...args), +})); vi.mock("../../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: (a: unknown) => applyPluginAutoEnable(a as { config: unknown }), })); @@ -52,6 +60,18 @@ import { resolveChannelSetupEntries } from "./discovery.js"; beforeEach(() => { vi.clearAllMocks(); loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); + loadPluginRegistrySnapshot.mockReturnValue({ + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "test", + generatedAtMs: 0, + installRecords: {}, + plugins: [], + diagnostics: [], + }); + listPluginContributionIds.mockReturnValue([]); listChatChannels.mockReturnValue([]); }); @@ -173,6 +193,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x- plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }], diagnostics: [], }); + listPluginContributionIds.mockReturnValue(["telegram"]); const result = resolveChannelSetupEntries({ cfg: { @@ -223,6 +244,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x- plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }], diagnostics: [], }); + listPluginContributionIds.mockReturnValue(["telegram"]); const result = resolveChannelSetupEntries({ cfg: { @@ -267,6 +289,7 @@ describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x- plugins: [{ id: "my-cool-plugin", channels: ["my-cool-plugin"] }], diagnostics: [], }); + listPluginContributionIds.mockReturnValue(["my-cool-plugin"]); const result = resolveChannelSetupEntries({ cfg: { diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 2ccc5156e98..af3f2600a8c 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -232,12 +232,12 @@ describe("config plugin validation", () => { clearPluginManifestRegistryCache(); }); - it("reports missing plugin refs across load paths, entries, and allowlist surfaces", async () => { + it("reports missing plugin refs across entries and allowlist surfaces", async () => { const missingPath = path.join(suiteHome, "missing-plugin-dir"); const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { - enabled: false, + enabled: true, load: { paths: [missingPath] }, entries: { "missing-plugin": { enabled: true } }, allow: ["missing-allow"], @@ -247,12 +247,6 @@ describe("config plugin validation", () => { }); expect(res.ok).toBe(false); if (!res.ok) { - expect( - res.issues.some( - (issue) => - issue.path === "plugins.load.paths" && issue.message.includes("plugin path not found"), - ), - ).toBe(true); expect(res.issues).toEqual( expect.arrayContaining([ { path: "plugins.deny", message: "plugin not found: missing-deny" }, @@ -354,9 +348,7 @@ describe("config plugin validation", () => { } expect(res.warnings).toContainEqual({ path: "plugins.entries.google", - message: expect.stringContaining( - "plugin google: duplicate plugin id detected; bundled plugin will be overridden by config plugin", - ), + message: "plugin disabled (not in allowlist) but config is present", }); }); diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts index 7e5ae190e56..fbbb394f740 100644 --- a/src/config/runtime-schema.test.ts +++ b/src/config/runtime-schema.test.ts @@ -27,6 +27,11 @@ vi.mock("../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry: (...args: unknown[]) => mockLoadPluginManifestRegistry(...args), })); +vi.mock("../plugins/plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: (...args: unknown[]) => + mockLoadPluginManifestRegistry(...args), +})); + function makeSnapshot(params: { valid: boolean; config?: OpenClawConfig }): ConfigFileSnapshot { return { path: "/tmp/openclaw.json", diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index 54bf3c59f3a..1aca9f2cc82 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -24,12 +24,22 @@ const loadPluginManifestRegistry = vi.hoisted(() => diagnostics: [], plugins: [ { + id: "test-channel-fixture", + channels: ["discord", "irc", "slack", "telegram"], + providers: [], + cliBackends: [], channelEnvVars: { discord: ["DISCORD_BOT_TOKEN"], irc: ["IRC_HOST", "IRC_NICK"], slack: ["SLACK_BOT_TOKEN"], telegram: ["TELEGRAM_BOT_TOKEN"], }, + skills: [], + hooks: [], + origin: "bundled", + rootDir: "/tmp/openclaw-test-channel-fixture", + source: "bundled", + manifestPath: "/tmp/openclaw-test-channel-fixture/openclaw.plugin.json", }, ], })), diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index bac633436ae..e1357a3e420 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -13,6 +13,10 @@ vi.mock("./manifest-registry.js", () => ({ loadPluginManifestRegistry: () => ({ diagnostics: [], plugins: mocks.plugins }), })); +vi.mock("./plugin-registry.js", () => ({ + loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: mocks.plugins }), +})); + vi.mock("./config-state.js", async (importOriginal) => ({ ...(await importOriginal()), hasExplicitPluginConfig: (plugins?: { entries?: Record }) => diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index 76230bcd403..24b2d52f9c6 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -474,9 +474,9 @@ describe("test-projects args", () => { const configs = buildFullSuiteVitestRunPlans([]).map((plan) => plan.config); expect(configs).toContain("test/vitest/vitest.full-core-unit-fast.config.ts"); - expect(configs).toContain("test/vitest/vitest.agents.config.ts"); - expect(configs).toContain("test/vitest/vitest.plugins.config.ts"); - expect(configs).not.toContain("test/vitest/vitest.full-agentic.config.ts"); + expect(configs).toContain("test/vitest/vitest.full-agentic.config.ts"); + expect(configs).not.toContain("test/vitest/vitest.agents.config.ts"); + expect(configs).not.toContain("test/vitest/vitest.plugins.config.ts"); } finally { if (originalVitestMaxWorkers === undefined) { delete process.env.OPENCLAW_VITEST_MAX_WORKERS; diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index dfe59c7fb9c..7e71417dae8 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -634,7 +634,7 @@ describe("scripts/test-projects full-suite sharding", () => { ).toBe(3); }); - it("splits untargeted runs into fixed core shards and per-extension configs", () => { + it("keeps serial untargeted runs on aggregate shards", () => { const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL; const previousSerial = process.env.OPENCLAW_TEST_PROJECTS_SERIAL; delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS; @@ -652,45 +652,9 @@ describe("scripts/test-projects full-suite sharding", () => { "test/vitest/vitest.full-core-contracts.config.ts", "test/vitest/vitest.full-core-bundled.config.ts", "test/vitest/vitest.full-core-runtime.config.ts", - "test/vitest/vitest.gateway-core.config.ts", - "test/vitest/vitest.gateway-client.config.ts", - "test/vitest/vitest.gateway-methods.config.ts", - "test/vitest/vitest.gateway-server.config.ts", - "test/vitest/vitest.cli.config.ts", - "test/vitest/vitest.commands-light.config.ts", - "test/vitest/vitest.commands.config.ts", - "test/vitest/vitest.agents.config.ts", - "test/vitest/vitest.daemon.config.ts", - "test/vitest/vitest.plugin-sdk-light.config.ts", - "test/vitest/vitest.plugin-sdk.config.ts", - "test/vitest/vitest.plugins.config.ts", - "test/vitest/vitest.channels.config.ts", + "test/vitest/vitest.full-agentic.config.ts", "test/vitest/vitest.full-auto-reply.config.ts", - "test/vitest/vitest.extension-acpx.config.ts", - "test/vitest/vitest.extension-bluebubbles.config.ts", - "test/vitest/vitest.extension-diffs.config.ts", - "test/vitest/vitest.extension-discord.config.ts", - "test/vitest/vitest.extension-feishu.config.ts", - "test/vitest/vitest.extension-imessage.config.ts", - "test/vitest/vitest.extension-irc.config.ts", - "test/vitest/vitest.extension-line.config.ts", - "test/vitest/vitest.extension-mattermost.config.ts", - "test/vitest/vitest.extension-matrix.config.ts", - "test/vitest/vitest.extension-memory.config.ts", - "test/vitest/vitest.extension-messaging.config.ts", - "test/vitest/vitest.extension-msteams.config.ts", - "test/vitest/vitest.extension-provider-openai.config.ts", - "test/vitest/vitest.extension-providers.config.ts", - "test/vitest/vitest.extension-signal.config.ts", - "test/vitest/vitest.extension-slack.config.ts", - "test/vitest/vitest.extension-telegram.config.ts", - "test/vitest/vitest.extension-voice-call.config.ts", - "test/vitest/vitest.extension-whatsapp.config.ts", - "test/vitest/vitest.extension-zalo.config.ts", - "test/vitest/vitest.extension-browser.config.ts", - "test/vitest/vitest.extension-qa.config.ts", - "test/vitest/vitest.extension-media.config.ts", - "test/vitest/vitest.extension-misc.config.ts", + "test/vitest/vitest.full-extensions.config.ts", ]); } finally { if (previousParallel === undefined) {