Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
217 changes: 214 additions & 3 deletions packages/cli/src/acp-integration/session/HistoryReplayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,22 @@ import type {
describe('HistoryReplayer', () => {
let mockContext: SessionContext;
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let setActiveRecordIdSpy: ReturnType<typeof vi.fn>;
let sentUpdateContexts: Array<{
activeRecordId: string | null;
activeRecordTimestamp: string | undefined;
}>;
let replayer: HistoryReplayer;

beforeEach(() => {
let activeRecordId: string | null = null;
let activeRecordTimestamp: string | undefined;
sentUpdateContexts = [];
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
setActiveRecordIdSpy = vi.fn((id: string | null, timestamp?: string) => {
activeRecordId = id;
activeRecordTimestamp = timestamp;
});
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(null),
} as unknown as ToolRegistry;
Expand All @@ -31,13 +43,24 @@ describe('HistoryReplayer', () => {
config: {
getToolRegistry: () => mockToolRegistry,
} as unknown as Config,
sendUpdate: sendUpdateSpy,
};
sendUpdate: vi.fn(async (update) => {
sentUpdateContexts.push({ activeRecordId, activeRecordTimestamp });
await sendUpdateSpy(update);
}),
setActiveRecordId: setActiveRecordIdSpy,
} as unknown as SessionContext;

replayer = new HistoryReplayer(mockContext);
});

const toEpochMs = (ts: string) => new Date(ts).getTime();
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
const missingToolResultMessage =
'Tool result missing from saved history; the previous run likely ended ' +
'before this tool completed.';
const sentUpdates = () =>
sendUpdateSpy.mock.calls.map(
(call: unknown[]) => call[0] as Record<string, unknown>,
);

const createUserRecord = (text: string): ChatRecord => ({
uuid: 'user-uuid',
Expand Down Expand Up @@ -295,6 +318,160 @@ describe('HistoryReplayer', () => {
}),
);
});

Comment thread
doudouOUC marked this conversation as resolved.
Resolved
it('should fail dangling function calls after replay completes', async () => {
const record: ChatRecord = {
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call-missing',
name: 'run_shell_command',
args: { command: 'sleep 10' },
},
},
],
},
};

await replayer.replay([record]);

const updates = sentUpdates();
expect(updates.map((update) => update['sessionUpdate'])).toEqual([
'tool_call',
'tool_call_update',
]);
expect(updates[1]).toMatchObject({
sessionUpdate: 'tool_call_update',
toolCallId: 'call-missing',
status: 'failed',
content: [
{
type: 'content',
content: {
type: 'text',
text: missingToolResultMessage,
},
},
],
_meta: {
toolName: 'run_shell_command',
provenance: 'builtin',
timestamp: toEpochMs(record.timestamp),
},
});
expect(setActiveRecordIdSpy).toHaveBeenCalledWith(
record.uuid,
record.timestamp,
);
expect(sentUpdateContexts[1]).toEqual({
activeRecordId: record.uuid,
activeRecordTimestamp: record.timestamp,
});
});

it('should not fail function calls that have matching tool results', async () => {
const records: ChatRecord[] = [
{
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call-123',
name: 'read_file',
args: { path: 'test.ts' },
},
},
],
},
},
createToolResultRecord('read_file', 'File contents here'),
];

await replayer.replay(records);

const updates = sentUpdates();
expect(updates.map((update) => update['sessionUpdate'])).toEqual([
'tool_call',
'tool_call_update',
]);
expect(updates[1]).toMatchObject({
toolCallId: 'call-123',
status: 'completed',
});
});

it('should only fail dangling calls when matched and dangling calls are mixed', async () => {
const records: ChatRecord[] = [
{
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'call-123',
name: 'read_file',
args: { path: 'test.ts' },
},
},
{
functionCall: {
id: 'call-missing',
name: 'run_shell_command',
args: { command: 'sleep 10' },
},
},
],
},
},
createToolResultRecord('read_file', 'File contents here'),
];

await replayer.replay(records);

const updates = sentUpdates();
expect(updates.map((update) => update['sessionUpdate'])).toEqual([
'tool_call',
'tool_call',
'tool_call_update',
'tool_call_update',
]);
expect(updates[2]).toMatchObject({
toolCallId: 'call-123',
status: 'completed',
});
expect(updates[3]).toMatchObject({
toolCallId: 'call-missing',
status: 'failed',
});
});

it('should not track skipped TodoWrite starts as dangling tool calls', async () => {
const record: ChatRecord = {
...createAssistantRecord(''),
message: {
role: 'model',
parts: [
{
functionCall: {
id: 'todo-call',
name: 'todo_write',
args: { todos: [] },
},
},
],
},
};

await replayer.replay([record]);

expect(sendUpdateSpy).not.toHaveBeenCalled();
});
});

describe('tool result replay', () => {
Expand Down Expand Up @@ -393,6 +570,40 @@ describe('HistoryReplayer', () => {
}),
);
});

it('should use functionResponse id as callId when toolCallResult.callId is missing', async () => {
const record: ChatRecord = {
...createToolResultRecord('test_tool'),
uuid: 'fallback-uuid',
message: {
role: 'user',
parts: [
{
functionResponse: {
id: 'response-call-id',
name: 'test_tool',
response: { result: 'ok' },
},
},
],
},
toolCallResult: {
callId: undefined as unknown as string,
responseParts: [],
resultDisplay: 'Result',
error: undefined,
errorType: undefined,
},
};

await replayer.replay([record]);

expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolCallId: 'response-call-id',
}),
);
});
});

describe('system records', () => {
Expand Down Expand Up @@ -426,7 +637,7 @@ describe('HistoryReplayer', () => {
{ text: "I'll read that file for you.", thought: true },
{
functionCall: {
id: 'call-read',
id: 'call-123',
name: 'read_file',
args: { path: 'test.ts' },
},
Expand Down
Loading
Loading