Render Codex image thread items as path markdown (#785)
This commit is contained in:
@@ -83,6 +83,14 @@ function asInternals(session: CodexTestSession): CodexSessionTestAccess {
|
|||||||
return castInternals<CodexSessionTestAccess>(session);
|
return castInternals<CodexSessionTestAccess>(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownImageSource(markdown: string): string {
|
||||||
|
const match = markdown.match(/^!\[[^\]]*]\((.*)\)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Expected markdown image, got: ${markdown}`);
|
||||||
|
}
|
||||||
|
return match[1].replace(/\\\)/g, ")");
|
||||||
|
}
|
||||||
|
|
||||||
describe("Codex app-server provider", () => {
|
describe("Codex app-server provider", () => {
|
||||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||||
const requests: Array<{ method: string; params: unknown }> = [];
|
const requests: Array<{ method: string; params: unknown }> = [];
|
||||||
@@ -1065,6 +1073,126 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("emits imageView thread items as assistant markdown images using the path", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "image-view-1",
|
||||||
|
type: "imageView",
|
||||||
|
path: "/tmp/paseo image.png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["savedPath", { savedPath: "/tmp/generated-camel.png" }, "/tmp/generated-camel.png"],
|
||||||
|
["saved_path", { saved_path: "/tmp/generated-snake.png" }, "/tmp/generated-snake.png"],
|
||||||
|
])(
|
||||||
|
"emits imageGeneration thread items with %s as assistant markdown images",
|
||||||
|
(_fieldName, imageFields, expectedPath) => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: `image-generation-${_fieldName}`,
|
||||||
|
type: "imageGeneration",
|
||||||
|
status: "completed",
|
||||||
|
...imageFields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: ``,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("materializes imageGeneration base64 results before rendering markdown", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "image-generation-base64",
|
||||||
|
type: "imageGeneration",
|
||||||
|
status: "completed",
|
||||||
|
result: `data:image/png;base64,${ONE_BY_ONE_PNG_BASE64}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
const event = events[0];
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "assistant_message" },
|
||||||
|
});
|
||||||
|
if (event?.type !== "timeline" || event.item.type !== "assistant_message") {
|
||||||
|
throw new Error("Expected assistant timeline event");
|
||||||
|
}
|
||||||
|
expect(event.item.text).not.toContain("data:image");
|
||||||
|
expect(event.item.text).not.toContain(ONE_BY_ONE_PNG_BASE64);
|
||||||
|
const source = markdownImageSource(event.item.text);
|
||||||
|
expect(source).toMatch(/paseo-attachments\/.+\.png$/);
|
||||||
|
expect(existsSync(source)).toBe(true);
|
||||||
|
rmSync(source, { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores incomplete imageGeneration thread items without failing the turn", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "image-generation-incomplete",
|
||||||
|
type: "imageGeneration",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
asInternals(session).handleNotification("turn/completed", {
|
||||||
|
turn: { status: "completed", error: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "turn_completed",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
usage: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("emits usage_updated on token usage updates and keeps usage on turn completion", () => {
|
test("emits usage_updated on token usage updates and keeps usage on turn completion", () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { homedir } from "node:os";
|
|||||||
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Dirent } from "node:fs";
|
import { Dirent } from "node:fs";
|
||||||
|
import * as fsSync from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -55,6 +56,10 @@ import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
|||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||||
|
import {
|
||||||
|
renderProviderImageOutputAsAssistantMarkdown,
|
||||||
|
type ProviderImageOutput,
|
||||||
|
} from "./provider-image-output.js";
|
||||||
import {
|
import {
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
@@ -85,6 +90,13 @@ const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
|||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
||||||
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
|
const ASSISTANT_MESSAGE_BOUNDARY_MARKDOWN = "\n\n---\n\n";
|
||||||
|
const CODEX_TOOL_THREAD_ITEM_TYPES = new Set([
|
||||||
|
"commandExecution",
|
||||||
|
"fileChange",
|
||||||
|
"mcpToolCall",
|
||||||
|
"webSearch",
|
||||||
|
"collabAgentToolCall",
|
||||||
|
]);
|
||||||
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
|
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
|
||||||
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
|
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
|
||||||
|
|
||||||
@@ -1202,6 +1214,10 @@ function normalizeCodexThreadItemType(rawType: string | undefined): string | und
|
|||||||
return "webSearch";
|
return "webSearch";
|
||||||
case "CollabAgentToolCall":
|
case "CollabAgentToolCall":
|
||||||
return "collabAgentToolCall";
|
return "collabAgentToolCall";
|
||||||
|
case "ImageView":
|
||||||
|
return "imageView";
|
||||||
|
case "ImageGeneration":
|
||||||
|
return "imageGeneration";
|
||||||
default:
|
default:
|
||||||
return rawType;
|
return rawType;
|
||||||
}
|
}
|
||||||
@@ -1544,6 +1560,84 @@ function mapCodexThreadUserMessageItem(
|
|||||||
return { type: "user_message", text };
|
return { type: "user_message", text };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstStringField(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
fields: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
for (const field of fields) {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null {
|
||||||
|
if (typeof result === "string") {
|
||||||
|
const trimmed = result.trim();
|
||||||
|
if (
|
||||||
|
trimmed.toLowerCase().startsWith("data:image/") ||
|
||||||
|
(/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed) && trimmed.length > 64)
|
||||||
|
) {
|
||||||
|
return { data: trimmed };
|
||||||
|
}
|
||||||
|
return { url: trimmed };
|
||||||
|
}
|
||||||
|
const resultRecord = toObjectRecord(result);
|
||||||
|
if (!resultRecord) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: firstStringField(resultRecord, ["path", "savedPath", "saved_path"]),
|
||||||
|
url: firstStringField(resultRecord, ["url"]),
|
||||||
|
data: firstStringField(resultRecord, ["data"]),
|
||||||
|
mimeType: firstStringField(resultRecord, ["mimeType", "mime_type"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeImageAttachmentSync(mimeType: string, data: string): string {
|
||||||
|
const attachmentsDir = path.join(os.tmpdir(), CODEX_IMAGE_ATTACHMENT_DIR);
|
||||||
|
fsSync.mkdirSync(attachmentsDir, { recursive: true });
|
||||||
|
const normalized = normalizeImageData(mimeType, data);
|
||||||
|
const extension = getImageExtension(normalized.mimeType);
|
||||||
|
const filename = `${randomUUID()}.${extension}`;
|
||||||
|
const filePath = path.join(attachmentsDir, filename);
|
||||||
|
fsSync.writeFileSync(filePath, Buffer.from(normalized.data, "base64"));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeCodexImageOutput(image: { data: string; mimeType: string | null }): {
|
||||||
|
path: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
path: writeImageAttachmentSync(image.mimeType ?? "image/png", image.data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCodexThreadImageItem(
|
||||||
|
normalizedType: string,
|
||||||
|
normalizedItem: Record<string, unknown>,
|
||||||
|
): AgentTimelineItem | null {
|
||||||
|
if (normalizedType === "imageView") {
|
||||||
|
return renderProviderImageOutputAsAssistantMarkdown({
|
||||||
|
path: firstStringField(normalizedItem, ["path"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
|
||||||
|
const result = codexImageOutputFromResult(normalizedItem.result);
|
||||||
|
return renderProviderImageOutputAsAssistantMarkdown(
|
||||||
|
{
|
||||||
|
path: savedPath ?? result?.path ?? null,
|
||||||
|
url: result?.url ?? null,
|
||||||
|
data: result?.data ?? null,
|
||||||
|
mimeType: result?.mimeType ?? null,
|
||||||
|
},
|
||||||
|
{ materialize: materializeCodexImageOutput },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function threadItemToTimeline(
|
function threadItemToTimeline(
|
||||||
item: unknown,
|
item: unknown,
|
||||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||||
@@ -1560,6 +1654,13 @@ function threadItemToTimeline(
|
|||||||
? { ...itemRecord, type: normalizedType }
|
? { ...itemRecord, type: normalizedType }
|
||||||
: itemRecord;
|
: itemRecord;
|
||||||
|
|
||||||
|
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
|
||||||
|
return mapCodexThreadImageItem(normalizedType, normalizedItem);
|
||||||
|
}
|
||||||
|
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
|
||||||
|
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
||||||
|
}
|
||||||
|
|
||||||
switch (normalizedType) {
|
switch (normalizedType) {
|
||||||
case "userMessage":
|
case "userMessage":
|
||||||
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
|
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
|
||||||
@@ -1572,12 +1673,6 @@ function threadItemToTimeline(
|
|||||||
return mapCodexThreadPlanItem(normalizedItem);
|
return mapCodexThreadPlanItem(normalizedItem);
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
return mapCodexThreadReasoningItem(normalizedItem);
|
return mapCodexThreadReasoningItem(normalizedItem);
|
||||||
case "commandExecution":
|
|
||||||
case "fileChange":
|
|
||||||
case "mcpToolCall":
|
|
||||||
case "webSearch":
|
|
||||||
case "collabAgentToolCall":
|
|
||||||
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
|
export interface ProviderImageOutput {
|
||||||
|
path?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
data?: string | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
altText?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterializedProviderImage {
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderProviderImageOutputOptions {
|
||||||
|
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(value: string | null | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataImageSource(source: string): boolean {
|
||||||
|
return source.trim().toLowerCase().startsWith("data:image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeMarkdownImageAlt(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeMarkdownImageSource(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderProviderImageOutputAsAssistantMarkdown(
|
||||||
|
image: ProviderImageOutput,
|
||||||
|
options: RenderProviderImageOutputOptions = {},
|
||||||
|
): AgentTimelineItem | null {
|
||||||
|
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
|
||||||
|
if (source && !isDataImageSource(source)) {
|
||||||
|
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
|
||||||
|
return {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: `})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = nonEmptyString(image.data) ?? (source && isDataImageSource(source) ? source : null);
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let materialized: MaterializedProviderImage | null = null;
|
||||||
|
try {
|
||||||
|
materialized = options.materialize
|
||||||
|
? options.materialize({
|
||||||
|
data,
|
||||||
|
mimeType: nonEmptyString(image.mimeType),
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
materialized = null;
|
||||||
|
}
|
||||||
|
if (!materialized?.path || isDataImageSource(materialized.path)) {
|
||||||
|
return {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: "Image output was omitted because it was not available as a file path or URL.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
|
||||||
|
return {
|
||||||
|
type: "assistant_message",
|
||||||
|
text: `})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user