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
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,54 @@ function normalizeRawInput(value) {
return undefined;
}

/**
* Extract locations from rawInput or toolCallResult for file-related tool calls.
* This ensures the exported data matches ACP format, enabling file links in UI.
*
* @param {object|undefined} rawInput - The raw input arguments of the tool call
* @param {object|undefined} toolCallResult - The tool call result object
* @returns {Array<{path: string, line?: number}>|undefined} - Locations array or undefined
*/
function extractLocations(rawInput, toolCallResult) {
const locations = [];

// Extract from rawInput - common path field names used by various tools
if (rawInput && typeof rawInput === 'object') {
// read_file, write_file use absolute_path
if (typeof rawInput.absolute_path === 'string' && rawInput.absolute_path) {
locations.push({ path: rawInput.absolute_path });
}
// edit tool uses file_path
else if (typeof rawInput.file_path === 'string' && rawInput.file_path) {
locations.push({ path: rawInput.file_path });
}
// some tools use just 'path'
else if (typeof rawInput.path === 'string' && rawInput.path) {
locations.push({ path: rawInput.path });
}
// glob/grep tools use 'pattern' with optional 'path' as search root
else if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
// For search tools, the pattern itself isn't a file path, skip
}
// run_shell_command might have 'command' but no file path
}

// Extract from toolCallResult.resultDisplay if available
if (toolCallResult && typeof toolCallResult === 'object') {
const display = toolCallResult.resultDisplay;
if (display && typeof display === 'object') {
if (typeof display.fileName === 'string' && display.fileName) {
// Avoid duplicates
if (!locations.some((loc) => loc.path === display.fileName)) {
locations.push({ path: display.fileName });
}
}
}
}

return locations.length > 0 ? locations : undefined;
}

function extractDiffContent(resultDisplay) {
if (!resultDisplay || typeof resultDisplay !== 'object') return null;
const display = resultDisplay;
Expand Down Expand Up @@ -799,6 +847,7 @@ function convertChatRecordsToSessionData(records) {
typeof fc.id === 'string' && fc.id
? fc.id
: `${toolName || 'tool'}-${record.uuid}`;
const rawInput = normalizeRawInput(fc.args);
const toolCallMessage = {
uuid: record.uuid,
parentUuid: record.parentUuid,
Expand All @@ -810,7 +859,8 @@ function convertChatRecordsToSessionData(records) {
kind: resolveToolKind(toolName),
title: resolveToolTitle(toolName),
status: 'in_progress',
rawInput: normalizeRawInput(fc.args),
rawInput,
locations: extractLocations(rawInput, undefined),
timestamp: Date.parse(record.timestamp),
},
};
Expand Down Expand Up @@ -845,6 +895,7 @@ function convertChatRecordsToSessionData(records) {
status: toolCallResult.error ? 'failed' : 'completed',
rawInput,
content,
locations: extractLocations(rawInput, toolCallResult),
timestamp: Date.parse(record.timestamp),
},
};
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/acp-integration/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,8 @@ export const sessionUpdateMetaSchema = z.object({
toolName: z.string().optional().nullable(),
parentToolCallId: z.string().optional().nullable(),
subagentType: z.string().optional().nullable(),
/** Server-side timestamp (ms since epoch) for correct message ordering */
timestamp: z.number().optional().nullable(),
});

export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
Expand Down Expand Up @@ -560,6 +562,7 @@ export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('user_message_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}),
z.object({
content: contentBlockSchema,
Expand Down
33 changes: 25 additions & 8 deletions packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ describe('HistoryReplayer', () => {
replayer = new HistoryReplayer(mockContext);
});

const toEpochMs = (ts: string) => new Date(ts).getTime();

const createUserRecord = (text: string): ChatRecord => ({
uuid: 'user-uuid',
parentUuid: null,
Expand Down Expand Up @@ -127,13 +129,15 @@ describe('HistoryReplayer', () => {

describe('user message replay', () => {
it('should emit user_message_chunk for user records', async () => {
const records = [createUserRecord('Hello, world!')];
const record = createUserRecord('Hello, world!');
const records = [record];

await replayer.replay(records);

expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'Hello, world!' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
});

Expand All @@ -151,24 +155,28 @@ describe('HistoryReplayer', () => {

describe('assistant message replay', () => {
it('should emit agent_message_chunk for assistant records', async () => {
const records = [createAssistantRecord('I can help with that.')];
const record = createAssistantRecord('I can help with that.');
const records = [record];

await replayer.replay(records);

expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'I can help with that.' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
});

it('should emit agent_thought_chunk for thought parts', async () => {
const records = [createAssistantRecord('Thinking about this...', true)];
const record = createAssistantRecord('Thinking about this...', true);
const records = [record];

await replayer.replay(records);

expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Thinking about this...' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
});

Expand All @@ -191,14 +199,17 @@ describe('HistoryReplayer', () => {
expect(sendUpdateSpy.mock.calls[0][0]).toEqual({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'First part' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
expect(sendUpdateSpy.mock.calls[1][0]).toEqual({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Second part' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
expect(sendUpdateSpy.mock.calls[2][0]).toEqual({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Third part' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
});
});
Expand Down Expand Up @@ -228,7 +239,10 @@ describe('HistoryReplayer', () => {
status: 'in_progress',
title: 'read_file',
rawInput: { path: '/test.ts' },
_meta: { toolName: 'read_file' },
_meta: {
toolName: 'read_file',
timestamp: toEpochMs(record.timestamp),
},
}),
);
});
Expand Down Expand Up @@ -262,9 +276,8 @@ describe('HistoryReplayer', () => {

describe('tool result replay', () => {
it('should emit tool_call_update for tool result records', async () => {
const records = [
createToolResultRecord('read_file', 'File contents here'),
];
const record = createToolResultRecord('read_file', 'File contents here');
const records = [record];

await replayer.replay(records);

Expand All @@ -281,7 +294,10 @@ describe('HistoryReplayer', () => {
],
// resultDisplay is included as rawOutput
rawOutput: 'File contents here',
_meta: { toolName: 'read_file' },
_meta: {
toolName: 'read_file',
timestamp: toEpochMs(record.timestamp),
},
});
});

Expand Down Expand Up @@ -441,6 +457,7 @@ describe('HistoryReplayer', () => {
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello!' },
_meta: { timestamp: toEpochMs(record.timestamp) },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
sessionUpdate: 'agent_message_chunk',
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/acp-integration/session/HistoryReplayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,17 @@ export class HistoryReplayer {
switch (record.type) {
case 'user':
if (record.message) {
await this.replayContent(record.message, 'user');
await this.replayContent(record.message, 'user', record.timestamp);
}
break;

case 'assistant':
if (record.message) {
await this.replayContent(record.message, 'assistant');
await this.replayContent(
record.message,
'assistant',
record.timestamp,
);
}
if (record.usageMetadata) {
await this.replayUsageMetadata(record.usageMetadata);
Expand All @@ -73,16 +77,26 @@ export class HistoryReplayer {
/**
* Replays content from a message (user or assistant).
* Handles text parts, thought parts, and function calls.
*
* @param content - The content to replay
* @param role - The role (user or assistant)
* @param timestamp - Optional server-side timestamp from the JSONL record
*/
private async replayContent(
content: Content,
role: 'user' | 'assistant',
timestamp?: string,
): Promise<void> {
for (const part of content.parts ?? []) {
// Text content
if ('text' in part && part.text) {
const isThought = (part as { thought?: boolean }).thought ?? false;
await this.messageEmitter.emitMessage(part.text, role, isThought);
await this.messageEmitter.emitMessage(
part.text,
role,
isThought,
timestamp,
);
}

// Function call (tool start)
Expand All @@ -95,6 +109,7 @@ export class HistoryReplayer {
callId,
args: part.functionCall.args as Record<string, unknown>,
status: 'in_progress',
timestamp,
});
}
}
Expand Down Expand Up @@ -134,6 +149,7 @@ export class HistoryReplayer {
// For TodoWriteTool fallback, try to extract args from the record
// Note: args aren't stored in tool_result records by default
args: undefined,
timestamp: record.timestamp,
});

// Special handling: Task tool execution summary contains token usage
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/acp-integration/session/emitters/BaseEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ import type * as acp from '../../acp.js';
export abstract class BaseEmitter {
constructor(protected readonly ctx: SessionContext) {}

/**
* Converts an ISO timestamp string or epoch ms to epoch ms number.
* Returns undefined if the input is not a valid timestamp.
*/
protected static toEpochMs(ts?: string | number): number | undefined {
if (typeof ts === 'number') {
return Number.isFinite(ts) ? ts : undefined;
}
if (typeof ts === 'string') {
const ms = new Date(ts).getTime();
return Number.isFinite(ms) ? ms : undefined;
}
return undefined;
}

/**
* Sends a session update to the ACP client.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,55 @@ import { BaseEmitter } from './BaseEmitter.js';
export class MessageEmitter extends BaseEmitter {
/**
* Emits a user message chunk.
*
* @param text - The user message text content
* @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering
*/
async emitUserMessage(text: string): Promise<void> {
async emitUserMessage(
text: string,
timestamp?: string | number,
): Promise<void> {
const epochMs = BaseEmitter.toEpochMs(timestamp);
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text },
...(epochMs != null && { _meta: { timestamp: epochMs } }),
});
}

/**
* Emits an agent thought chunk.
*
* @param text - The thought text content
* @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering
*/
async emitAgentThought(text: string): Promise<void> {
async emitAgentThought(
text: string,
timestamp?: string | number,
): Promise<void> {
const epochMs = BaseEmitter.toEpochMs(timestamp);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
...(epochMs != null && { _meta: { timestamp: epochMs } }),
});
}

/**
* Emits an agent message chunk.
*
* @param text - The agent message text content
* @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering
*/
async emitAgentMessage(text: string): Promise<void> {
async emitAgentMessage(
text: string,
timestamp?: string | number,
): Promise<void> {
const epochMs = BaseEmitter.toEpochMs(timestamp);
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
...(epochMs != null && { _meta: { timestamp: epochMs } }),
});
}

Expand Down Expand Up @@ -82,17 +106,19 @@ export class MessageEmitter extends BaseEmitter {
* @param text - The message text content
* @param role - Whether this is a user or assistant message
* @param isThought - Whether this is an assistant thought (only applies to assistant role)
* @param timestamp - Optional server-side timestamp (ISO string or ms) for message ordering
*/
async emitMessage(
text: string,
role: 'user' | 'assistant',
isThought: boolean = false,
timestamp?: string | number,
): Promise<void> {
if (role === 'user') {
return this.emitUserMessage(text);
return this.emitUserMessage(text, timestamp);
}
return isThought
? this.emitAgentThought(text)
: this.emitAgentMessage(text);
? this.emitAgentThought(text, timestamp)
: this.emitAgentMessage(text, timestamp);
}
}
Loading