Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions apps/app/src/components/thread/timeline/TimelineRowDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -62,7 +66,7 @@ interface OutputPreviewNoteArgs {

interface ResolveImageViewSourceArgs {
resolveImageViewSrc: ThreadTimelineImageViewSrcResolver | undefined;
row: TimelineImageViewViewWorkRow;
row: ImageWorkRow;
}

function compactDetailLines(lines: readonly DetailLine[]): string[] {
Expand All @@ -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 (
<EmptyStatePanel className="rounded-lg">
<div>Image preview unavailable.</div>
<div className="whitespace-pre-wrap break-words">
{row.workKind === "image-generation" && row.error
? row.error
: "Image preview unavailable."}
</div>
<div className="mt-1 break-all font-mono text-xs">{row.path}</div>
</EmptyStatePanel>
);
Expand Down Expand Up @@ -274,15 +282,12 @@ export function WorkRowBody({
return <PlanStepsWorkRowBody row={row} />;
case "extension":
return <PresentationDetail presentation={row.presentation} />;
case "image-generation":
case "image-view":
return (
<ImageViewWorkRowBody
row={row}
resolveImageViewSrc={resolveImageViewSrc}
/>
<ImageWorkRowBody row={row} resolveImageViewSrc={resolveImageViewSrc} />
);
case "approval":
case "image-generation":
case "web-search":
case "web-fetch":
case "file-read":
Expand Down
80 changes: 80 additions & 0 deletions apps/server/test/services/threads/timeline-in-turn-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] });
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/client-core/src/timeline/timeline-auto-expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
14 changes: 12 additions & 2 deletions packages/db/src/data/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
)
Expand Down
1 change: 1 addition & 0 deletions packages/domain/src/legacy-image-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions packages/domain/test/legacy-image-generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions packages/templates/src/templates/bb-guide-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions plugins/account-pool/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 11 additions & 5 deletions plugins/account-pool/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading