mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
459 lines
12 KiB
TypeScript
459 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { HistoryReplayer } from './HistoryReplayer.js';
|
|
import type { SessionContext } from './types.js';
|
|
import type {
|
|
Config,
|
|
ChatRecord,
|
|
ToolRegistry,
|
|
ToolResultDisplay,
|
|
TodoResultDisplay,
|
|
} from '@qwen-code/qwen-code-core';
|
|
|
|
describe('HistoryReplayer', () => {
|
|
let mockContext: SessionContext;
|
|
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
|
let replayer: HistoryReplayer;
|
|
|
|
beforeEach(() => {
|
|
sendUpdateSpy = vi.fn().mockResolvedValue(undefined);
|
|
const mockToolRegistry = {
|
|
getTool: vi.fn().mockReturnValue(null),
|
|
} as unknown as ToolRegistry;
|
|
|
|
mockContext = {
|
|
sessionId: 'test-session-id',
|
|
config: {
|
|
getToolRegistry: () => mockToolRegistry,
|
|
} as unknown as Config,
|
|
sendUpdate: sendUpdateSpy,
|
|
};
|
|
|
|
replayer = new HistoryReplayer(mockContext);
|
|
});
|
|
|
|
const createUserRecord = (text: string): ChatRecord => ({
|
|
uuid: 'user-uuid',
|
|
parentUuid: null,
|
|
sessionId: 'test-session',
|
|
timestamp: new Date().toISOString(),
|
|
type: 'user',
|
|
cwd: '/test',
|
|
version: '1.0.0',
|
|
message: {
|
|
role: 'user',
|
|
parts: [{ text }],
|
|
},
|
|
});
|
|
|
|
const createAssistantRecord = (
|
|
text: string,
|
|
thought = false,
|
|
): ChatRecord => ({
|
|
uuid: 'assistant-uuid',
|
|
parentUuid: 'user-uuid',
|
|
sessionId: 'test-session',
|
|
timestamp: new Date().toISOString(),
|
|
type: 'assistant',
|
|
cwd: '/test',
|
|
version: '1.0.0',
|
|
message: {
|
|
role: 'model',
|
|
parts: [{ text, thought }],
|
|
},
|
|
});
|
|
|
|
const createToolResultRecord = (
|
|
toolName: string,
|
|
resultDisplay?: ToolResultDisplay,
|
|
hasError = false,
|
|
): ChatRecord => ({
|
|
uuid: 'tool-uuid',
|
|
parentUuid: 'assistant-uuid',
|
|
sessionId: 'test-session',
|
|
timestamp: new Date().toISOString(),
|
|
type: 'tool_result',
|
|
cwd: '/test',
|
|
version: '1.0.0',
|
|
message: {
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
functionResponse: {
|
|
name: toolName,
|
|
response: { result: 'ok' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
toolCallResult: {
|
|
callId: 'call-123',
|
|
responseParts: [],
|
|
resultDisplay,
|
|
error: hasError ? new Error('Tool failed') : undefined,
|
|
errorType: undefined,
|
|
},
|
|
});
|
|
|
|
describe('replay', () => {
|
|
it('should replay empty records array', async () => {
|
|
await replayer.replay([]);
|
|
|
|
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should replay records in order', async () => {
|
|
const records = [
|
|
createUserRecord('Hello'),
|
|
createAssistantRecord('Hi there'),
|
|
];
|
|
|
|
await replayer.replay(records);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
|
expect(sendUpdateSpy.mock.calls[0][0].sessionUpdate).toBe(
|
|
'user_message_chunk',
|
|
);
|
|
expect(sendUpdateSpy.mock.calls[1][0].sessionUpdate).toBe(
|
|
'agent_message_chunk',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('user message replay', () => {
|
|
it('should emit user_message_chunk for user records', async () => {
|
|
const records = [createUserRecord('Hello, world!')];
|
|
|
|
await replayer.replay(records);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'user_message_chunk',
|
|
content: { type: 'text', text: 'Hello, world!' },
|
|
});
|
|
});
|
|
|
|
it('should skip user records without message', async () => {
|
|
const record: ChatRecord = {
|
|
...createUserRecord('test'),
|
|
message: undefined,
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('assistant message replay', () => {
|
|
it('should emit agent_message_chunk for assistant records', async () => {
|
|
const records = [createAssistantRecord('I can help with that.')];
|
|
|
|
await replayer.replay(records);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { type: 'text', text: 'I can help with that.' },
|
|
});
|
|
});
|
|
|
|
it('should emit agent_thought_chunk for thought parts', async () => {
|
|
const records = [createAssistantRecord('Thinking about this...', true)];
|
|
|
|
await replayer.replay(records);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'agent_thought_chunk',
|
|
content: { type: 'text', text: 'Thinking about this...' },
|
|
});
|
|
});
|
|
|
|
it('should handle assistant records with multiple parts', async () => {
|
|
const record: ChatRecord = {
|
|
...createAssistantRecord('First'),
|
|
message: {
|
|
role: 'model',
|
|
parts: [
|
|
{ text: 'First part' },
|
|
{ text: 'Second part', thought: true },
|
|
{ text: 'Third part' },
|
|
],
|
|
},
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
|
|
expect(sendUpdateSpy.mock.calls[0][0]).toEqual({
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { type: 'text', text: 'First part' },
|
|
});
|
|
expect(sendUpdateSpy.mock.calls[1][0]).toEqual({
|
|
sessionUpdate: 'agent_thought_chunk',
|
|
content: { type: 'text', text: 'Second part' },
|
|
});
|
|
expect(sendUpdateSpy.mock.calls[2][0]).toEqual({
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { type: 'text', text: 'Third part' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('function call replay', () => {
|
|
it('should emit tool_call for function call parts', async () => {
|
|
const record: ChatRecord = {
|
|
...createAssistantRecord(''),
|
|
message: {
|
|
role: 'model',
|
|
parts: [
|
|
{
|
|
functionCall: {
|
|
name: 'read_file',
|
|
args: { path: '/test.ts' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call',
|
|
status: 'in_progress',
|
|
title: 'read_file',
|
|
rawInput: { path: '/test.ts' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use function call id as callId when available', async () => {
|
|
const record: ChatRecord = {
|
|
...createAssistantRecord(''),
|
|
message: {
|
|
role: 'model',
|
|
parts: [
|
|
{
|
|
functionCall: {
|
|
id: 'custom-call-id',
|
|
name: 'read_file',
|
|
args: {},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolCallId: 'custom-call-id',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('tool result replay', () => {
|
|
it('should emit tool_call_update for tool result records', async () => {
|
|
const records = [
|
|
createToolResultRecord('read_file', 'File contents here'),
|
|
];
|
|
|
|
await replayer.replay(records);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'tool_call_update',
|
|
toolCallId: 'call-123',
|
|
status: 'completed',
|
|
content: [
|
|
{
|
|
type: 'content',
|
|
// Content comes from functionResponse.response (stringified)
|
|
content: { type: 'text', text: '{"result":"ok"}' },
|
|
},
|
|
],
|
|
// resultDisplay is included as rawOutput
|
|
rawOutput: 'File contents here',
|
|
});
|
|
});
|
|
|
|
it('should emit failed status for tool results with errors', async () => {
|
|
const records = [createToolResultRecord('failing_tool', undefined, true)];
|
|
|
|
await replayer.replay(records);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionUpdate: 'tool_call_update',
|
|
status: 'failed',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should emit plan update for TodoWriteTool results', async () => {
|
|
const todoDisplay: TodoResultDisplay = {
|
|
type: 'todo_list',
|
|
todos: [
|
|
{ id: '1', content: 'Task 1', status: 'pending' },
|
|
{ id: '2', content: 'Task 2', status: 'completed' },
|
|
],
|
|
};
|
|
const record = createToolResultRecord('todo_write', todoDisplay);
|
|
// Override the function response name
|
|
record.message = {
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
functionResponse: {
|
|
name: 'todo_write',
|
|
response: { result: 'ok' },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
|
sessionUpdate: 'plan',
|
|
entries: [
|
|
{ content: 'Task 1', priority: 'medium', status: 'pending' },
|
|
{ content: 'Task 2', priority: 'medium', status: 'completed' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should use record uuid as callId when toolCallResult.callId is missing', async () => {
|
|
const record: ChatRecord = {
|
|
...createToolResultRecord('test_tool'),
|
|
uuid: 'fallback-uuid',
|
|
toolCallResult: {
|
|
callId: undefined as unknown as string,
|
|
responseParts: [],
|
|
resultDisplay: 'Result',
|
|
error: undefined,
|
|
errorType: undefined,
|
|
},
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolCallId: 'fallback-uuid',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('system records', () => {
|
|
it('should skip system records', async () => {
|
|
const systemRecord: ChatRecord = {
|
|
uuid: 'system-uuid',
|
|
parentUuid: null,
|
|
sessionId: 'test-session',
|
|
timestamp: new Date().toISOString(),
|
|
type: 'system',
|
|
subtype: 'chat_compression',
|
|
cwd: '/test',
|
|
version: '1.0.0',
|
|
};
|
|
|
|
await replayer.replay([systemRecord]);
|
|
|
|
expect(sendUpdateSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('mixed record types', () => {
|
|
it('should handle a complete conversation replay', async () => {
|
|
const records: ChatRecord[] = [
|
|
createUserRecord('Read the file test.ts'),
|
|
{
|
|
...createAssistantRecord(''),
|
|
message: {
|
|
role: 'model',
|
|
parts: [
|
|
{ text: "I'll read that file for you.", thought: true },
|
|
{
|
|
functionCall: {
|
|
id: 'call-read',
|
|
name: 'read_file',
|
|
args: { path: 'test.ts' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
createToolResultRecord('read_file', 'export const x = 1;'),
|
|
createAssistantRecord('The file contains a simple export.'),
|
|
];
|
|
|
|
await replayer.replay(records);
|
|
|
|
// Verify order and types of updates
|
|
const updateTypes = sendUpdateSpy.mock.calls.map(
|
|
(call: unknown[]) =>
|
|
(call[0] as { sessionUpdate: string }).sessionUpdate,
|
|
);
|
|
expect(updateTypes).toEqual([
|
|
'user_message_chunk',
|
|
'agent_thought_chunk',
|
|
'tool_call',
|
|
'tool_call_update',
|
|
'agent_message_chunk',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('usage metadata replay', () => {
|
|
it('should emit usage metadata after assistant message content', async () => {
|
|
const record: ChatRecord = {
|
|
uuid: 'assistant-uuid',
|
|
parentUuid: 'user-uuid',
|
|
sessionId: 'test-session',
|
|
timestamp: new Date().toISOString(),
|
|
type: 'assistant',
|
|
cwd: '/test',
|
|
version: '1.0.0',
|
|
message: {
|
|
role: 'model',
|
|
parts: [{ text: 'Hello!' }],
|
|
},
|
|
usageMetadata: {
|
|
promptTokenCount: 100,
|
|
candidatesTokenCount: 50,
|
|
totalTokenCount: 150,
|
|
},
|
|
};
|
|
|
|
await replayer.replay([record]);
|
|
|
|
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
|
|
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { type: 'text', text: 'Hello!' },
|
|
});
|
|
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
|
|
sessionUpdate: 'agent_message_chunk',
|
|
content: { type: 'text', text: '' },
|
|
_meta: {
|
|
usage: {
|
|
promptTokens: 100,
|
|
completionTokens: 50,
|
|
thoughtsTokens: undefined,
|
|
totalTokens: 150,
|
|
cachedTokens: undefined,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|