From 7a8ee2f015d6619c33c1eaa316607f5cc5054f18 Mon Sep 17 00:00:00 2001 From: superche Date: Tue, 21 Jul 2026 23:19:35 +0800 Subject: [PATCH 1/2] fix(xiaoyunque): register reference image assets --- bun.lock | 4 +- .../xiaoyunque-generation/convax-package.json | 4 +- .../xiaoyunque-generation/package.json | 2 +- .../package/manifest.json | 2 +- packages/tools/xiaoyunque-mcp/package.json | 2 +- .../scripts/live-image-smoke.ts | 2 +- .../scripts/live-video-smoke.ts | 2 +- .../tools/xiaoyunque-mcp/src/mcp-server.ts | 10 +- .../xiaoyunque-mcp/src/xiaoyunque-api.ts | 86 ++++++++++++++-- .../xiaoyunque-mcp/test/generator.test.ts | 84 +++++++++++++++- .../xiaoyunque-mcp/test/mcp-process.test.ts | 2 +- .../test/mcp-server-errors.test.ts | 8 ++ .../test/xiaoyunque-api.test.ts | 97 +++++++++++++++++++ registry/config.json | 2 +- tooling/registry.test.js | 8 +- 15 files changed, 292 insertions(+), 23 deletions(-) diff --git a/bun.lock b/bun.lock index 4cac622..ac2e31b 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/plugins/xiaoyunque-generation": { "name": "@microvoid/convax-plugin-xiaoyunque-generation", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "@microvoid/convax-xiaoyunque-mcp": "workspace:*" } @@ -83,7 +83,7 @@ }, "packages/tools/xiaoyunque-mcp": { "name": "@microvoid/convax-xiaoyunque-mcp", - "version": "0.3.1", + "version": "0.3.2", "devDependencies": { "@types/bun": "1.3.14", "typescript": "5.9.3" diff --git a/packages/plugins/xiaoyunque-generation/convax-package.json b/packages/plugins/xiaoyunque-generation/convax-package.json index 09a3c5b..17353b8 100644 --- a/packages/plugins/xiaoyunque-generation/convax-package.json +++ b/packages/plugins/xiaoyunque-generation/convax-package.json @@ -4,7 +4,7 @@ "id": "xiaoyunque-generation", "name": "小云雀生成", "description": "通过宿主管理的小云雀网页授权展示服务状态,并使用小云雀第一方网页能力生成图片和视频。", - "version": "0.3.3", + "version": "0.3.4", "license": "MIT", "compatibility": { "pluginSchema": "convax.plugin/3", @@ -13,7 +13,7 @@ "companions": [ { "command": "convax-xiaoyunque-mcp", - "version": "0.3.1", + "version": "0.3.2", "source": "packages/tools/xiaoyunque-mcp", "targets": [ { diff --git a/packages/plugins/xiaoyunque-generation/package.json b/packages/plugins/xiaoyunque-generation/package.json index a80f4ac..03e8d11 100644 --- a/packages/plugins/xiaoyunque-generation/package.json +++ b/packages/plugins/xiaoyunque-generation/package.json @@ -1,6 +1,6 @@ { "name": "@microvoid/convax-plugin-xiaoyunque-generation", - "version": "0.3.3", + "version": "0.3.4", "private": true, "type": "module", "dependencies": { diff --git a/packages/plugins/xiaoyunque-generation/package/manifest.json b/packages/plugins/xiaoyunque-generation/package/manifest.json index 8a3a12e..1d9bbc4 100644 --- a/packages/plugins/xiaoyunque-generation/package/manifest.json +++ b/packages/plugins/xiaoyunque-generation/package/manifest.json @@ -3,7 +3,7 @@ "id": "xiaoyunque-generation", "name": "小云雀生成", "description": "通过宿主管理的小云雀网页授权展示服务状态,并使用小云雀第一方网页能力生成图片和视频。", - "version": "0.3.3", + "version": "0.3.4", "contributes": { "generation": { "models": [ diff --git a/packages/tools/xiaoyunque-mcp/package.json b/packages/tools/xiaoyunque-mcp/package.json index 8ffea40..9905b53 100644 --- a/packages/tools/xiaoyunque-mcp/package.json +++ b/packages/tools/xiaoyunque-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@microvoid/convax-xiaoyunque-mcp", - "version": "0.3.1", + "version": "0.3.2", "private": true, "type": "module", "bin": { diff --git a/packages/tools/xiaoyunque-mcp/scripts/live-image-smoke.ts b/packages/tools/xiaoyunque-mcp/scripts/live-image-smoke.ts index 2a75550..9e4fc10 100644 --- a/packages/tools/xiaoyunque-mcp/scripts/live-image-smoke.ts +++ b/packages/tools/xiaoyunque-mcp/scripts/live-image-smoke.ts @@ -55,7 +55,7 @@ function notify(method: string, params: unknown) { try { const initialized = await request("initialize", { capabilities: {}, - clientInfo: { name: "convax-xiaoyunque-live-smoke", version: "0.3.1" }, + clientInfo: { name: "convax-xiaoyunque-live-smoke", version: "0.3.2" }, protocolVersion: "2025-03-26", }) if ((initialized.result as Record | undefined)?.protocolVersion !== "2025-03-26") { diff --git a/packages/tools/xiaoyunque-mcp/scripts/live-video-smoke.ts b/packages/tools/xiaoyunque-mcp/scripts/live-video-smoke.ts index edf0985..6723535 100644 --- a/packages/tools/xiaoyunque-mcp/scripts/live-video-smoke.ts +++ b/packages/tools/xiaoyunque-mcp/scripts/live-video-smoke.ts @@ -117,7 +117,7 @@ try { progress("mcp.initialize") const initialized = await request("initialize", { capabilities: {}, - clientInfo: { name: "convax-xiaoyunque-live-video-smoke", version: "0.3.1" }, + clientInfo: { name: "convax-xiaoyunque-live-video-smoke", version: "0.3.2" }, protocolVersion: "2025-03-26", }) if ((initialized.result as Record | undefined)?.protocolVersion !== "2025-03-26") { diff --git a/packages/tools/xiaoyunque-mcp/src/mcp-server.ts b/packages/tools/xiaoyunque-mcp/src/mcp-server.ts index a9ef087..ae5b890 100644 --- a/packages/tools/xiaoyunque-mcp/src/mcp-server.ts +++ b/packages/tools/xiaoyunque-mcp/src/mcp-server.ts @@ -24,6 +24,7 @@ import { import { XiaoYunqueAuthenticationError, XiaoYunqueQueryTimeoutError, + XiaoYunqueReferenceAssetRegistrationError, XiaoYunqueRequestRejectedError, } from "./xiaoyunque-api.ts" @@ -151,6 +152,9 @@ export function publicGenerationErrorMessage(error: unknown) { if (error instanceof XiaoYunqueUnsupportedImageModelError) { return "The selected XiaoYunque image model is no longer available. Choose another image model and try again." } + if (error instanceof XiaoYunqueReferenceAssetRegistrationError) { + return "XiaoYunque could not prepare the reference image for generation. No generation was submitted; try again." + } if (error instanceof XiaoYunqueRequestRejectedError) { return "XiaoYunque did not accept this generation request. Refresh Services and try a model listed for this capability." } @@ -164,6 +168,7 @@ export const safeGenerationDiagnosticCodes = [ "status-check-rejected", "status-check-timeout", "unsupported-image-model", + "reference-image-registration-failed", "upstream-envelope-rejected", "upstream-http-rejected", "upstream-request-rejected", @@ -179,6 +184,9 @@ export function safeGenerationDiagnosticCode(error: unknown): SafeGenerationDiag if (error instanceof XiaoYunqueObservationRejectedError) return "status-check-rejected" if (error instanceof XiaoYunqueQueryTimeoutError) return "status-check-timeout" if (error instanceof XiaoYunqueUnsupportedImageModelError) return "unsupported-image-model" + if (error instanceof XiaoYunqueReferenceAssetRegistrationError) { + return "reference-image-registration-failed" + } if (error instanceof XiaoYunqueRequestRejectedError) return error.diagnosticCode return "unclassified-failure" } @@ -296,7 +304,7 @@ export class McpServer { this.#sendResult(request.id, { capabilities: { tools: {} }, protocolVersion, - serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.1" }, + serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.2" }, }) return } diff --git a/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts b/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts index 3be22db..d3a00e0 100644 --- a/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts +++ b/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts @@ -11,6 +11,7 @@ import { const odinUserInfoPath = "/api/biz/v1/common/get_odin_user_info" const userWorkspacePath = "/api/web/v1/workspace/get_user_workspace" const uploadPath = "/api/web/v1/common/upload_file" +const assetCreateV2Path = "/api/biz/v1/asset/create_v2" const submitPath = "/api/biz/v1/agent/submit_run" const getThreadPath = "/api/biz/v1/agent/get_thread" @@ -28,6 +29,20 @@ export class XiaoYunqueQueryTimeoutError extends Error { override name = "XiaoYunqueQueryTimeoutError" } +/** + * The first-party upload succeeded, but its EverPhoto asset could not be + * registered as the Pippit asset identity required by image generation. + * Never attach upstream response details to this error because it crosses the + * MCP diagnostic boundary. + */ +export class XiaoYunqueReferenceAssetRegistrationError extends Error { + override name = "XiaoYunqueReferenceAssetRegistrationError" + + constructor() { + super("XiaoYunque reference image asset registration failed") + } +} + /** * The first-party Web API returned a non-success status or envelope. Keep the * raw response inside the API boundary while still letting the MCP surface @@ -152,6 +167,16 @@ function optionalBoundedString(value: unknown, label: string, maximumBytes = 4_0 return boundedString(value, label, maximumBytes) } +function optionalPippitAssetId(value: unknown) { + if (value === undefined || value === null) return undefined + if (typeof value !== "string") { + return boundedString(value, "XiaoYunque uploaded Pippit asset id", 1_024) + } + const candidate = value.trim() + if (candidate.length === 0) return undefined + return boundedString(candidate, "XiaoYunque uploaded Pippit asset id", 1_024) +} + function nonNegativeInteger(value: unknown, label: string) { const number = typeof value === "string" && /^(?:0|[1-9]\d*)$/u.test(value) ? Number(value) @@ -209,6 +234,24 @@ function requireSuccess(value: unknown, status: number, label: string) { return envelope } +function requireAssetRegistrationSuccess(value: unknown, status: number) { + const label = "XiaoYunque reference image asset registration" + const envelope = record(value) + if (!envelope) throw new Error(`${label} returned an invalid response`) + if (looksLikeAuthenticationFailure(envelope, status)) { + throw new XiaoYunqueAuthenticationError("XiaoYunque authorization is no longer valid") + } + if (status < 200 || status >= 300) { + throw new XiaoYunqueRequestRejectedError(`${label} was rejected`, "upstream-http-rejected") + } + // Unlike generation envelopes, the first-party AssetCreateV2 contract may + // omit `ret`; an explicit value must still be the canonical numeric zero. + if (envelope.ret !== undefined && envelope.ret !== 0 && envelope.ret !== "0") { + throw new XiaoYunqueRequestRejectedError(`${label} was rejected`, "upstream-envelope-rejected") + } + return envelope +} + async function boundedJson(response: Response, label: string) { if (response.status === 401) { await response.body?.cancel().catch(() => undefined) @@ -292,11 +335,7 @@ function uploadedAsset( const format = optionalBoundedString(data.format, "XiaoYunque uploaded asset format", 128) const md5 = optionalBoundedString(data.md5, "XiaoYunque uploaded asset md5", 256) const mime = optionalBoundedString(data.mime, "XiaoYunque uploaded asset MIME type", 256) - const pippitAssetId = optionalBoundedString( - data.pippit_asset_id, - "XiaoYunque uploaded Pippit asset id", - 1_024, - ) + const pippitAssetId = optionalPippitAssetId(data.pippit_asset_id) const ratio = width && height ? `${width}:${height}` : undefined return { assetId, @@ -578,7 +617,42 @@ export class XiaoYunqueApi { body.append("asset_type", String(assetType)) const response = await this.#request(uploadPath, session, signal, { body, method: "POST" }) const payload = requireSuccess(response.value, response.status, "XiaoYunque reference upload") - return uploadedAsset(payload.data, reference, assetType, this.#allowLoopbackTest) + const asset = uploadedAsset(payload.data, reference, assetType, this.#allowLoopbackTest) + if (assetType !== 2 || asset.pippitAssetId !== undefined) return asset + + return { + ...asset, + pippitAssetId: await this.#registerUploadedImage(asset.assetId, session, signal), + } + } + + async #registerUploadedImage( + assetId: string, + session: StoredWebSession, + signal: AbortSignal, + ) { + try { + // `upload_file.asset_id` is an EverPhoto source id. The first-party Web + // product converts it into the identity consumed by image generation via + // AssetCreateV2; the two ids are deliberately never treated as aliases. + const response = await this.#jsonRequest(assetCreateV2Path, { + asset_source_type: 3, + asset_source_id: assetId, + asset_type: 1, + Base: { Client: "web" }, + }, session, signal) + const payload = requireAssetRegistrationSuccess(response.value, response.status) + const data = record(payload.data) + return boundedString( + data?.PippitAssetID, + "XiaoYunque registered Pippit asset id", + 1_024, + ) + } catch (error) { + if (signal.aborted) throw cancellationError(signal) + if (error instanceof XiaoYunqueAuthenticationError) throw error + throw new XiaoYunqueReferenceAssetRegistrationError() + } } async submitImage( diff --git a/packages/tools/xiaoyunque-mcp/test/generator.test.ts b/packages/tools/xiaoyunque-mcp/test/generator.test.ts index 2792193..b1bcbc2 100644 --- a/packages/tools/xiaoyunque-mcp/test/generator.test.ts +++ b/packages/tools/xiaoyunque-mcp/test/generator.test.ts @@ -11,7 +11,11 @@ import { } from "../src/generator.ts" import { fingerprintGenerationCall, OperationStore } from "../src/operation-store.ts" import { webSessionSchema, type StoredWebSession } from "../src/web-session-store.ts" -import { XiaoYunqueApi, XiaoYunqueRequestRejectedError } from "../src/xiaoyunque-api.ts" +import { + XiaoYunqueApi, + XiaoYunqueReferenceAssetRegistrationError, + XiaoYunqueRequestRejectedError, +} from "../src/xiaoyunque-api.ts" const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]) const mp4 = Uint8Array.from([0, 0, 0, 24, 102, 116, 121, 112, 105, 115, 111, 109, 0, 0, 0, 0]) @@ -290,6 +294,84 @@ describe("XiaoYunque generation engine", () => { } }) + test("does not submit or persist an operation when reference image registration is rejected or incomplete", async () => { + const cases = [ + { + name: "rejected", + response: { ret: 4001, errmsg: "private upstream registration detail" }, + }, + { + name: "missing-id", + response: { data: {} }, + }, + ] + + for (const registrationCase of cases) { + const directory = await mkdtemp(path.join(os.tmpdir(), `xiaoyunque-reference-${registrationCase.name}-`)) + directories.push(directory) + const output = path.join(directory, "output") + const referencePath = path.join(directory, "reference.png") + await Promise.all([mkdir(output), writeFile(referencePath, png)]) + const requestedPaths: string[] = [] + let submitCount = 0 + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request): Promise => { + const url = new URL(request.url) + requestedPaths.push(url.pathname) + if (url.pathname === "/api/web/v1/common/upload_file") { + return Response.json({ + ret: 0, + data: { + asset_id: "everphoto-reference", + download_url: `${url.origin}/uploaded-reference.png`, + }, + }) + } + if (url.pathname === "/api/biz/v1/asset/create_v2") { + return Response.json(registrationCase.response) + } + if (url.pathname === "/api/biz/v1/agent/submit_run") submitCount += 1 + return new Response("unexpected", { status: 500 }) + }, + }) + const operationId = `reference-registration-${registrationCase.name}` + const operationStore = new OperationStore(path.join(directory, "state", "operations.json")) + const engine = new GenerationEngine({ + api: new XiaoYunqueApi(`http://127.0.0.1:${server.port}`), + authorizer: { session: async () => testSession }, + operationStore, + pollIntervalMs: 1, + }) + try { + const error = await engine.generate(call({ + operation_id: operationId, + output: "image", + output_directory: output, + references: [{ + kind: "file", + mime_type: "image/png", + name: "reference.png", + node_id: "reference-node", + path: referencePath, + role: "reference_image", + }], + }), imageModel, new AbortController().signal).catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(XiaoYunqueReferenceAssetRegistrationError) + expect(requestedPaths).toEqual([ + "/api/web/v1/common/upload_file", + "/api/biz/v1/asset/create_v2", + ]) + expect(submitCount).toBe(0) + expect(await operationStore.find(operationId)).toBeNull() + } finally { + server.stop(true) + } + } + }) + test("uses one first frame as a normal image-to-video reference", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "xiaoyunque-first-frame-video-")) directories.push(directory) diff --git a/packages/tools/xiaoyunque-mcp/test/mcp-process.test.ts b/packages/tools/xiaoyunque-mcp/test/mcp-process.test.ts index d0ac733..4db7d3e 100644 --- a/packages/tools/xiaoyunque-mcp/test/mcp-process.test.ts +++ b/packages/tools/xiaoyunque-mcp/test/mcp-process.test.ts @@ -253,7 +253,7 @@ describe("MCP process", () => { }) expect(initialized).toMatchObject({ result: { - serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.1" }, + serverInfo: { name: "convax-xiaoyunque-mcp", version: "0.3.2" }, }, }) const authorization = await harness.request("tools/call", { name: "service.authorize", arguments: {} }) diff --git a/packages/tools/xiaoyunque-mcp/test/mcp-server-errors.test.ts b/packages/tools/xiaoyunque-mcp/test/mcp-server-errors.test.ts index 6b7f511..76ae714 100644 --- a/packages/tools/xiaoyunque-mcp/test/mcp-server-errors.test.ts +++ b/packages/tools/xiaoyunque-mcp/test/mcp-server-errors.test.ts @@ -9,6 +9,7 @@ import { publicGenerationErrorMessage, safeGenerationDiagnosticCode } from "../s import { XiaoYunqueAuthenticationError, XiaoYunqueQueryTimeoutError, + XiaoYunqueReferenceAssetRegistrationError, XiaoYunqueRequestRejectedError, } from "../src/xiaoyunque-api.ts" @@ -33,6 +34,10 @@ describe("safe public generation errors", () => { .toBe( "The selected XiaoYunque image model is no longer available. Choose another image model and try again.", ) + expect(publicGenerationErrorMessage(new XiaoYunqueReferenceAssetRegistrationError())) + .toBe( + "XiaoYunque could not prepare the reference image for generation. No generation was submitted; try again.", + ) expect(publicGenerationErrorMessage(new XiaoYunqueRequestRejectedError("private rejection detail"))) .toBe( "XiaoYunque did not accept this generation request. Refresh Services and try a model listed for this capability.", @@ -45,6 +50,7 @@ describe("safe public generation errors", () => { publicGenerationErrorMessage(new XiaoYunqueAuthenticationError()), publicGenerationErrorMessage(new XiaoYunqueObservationRejectedError("upstream-envelope-rejected")), publicGenerationErrorMessage(new XiaoYunqueQueryTimeoutError()), + publicGenerationErrorMessage(new XiaoYunqueReferenceAssetRegistrationError()), publicGenerationErrorMessage(new XiaoYunqueRequestRejectedError("private rejection detail")), publicGenerationErrorMessage(new XiaoYunqueUnsupportedImageModelError()), ] @@ -67,6 +73,7 @@ describe("safe public generation errors", () => { safeGenerationDiagnosticCode(new XiaoYunqueAuthenticationError(privateDetail)), safeGenerationDiagnosticCode(new XiaoYunqueObservationRejectedError("upstream-http-rejected")), safeGenerationDiagnosticCode(new XiaoYunqueQueryTimeoutError(privateDetail)), + safeGenerationDiagnosticCode(new XiaoYunqueReferenceAssetRegistrationError()), safeGenerationDiagnosticCode(new XiaoYunqueUnsupportedImageModelError()), safeGenerationDiagnosticCode(new XiaoYunqueRequestRejectedError( privateDetail, @@ -79,6 +86,7 @@ describe("safe public generation errors", () => { "sign-in-expired", "status-check-rejected", "status-check-timeout", + "reference-image-registration-failed", "unsupported-image-model", "upstream-envelope-rejected", "unclassified-failure", diff --git a/packages/tools/xiaoyunque-mcp/test/xiaoyunque-api.test.ts b/packages/tools/xiaoyunque-mcp/test/xiaoyunque-api.test.ts index b00fbcb..b0e78a7 100644 --- a/packages/tools/xiaoyunque-mcp/test/xiaoyunque-api.test.ts +++ b/packages/tools/xiaoyunque-mcp/test/xiaoyunque-api.test.ts @@ -9,6 +9,7 @@ import { XiaoYunqueApi, XiaoYunqueAuthenticationError, XiaoYunqueQueryTimeoutError, + XiaoYunqueReferenceAssetRegistrationError, XiaoYunqueRequestRejectedError, } from "../src/xiaoyunque-api.ts" import { @@ -144,7 +145,9 @@ describe("XiaoYunque first-party Web generation API", () => { const referencePath = path.join(directory, "reference.png") await writeFile(referencePath, Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10])) + let requestCount = 0 const api = apiFor(async (url, init) => { + requestCount += 1 expect(url.pathname).toBe("/api/web/v1/common/upload_file") expect(init?.method).toBe("POST") expectWebHeaders(init?.headers) @@ -179,6 +182,100 @@ describe("XiaoYunque first-party Web generation API", () => { path: referencePath, role: "reference_image", }, session, signal)).toEqual(imageAsset) + expect(requestCount).toBe(1) + }) + + test("registers an uploaded EverPhoto image when upload_file returns a blank Pippit asset id", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "xiaoyunque-web-register-upload-")) + directories.push(directory) + const referencePath = path.join(directory, "reference.png") + await writeFile(referencePath, Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10])) + + for (const directPippitAssetId of ["", " \t "]) { + const requests: string[] = [] + const api = apiFor((url, init) => { + requests.push(url.pathname) + expectWebHeaders(init?.headers) + if (url.pathname === "/api/web/v1/common/upload_file") { + return Response.json({ + ret: 0, + data: { + asset_id: "asset-image", + download_url: "https://cdn.example.test/reference.png", + format: "png", + height: "720", + md5: "0123456789abcdef", + mime: "image/png", + pippit_asset_id: directPippitAssetId, + size: "8", + width: "1280", + }, + }) + } + expect(url.pathname).toBe("/api/biz/v1/asset/create_v2") + expect(init?.method).toBe("POST") + expect(JSON.parse(String(init?.body))).toEqual({ + asset_source_type: 3, + asset_source_id: "asset-image", + asset_type: 1, + Base: { Client: "web" }, + }) + return Response.json({ + data: { PippitAssetID: "pippit-image" }, + }) + }) + + expect(await api.upload({ + kind: "file", + mime_type: "image/png", + name: "reference.png", + node_id: "node-image", + path: referencePath, + role: "reference_image", + }, session, signal)).toEqual(imageAsset) + expect(requests).toEqual([ + "/api/web/v1/common/upload_file", + "/api/biz/v1/asset/create_v2", + ]) + } + }) + + test("classifies failed or incomplete reference image registration without leaking upstream details", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "xiaoyunque-web-register-failure-")) + directories.push(directory) + const referencePath = path.join(directory, "reference.png") + await writeFile(referencePath, Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10])) + const reference = { + kind: "file", + mime_type: "image/png", + name: "reference.png", + node_id: "node-image", + path: referencePath, + role: "reference_image", + } as const + const uploadResponse = () => Response.json({ + ret: 0, + data: { + asset_id: "asset-image", + download_url: "https://cdn.example.test/reference.png", + }, + }) + const rejected = apiFor((url) => url.pathname === "/api/web/v1/common/upload_file" + ? uploadResponse() + : Response.json({ ret: 4001, errmsg: "private upstream registration detail" })) + const incomplete = apiFor((url) => url.pathname === "/api/web/v1/common/upload_file" + ? uploadResponse() + : Response.json({ ret: 0, data: { PippitAssetID: "" } })) + + for (const api of [rejected, incomplete]) { + const error = await api.upload(reference, session, signal).catch((reason: unknown) => reason) + expect(error).toBeInstanceOf(XiaoYunqueReferenceAssetRegistrationError) + expect(String(error)).toBe( + "XiaoYunqueReferenceAssetRegistrationError: XiaoYunque reference image asset registration failed", + ) + expect(String(error)).not.toContain("private upstream registration detail") + expect(String(error)).not.toContain("asset-image") + } }) test("maps video and audio references to the first-party upload asset types", async () => { diff --git a/registry/config.json b/registry/config.json index f433fc1..a55890d 100644 --- a/registry/config.json +++ b/registry/config.json @@ -1,4 +1,4 @@ { - "sequence": 22, + "sequence": 23, "yanked": [] } diff --git a/tooling/registry.test.js b/tooling/registry.test.js index dc724c3..dc53b32 100644 --- a/tooling/registry.test.js +++ b/tooling/registry.test.js @@ -85,7 +85,7 @@ describe("source packages", () => { } expect(xiaoyunque.metadata.companions).toEqual([{ command: "convax-xiaoyunque-mcp", - version: "0.3.1", + version: "0.3.2", source: "packages/tools/xiaoyunque-mcp", targets: [{ platform: "darwin", @@ -170,9 +170,9 @@ describe("source packages", () => { expect(readStoredZip(hello.zip).map((entry) => entry.relativePath)).toContain("manifest.json") expect(readStoredZip(xiaoyunque.zip).map((entry) => entry.relativePath)).toEqual(["LICENSE", "manifest.json"]) expect(xiaoyunque.companionAssets.map((asset) => asset.assetName)).toEqual([ - "convax-companion-convax-xiaoyunque-mcp-0.3.1-darwin-arm64", + "convax-companion-convax-xiaoyunque-mcp-0.3.2-darwin-arm64", ]) - expect(xiaoyunque.tag).toBe("plugin-xiaoyunque-generation-v0.3.3") + expect(xiaoyunque.tag).toBe("plugin-xiaoyunque-generation-v0.3.4") expect(await fs.readFile(xiaoyunque.companionAssets[0].path)).toEqual(xiaoyunque.companionAssets[0].data) expect(readStoredZip(skill.zip).map((entry) => entry.relativePath)).toContain("SKILL.md") expect(readStoredZip(ffmpeg.zip).map((entry) => entry.relativePath)).toEqual([ @@ -308,7 +308,7 @@ describe("source packages", () => { expect(helloEntry.artifact.url).toContain("/plugin-hello-convax-v0.2.0/") expect(xiaoyunqueEntry.manifest.schema).toBe("convax.plugin/3") expect(xiaoyunqueEntry.companions[0].targets[0].artifact.url).toContain( - "/convax-companion-convax-xiaoyunque-mcp-0.3.1-darwin-arm64", + "/convax-companion-convax-xiaoyunque-mcp-0.3.2-darwin-arm64", ) expect(firstSkill).not.toHaveProperty("manifest") expect(ffmpegSkillEntry.ownerPluginId).toBe("ffmpeg-tools") From edbbb64e541fef5d8faa875ec8395acb46cd8fe4 Mon Sep 17 00:00:00 2001 From: superche Date: Wed, 22 Jul 2026 00:54:47 +0800 Subject: [PATCH 2/2] test(xiaoyunque): cover video reference registration --- .../tools/xiaoyunque-mcp/src/xiaoyunque-api.ts | 4 ++-- .../tools/xiaoyunque-mcp/test/generator.test.ts | 17 ++++++++++++++--- .../xiaoyunque-mcp/test/model-catalog.test.ts | 3 +++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts b/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts index d3a00e0..e227220 100644 --- a/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts +++ b/packages/tools/xiaoyunque-mcp/src/xiaoyunque-api.ts @@ -31,7 +31,7 @@ export class XiaoYunqueQueryTimeoutError extends Error { /** * The first-party upload succeeded, but its EverPhoto asset could not be - * registered as the Pippit asset identity required by image generation. + * registered as the Pippit asset identity required by image and video generation. * Never attach upstream response details to this error because it crosses the * MCP diagnostic boundary. */ @@ -633,7 +633,7 @@ export class XiaoYunqueApi { ) { try { // `upload_file.asset_id` is an EverPhoto source id. The first-party Web - // product converts it into the identity consumed by image generation via + // product converts it into the identity consumed by image and video generation via // AssetCreateV2; the two ids are deliberately never treated as aliases. const response = await this.#jsonRequest(assetCreateV2Path, { asset_source_type: 3, diff --git a/packages/tools/xiaoyunque-mcp/test/generator.test.ts b/packages/tools/xiaoyunque-mcp/test/generator.test.ts index b1bcbc2..425d8a8 100644 --- a/packages/tools/xiaoyunque-mcp/test/generator.test.ts +++ b/packages/tools/xiaoyunque-mcp/test/generator.test.ts @@ -372,13 +372,14 @@ describe("XiaoYunque generation engine", () => { } }) - test("uses one first frame as a normal image-to-video reference", async () => { + test("registers and submits one Canvas reference image for video generation", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "xiaoyunque-first-frame-video-")) directories.push(directory) const output = path.join(directory, "output") const firstFrame = path.join(directory, "first.png") await Promise.all([mkdir(output), writeFile(firstFrame, png)]) let uploadCount = 0 + let registrationCount = 0 let submittedBody: Record | undefined let submittedTask: ReturnType | undefined const server = Bun.serve({ @@ -396,10 +397,19 @@ describe("XiaoYunque generation engine", () => { data: { asset_id: "first-frame-asset", download_url: `${url.origin}/uploaded-first.png`, - pippit_asset_id: "pippit-first-frame", }, }) } + if (url.pathname === "/api/biz/v1/asset/create_v2") { + registrationCount += 1 + expect(await request.json()).toEqual({ + asset_source_type: 3, + asset_source_id: "first-frame-asset", + asset_type: 1, + Base: { Client: "web" }, + }) + return Response.json({ data: { PippitAssetID: "pippit-first-frame" } }) + } const accountPreflight = await accountPreflightResponse(request) if (accountPreflight) return accountPreflight if (url.pathname === "/api/biz/v1/agent/submit_run") { @@ -450,12 +460,13 @@ describe("XiaoYunque generation engine", () => { name: "first.png", node_id: "ordinary-canvas-image-node", path: firstFrame, - role: "first_frame", + role: "reference_image", }], }), videoModel, new AbortController().signal) expect(artifacts[0]?.mimeType).toBe("video/mp4") expect(uploadCount).toBe(1) + expect(registrationCount).toBe(1) expect(submittedBody).toMatchObject({ agent_name: "pippit_novel_video_part_agent", }) diff --git a/packages/tools/xiaoyunque-mcp/test/model-catalog.test.ts b/packages/tools/xiaoyunque-mcp/test/model-catalog.test.ts index 18dacd8..90fd13b 100644 --- a/packages/tools/xiaoyunque-mcp/test/model-catalog.test.ts +++ b/packages/tools/xiaoyunque-mcp/test/model-catalog.test.ts @@ -56,6 +56,9 @@ describe("XiaoYunque governed model catalog", () => { ]) const mcpNames: string[] = generationMcpTools.map((tool) => tool.name) expect(mcpNames).toEqual(manifestIds) + for (const tool of manifest.contributes.generation.tools.filter((item) => item.id.startsWith("image."))) { + expect(tool.acceptedInputs).toEqual(["reference_image"]) + } for (const tool of manifest.contributes.generation.tools.filter((item) => item.id.startsWith("video."))) { expect(tool.acceptedInputs).toEqual([ "reference_image",