Skip to content
Draft
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
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions cli/src/agent/backends/acp/AcpMessageHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import type { AgentMessage } from '@/agent/types';
import { AcpMessageHandler } from './AcpMessageHandler';
import { ACP_SESSION_UPDATE_TYPES } from './constants';
import { clearGeneratedImages } from '@/modules/common/generatedImages';

function getToolResult(messages: AgentMessage[], id: string): Extract<AgentMessage, { type: 'tool_result' }> {
const result = messages.find((message): message is Extract<AgentMessage, { type: 'tool_result' }> =>
Expand Down Expand Up @@ -2093,4 +2094,93 @@ describe('AcpMessageHandler', () => {
});
});
});

it('emits generated_image agent messages from ACP image content blocks', async () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]);

handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: {
type: 'image',
mimeType: 'image/png',
data: pngHeader.toString('base64')
}
});

await vi.waitFor(() => {
expect(messages.some((message) => message.type === 'generated_image')).toBe(true);
});

const imageMessage = messages.find(
(message): message is Extract<AgentMessage, { type: 'generated_image' }> =>
message.type === 'generated_image'
);
expect(imageMessage?.mimeType).toBe('image/png');
expect(imageMessage?.fileName).toBeTruthy();
expect(imageMessage?.imageId).toBeTruthy();
expect(imageMessage?.source).toEqual({ ingress: 'acp' });
clearGeneratedImages();
});

it('emits generated_image before later tool_call when image registration is async', async () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]);

await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: {
type: 'image',
mimeType: 'image/png',
data: pngHeader.toString('base64'),
},
});
await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall,
toolCallId: 'call-after-image',
title: 'Read',
kind: 'read',
status: 'in_progress',
});

const imageIndex = messages.findIndex((message) => message.type === 'generated_image');
const toolIndex = messages.findIndex((message) => message.type === 'tool_call');
expect(imageIndex).toBeGreaterThanOrEqual(0);
expect(toolIndex).toBeGreaterThan(imageIndex);
clearGeneratedImages();
});

it('emits buffered text before generated_image when text precedes an ACP image block', async () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message), { flavor: 'cursor' });
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00]);

await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: { type: 'text', text: 'Here is the screenshot:' }
});
await handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: {
type: 'image',
mimeType: 'image/png',
data: pngHeader.toString('base64')
}
});

await vi.waitFor(() => {
expect(messages.some((message) => message.type === 'generated_image')).toBe(true);
});

