Skip to content
Open
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
63 changes: 52 additions & 11 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as acp from "@agentclientprotocol/sdk";
import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk";
import {readFile} from "node:fs/promises";
import {extname} from "node:path";
import {fileURLToPath, pathToFileURL} from "node:url";
import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler";
import {CodexApprovalHandler} from "./permissions/CodexApprovalHandler";
import {PermissionLifecycleContext} from "./permissions/lifecycle";
Expand Down Expand Up @@ -112,7 +115,9 @@ import {
createAgentTextThoughtChunk,
createCodexMessagePhaseMeta,
createUserMessageChunk,
visibleUserMessageText,
} from "./ContentChunks";

import {sameThreadGoalSnapshot, type ThreadGoalSnapshot, toThreadGoalSnapshot,} from "./ThreadGoalSnapshot";
import {
clientSupportsSubagents,
Expand Down Expand Up @@ -152,6 +157,19 @@ import {
parseAgentFileChangeReportRequest,
} from "./AgentFileChangeReport";

const LOCAL_IMAGE_MIME_TYPES: Readonly<Record<string, string>> = {
".avif": "image/avif",
".bmp": "image/bmp",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
};

function localImageMimeType(localPath: string): string | null {
return LOCAL_IMAGE_MIME_TYPES[extname(localPath).toLowerCase()] ?? null;
}

export interface SessionState {
sessionId: string,
Expand Down Expand Up @@ -2282,13 +2300,11 @@ export class CodexAcpServer {
}
}

private createUserMessageUpdates(item: ThreadItem & { type: "userMessage" }): UpdateSessionEvent[] {
private async createUserMessageUpdates(item: ThreadItem & { type: "userMessage" }): Promise<UpdateSessionEvent[]> {
const updates: UpdateSessionEvent[] = [];
const messageId = item.id;
for (const input of item.content) {
const blocks = this.userInputToContentBlocks(input);
for (const block of blocks) {
updates.push(createUserMessageChunk(block, messageId));
for (const block of await this.userInputToContentBlocks(input)) {
updates.push(createUserMessageChunk(block, item.id));
}
}
return updates;
Expand Down Expand Up @@ -2349,20 +2365,45 @@ export class CodexAcpServer {
);
}

private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] {
private async userInputToContentBlocks(input: UserInput): Promise<acp.ContentBlock[]> {
switch (input.type) {
case "text":
return input.text.length > 0 ? [{ type: "text", text: input.text }] : [];
case "image":
case "text": {
const visibleText = visibleUserMessageText(input.text);
return visibleText.length > 0 ? [{ type: "text", text: visibleText }] : [];
}
case "image": {
const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(input.url);
if (match?.[1] && match[2]) {
return [{ type: "image", mimeType: match[1], data: match[2].replace(/\s/g, "") }];
}
return [{ type: "text", text: this.formatUriAsLink("image", input.url) }];
}
case "localImage": {
const uri = input.path.startsWith("file://") ? input.path : `file://${input.path}`;
let localPath: string;
let uri: string;
try {
localPath = input.path.startsWith("file://") ? fileURLToPath(input.path) : input.path;
uri = pathToFileURL(localPath).href;
} catch {
return [{ type: "text", text: this.formatUriAsLink(null, input.path) }];
}
const mimeType = localImageMimeType(localPath);
const data = mimeType ? await readFile(localPath).catch(() => null) : null;
if (mimeType && data) {
return [{ type: "image", mimeType, data: data.toString("base64"), uri }];
}
return [{ type: "text", text: this.formatUriAsLink(null, uri) }];
}
case "skill":
return [{ type: "text", text: `skill:${input.name} (${input.path})` }];
case "audio":
case "localAudio":
return [];
case "mention": {
const uri = input.path.startsWith("file://") ? input.path : pathToFileURL(input.path).href;
return [{ type: "resource_link", name: input.name, uri }];
}
}
return [];
}

private formatUriAsLink(name: string | null, uri: string): string {
Expand Down
12 changes: 12 additions & 0 deletions src/ContentChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ import type {UpdateSessionEvent} from "./ACPSessionConnection";

type AcpMeta = Record<string, unknown>;

const FILES_MENTIONED_HEADER = "# Files mentioned by the user:\n";
const REQUEST_MARKER = "\n## My request for Codex:\n";

export function visibleUserMessageText(text: string): string {
const normalized = text.trimStart();
const requestIndex = normalized.indexOf(REQUEST_MARKER);
if (normalized.startsWith(FILES_MENTIONED_HEADER) && requestIndex !== -1) {
return normalized.slice(requestIndex + REQUEST_MARKER.length);
}
return text;
}

export function createCodexMessagePhaseMeta(phase: string | null | undefined): AcpMeta | undefined {
if (!phase) {
return undefined;
Expand Down
38 changes: 10 additions & 28 deletions src/ResponseItemHistoryFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { stripShellPrefix } from "./CommandUtils";
import type { CommandAction, Thread, ThreadItem } from "./app-server/v2";
import { createCommandActionEvent } from "./CodexToolCallMapper";
import { createTerminalOutputMeta, type TerminalOutputMode } from "./TerminalOutputMode";
import { createAgentMessageChunk, createCodexMessagePhaseMeta } from "./ContentChunks";
import {
createAgentMessageChunk,
createCodexMessagePhaseMeta,
createUserMessageChunk,
visibleUserMessageText,
} from "./ContentChunks";

type JsonRecord = Record<string, unknown>;
type AcpToolCallEvent = Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>;
Expand Down Expand Up @@ -263,18 +268,11 @@ function createEventMsgUpdates(record: JsonRecord): UpdateSessionEvent[] | null
}

function createUserMessageEventUpdates(payload: JsonRecord): UpdateSessionEvent[] {
const blocks: ContentBlock[] = [];
const message = stringValue(payload["message"]);
if (message !== null && message.length > 0) {
blocks.push({ type: "text", text: message });
const text = visibleUserMessageText(stringValue(payload["message"]) ?? "");
if (text.length > 0) {
return [createUserMessageChunk({ type: "text", text })];
}
blocks.push(...imageBlocks(payload["images"]));
blocks.push(...imageBlocks(payload["local_images"]));

return blocks.map((content) => ({
sessionUpdate: "user_message_chunk",
content,
}));
return [];
}

function createAgentReasoningEventUpdates(payload: JsonRecord): UpdateSessionEvent[] {
Expand All @@ -289,22 +287,6 @@ function createAgentReasoningEventUpdates(payload: JsonRecord): UpdateSessionEve
}];
}

function imageBlocks(images: unknown): ContentBlock[] {
if (!Array.isArray(images)) {
return [];
}

return images.flatMap((image): ContentBlock[] => {
if (typeof image === "string") {
return [{ type: "text", text: `[@image](${image})` }];
}

const record = asRecord(image);
const path = record ? stringValue(record["path"]) ?? stringValue(record["url"]) : null;
return path ? [{ type: "text", text: `[@image](${path})` }] : [];
});
}

function contentBlocksFromResponseContent(content: unknown): ContentBlock[] {
if (!Array.isArray(content)) {
return [];
Expand Down
68 changes: 68 additions & 0 deletions src/__tests__/CodexACPAgent/data/load-session-history.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,74 @@
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-1",
"update": {
"sessionUpdate": "user_message_chunk",
"messageId": "item-user-1",
"content": {
"type": "image",
"mimeType": "image/png",
"data": "dGVzdCBpbWFnZQ=="
}
}
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-1",
"update": {
"sessionUpdate": "user_message_chunk",
"messageId": "item-user-1",
"content": {
"type": "image",
"mimeType": "image/png",
"data": "dGVzdCBpbWFnZQ==",
"uri": "file:///tmp/codex-acp-load-session-image.png"
}
}
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-1",
"update": {
"sessionUpdate": "user_message_chunk",
"messageId": "item-user-1",
"content": {
"type": "text",
"text": "[@invalid%2Fimage.png](file:///tmp/invalid%2Fimage.png)"
}
}
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-1",
"update": {
"sessionUpdate": "user_message_chunk",
"messageId": "item-user-1",
"content": {
"type": "resource_link",
"name": "notes.txt",
"uri": "file:///test/project/notes.txt"
}
}
}
]
}
{
"method": "sessionUpdate",
"args": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-legacy",
"update": {
"sessionUpdate": "user_message_chunk",
"messageId": "item-user-1",
"content": {
"type": "image",
"mimeType": "image/png",
"data": "dW5pcXVlLWZhbGxiYWNrLWltYWdl"
}
}
}
]
}
{
"method": "sessionUpdate",
"args": [
Expand Down
50 changes: 44 additions & 6 deletions src/__tests__/CodexACPAgent/load-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, onTestFinished, vi } from "vitest";
import type * as acp from "@agentclientprotocol/sdk";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { createCodexMockTestFixture, createTestModel } from "../acp-test-utils";
import type { UpdateSessionEvent } from "../../ACPSessionConnection";
import type { Model, Thread, ThreadGoal } from "../../app-server/v2";

describe("CodexACPAgent - loadSession", () => {
Expand Down Expand Up @@ -191,6 +193,11 @@ describe("CodexACPAgent - loadSession", () => {
});

it("should replay history during loadSession", async () => {
const localImageDirectory = await mkdtemp(join(tmpdir(), "codex-acp-load-session-"));
const localImagePath = join(localImageDirectory, "image.png");
await writeFile(localImagePath, "test image");
onTestFinished(() => rm(localImageDirectory, { recursive: true }));

const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
const codexAcpClient = fixture.getCodexAcpClient();
Expand Down Expand Up @@ -273,8 +280,16 @@ describe("CodexACPAgent - loadSession", () => {
id: "item-user-1",
clientId: null,
content: [
{ type: "text", text: "Hi", text_elements: [] },
{
type: "text",
text: `\n# Files mentioned by the user:\n\n## image.png: ${localImagePath}\n\n## My request for Codex:\nHi`,
text_elements: [],
},
{ type: "image", url: "https://example.com/image.png" },
{ type: "image", url: "data:image/png;base64,dGVzdCBpbWFnZQ==" },
{ type: "localImage", path: localImagePath },
{ type: "localImage", path: "file:///tmp/invalid%2Fimage.png" },
{ type: "mention", name: "notes.txt", path: "/test/project/notes.txt" },
],
},
{
Expand Down Expand Up @@ -417,7 +432,11 @@ describe("CodexACPAgent - loadSession", () => {

expect(codexAppServerClient.threadReadWithHistory).toHaveBeenCalledWith(thread.id);
expect(codexAppServerClient.threadGoalGet).toHaveBeenCalledWith({ threadId: thread.id });
await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot(
const replay = fixture.getAcpConnectionDump([]).replaceAll(
pathToFileURL(localImagePath).href,
"file:///tmp/codex-acp-load-session-image.png",
);
await expect(replay).toMatchFileSnapshot(
"data/load-session-history.json"
);
});
Expand Down Expand Up @@ -534,8 +553,8 @@ describe("CodexACPAgent - loadSession", () => {
type: "event_msg",
payload: {
type: "user_message",
message: "List the files",
images: [],
message: "\n# Files mentioned by the user:\n\n## screenshot.png: embedded\n\n## My request for Codex:\nList the files",
images: ["data:image/png;base64,dW5pcXVlLWZhbGxiYWNrLWltYWdl"],
local_images: [],
text_elements: [],
},
Expand Down Expand Up @@ -718,7 +737,14 @@ describe("CodexACPAgent - loadSession", () => {
type: "userMessage",
id: "item-user-1",
clientId: null,
content: [{ type: "text", text: "List the files", text_elements: [] }],
content: [
{
type: "text",
text: "List the files",
text_elements: [],
},
{ type: "image", url: "data:image/png;base64,dW5pcXVlLWZhbGxiYWNrLWltYWdl" },
],
},
{
type: "reasoning",
Expand Down Expand Up @@ -772,6 +798,18 @@ describe("CodexACPAgent - loadSession", () => {
mcpServers: [],
});

const replayedUserContent = fixture.getAcpConnectionEvents([])
.filter(event => event.method === "sessionUpdate")
.map(event => event.args[0].update)
.filter((update): update is Extract<UpdateSessionEvent, {sessionUpdate: "user_message_chunk"}> => (
update.sessionUpdate === "user_message_chunk"
))
.map(update => update.content);
expect(replayedUserContent).toEqual([
{type: "text", text: "List the files"},
{type: "image", mimeType: "image/png", data: "dW5pcXVlLWZhbGxiYWNrLWltYWdl"},
]);

await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot(
"data/load-session-response-item-history-fallback.json",
);
Expand Down
Loading