From bff1d704e82a53e4a87d04d236aa3f315d71dab4 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 15 Jul 2026 12:06:30 +0000 Subject: [PATCH 1/2] Pass action context through image generation actions --- templates/assets/actions/edit-image.spec.ts | 17 +++-- templates/assets/actions/edit-image.ts | 43 ++++++------ templates/assets/actions/generate-image.ts | 9 +++ templates/assets/actions/refine-image.ts | 66 +++++++++++-------- .../assets/actions/restyle-image.spec.ts | 19 ++++-- templates/assets/actions/restyle-image.ts | 45 +++++++------ .../assets/actions/variant-slots.spec.ts | 64 ++++++++++++++++++ templates/assets/actions/variant-slots.ts | 11 +++- ...pear-in-the-generated-candidates-panel-.md | 6 ++ 9 files changed, 198 insertions(+), 82 deletions(-) create mode 100644 templates/assets/changelog/2026-07-15-refined-images-now-appear-in-the-generated-candidates-panel-.md diff --git a/templates/assets/actions/edit-image.spec.ts b/templates/assets/actions/edit-image.spec.ts index 4350079fbc..73a6b0f712 100644 --- a/templates/assets/actions/edit-image.spec.ts +++ b/templates/assets/actions/edit-image.spec.ts @@ -33,12 +33,15 @@ describe("edit-image", () => { }); it("delegates to generate-image as a source-guided full-image edit", async () => { - await action.run({ - assetId: "asset-target", - instruction: "Make the background navy", - tier: "fast", - source: "chat", - }); + await action.run( + { + assetId: "asset-target", + instruction: "Make the background navy", + tier: "fast", + source: "chat", + }, + { threadId: "thread-9" } as any, + ); expect(generateImageRunMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -52,7 +55,9 @@ describe("edit-image", () => { groundingMode: "off", includeLogo: false, tier: "fast", + appendVariant: true, }), + { threadId: "thread-9" }, ); }); }); diff --git a/templates/assets/actions/edit-image.ts b/templates/assets/actions/edit-image.ts index e6fd0dc0b7..99fbc63092 100644 --- a/templates/assets/actions/edit-image.ts +++ b/templates/assets/actions/edit-image.ts @@ -1,4 +1,5 @@ import { defineAction } from "@agent-native/core"; +import type { ActionRunContext } from "@agent-native/core/action"; import { z } from "zod"; import { IMAGE_MODELS, IMAGE_QUALITY_TIERS } from "../shared/api.js"; @@ -23,25 +24,29 @@ export default defineAction({ ), }), parallelSafe: true, - run: async (args) => { + run: async (args, context?: ActionRunContext) => { const asset = await getAssetOrThrow(args.assetId); - return generateImage.run({ - libraryId: asset.libraryId, - collectionId: asset.collectionId ?? undefined, - prompt: args.instruction, - aspectRatio: (asset.aspectRatio ?? "16:9") as any, - imageSize: (asset.imageSize ?? "2K") as any, - model: args.model, - tier: args.tier, - intent: "edit", - styleStrength: "balanced", - referenceAssetIds: [], - includeLogo: false, - groundingMode: "off", - subjectAssetId: asset.id, - slotId: args.slotId, - source: args.source, - callerAppId: args.callerAppId, - }); + return generateImage.run( + { + libraryId: asset.libraryId, + collectionId: asset.collectionId ?? undefined, + prompt: args.instruction, + aspectRatio: (asset.aspectRatio ?? "16:9") as any, + imageSize: (asset.imageSize ?? "2K") as any, + model: args.model, + tier: args.tier, + intent: "edit", + styleStrength: "balanced", + referenceAssetIds: [], + includeLogo: false, + groundingMode: "off", + subjectAssetId: asset.id, + slotId: args.slotId, + source: args.source, + callerAppId: args.callerAppId, + appendVariant: true, + }, + context, + ); }, }); diff --git a/templates/assets/actions/generate-image.ts b/templates/assets/actions/generate-image.ts index 512e6a466f..0db9f60895 100644 --- a/templates/assets/actions/generate-image.ts +++ b/templates/assets/actions/generate-image.ts @@ -156,6 +156,12 @@ export default defineAction({ .describe( "Internal UI state scope for live candidate slots. Usually omitted; embedded picker UIs pass a browser-tab scope.", ), + appendVariant: z.coerce + .boolean() + .default(false) + .describe( + "Internal: when true, add this candidate to the existing live tray for its scope instead of resetting to a fresh candidate set. Refine/edit/restyle set this so iterative results accumulate alongside prior candidates (newest-first).", + ), dismissible: z.coerce .boolean() .default(true) @@ -650,6 +656,7 @@ export default defineAction({ sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, + appendVariant: args.appendVariant, prompt: args.prompt, slotId, status: "pending", @@ -854,6 +861,7 @@ export default defineAction({ sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, + appendVariant: args.appendVariant, prompt: args.prompt, slotId, status: "ready", @@ -901,6 +909,7 @@ export default defineAction({ sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, + appendVariant: args.appendVariant, prompt: args.prompt, slotId, status: "failed", diff --git a/templates/assets/actions/refine-image.ts b/templates/assets/actions/refine-image.ts index f2a0dc3129..f243dac6e5 100644 --- a/templates/assets/actions/refine-image.ts +++ b/templates/assets/actions/refine-image.ts @@ -1,4 +1,5 @@ import { defineAction } from "@agent-native/core"; +import type { ActionRunContext } from "@agent-native/core/action"; import { z } from "zod"; import { parseJson } from "../server/lib/json.js"; @@ -30,18 +31,21 @@ export default defineAction({ ), }), parallelSafe: true, - run: async ({ - assetId, - feedback, - presetId, - sessionId, - model, - aspectRatio, - imageSize, - slotId, - source, - callerAppId, - }) => { + run: async ( + { + assetId, + feedback, + presetId, + sessionId, + model, + aspectRatio, + imageSize, + slotId, + source, + callerAppId, + }, + context?: ActionRunContext, + ) => { const asset = await getAssetOrThrow(assetId); if (sessionId) { await requireGenerationSessionInLibrary(sessionId, asset.libraryId); @@ -55,22 +59,26 @@ export default defineAction({ "", "Preserve the strongest successful parts of the prior candidate unless the feedback contradicts them.", ].join("\n"); - return generateImage.run({ - libraryId: asset.libraryId, - collectionId: asset.collectionId ?? undefined, - presetId, - sessionId, - prompt, - aspectRatio: (aspectRatio ?? asset.aspectRatio ?? "16:9") as any, - imageSize: (imageSize ?? asset.imageSize ?? "2K") as any, - model: (model ?? asset.model ?? "gemini-3.1-flash-image") as any, - categories: metadata.category ? [metadata.category] : undefined, - includeLogo: false, - groundingMode: "auto", - sourceAssetId: asset.id, - slotId, - source, - callerAppId, - }); + return generateImage.run( + { + libraryId: asset.libraryId, + collectionId: asset.collectionId ?? undefined, + presetId, + sessionId, + prompt, + aspectRatio: (aspectRatio ?? asset.aspectRatio ?? "16:9") as any, + imageSize: (imageSize ?? asset.imageSize ?? "2K") as any, + model: (model ?? asset.model ?? "gemini-3.1-flash-image") as any, + categories: metadata.category ? [metadata.category] : undefined, + includeLogo: false, + groundingMode: "auto", + sourceAssetId: asset.id, + slotId, + source, + callerAppId, + appendVariant: true, + }, + context, + ); }, }); diff --git a/templates/assets/actions/restyle-image.spec.ts b/templates/assets/actions/restyle-image.spec.ts index 95474c3c01..d6dab3fba6 100644 --- a/templates/assets/actions/restyle-image.spec.ts +++ b/templates/assets/actions/restyle-image.spec.ts @@ -33,13 +33,16 @@ describe("restyle-image", () => { }); it("delegates to generate-image with the subject first and restyle intent", async () => { - await action.run({ - subjectAssetId: "asset-subject", - prompt: "Make it match the launch campaign", - styleStrength: "strong", - tier: "best", - source: "chat", - }); + await action.run( + { + subjectAssetId: "asset-subject", + prompt: "Make it match the launch campaign", + styleStrength: "strong", + tier: "best", + source: "chat", + }, + { threadId: "thread-9" } as any, + ); expect(generateImageRunMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -51,7 +54,9 @@ describe("restyle-image", () => { styleStrength: "strong", tier: "best", includeLogo: false, + appendVariant: true, }), + { threadId: "thread-9" }, ); }); }); diff --git a/templates/assets/actions/restyle-image.ts b/templates/assets/actions/restyle-image.ts index 4d2c00e869..2f4a0b3758 100644 --- a/templates/assets/actions/restyle-image.ts +++ b/templates/assets/actions/restyle-image.ts @@ -1,4 +1,5 @@ import { defineAction } from "@agent-native/core"; +import type { ActionRunContext } from "@agent-native/core/action"; import { z } from "zod"; import { @@ -37,29 +38,33 @@ export default defineAction({ ), }), parallelSafe: true, - run: async (args) => { + run: async (args, context?: ActionRunContext) => { const subject = await getAssetOrThrow(args.subjectAssetId); const prompt = args.prompt?.trim() || "Apply this library's brand style to the subject image while preserving the subject, pose, composition, and framing."; - return generateImage.run({ - libraryId: subject.libraryId, - collectionId: subject.collectionId ?? undefined, - presetId: args.presetId, - sessionId: args.sessionId, - prompt, - aspectRatio: (args.aspectRatio ?? subject.aspectRatio ?? "16:9") as any, - imageSize: (args.imageSize ?? subject.imageSize ?? "2K") as any, - model: args.model, - tier: args.tier, - intent: "restyle", - styleStrength: args.styleStrength, - includeLogo: false, - groundingMode: "auto", - subjectAssetId: subject.id, - slotId: args.slotId, - source: args.source, - callerAppId: args.callerAppId, - }); + return generateImage.run( + { + libraryId: subject.libraryId, + collectionId: subject.collectionId ?? undefined, + presetId: args.presetId, + sessionId: args.sessionId, + prompt, + aspectRatio: (args.aspectRatio ?? subject.aspectRatio ?? "16:9") as any, + imageSize: (args.imageSize ?? subject.imageSize ?? "2K") as any, + model: args.model, + tier: args.tier, + intent: "restyle", + styleStrength: args.styleStrength, + includeLogo: false, + groundingMode: "auto", + subjectAssetId: subject.id, + slotId: args.slotId, + source: args.source, + callerAppId: args.callerAppId, + appendVariant: true, + }, + context, + ); }, }); diff --git a/templates/assets/actions/variant-slots.spec.ts b/templates/assets/actions/variant-slots.spec.ts index 14468fba6e..38991a3aab 100644 --- a/templates/assets/actions/variant-slots.spec.ts +++ b/templates/assets/actions/variant-slots.spec.ts @@ -280,4 +280,68 @@ describe("variant slot state", () => { expect.objectContaining({ slotId: "slot-2", status: "pending" }), ]); }); + + it("appends a refined candidate to the existing scope instead of resetting", async () => { + await Promise.all( + ["slot-1", "slot-2", "slot-3"].map((slotId, index) => + upsertVariantSlot({ + runId: `run-${index + 1}`, + batchId: "batch-1", + libraryId: "lib-1", + prompt: "First prompt", + slotId, + status: "ready", + assetId: `asset-${index + 1}`, + threadId: "thread-1", + }), + ), + ); + + // A refine is a distinct run with no batchId; without appendVariant this + // would reset the tray. appendVariant keeps prior candidates and adds this. + await upsertVariantSlot({ + runId: "refine-run", + libraryId: "lib-1", + prompt: "First prompt\n\nUser feedback:\nmake it warmer", + slotId: "refine-slot", + status: "ready", + assetId: "asset-refined", + threadId: "thread-1", + appendVariant: true, + }); + + expect( + (appStateByKey["asset-variants:thread-1"] as any).slots.map( + (slot: any) => slot.slotId, + ), + ).toEqual(["slot-1", "slot-2", "slot-3", "refine-slot"]); + }); + + it("does not append across libraries even when appendVariant is set", async () => { + await upsertVariantSlot({ + runId: "run-1", + batchId: "batch-1", + libraryId: "lib-1", + prompt: "First prompt", + slotId: "slot-1", + status: "ready", + assetId: "asset-1", + threadId: "thread-1", + }); + + await upsertVariantSlot({ + runId: "refine-run", + libraryId: "lib-2", + prompt: "Different library", + slotId: "refine-slot", + status: "ready", + assetId: "asset-refined", + threadId: "thread-1", + appendVariant: true, + }); + + expect((appStateByKey["asset-variants:thread-1"] as any).slots).toEqual([ + expect.objectContaining({ slotId: "refine-slot", status: "ready" }), + ]); + }); }); diff --git a/templates/assets/actions/variant-slots.ts b/templates/assets/actions/variant-slots.ts index 14ff4d7eb2..8ef89125fc 100644 --- a/templates/assets/actions/variant-slots.ts +++ b/templates/assets/actions/variant-slots.ts @@ -29,6 +29,11 @@ type VariantSlotInput = VariantScopeInput & { previewUrl?: string; thumbnailUrl?: string; error?: string; + // When true, add this candidate to the existing live tray for this scope + // instead of starting a fresh candidate set. Refine/edit/restyle set this so + // iterative results accumulate alongside the prior candidates (newest-first) + // rather than replacing them. + appendVariant?: boolean; }; let variantStateLock: Promise = Promise.resolve(); @@ -96,8 +101,12 @@ export async function upsertVariantSlot(input: VariantSlotInput) { await withVariantStateLock(async () => { const scopeId = variantScopeIdFor(input); const previous = await readVariantStateUnlocked(scopeId); + const appendToPrevious = + previous != null && + input.appendVariant === true && + previous.libraryId === input.libraryId; const state = - previous && isSameVariantScope(previous, input) + previous && (isSameVariantScope(previous, input) || appendToPrevious) ? previous : { runId: input.runId, diff --git a/templates/assets/changelog/2026-07-15-refined-images-now-appear-in-the-generated-candidates-panel-.md b/templates/assets/changelog/2026-07-15-refined-images-now-appear-in-the-generated-candidates-panel-.md new file mode 100644 index 0000000000..7bc2ae711c --- /dev/null +++ b/templates/assets/changelog/2026-07-15-refined-images-now-appear-in-the-generated-candidates-panel-.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-07-15 +--- + +Refined images now appear in the Generated candidates panel, newest first, alongside the originals From 567ad5f676d62cf15bbf53d1c50bb7d48b7ef26b Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 16 Jul 2026 06:46:21 +0000 Subject: [PATCH 2/2] Add integration tests for image variant tray generation --- .../generate-image-variant-tray.spec.ts | 323 ++++++++++++++++++ templates/assets/actions/generate-image.ts | 11 +- templates/assets/actions/refine-image.spec.ts | 74 ++++ 3 files changed, 405 insertions(+), 3 deletions(-) create mode 100644 templates/assets/actions/generate-image-variant-tray.spec.ts create mode 100644 templates/assets/actions/refine-image.spec.ts diff --git a/templates/assets/actions/generate-image-variant-tray.spec.ts b/templates/assets/actions/generate-image-variant-tray.spec.ts new file mode 100644 index 0000000000..bd7d0bc507 --- /dev/null +++ b/templates/assets/actions/generate-image-variant-tray.spec.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Unlike generate-image-reference-board.spec.ts, this file keeps the REAL +// variant-slots.ts (and a fake application-state store) so the test exercises +// the actual read-modify-write merge logic, not just "was upsertVariantSlot +// called with X". This is the seam where the reported bug lives: a refine +// call must land in the SAME `asset-variants:` app-state entry as +// the original batch, and must append rather than reset it. + +const getDbMock = vi.hoisted(() => vi.fn()); +const assertAccessMock = vi.hoisted(() => vi.fn()); +const generateProviderMock = vi.hoisted(() => vi.fn()); +const createAssetFromBufferMock = vi.hoisted(() => vi.fn()); +let nanoidCounter = 0; + +const readAppStateMock = vi.hoisted(() => vi.fn()); +const writeAppStateMock = vi.hoisted(() => vi.fn()); +const deleteAppStateMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core", () => ({ + defineAction: (entry: unknown) => entry, +})); + +vi.mock("@agent-native/core/action", () => ({})); + +vi.mock("@agent-native/core/application-state", () => ({ + readAppState: readAppStateMock, + writeAppState: writeAppStateMock, + deleteAppState: deleteAppStateMock, +})); + +vi.mock("@agent-native/core/server/request-context", () => ({ + getRequestUserEmail: vi.fn(() => "designer@example.com"), + getRequestOrgId: vi.fn(() => "org-1"), +})); + +vi.mock("@agent-native/core/sharing", () => ({ + assertAccess: assertAccessMock, +})); + +vi.mock("drizzle-orm", () => ({ + eq: vi.fn((column, value) => ({ op: "eq", column, value })), + inArray: vi.fn((column, values) => ({ op: "inArray", column, values })), +})); + +vi.mock("nanoid", () => ({ + nanoid: vi.fn(() => `run-${++nanoidCounter}`), +})); + +vi.mock("../server/db/index.js", () => ({ + getDb: getDbMock, + schema: { + assetLibraries: { id: "libraries.id" }, + assetCollections: { id: "collections.id" }, + assetGenerationPresets: { id: "presets.id" }, + assetGenerationRuns: { id: "runs.id" }, + assetGenerationSessions: { id: "sessions.id" }, + assetGenerationSessionItems: {}, + assets: { + id: "assets.id", + libraryId: "assets.library_id", + mimeType: "assets.mime_type", + status: "assets.status", + }, + }, +})); + +vi.mock("../server/lib/assets.js", () => ({ + createAssetFromBuffer: createAssetFromBufferMock, +})); + +vi.mock("../server/lib/generation-presets.js", () => ({ + applyPromptTemplate: vi.fn((_template, prompt) => prompt), +})); + +vi.mock("../server/lib/generation.js", () => ({ + DEFAULT_GENERATION_REFERENCE_LIMIT: 6, + compilePrompt: vi.fn(() => "compiled prompt"), + generateWithManagedImageProvider: generateProviderMock, + isImageGenerationSetupError: vi.fn(() => false), + loadReferenceData: vi.fn(async () => []), + resolveImageModelForRequest: vi.fn( + ({ presetModel, explicitModel }) => + explicitModel ?? presetModel ?? "gemini-3.1-flash-image", + ), + selectReferences: vi.fn(async () => []), +})); + +vi.mock("../server/lib/image-processing.js", () => ({ + applyPresetSkeleton: vi.fn(async ({ subject }) => subject), + compositeLogo: vi.fn(async ({ image }) => image), + maskFromManualMaskAlpha: vi.fn(async () => Buffer.from("mask")), + maskFromPlateAlpha: vi.fn(async () => Buffer.from("mask")), + prepareGptImage2SkeletonInpaintImages: vi.fn(), +})); + +vi.mock("../server/lib/json.js", () => ({ + nowIso: vi.fn(() => "2026-07-09T00:00:00.000Z"), + parseJson: vi.fn((value: string | null | undefined, fallback: unknown) => { + if (!value) return fallback; + try { + return JSON.parse(value); + } catch { + return fallback; + } + }), + stringifyJson: vi.fn((value: unknown) => JSON.stringify(value)), +})); + +vi.mock("../server/lib/storage.js", () => ({ + getObject: vi.fn(), +})); + +vi.mock("./_helpers.js", () => ({ + requireGenerationSessionInLibrary: vi.fn(), + serializeAsset: vi.fn((asset) => ({ + ...asset, + previewUrl: `/preview/${asset.id}`, + thumbnailUrl: `/thumb/${asset.id}`, + })), +})); + +vi.mock("./_image-model-default.js", () => ({ + readImageModelDefault: vi.fn(async () => "gemini-3.1-flash-image"), +})); + +vi.mock("./_tool-activity.js", () => ({ + withToolActivity: vi.fn(async (_context, _activity, fn) => fn()), +})); + +import generateImage from "./generate-image.js"; + +function createWhereResult(rows: unknown[]) { + return { + limit: vi.fn(async () => rows), + }; +} + +function createDb(selectRows: unknown[][]) { + const inserted: unknown[] = []; + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => createWhereResult(selectRows.shift() ?? [])), + })), + })); + return { + inserted, + select, + insert: vi.fn(() => ({ + values: vi.fn(async (row: unknown) => { + inserted.push(row); + }), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ where: vi.fn(async () => undefined) })), + })), + }; +} + +const library = { + id: "lib-1", + title: "Acme", + customInstructions: "", + styleBrief: "{}", + settings: "{}", + canonicalLogoAssetId: null, +}; + +let assetCounter = 0; + +describe("generate-image variant tray integration", () => { + let store: Record>; + + beforeEach(() => { + vi.clearAllMocks(); + nanoidCounter = 0; + assetCounter = 0; + store = {}; + assertAccessMock.mockResolvedValue(undefined); + generateProviderMock.mockResolvedValue({ + image: Buffer.from("image"), + mimeType: "image/png", + model: "gemini-3.1-flash-image", + provider: "gemini", + }); + createAssetFromBufferMock.mockImplementation(async () => { + const id = `asset-${++assetCounter}`; + return { id, libraryId: "lib-1", metadata: "{}" }; + }); + readAppStateMock.mockImplementation(async (key: string) => + store[key] ? JSON.parse(JSON.stringify(store[key])) : null, + ); + writeAppStateMock.mockImplementation( + async (key: string, value: Record) => { + store[key] = JSON.parse(JSON.stringify(value)); + }, + ); + deleteAppStateMock.mockImplementation(async (key: string) => { + delete store[key]; + return true; + }); + getDbMock.mockImplementation(() => createDb([[library], [library]])); + }); + + it("appends a refine result to the same thread tray instead of replacing the original batch", async () => { + const threadId = "thread-1"; + + // Simulate the initial batch of 3: same batchId, same thread scope. + for (const slotId of ["slot-1", "slot-2", "slot-3"]) { + getDbMock.mockReturnValueOnce(createDb([[library]])); + await generateImage.run( + { + libraryId: "lib-1", + prompt: "A Steve quote about Star Wars", + slotId, + variantBatchId: "batch-1", + } as any, + { threadId } as any, + ); + } + + const afterBatch = store[`asset-variants:${threadId}`] as any; + expect(afterBatch.slots.map((s: any) => s.slotId)).toEqual([ + "slot-1", + "slot-2", + "slot-3", + ]); + + // Simulate refine-image delegating to generate-image with the source + // asset's context: no batchId (new generation boundary), but + // appendVariant: true and the SAME thread context forwarded. + getDbMock.mockReturnValueOnce(createDb([[library]])); + await generateImage.run( + { + libraryId: "lib-1", + prompt: + "A Steve quote about Star Wars\n\nUser feedback:\nadd illustrations", + sourceAssetId: "asset-1", + appendVariant: true, + } as any, + { threadId } as any, + ); + + const afterRefine = store[`asset-variants:${threadId}`] as any; + expect(afterRefine.slots.map((s: any) => s.slotId)).toEqual([ + "slot-1", + "slot-2", + "slot-3", + "run-4", + ]); + expect(afterRefine.slots[3]).toEqual( + expect.objectContaining({ status: "ready", assetId: "asset-4" }), + ); + }); + + it("resets the tray for a fresh generation that does not set appendVariant", async () => { + const threadId = "thread-1"; + + for (const slotId of ["slot-1", "slot-2", "slot-3"]) { + getDbMock.mockReturnValueOnce(createDb([[library]])); + await generateImage.run( + { + libraryId: "lib-1", + prompt: "First prompt", + slotId, + variantBatchId: "batch-1", + } as any, + { threadId } as any, + ); + } + + getDbMock.mockReturnValueOnce(createDb([[library]])); + await generateImage.run( + { + libraryId: "lib-1", + prompt: "Totally new direction", + variantBatchId: "batch-2", + } as any, + { threadId } as any, + ); + + const state = store[`asset-variants:${threadId}`] as any; + expect(state.slots).toHaveLength(1); + expect(state.batchId).toBe("batch-2"); + }); + + it("still appends when a caller sets sourceAssetId directly without appendVariant", async () => { + const threadId = "thread-1"; + + for (const slotId of ["slot-1", "slot-2", "slot-3"]) { + getDbMock.mockReturnValueOnce(createDb([[library]])); + await generateImage.run( + { + libraryId: "lib-1", + prompt: "First prompt", + slotId, + variantBatchId: "batch-1", + } as any, + { threadId } as any, + ); + } + + // Simulates the agent calling generate-image directly (bypassing + // refine-image) with sourceAssetId set but no explicit appendVariant. + getDbMock.mockReturnValueOnce(createDb([[library]])); + await generateImage.run( + { + libraryId: "lib-1", + prompt: "Iterate on asset-1", + sourceAssetId: "asset-1", + } as any, + { threadId } as any, + ); + + const state = store[`asset-variants:${threadId}`] as any; + expect(state.slots.map((s: any) => s.slotId)).toEqual([ + "slot-1", + "slot-2", + "slot-3", + "run-4", + ]); + }); +}); diff --git a/templates/assets/actions/generate-image.ts b/templates/assets/actions/generate-image.ts index 0db9f60895..f21b570c22 100644 --- a/templates/assets/actions/generate-image.ts +++ b/templates/assets/actions/generate-image.ts @@ -553,6 +553,11 @@ export default defineAction({ const now = nowIso(); const slotId = args.slotId ?? runId; const variantScopeId = args.variantScopeId ?? context?.threadId ?? null; + // A run derived from a prior asset (refine/edit/restyle, or a caller that + // sets sourceAssetId directly) is an iteration on the existing candidate + // set, not a new direction — always add it to the tray instead of + // resetting it, regardless of whether the caller remembered the flag. + const appendVariant = args.appendVariant || Boolean(args.sourceAssetId); // Capture identity at insert time so the org-admin audit log can filter // by owner / org without re-resolving who triggered the run later. const ownerEmail = getRequestUserEmail() ?? null; @@ -656,7 +661,7 @@ export default defineAction({ sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, - appendVariant: args.appendVariant, + appendVariant, prompt: args.prompt, slotId, status: "pending", @@ -861,7 +866,7 @@ export default defineAction({ sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, - appendVariant: args.appendVariant, + appendVariant, prompt: args.prompt, slotId, status: "ready", @@ -909,7 +914,7 @@ export default defineAction({ sessionId: session?.id ?? null, threadId: context?.threadId ?? null, variantScopeId, - appendVariant: args.appendVariant, + appendVariant, prompt: args.prompt, slotId, status: "failed", diff --git a/templates/assets/actions/refine-image.spec.ts b/templates/assets/actions/refine-image.spec.ts new file mode 100644 index 0000000000..e575521982 --- /dev/null +++ b/templates/assets/actions/refine-image.spec.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getAssetOrThrowMock = vi.hoisted(() => vi.fn()); +const requireGenerationSessionInLibraryMock = vi.hoisted(() => vi.fn()); +const generateImageRunMock = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core", () => ({ + defineAction: (entry: unknown) => entry, +})); + +vi.mock("./_helpers.js", () => ({ + getAssetOrThrow: getAssetOrThrowMock, + requireGenerationSessionInLibrary: requireGenerationSessionInLibraryMock, +})); + +vi.mock("./generate-image.js", () => ({ + default: { + run: generateImageRunMock, + }, +})); + +import action from "./refine-image.js"; + +describe("refine-image", () => { + beforeEach(() => { + vi.clearAllMocks(); + getAssetOrThrowMock.mockResolvedValue({ + id: "asset-source", + libraryId: "library-1", + collectionId: null, + prompt: "A Steve quote about Star Wars", + aspectRatio: "1:1", + imageSize: "2K", + model: "gemini-3.1-flash-image", + metadata: "{}", + }); + generateImageRunMock.mockResolvedValue({ id: "generated-1" }); + }); + + it("forwards the chat thread context and appends the result to the candidate tray", async () => { + await action.run( + { + assetId: "asset-source", + feedback: "add some illustrations on the right side", + source: "chat", + }, + { threadId: "thread-9" } as any, + ); + + expect(generateImageRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + libraryId: "library-1", + sourceAssetId: "asset-source", + appendVariant: true, + }), + { threadId: "thread-9" }, + ); + }); + + it("includes the source prompt and feedback in the refine prompt", async () => { + await action.run( + { + assetId: "asset-source", + feedback: "make the gradient tighter", + source: "chat", + }, + { threadId: "thread-9" } as any, + ); + + const prompt = generateImageRunMock.mock.calls[0][0].prompt as string; + expect(prompt).toContain("A Steve quote about Star Wars"); + expect(prompt).toContain("make the gradient tighter"); + }); +});