const textIndex = messages.findIndex((message) => message.type === 'text');
const imageIndex = messages.findIndex((message) => message.type === 'generated_image');
expect(textIndex).toBeGreaterThanOrEqual(0);
expect(imageIndex).toBeGreaterThan(textIndex);
if (messages[imageIndex]?.type === 'generated_image') {
expect(messages[imageIndex].source).toEqual({ ingress: 'acp', flavor: 'cursor' });
}
clearGeneratedImages();
});
});
47 changes: 44 additions & 3 deletions cli/src/agent/backends/acp/AcpMessageHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { AgentMessage, PlanItem } from '@/agent/types';
import { randomUUID } from 'node:crypto';
import { logger } from '@/ui/logger';
import type { AgentMessage, PlanItem } from '@/agent/types';
import { registerGeneratedImageFromAcpBlock } from '@/modules/common/generatedImages';
import type { InlineMediaSource } from '@/modules/common/inlineMediaSource';
import { asString, isObject } from '@hapi/protocol';
import { deriveToolNameWithSource, isPlaceholderToolName } from '@/agent/utils';
import { parseRateLimitText } from '@/agent/rateLimitParser';
Expand Down Expand Up @@ -381,7 +384,10 @@ export class AcpMessageHandler {
private lastReasoningSnapshotText = '';
private reasoningSnapshotEmitted = false;

constructor(private readonly onMessage: (message: AgentMessage) => void) {}
constructor(
private readonly onMessage: (message: AgentMessage) => void,
private readonly options?: { flavor?: string }
) {}

/**
* Emits any buffered assistant text as a single message and clears the
Expand Down Expand Up @@ -528,7 +534,7 @@ export class AcpMessageHandler {
this.reasoningSnapshotEmitted = false;
}

handleUpdate(update: unknown): void {
async handleUpdate(update: unknown): Promise<void> {
if (!isObject(update)) return;
const updateType = asString(update.sessionUpdate);
if (!updateType) return;
Expand All @@ -554,6 +560,12 @@ export class AcpMessageHandler {

if (updateType === ACP_SESSION_UPDATE_TYPES.agentMessageChunk) {
const content = update.content;
if (isObject(content) && content.type === 'image') {
this.flushReasoning();
this.flushText();
await this.emitGeneratedImageFromAcpContent(content);
return;
}
const text = extractTextContent(content);
if (text) {
// Check once whether the buffered text is a prefix of this
Expand Down Expand Up @@ -629,6 +641,35 @@ export class AcpMessageHandler {
}
}

private async emitGeneratedImageFromAcpContent(content: Record<string, unknown>): Promise<void> {
try {
const image = await registerGeneratedImageFromAcpBlock(content);
if (!image) {
return;
}
this.onMessage({
type: 'generated_image',
imageId: image.id,
fileName: image.fileName,
mimeType: image.mimeType,
source: this.buildAcpInlineMediaSource(),
});
} catch (error) {
logger.debug(
'[AcpMessageHandler] Failed to register ACP image block:',
error instanceof Error ? error.message : String(error)
);
}
}

private buildAcpInlineMediaSource(): InlineMediaSource {
const source: InlineMediaSource = { ingress: 'acp' };
if (this.options?.flavor) {
source.flavor = this.options.flavor;
}
return source;
}

private handleToolCall(update: Record<string, unknown>): void {
const toolCallId = asString(update.toolCallId);
if (!toolCallId) return;
Expand Down
30 changes: 26 additions & 4 deletions cli/src/agent/backends/acp/AcpSdkBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export class AcpSdkBackend implements AgentBackend {
private promptUsageCallback: ((msg: AgentMessage) => void) | null = null;
private usageUpdateListener: ((msg: AgentMessage) => void) | null = null;
private lastForwardedUsageUpdate: AcpUsageUpdate | null = null;
private sessionUpdateQueue: Promise<void> = Promise.resolve();

/** Retry configuration for ACP initialization */
private static readonly INIT_RETRY_OPTIONS = {
Expand Down Expand Up @@ -101,7 +102,12 @@ export class AcpSdkBackend implements AgentBackend {
private static readonly LATE_FLUSH_QUIET_PERIOD_MS = 250;
private static readonly LATE_FLUSH_WINDOW_MS = 6000;

constructor(private readonly options: { command: string; args?: string[]; env?: Record<string, string> }) {}
constructor(private readonly options: {
command: string;
args?: string[];
env?: Record<string, string>;
flavor?: AgentFlavor;
}) {}

async initialize(): Promise<void> {
if (this.transport) return;
Expand Down Expand Up @@ -400,8 +406,9 @@ export class AcpSdkBackend implements AgentBackend {
AcpSdkBackend.PRE_PROMPT_UPDATE_QUIET_PERIOD_MS,
AcpSdkBackend.PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS
);
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
this.messageHandler = new AcpMessageHandler(onUpdate);
this.messageHandler = new AcpMessageHandler(onUpdate, { flavor: this.options.flavor });
this.isProcessingMessage = true;
this.lastSessionUpdateAt = Date.now();
this.latestUsageUpdate = null;
Expand All @@ -425,12 +432,17 @@ export class AcpSdkBackend implements AgentBackend {
AcpSdkBackend.UPDATE_QUIET_PERIOD_MS,
AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS
);
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
// Block here until the model truly stops streaming straggler
// chunks (or LATE_FLUSH_WINDOW_MS elapses), so turn_complete and
// the launcher's ready signal only fire once every chunk has been
// emitted to this turn's onUpdate.
await this.drainLateBuffers();
// Late window can enqueue async image registration; drain again
// before turn_complete so generated_image precedes turn boundary.
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
try {
const latestUsageUpdate = this.readLatestUsageUpdate();
if (promptUsage) {
Expand Down Expand Up @@ -543,6 +555,7 @@ export class AcpSdkBackend implements AgentBackend {

async disconnect(): Promise<void> {
if (!this.transport) return;
await this.sessionUpdateQueue;
this.messageHandler?.drainBuffers();
this.messageHandler = null;
this.activeSessionId = null;
Expand All @@ -561,8 +574,17 @@ export class AcpSdkBackend implements AgentBackend {
}
this.lastSessionUpdateAt = Date.now();
const update = params.update;
this.captureUsageUpdate(update);
this.messageHandler?.handleUpdate(update);
this.sessionUpdateQueue = this.sessionUpdateQueue
.then(async () => {
this.captureUsageUpdate(update);
await this.messageHandler?.handleUpdate(update);
})
.catch((error) => {
logger.debug(
'[AcpSdkBackend] session update failed:',
error instanceof Error ? error.message : String(error)
);
});
}

private captureUsageUpdate(update: unknown): void {
Expand Down
19 changes: 19 additions & 0 deletions cli/src/agent/messageConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,23 @@ describe('convertAgentMessage', () => {
}
});
});

it('converts generated_image messages into generated-image wire payloads', () => {
const converted = convertAgentMessage({
type: 'generated_image',
imageId: 'img-1',
fileName: 'inline.png',
mimeType: 'image/png',
source: { ingress: 'mcp', toolName: 'display_image' },
});

expect(converted).toMatchObject({
type: 'generated-image',
imageId: 'img-1',
fileName: 'inline.png',
mimeType: 'image/png',
source: { ingress: 'mcp', toolName: 'display_image' },
});
expect(converted && 'id' in converted && typeof converted.id === 'string').toBe(true);
});
});
20 changes: 19 additions & 1 deletion cli/src/agent/messageConverter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import type { AgentMessage, PlanItem } from './types';
import type { InlineMediaSource } from '@/modules/common/inlineMediaSource';

export type CodexMessage =
| { type: 'message'; message: string }
Expand Down Expand Up @@ -32,7 +33,15 @@ export type CodexMessage =
is_error?: boolean;
}
| { type: 'plan'; entries: PlanItem[] }
| { type: 'error'; message: string };
| { type: 'error'; message: string }
| {
type: 'generated-image';
imageId: string;
fileName: string;
mimeType: string;
id: string;
source?: InlineMediaSource;
};

export function convertAgentMessage(message: AgentMessage): CodexMessage | null {
switch (message.type) {
Expand Down Expand Up @@ -78,6 +87,15 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null
type: 'plan',
entries: message.items
};
case 'generated_image':
return {
type: 'generated-image',
imageId: message.imageId,
fileName: message.fileName,
mimeType: message.mimeType,
id: randomUUID(),
source: message.source,
};
case 'error':
return { type: 'error', message: message.message };
case 'turn_complete':
Expand Down
2 changes: 2 additions & 0 deletions cli/src/agent/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AgentFlavor } from '@hapi/protocol';
import type { InlineMediaSource } from '@/modules/common/inlineMediaSource';

export type McpEnvVar = {
name: string;
Expand Down Expand Up @@ -44,6 +45,7 @@ export type AgentMessage =
contextWindow?: number;
}
| { type: 'plan'; items: PlanItem[] }
| { type: 'generated_image'; imageId: string; fileName: string; mimeType: string; source?: InlineMediaSource }
| { type: 'turn_complete'; stopReason: string }
| { type: 'error'; message: string };

Expand Down
Loading
Loading