Skip to content

Commit a2956d5

Browse files
committed
fix(image): collect every async concurrent result image
Prefer choices per task, then fall back to results, so concurrent waits no longer keep only the first non-empty task.
1 parent f6d5308 commit a2956d5

4 files changed

Lines changed: 158 additions & 51 deletions

File tree

‎packages/commands/src/commands/image/edit.ts‎

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
mcpSingleImageOnlyError,
4343
type Localize,
4444
} from "../shared/aigc-mcp.ts";
45+
import { collectTaskImageUrls } from "../shared/task-images.ts";
4546

4647
const EDIT_FLAGS = {
4748
image: {
@@ -491,23 +492,7 @@ async function handleAsyncMode(
491492
);
492493

493494
const results = await Promise.all(pollPromises);
494-
495-
let imageUrls: string[] = [];
496-
for (const result of results) {
497-
if (result.output.choices) {
498-
const urls = result.output.choices
499-
.flatMap((choice) => choice.message?.content || [])
500-
.map((item) => item.image)
501-
.filter(Boolean);
502-
imageUrls.push(...urls);
503-
}
504-
if (result.output.results) {
505-
const urls = result.output.results.map((item) => item.url).filter(Boolean);
506-
if (urls.length > 0 && imageUrls.length === 0) {
507-
imageUrls.push(...urls);
508-
}
509-
}
510-
}
495+
const imageUrls = results.flatMap((result) => collectTaskImageUrls(result.output));
511496

512497
if (imageUrls.length === 0) {
513498
throw new BailianError("All tasks completed but no images returned.", ExitCode.GENERAL);

‎packages/commands/src/commands/image/generate.ts‎

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
withAigcMcpSession,
3939
type Localize,
4040
} from "../shared/aigc-mcp.ts";
41+
import { collectTaskImageUrls } from "../shared/task-images.ts";
4142

4243
import { join } from "path";
4344

@@ -394,23 +395,7 @@ async function handleAsyncMode(
394395
});
395396

396397
const results = await Promise.all(pollPromises);
397-
398-
let imageUrls: string[] = [];
399-
for (const result of results) {
400-
if (result.output.choices) {
401-
const urls = result.output.choices
402-
.flatMap((choice) => choice.message?.content || [])
403-
.map((item) => item.image)
404-
.filter(Boolean);
405-
imageUrls.push(...urls);
406-
}
407-
if (result.output.results) {
408-
const urls = result.output.results.map((item) => item.url).filter(Boolean);
409-
if (urls.length > 0 && imageUrls.length === 0) {
410-
imageUrls.push(...urls);
411-
}
412-
}
413-
}
398+
const imageUrls = results.flatMap((result) => collectTaskImageUrls(result.output));
414399

415400
if (imageUrls.length === 0) {
416401
throw new BailianError("All tasks completed but no images returned.", ExitCode.GENERAL);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { DashScopeTaskResponse } from "bailian-cli-core";
2+
3+
/** Image URLs from one async task: use choices when present, otherwise results. */
4+
export function collectTaskImageUrls(output: DashScopeTaskResponse["output"]): string[] {
5+
const fromChoices = (output.choices ?? [])
6+
.flatMap((choice) => choice.message?.content ?? [])
7+
.map((item) => item.image)
8+
.filter(Boolean);
9+
if (fromChoices.length > 0) return fromChoices;
10+
return (output.results ?? []).map((item) => item.url).filter(Boolean);
11+
}

‎packages/commands/tests/output/image-output.test.ts‎

Lines changed: 143 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ let origin: string;
1515
let scenario: "success" | "submit-error" | "task-failed" | "empty" | "download-error";
1616
let requests: Array<{ method: string; path: string; body: unknown; async: boolean }>;
1717
let legacyResults: boolean;
18+
let asyncImageShape: "default" | "results" | "mixed" | "both" | "partial-empty" | "all-empty";
1819
let submissions: number;
1920
let downloads: number;
2021

@@ -25,6 +26,7 @@ beforeEach(async () => {
2526
submissions = 0;
2627
downloads = 0;
2728
legacyResults = false;
29+
asyncImageShape = "default";
2830
server = createServer(async (request, response) => {
2931
if (request.url?.startsWith("/files/")) {
3032
downloads++;
@@ -74,30 +76,21 @@ beforeEach(async () => {
7476
return;
7577
}
7678
const taskId = request.url!.split("/").at(-1)!;
79+
const taskNumber = taskId.slice(5);
80+
const fileUrl = `${origin}/files/${taskNumber}.png`;
81+
const choiceImage = { message: { content: [{ image: fileUrl }] } };
82+
const resultImage = { url: fileUrl };
83+
const ignoredImage = { url: `${origin}/files/ignored-${taskNumber}.png` };
84+
const useResults = legacyResults || asyncImageShape === "results";
85+
const images = taskImages(taskNumber, choiceImage, resultImage, ignoredImage, useResults);
7786
response.end(
7887
JSON.stringify({
7988
output: {
8089
task_id: taskId,
8190
task_status: scenario === "task-failed" ? "FAILED" : "SUCCEEDED",
8291
...(scenario === "task-failed"
8392
? { code: "TaskFailure", message: "Original task failure" }
84-
: legacyResults
85-
? {
86-
results:
87-
scenario === "empty" ? [] : [{ url: `${origin}/files/${taskId.slice(5)}.png` }],
88-
}
89-
: {
90-
choices:
91-
scenario === "empty"
92-
? []
93-
: [
94-
{
95-
message: {
96-
content: [{ image: `${origin}/files/${taskId.slice(5)}.png` }],
97-
},
98-
},
99-
],
100-
}),
93+
: images),
10194
},
10295
}),
10396
);
@@ -117,6 +110,25 @@ afterEach(async () => {
117110
rmSync(directory, { recursive: true, force: true });
118111
});
119112

113+
function taskImages(
114+
taskNumber: string,
115+
choiceImage: { message: { content: Array<{ image: string }> } },
116+
resultImage: { url: string },
117+
ignoredImage: { url: string },
118+
useResults: boolean,
119+
) {
120+
if (asyncImageShape === "all-empty") return { results: [] };
121+
if (asyncImageShape === "partial-empty") {
122+
return taskNumber === "2" ? { results: [] } : { results: [resultImage] };
123+
}
124+
if (asyncImageShape === "both") return { choices: [choiceImage], results: [ignoredImage] };
125+
if (asyncImageShape === "mixed") {
126+
return taskNumber === "1" ? { choices: [choiceImage] } : { results: [resultImage] };
127+
}
128+
if (useResults) return { results: scenario === "empty" ? [] : [resultImage] };
129+
return { choices: scenario === "empty" ? [] : [choiceImage] };
130+
}
131+
120132
const commands = [
121133
{ name: "generate", command: generate },
122134
{ name: "edit", command: edit },
@@ -381,3 +393,117 @@ for (const entry of commands) {
381393
expect(result.stdout).not.toContain("cGF5bG9hZA==");
382394
});
383395
}
396+
397+
function savedPaths(count: number) {
398+
const paths: string[] = [];
399+
for (let index = 1; index <= count; index += 1) {
400+
paths.push(join(directory, "explicit", `fixed_${String(index).padStart(3, "0")}.png`));
401+
}
402+
return paths;
403+
}
404+
405+
function fileUrls(numbers: number[]) {
406+
return numbers.map((number) => `${origin}/files/${number}.png`);
407+
}
408+
409+
for (const entry of commands) {
410+
test(`${entry.name} async concurrent results keeps every task image`, async () => {
411+
asyncImageShape = "results";
412+
const snapshots = [];
413+
for (const format of ["json", "text"] as const) {
414+
submissions = 0;
415+
downloads = 0;
416+
const result = await invoke(entry.command, flagsFor(entry.name, "wait", 3), {
417+
format,
418+
quiet: true,
419+
});
420+
expect(result.exitCode, result.stderr).toBe(0);
421+
const data = format === "json" ? JSON.parse(result.stdout) : parseYaml(result.stdout);
422+
const saved = savedPaths(3);
423+
expect(data).toEqual({
424+
urls: fileUrls([1, 2, 3]),
425+
saved,
426+
total: 3,
427+
...(entry.name === "generate" ? { task_ids: ["task-1", "task-2", "task-3"] } : {}),
428+
});
429+
for (const path of saved) expect(readFileSync(path, "utf8")).toBe("payload");
430+
expect(downloads).toBe(3);
431+
snapshots.push(data);
432+
}
433+
expect(snapshots[1]).toEqual(snapshots[0]);
434+
});
435+
436+
test(`${entry.name} async mixed choices and results keeps both tasks`, async () => {
437+
asyncImageShape = "mixed";
438+
const result = await invoke(entry.command, flagsFor(entry.name, "wait", 2), {
439+
format: "json",
440+
quiet: true,
441+
});
442+
expect(result.exitCode, result.stderr).toBe(0);
443+
expect(JSON.parse(result.stdout)).toEqual({
444+
urls: fileUrls([1, 2]),
445+
saved: savedPaths(2),
446+
total: 2,
447+
...(entry.name === "generate" ? { task_ids: ["task-1", "task-2"] } : {}),
448+
});
449+
expect(downloads).toBe(2);
450+
});
451+
452+
test(`${entry.name} async task with both fields keeps choices only`, async () => {
453+
asyncImageShape = "both";
454+
const result = await invoke(entry.command, flagsFor(entry.name, "wait", 2), {
455+
format: "json",
456+
quiet: true,
457+
});
458+
expect(result.exitCode, result.stderr).toBe(0);
459+
const data = JSON.parse(result.stdout);
460+
expect(data.urls).toEqual(fileUrls([1, 2]));
461+
expect(data.total).toBe(2);
462+
expect(data.saved).toEqual(savedPaths(2));
463+
expect(JSON.stringify(data)).not.toContain("ignored-");
464+
expect(downloads).toBe(2);
465+
});
466+
467+
test(`${entry.name} async partial empty results keeps the remaining images`, async () => {
468+
asyncImageShape = "partial-empty";
469+
const result = await invoke(entry.command, flagsFor(entry.name, "wait", 3), {
470+
format: "json",
471+
quiet: true,
472+
});
473+
expect(result.exitCode, result.stderr).toBe(0);
474+
expect(JSON.parse(result.stdout)).toEqual({
475+
urls: fileUrls([1, 3]),
476+
saved: savedPaths(2),
477+
total: 2,
478+
...(entry.name === "generate" ? { task_ids: ["task-1", "task-2", "task-3"] } : {}),
479+
});
480+
expect(downloads).toBe(2);
481+
});
482+
483+
test(`${entry.name} async empty results still fails without images`, async () => {
484+
asyncImageShape = "all-empty";
485+
const result = await invoke(entry.command, flagsFor(entry.name, "wait", 2), {
486+
format: "json",
487+
quiet: true,
488+
});
489+
expect(result.exitCode).toBe(1);
490+
expect(result.stdout).toBe("");
491+
expect(result.stderr).toContain("All tasks completed but no images returned.");
492+
expect(downloads).toBe(0);
493+
});
494+
495+
test(`${entry.name} async concurrent choices still collects every image`, async () => {
496+
const result = await invoke(entry.command, flagsFor(entry.name, "wait", 3), {
497+
format: "json",
498+
quiet: true,
499+
});
500+
expect(result.exitCode, result.stderr).toBe(0);
501+
expect(JSON.parse(result.stdout)).toEqual({
502+
urls: fileUrls([1, 2, 3]),
503+
saved: savedPaths(3),
504+
total: 3,
505+
...(entry.name === "generate" ? { task_ids: ["task-1", "task-2", "task-3"] } : {}),
506+
});
507+
expect(downloads).toBe(3);
508+
});
509+
}

0 commit comments

Comments
 (0)