mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
Self-review pass on the previous two perf commits surfaced a few
follow-ups worth pinning down before they bite:
- Module-level caches (paths.isDirectoryCache, ripGrep dirIsDir/qwen-
Ignore, jsonl-utils.ensuredDirs) persisted across vitest cases
silently. Added underscore-prefixed `_reset*ForTest` exports and
wired one into the validatePath describe block so future cases
mutating the same absolute paths can't pass by accident.
- Documented the parentUuid-chain tradeoff on chatRecordingService
.appendRecord: when the async write rejects, lastRecordUuid was
already set sync, so subsequent records reference an absent
ancestor — readers like sessionService.reconstructHistory then
silently drop those descendants. Same observable failure mode as
the prior sync code's caught-and-logged throw.
- Documented the dir<->file mutation and mid-session .qwenignore
staleness windows for the validatePath / ripGrep caches.
- Added regression tests:
* validatePath does NOT cache ENOENT (Edit-then-Read works)
* validatePath skips re-stat on cache hit (perf assertion)
* flush() resolves immediately on a fresh service
* a rejected writeLine does not block the next record
Full core suite: 6061 pass, 2 skipped — no regressions.
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import { execSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { Config } from '../config/config.js';
|
|
import {
|
|
ChatRecordingService,
|
|
type ChatRecord,
|
|
type AtCommandRecordPayload,
|
|
} from './chatRecordingService.js';
|
|
import * as jsonl from '../utils/jsonl-utils.js';
|
|
import type { Part } from '@google/genai';
|
|
|
|
vi.mock('node:path');
|
|
vi.mock('node:child_process');
|
|
vi.mock('node:crypto', () => ({
|
|
randomUUID: vi.fn(),
|
|
createHash: vi.fn(() => ({
|
|
update: vi.fn(() => ({
|
|
digest: vi.fn(() => 'mocked-hash'),
|
|
})),
|
|
})),
|
|
}));
|
|
vi.mock('../utils/jsonl-utils.js');
|
|
|
|
describe('ChatRecordingService', () => {
|
|
let chatRecordingService: ChatRecordingService;
|
|
let mockConfig: Config;
|
|
|
|
let uuidCounter = 0;
|
|
|
|
beforeEach(() => {
|
|
uuidCounter = 0;
|
|
|
|
mockConfig = {
|
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
|
|
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
|
storage: {
|
|
getProjectTempDir: vi
|
|
.fn()
|
|
.mockReturnValue('/test/project/root/.gemini/tmp/hash'),
|
|
getProjectDir: vi
|
|
.fn()
|
|
.mockReturnValue('/test/project/root/.gemini/projects/test-project'),
|
|
},
|
|
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
|
getFastModel: vi.fn().mockReturnValue(undefined),
|
|
isInteractive: vi.fn().mockReturnValue(false),
|
|
getDebugMode: vi.fn().mockReturnValue(false),
|
|
getToolRegistry: vi.fn().mockReturnValue({
|
|
getTool: vi.fn().mockReturnValue({
|
|
displayName: 'Test Tool',
|
|
description: 'A test tool',
|
|
isOutputMarkdown: false,
|
|
}),
|
|
}),
|
|
getResumedSessionData: vi.fn().mockReturnValue(undefined),
|
|
} as unknown as Config;
|
|
|
|
vi.mocked(randomUUID).mockImplementation(
|
|
() =>
|
|
`00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`,
|
|
);
|
|
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
|
|
vi.mocked(path.dirname).mockImplementation((p) => {
|
|
const parts = p.split('/');
|
|
parts.pop();
|
|
return parts.join('/');
|
|
});
|
|
vi.mocked(execSync).mockReturnValue('main\n');
|
|
vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined);
|
|
vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
|
|
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
|
|
|
chatRecordingService = new ChatRecordingService(mockConfig);
|
|
|
|
// Mock jsonl-utils. writeLine is async — mockResolvedValue returns
|
|
// a settled Promise so the writeChain in ChatRecordingService advances
|
|
// when flushed.
|
|
vi.mocked(jsonl.writeLine).mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('recordUserMessage', () => {
|
|
it('should record a user message immediately', async () => {
|
|
const userParts: Part[] = [{ text: 'Hello, world!' }];
|
|
chatRecordingService.recordUserMessage(userParts);
|
|
await chatRecordingService.flush();
|
|
|
|
expect(jsonl.writeLine).toHaveBeenCalledTimes(1);
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
|
|
|
expect(record.uuid).toBe('00000000-0000-0000-0000-000000000001');
|
|
expect(record.parentUuid).toBeNull();
|
|
expect(record.type).toBe('user');
|
|
// The service wraps parts in a Content object using createUserContent
|
|
expect(record.message).toEqual({ role: 'user', parts: userParts });
|
|
expect(record.sessionId).toBe('test-session-id');
|
|
expect(record.cwd).toBe('/test/project/root');
|
|
expect(record.version).toBe('1.0.0');
|
|
expect(record.gitBranch).toBe('main');
|
|
});
|
|
|
|
it('should chain messages correctly with parentUuid', async () => {
|
|
chatRecordingService.recordUserMessage([{ text: 'First message' }]);
|
|
chatRecordingService.recordAssistantTurn({
|
|
model: 'gemini-pro',
|
|
message: [{ text: 'Response' }],
|
|
});
|
|
chatRecordingService.recordUserMessage([{ text: 'Second message' }]);
|
|
await chatRecordingService.flush();
|
|
|
|
const calls = vi.mocked(jsonl.writeLine).mock.calls;
|
|
const user1 = calls[0][1] as ChatRecord;
|
|
const assistant = calls[1][1] as ChatRecord;
|
|
const user2 = calls[2][1] as ChatRecord;
|
|
|
|
expect(user1.uuid).toBe('00000000-0000-0000-0000-000000000001');
|
|
expect(user1.parentUuid).toBeNull();
|
|
|
|
expect(assistant.uuid).toBe('00000000-0000-0000-0000-000000000002');
|
|
expect(assistant.parentUuid).toBe('00000000-0000-0000-0000-000000000001');
|
|
|
|
expect(user2.uuid).toBe('00000000-0000-0000-0000-000000000003');
|
|
expect(user2.parentUuid).toBe('00000000-0000-0000-0000-000000000002');
|
|
});
|
|
});
|
|
|
|
describe('recordAtCommand', () => {
|
|
it('should record @-command metadata as a system payload', async () => {
|
|
const userParts: Part[] = [{ text: 'Hello, world!' }];
|
|
const payload: AtCommandRecordPayload = {
|
|
filesRead: ['foo.txt'],
|
|
status: 'success',
|
|
message: 'Success',
|
|
userText: '@foo.txt',
|
|
};
|
|
|
|
chatRecordingService.recordUserMessage(userParts);
|
|
chatRecordingService.recordAtCommand(payload);
|
|
await chatRecordingService.flush();
|
|
|
|
expect(jsonl.writeLine).toHaveBeenCalledTimes(2);
|
|
const userRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[0][1] as ChatRecord;
|
|
const systemRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[1][1] as ChatRecord;
|
|
|
|
expect(userRecord.type).toBe('user');
|
|
expect(systemRecord.type).toBe('system');
|
|
expect(systemRecord.subtype).toBe('at_command');
|
|
expect(systemRecord.systemPayload).toEqual(payload);
|
|
expect(systemRecord.parentUuid).toBe(userRecord.uuid);
|
|
});
|
|
});
|
|
|
|
describe('recordAssistantTurn', () => {
|
|
it('should record assistant turn with content only', async () => {
|
|
const parts: Part[] = [{ text: 'Hello!' }];
|
|
chatRecordingService.recordAssistantTurn({
|
|
model: 'gemini-pro',
|
|
message: parts,
|
|
});
|
|
await chatRecordingService.flush();
|
|
|
|
expect(jsonl.writeLine).toHaveBeenCalledTimes(1);
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
|
|
|
expect(record.type).toBe('assistant');
|
|
// The service wraps parts in a Content object using createModelContent
|
|
expect(record.message).toEqual({ role: 'model', parts });
|
|
expect(record.model).toBe('gemini-pro');
|
|
expect(record.usageMetadata).toBeUndefined();
|
|
expect(record.toolCallResult).toBeUndefined();
|
|
});
|
|
|
|
it('should record assistant turn with all data', async () => {
|
|
const parts: Part[] = [
|
|
{ thought: true, text: 'Thinking...' },
|
|
{ text: 'Here is the result.' },
|
|
{ functionCall: { name: 'read_file', args: { path: '/test.txt' } } },
|
|
];
|
|
chatRecordingService.recordAssistantTurn({
|
|
model: 'gemini-pro',
|
|
message: parts,
|
|
tokens: {
|
|
promptTokenCount: 100,
|
|
candidatesTokenCount: 50,
|
|
cachedContentTokenCount: 10,
|
|
totalTokenCount: 160,
|
|
},
|
|
});
|
|
await chatRecordingService.flush();
|
|
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
|
|
|
// The service wraps parts in a Content object using createModelContent
|
|
expect(record.message).toEqual({ role: 'model', parts });
|
|
expect(record.model).toBe('gemini-pro');
|
|
expect(record.usageMetadata?.totalTokenCount).toBe(160);
|
|
});
|
|
|
|
it('should record assistant turn with only tokens', async () => {
|
|
chatRecordingService.recordAssistantTurn({
|
|
model: 'gemini-pro',
|
|
tokens: {
|
|
promptTokenCount: 10,
|
|
candidatesTokenCount: 20,
|
|
cachedContentTokenCount: 0,
|
|
totalTokenCount: 30,
|
|
},
|
|
});
|
|
await chatRecordingService.flush();
|
|
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
|
|
|
expect(record.message).toBeUndefined();
|
|
expect(record.usageMetadata?.totalTokenCount).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('recordToolResult', () => {
|
|
it('should record tool result with Parts', async () => {
|
|
// First record a user and assistant message to set up the chain
|
|
chatRecordingService.recordUserMessage([{ text: 'Hello' }]);
|
|
chatRecordingService.recordAssistantTurn({
|
|
model: 'gemini-pro',
|
|
message: [{ functionCall: { name: 'shell', args: { command: 'ls' } } }],
|
|
});
|
|
|
|
// Now record the tool result (Parts with functionResponse)
|
|
const toolResultParts: Part[] = [
|
|
{
|
|
functionResponse: {
|
|
id: 'call-1',
|
|
name: 'shell',
|
|
response: { output: 'file1.txt\nfile2.txt' },
|
|
},
|
|
},
|
|
];
|
|
chatRecordingService.recordToolResult(toolResultParts);
|
|
await chatRecordingService.flush();
|
|
|
|
expect(jsonl.writeLine).toHaveBeenCalledTimes(3);
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[2][1] as ChatRecord;
|
|
|
|
expect(record.type).toBe('tool_result');
|
|
// The service wraps parts in a Content object using createUserContent
|
|
expect(record.message).toEqual({ role: 'user', parts: toolResultParts });
|
|
});
|
|
|
|
it('should record tool result with toolCallResult metadata', async () => {
|
|
const toolResultParts: Part[] = [
|
|
{
|
|
functionResponse: {
|
|
id: 'call-1',
|
|
name: 'shell',
|
|
response: { output: 'result' },
|
|
},
|
|
},
|
|
];
|
|
const metadata = {
|
|
callId: 'call-1',
|
|
status: 'success',
|
|
responseParts: toolResultParts,
|
|
resultDisplay: undefined,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
|
|
chatRecordingService.recordToolResult(toolResultParts, metadata);
|
|
await chatRecordingService.flush();
|
|
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
|
|
|
expect(record.type).toBe('tool_result');
|
|
// The service wraps parts in a Content object using createUserContent
|
|
expect(record.message).toEqual({ role: 'user', parts: toolResultParts });
|
|
expect(record.toolCallResult).toBeDefined();
|
|
expect(record.toolCallResult?.callId).toBe('call-1');
|
|
});
|
|
|
|
it('should chain tool result correctly with parentUuid', async () => {
|
|
chatRecordingService.recordUserMessage([{ text: 'Hello' }]);
|
|
chatRecordingService.recordAssistantTurn({
|
|
model: 'gemini-pro',
|
|
message: [{ text: 'Using tool' }],
|
|
});
|
|
const toolResultParts: Part[] = [
|
|
{
|
|
functionResponse: {
|
|
id: 'call-1',
|
|
name: 'shell',
|
|
response: { output: 'done' },
|
|
},
|
|
},
|
|
];
|
|
chatRecordingService.recordToolResult(toolResultParts);
|
|
await chatRecordingService.flush();
|
|
|
|
const userRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[0][1] as ChatRecord;
|
|
const assistantRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[1][1] as ChatRecord;
|
|
const toolResultRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[2][1] as ChatRecord;
|
|
|
|
expect(userRecord.parentUuid).toBeNull();
|
|
expect(assistantRecord.parentUuid).toBe(userRecord.uuid);
|
|
expect(toolResultRecord.parentUuid).toBe(assistantRecord.uuid);
|
|
});
|
|
});
|
|
|
|
describe('recordSlashCommand', () => {
|
|
it('should record slash command with payload and subtype', async () => {
|
|
chatRecordingService.recordSlashCommand({
|
|
phase: 'invocation',
|
|
rawCommand: '/about',
|
|
});
|
|
await chatRecordingService.flush();
|
|
|
|
expect(jsonl.writeLine).toHaveBeenCalledTimes(1);
|
|
const record = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
|
|
|
|
expect(record.type).toBe('system');
|
|
expect(record.subtype).toBe('slash_command');
|
|
expect(record.systemPayload).toMatchObject({
|
|
phase: 'invocation',
|
|
rawCommand: '/about',
|
|
});
|
|
});
|
|
|
|
it('should chain slash command after prior records', async () => {
|
|
chatRecordingService.recordUserMessage([{ text: 'Hello' }]);
|
|
chatRecordingService.recordSlashCommand({
|
|
phase: 'result',
|
|
rawCommand: '/about',
|
|
});
|
|
await chatRecordingService.flush();
|
|
|
|
const userRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[0][1] as ChatRecord;
|
|
const slashRecord = vi.mocked(jsonl.writeLine).mock
|
|
.calls[1][1] as ChatRecord;
|
|
|
|
expect(userRecord.parentUuid).toBeNull();
|
|
expect(slashRecord.parentUuid).toBe(userRecord.uuid);
|
|
});
|
|
});
|
|
|
|
describe('flush', () => {
|
|
it('resolves immediately on a service with no enqueued writes', async () => {
|
|
// The writeChain starts as Promise.resolve(), so flush() on a fresh
|
|
// service should settle in a single microtask — important because
|
|
// Config.shutdown awaits flush on every exit path, even for sessions
|
|
// that never recorded anything.
|
|
await expect(chatRecordingService.flush()).resolves.toBeUndefined();
|
|
expect(jsonl.writeLine).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a failed write does not block subsequent records', async () => {
|
|
// Regression guard: the inner .catch swallows fs errors and keeps
|
|
// the chain alive so the next record's write still runs.
|
|
vi.mocked(jsonl.writeLine).mockRejectedValueOnce(
|
|
new Error('simulated EACCES'),
|
|
);
|
|
chatRecordingService.recordUserMessage([{ text: 'first' }]);
|
|
chatRecordingService.recordUserMessage([{ text: 'second' }]);
|
|
await chatRecordingService.flush();
|
|
|
|
expect(jsonl.writeLine).toHaveBeenCalledTimes(2);
|
|
const second = vi.mocked(jsonl.writeLine).mock.calls[1][1] as ChatRecord;
|
|
expect(
|
|
(second.message as { parts: Array<{ text: string }> }).parts[0].text,
|
|
).toBe('second');
|
|
});
|
|
});
|
|
|
|
// Note: Session management tests (listSessions, loadSession, deleteSession, etc.)
|
|
// have been moved to sessionService.test.ts
|
|
// Session resume integration tests should test via SessionService mock
|
|
});
|