From b36cbd513f12d642a378628174544629c29d05d2 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 8 Sep 2026 21:11:21 -0700 Subject: [PATCH 1/5] Render generated image timeline previews and failures --- .../TimelineRowDetails.images.test.tsx | 131 ++++++++++++++++++ .../thread/timeline/TimelineRowDetails.tsx | 41 +++--- .../src/timeline/timeline-auto-expand.ts | 3 +- 3 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx diff --git a/apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx b/apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx new file mode 100644 index 0000000000..0dd9fa3870 --- /dev/null +++ b/apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, it, vi } from "vitest"; +import type { TimelineImageGenerationWorkRow } from "@bb/server-contract"; +import { imageViewRow } from "@/test/fixtures/thread-timeline-rows"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { WorkRowBody } from "./TimelineRowDetails"; +import { ThreadTimelineRows } from "./ThreadTimelineRows"; +import { isWorkRowExpandable } from "@bb/client-core"; + +const generated: TimelineImageGenerationWorkRow = { + ...imageViewRow({ + path: "/tmp/generated.png", + threadId: "thr_main", + durationMs: 0, + }), + workKind: "image-generation", + prompt: "Draw a circle", + error: null, + transparentBackground: false, +}; + +afterEach(cleanup); + +it("expands a saved generated image and opens its preview", async () => { + const { wrapper: Wrapper } = createQueryClientTestHarness(); + render( + + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Generated image" })); + const preview = await screen.findByRole("button", { + name: "Open image preview: generated.png", + }); + expect(preview.querySelector("img")?.getAttribute("src")).toBe( + "/api/v1/threads/thr_main/host-files/content?path=%2Ftmp%2Fgenerated.png", + ); + fireEvent.click(preview); + expect( + await screen.findByRole("dialog", { + name: "Generated image: generated.png", + }), + ).toBeTruthy(); + fireEvent.keyDown(window, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); +}); + +it.each([ + generated, + imageViewRow({ + path: "/tmp/generated.png", + threadId: "thr_main", + durationMs: 0, + }), +])( + "preserves custom resolution and handles a missing file for $workKind", + (row) => { + const resolve = vi.fn(() => "/fixture-image.png"); + render( + , + ); + expect(resolve).toHaveBeenCalledWith({ + path: row.path, + threadId: row.threadId, + }); + const image = screen + .getByRole("button", { name: "Open image preview: generated.png" }) + .querySelector("img"); + expect(image?.getAttribute("src")).toBe("/fixture-image.png"); + if (!image) throw new Error("Expected preview image"); + fireEvent.error(image); + expect(screen.getByText("Image preview unavailable.")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /Open image preview/ }), + ).toBeNull(); + }, +); + +it.each([ + { status: "completed", error: null, message: "Image preview unavailable." }, + { + status: "error", + error: "", + message: "", + }, + { status: "interrupted", error: null, message: "Image preview unavailable." }, +] as const)( + "shows safe details for a $status generation without a saved path", + (state) => { + const row = { ...generated, ...state, path: null }; + expect(isWorkRowExpandable(row)).toBe(true); + const resolve = vi.fn(); + const { container } = render( + , + ); + expect(screen.getByText(state.message)).toBeTruthy(); + expect(container.querySelector("img")).toBeNull(); + expect(resolve).not.toHaveBeenCalled(); + }, +); + +it("waits for a path or failure before expanding a pending generation", () => { + expect( + isWorkRowExpandable({ ...generated, status: "pending", path: null }), + ).toBe(false); + expect(isWorkRowExpandable({ ...generated, status: "pending" })).toBe(true); +}); diff --git a/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx b/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx index 72f9b4aa00..1b2e41560a 100644 --- a/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx +++ b/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { assertNever, fileNameFromPath, - type TimelineImageViewViewWorkRow, type TimelineViewWorkRow, } from "@bb/thread-view"; import { Button } from "@bb/shared-ui/button"; @@ -37,9 +36,14 @@ interface WorkRowBodyProps { type DetailLine = string | null; -interface ImageViewWorkRowBodyProps { +type ImageWorkRow = Extract< + TimelineViewWorkRow, + { workKind: "image-view" | "image-generation" } +>; + +interface ImageWorkRowBodyProps { resolveImageViewSrc?: ThreadTimelineImageViewSrcResolver; - row: TimelineImageViewViewWorkRow; + row: ImageWorkRow; } interface CommandWorkRowBodyProps { @@ -62,7 +66,7 @@ interface OutputPreviewNoteArgs { interface ResolveImageViewSourceArgs { resolveImageViewSrc: ThreadTimelineImageViewSrcResolver | undefined; - row: TimelineImageViewViewWorkRow; + row: ImageWorkRow; } function compactDetailLines(lines: readonly DetailLine[]): string[] { @@ -78,31 +82,35 @@ function compactDetailLines(lines: readonly DetailLine[]): string[] { function resolveImageViewSource({ resolveImageViewSrc, row, -}: ResolveImageViewSourceArgs): string { +}: ResolveImageViewSourceArgs): string | null { + if (!row.path || (row.workKind === "image-generation" && row.error)) { + return null; + } return resolveImageViewSrc ? resolveImageViewSrc({ path: row.path, threadId: row.threadId }) : buildThreadHostFileContentUrl(row.threadId, row.path); } -function ImageViewWorkRowBody({ - resolveImageViewSrc, - row, -}: ImageViewWorkRowBodyProps) { +function ImageWorkRowBody({ resolveImageViewSrc, row }: ImageWorkRowBodyProps) { const [loadError, setLoadError] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false); const imageSrc = resolveImageViewSource({ resolveImageViewSrc, row }); - const imageName = fileNameFromPath(row.path); - const imageAlt = `Viewed image: ${imageName}`; + const imageName = row.path ? fileNameFromPath(row.path) : ""; + const imageAlt = `${row.workKind === "image-generation" ? "Generated" : "Viewed"} image: ${imageName}`; useEffect(() => { setLoadError(false); setLightboxOpen(false); }, [imageSrc, row.completedAt, row.status]); - if (loadError) { + if (loadError || !imageSrc) { return ( -
Image preview unavailable.
+
+ {row.workKind === "image-generation" && row.error + ? row.error + : "Image preview unavailable."} +
{row.path}
); @@ -274,15 +282,12 @@ export function WorkRowBody({ return ; case "extension": return ; + case "image-generation": case "image-view": return ( - + ); case "approval": - case "image-generation": case "web-search": case "web-fetch": case "file-read": diff --git a/packages/client-core/src/timeline/timeline-auto-expand.ts b/packages/client-core/src/timeline/timeline-auto-expand.ts index 50942ab836..e2aa906f15 100644 --- a/packages/client-core/src/timeline/timeline-auto-expand.ts +++ b/packages/client-core/src/timeline/timeline-auto-expand.ts @@ -25,9 +25,10 @@ export function isWorkRowExpandable(row: TimelineViewWorkRow): boolean { switch (row.workKind) { case "web-search": case "web-fetch": - case "image-generation": case "approval": return false; + case "image-generation": + return row.status !== "pending" || Boolean(row.path || row.error); case "image-view": return true; case "question": From 8481d2688901a204c6586823d421b4fb2dffaee4 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 8 Sep 2026 21:46:59 -0700 Subject: [PATCH 2/5] Remove generated-image UI test file as requested --- .../TimelineRowDetails.images.test.tsx | 131 ------------------ 1 file changed, 131 deletions(-) delete mode 100644 apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx diff --git a/apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx b/apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx deleted file mode 100644 index 0dd9fa3870..0000000000 --- a/apps/app/src/components/thread/timeline/TimelineRowDetails.images.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -// @vitest-environment jsdom - -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, expect, it, vi } from "vitest"; -import type { TimelineImageGenerationWorkRow } from "@bb/server-contract"; -import { imageViewRow } from "@/test/fixtures/thread-timeline-rows"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { WorkRowBody } from "./TimelineRowDetails"; -import { ThreadTimelineRows } from "./ThreadTimelineRows"; -import { isWorkRowExpandable } from "@bb/client-core"; - -const generated: TimelineImageGenerationWorkRow = { - ...imageViewRow({ - path: "/tmp/generated.png", - threadId: "thr_main", - durationMs: 0, - }), - workKind: "image-generation", - prompt: "Draw a circle", - error: null, - transparentBackground: false, -}; - -afterEach(cleanup); - -it("expands a saved generated image and opens its preview", async () => { - const { wrapper: Wrapper } = createQueryClientTestHarness(); - render( - - - - - , - ); - fireEvent.click(screen.getByRole("button", { name: "Generated image" })); - const preview = await screen.findByRole("button", { - name: "Open image preview: generated.png", - }); - expect(preview.querySelector("img")?.getAttribute("src")).toBe( - "/api/v1/threads/thr_main/host-files/content?path=%2Ftmp%2Fgenerated.png", - ); - fireEvent.click(preview); - expect( - await screen.findByRole("dialog", { - name: "Generated image: generated.png", - }), - ).toBeTruthy(); - fireEvent.keyDown(window, { key: "Escape" }); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); -}); - -it.each([ - generated, - imageViewRow({ - path: "/tmp/generated.png", - threadId: "thr_main", - durationMs: 0, - }), -])( - "preserves custom resolution and handles a missing file for $workKind", - (row) => { - const resolve = vi.fn(() => "/fixture-image.png"); - render( - , - ); - expect(resolve).toHaveBeenCalledWith({ - path: row.path, - threadId: row.threadId, - }); - const image = screen - .getByRole("button", { name: "Open image preview: generated.png" }) - .querySelector("img"); - expect(image?.getAttribute("src")).toBe("/fixture-image.png"); - if (!image) throw new Error("Expected preview image"); - fireEvent.error(image); - expect(screen.getByText("Image preview unavailable.")).toBeTruthy(); - expect( - screen.queryByRole("button", { name: /Open image preview/ }), - ).toBeNull(); - }, -); - -it.each([ - { status: "completed", error: null, message: "Image preview unavailable." }, - { - status: "error", - error: "", - message: "", - }, - { status: "interrupted", error: null, message: "Image preview unavailable." }, -] as const)( - "shows safe details for a $status generation without a saved path", - (state) => { - const row = { ...generated, ...state, path: null }; - expect(isWorkRowExpandable(row)).toBe(true); - const resolve = vi.fn(); - const { container } = render( - , - ); - expect(screen.getByText(state.message)).toBeTruthy(); - expect(container.querySelector("img")).toBeNull(); - expect(resolve).not.toHaveBeenCalled(); - }, -); - -it("waits for a path or failure before expanding a pending generation", () => { - expect( - isWorkRowExpandable({ ...generated, status: "pending", path: null }), - ).toBe(false); - expect(isWorkRowExpandable({ ...generated, status: "pending" })).toBe(true); -}); From 58eb95b5bb72247b59f204c0f272dc4cb7f09034 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 8 Sep 2026 21:46:59 -0700 Subject: [PATCH 3/5] Handle native Codex image events and pooled image requests --- docs/configuration.md | 1 + .../src/templates/bb-guide-plugins.md | 5 +- .../references/accounts-and-routing.md | 1 + plugins/account-pool/src/server.test.ts | 51 +++++++++++++++++++ plugins/account-pool/src/server.ts | 16 ++++-- .../src/delta-translation.test.ts | 41 +++++++++++++++ plugins/provider-codex/src/schemas.ts | 7 ++- 7 files changed, 113 insertions(+), 9 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index addc2320b4..ac829fdad7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -670,6 +670,7 @@ Claude Code also receives `ENABLE_TOOL_SEARCH=true`. Codex receives `CODEX_OPENAI_BASE_URL` and the secret `CODEX_POOL_AUTH_TOKEN`; bb applies both when launching `codex app-server` without writing to `~/.codex/config.toml`. +Codex image generation and editing use the same authenticated pool route. Claude Code disables tool search behind a custom base URL by default; the hub forwards `tool_reference` blocks unchanged, so the override keeps it on. Tokens are never printed diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 5c1eb1ab65..b811e1f8a8 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -67,8 +67,9 @@ contributes its provider-specific server route and a distinct secret token to Claude Code or Codex sessions on every host. Claude Code also receives `ENABLE_TOOL_SEARCH=true` so tool search stays on through the hub. Codex receives `CODEX_OPENAI_BASE_URL` and the secret `CODEX_POOL_AUTH_TOKEN`; its -app server uses those values without editing `~/.codex/config.toml`. Tokens are -never printed. `status` prunes tokens for +app server uses those values without editing `~/.codex/config.toml`. +Codex image generation and editing use the same authenticated pool route. +Tokens are never printed. `status` prunes tokens for unenrolled machines and shows token timestamps plus recently routed threads whose machines need a local Claude login before the pool can be disabled safely. Rotation keeps the prior token valid for ten minutes. Agents should use diff --git a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md index 873d5e63b7..f6c39ae838 100644 --- a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md +++ b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md @@ -38,6 +38,7 @@ Code or Codex sessions receive the pool route and a distinct secret token for their machine. Codex receives `CODEX_OPENAI_BASE_URL` and the secret `CODEX_POOL_AUTH_TOKEN`; bb applies them as in-memory app-server config. +Codex image generation and editing use the same authenticated pool route. Tokens are never printed. `status` prunes tokens for unenrolled machines and shows token timestamps plus recently routed threads whose machines need a local Claude login before the pool can be disabled safely. Rotation keeps the diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index 11f429bc88..ffae373167 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -416,6 +416,57 @@ describe("Account Pool plugin", () => { }); }); + it.each(["generations", "edits"])( + "routes native Codex image %s with pool authentication", + async (operation) => { + const requests: Request[] = []; + const image = { data: [{ b64_json: "generated-image" }] }; + const fixture = await createOAuthRequestFixture( + "codex", + async (input, init) => { + requests.push(new Request(input, init)); + return Response.json(image); + }, + Date.now, + ); + const route = `/v1/images/${operation}`; + const body = JSON.stringify({ prompt: "A fox astronaut", images: [] }); + const denied = await fixture.host.harness.behavior.fetchHttp( + "POST", + route, + { body }, + ); + expect(denied.status).toBe(401); + expect(requests).toHaveLength(0); + const response = await fixture.host.harness.behavior.fetchHttp( + "POST", + route, + { + headers: { + "content-type": "application/json", + "x-bb-account-pool-token": fixture.key, + authorization: "Bearer local-token", + }, + body, + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(image); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + `https://upstream.example/images/${operation}`, + ); + expect(requests[0]?.headers.get("authorization")).toBe( + "Bearer oauth-old", + ); + expect(requests[0]?.headers.get("chatgpt-account-id")).toBe( + "chatgpt-account", + ); + expect(requests[0]?.headers.has("x-bb-account-pool-token")).toBe(false); + expect(await requests[0]?.text()).toBe(body); + }, + ); + it("imports, refreshes, and routes Codex HTTP sessions by provider", async () => { const seen: Array<{ path: string; diff --git a/plugins/account-pool/src/server.ts b/plugins/account-pool/src/server.ts index 8633efc28d..200f084fdb 100644 --- a/plugins/account-pool/src/server.ts +++ b/plugins/account-pool/src/server.ts @@ -288,12 +288,18 @@ export function createAccountPoolPlugin( (context) => hub.handle(context.req.raw, "claude"), { auth: "none" }, ); - bb.http.route( - "POST", + for (const route of [ "/v1/responses", - (context) => hub.handle(context.req.raw, "codex"), - { auth: "none" }, - ); + "/v1/images/generations", + "/v1/images/edits", + ]) { + bb.http.route( + "POST", + route, + (context) => hub.handle(context.req.raw, "codex"), + { auth: "none" }, + ); + } bb.http.route( "GET", "/v1/models", diff --git a/plugins/provider-codex/src/delta-translation.test.ts b/plugins/provider-codex/src/delta-translation.test.ts index 42666c0eda..25e5534d51 100644 --- a/plugins/provider-codex/src/delta-translation.test.ts +++ b/plugins/provider-codex/src/delta-translation.test.ts @@ -603,6 +603,47 @@ describe("codex item translation", () => { ); }); + it("accepts native image generation status and nullable background", () => { + const harness = createHarness(); + for (const [method, status, expectedStatus] of [ + ["item/started", "in_progress", "pending"], + ["item/completed", "completed", "completed"], + ]) { + const events = harness.translate({ + jsonrpc: "2.0", + method, + params: { + threadId: "t1", + turnId: "turn-1", + item: { + type: "imageGeneration", + id: "generated-image-1", + status, + revisedPrompt: null, + result: "", + failure: null, + savedPath: "/tmp/generated.png", + transparentBackground: null, + }, + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ + type: method, + item: expect.objectContaining({ + type: "imageGeneration", + status: expectedStatus, + path: "/tmp/generated.png", + transparentBackground: false, + }), + }), + ); + expect(events.some((event) => event.type === "provider/unhandled")).toBe( + false, + ); + } + }); + it("falls back to thread-scoped provider/unhandled for unknown notifications", () => { const harness = createHarness(); const events = harness.translate({ diff --git a/plugins/provider-codex/src/schemas.ts b/plugins/provider-codex/src/schemas.ts index bf536ea4c1..add605da45 100644 --- a/plugins/provider-codex/src/schemas.ts +++ b/plugins/provider-codex/src/schemas.ts @@ -442,10 +442,13 @@ export const codexHandledThreadItemSchema = z.discriminatedUnion("type", [ .object({ type: z.literal("imageGeneration"), id: z.string(), - status: codexToolReferenceStatusSchema, + status: z.union([ + codexToolReferenceStatusSchema, + z.literal("in_progress").transform(() => "inProgress" as const), + ]), revisedPrompt: z.string().nullable(), result: z.string(), - transparentBackground: z.boolean().optional(), + transparentBackground: z.boolean().nullish(), failure: z .object({ type: z.literal("usageLimitExceeded"), From 6045dbd7ff9fe0b94ab9a016ed171c8b618daeee Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 8 Sep 2026 21:23:13 -0700 Subject: [PATCH 4/5] Preserve bounded legacy image completions in timelines --- .../threads/timeline-in-turn-window.test.ts | 80 +++++++++++++++++++ packages/db/src/data/events.ts | 14 +++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/apps/server/test/services/threads/timeline-in-turn-window.test.ts b/apps/server/test/services/threads/timeline-in-turn-window.test.ts index ef2ee71c63..fec65cd720 100644 --- a/apps/server/test/services/threads/timeline-in-turn-window.test.ts +++ b/apps/server/test/services/threads/timeline-in-turn-window.test.ts @@ -1010,6 +1010,86 @@ describe("in-turn timeline windows", () => { expect(latest.profile.eventRowCount).toBe(0); }); + it("keeps bounded legacy image completions visible before and after migration sweeps", () => { + const { db, thread } = setup(); + seedTurns(db, thread, { completeLastTurn: false, itemsPerTurn: [0] }); + const migratedAt = Date.now() + 1_000; + const cases = [ + { id: "short", result: "encoded-small", status: "completed" }, + { id: "threshold", result: "i".repeat(32 * 1024), status: "completed" }, + { id: "unicode", result: "画像".repeat(8 * 1024), status: "completed" }, + { id: "empty", result: "", status: "failed" }, + { id: "absent", result: undefined, status: "failed" }, + { id: "null", result: null, status: "failed" }, + { id: "large", result: "i".repeat(40_000), status: "completed" }, + { id: "diagnostic", result: "", status: "failed" }, + ]; + const sequence = getLatestThreadSequence(db, { threadId: thread.id }); + insertEvents( + db, + noopNotifier, + cases.map((item, index) => ({ + createdAt: migratedAt - 1, + threadId: thread.id, + sequence: sequence + index + 1, + type: "provider/unhandled", + scope: turnScope("turn-1"), + providerThreadId, + itemId: null, + itemKind: null, + parentToolCallId: null, + data: JSON.stringify({ + providerId: "codex", + rawType: "item/completed", + rawEvent: { + jsonrpc: "2.0", + method: "item/completed", + params: { + threadId: providerThreadId, + turnId: "turn-1", + item: { + ...item, + type: + item.id === "diagnostic" ? "unrelated" : "imageGeneration", + revisedPrompt: "Draw an image", + savedPath: "/tmp/generated.png", + failure: + item.status === "failed" ? { message: "Failed" } : null, + }, + }, + }, + }), + })), + ); + const expected = cases.slice(0, 6).map(({ id, status }) => ({ + callId: id, + status: status === "failed" ? "error" : "completed", + })); + const visible = () => + buildNestedPage(db, thread, LARGE_BUDGET, null) + .response.rows.flatMap((row) => + row.kind === "turn" && row.children ? row.children : [row], + ) + .filter( + (row) => row.kind === "work" && row.workKind === "image-generation", + ) + .map((row) => ({ callId: row.callId, status: row.status })); + expect(visible()).toEqual(expected); + let migratedRows = 0; + for (let pass = 0; pass < cases.length; pass += 1) { + migratedRows += migrateNextLegacyImageGenerationOutput(db, { + limit: 100, + migratedAt, + }).migratedRows; + } + expect(migratedRows).toBe(1); + expect(visible()).toEqual([ + ...expected, + { callId: "large", status: "completed" }, + ]); + db.$client.close(); + }); + it("renders a migrated oversized Codex image generation as a compact row", () => { const { db, thread } = setup(); seedTurns(db, thread, { completeLastTurn: false, itemsPerTurn: [0] }); diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 9957b2ef2c..650d37d4fd 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -55,6 +55,7 @@ import { threads, } from "../schema.js"; import { createEventId } from "../ids.js"; +import { COMPLETED_EVENT_OUTPUT_TRUNCATION_THRESHOLD_CHARS } from "../retained-event-output.js"; import { truncatedEventDataColumn } from "./event-output-truncation.js"; import { deriveStoredEventItemFieldsFromSource } from "../stored-event-item-fields.js"; import { @@ -3254,12 +3255,21 @@ const isNotDiagnosticEvent = sql`( AND length(json_extract(${events.data}, '$.rawEvent.params.message.fallback_model')) > 0 ), 0) OR CASE - WHEN instr(${events.data}, '"truncation"') = 0 THEN 0 + WHEN instr(${events.data}, '"imageGeneration"') = 0 THEN 0 WHEN json_valid(${events.data}) THEN json_extract(${events.data}, '$.rawType') = 'item/completed' AND json_extract(${events.data}, '$.rawEvent.method') = 'item/completed' AND json_extract(${events.data}, '$.rawEvent.params.item.type') = 'imageGeneration' - AND json_type(${events.data}, '$.rawEvent.params.item.truncation.result') = 'object' + AND ( + json_type(${events.data}, '$.rawEvent.params.item.truncation.result') = 'object' + OR json_type(${events.data}, '$.rawEvent.params.item.result') IS NULL + OR json_type(${events.data}, '$.rawEvent.params.item.result') = 'null' + OR ( + json_type(${events.data}, '$.rawEvent.params.item.result') = 'text' + AND length(json_extract(${events.data}, '$.rawEvent.params.item.result')) + <= ${COMPLETED_EVENT_OUTPUT_TRUNCATION_THRESHOLD_CHARS} + ) + ) ELSE 0 END ) From a9bfea320667ce4dd612844c0fe16b1ee2f78901 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Tue, 8 Sep 2026 21:23:13 -0700 Subject: [PATCH 5/5] Accept nullable background in retained native image attempts --- packages/domain/src/legacy-image-generation.ts | 1 + .../domain/test/legacy-image-generation.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/domain/src/legacy-image-generation.ts b/packages/domain/src/legacy-image-generation.ts index fbe1a689d6..0692461875 100644 --- a/packages/domain/src/legacy-image-generation.ts +++ b/packages/domain/src/legacy-image-generation.ts @@ -51,6 +51,7 @@ export function parseLegacyImageGenerationCompletion( !(item.savedPath === undefined || typeof item.savedPath === "string") || !( item.transparentBackground === undefined || + item.transparentBackground === null || typeof item.transparentBackground === "boolean" ) || !(item.failure === null || jsonObject(item.failure) !== null) diff --git a/packages/domain/test/legacy-image-generation.test.ts b/packages/domain/test/legacy-image-generation.test.ts index 5ef3f74c5b..fac335d7ab 100644 --- a/packages/domain/test/legacy-image-generation.test.ts +++ b/packages/domain/test/legacy-image-generation.test.ts @@ -53,6 +53,20 @@ describe("legacy image generation completion", () => { expect(parseLegacyImageGenerationCompletion(value)?.status).toBe(status); }); + it("preserves native failed attempts with a nullable background", () => { + const { value } = completion({ + status: "failed", + result: "", + savedPath: undefined, + transparentBackground: null, + }); + expect(parseLegacyImageGenerationCompletion(value)).toMatchObject({ + status: "failed", + path: null, + transparentBackground: false, + }); + }); + it("rejects malformed and unknown envelopes", () => { expect( parseLegacyImageGenerationCompletion(