qwen-code/packages/core/src/services/chatRecordingService.test.ts
wenshao d6485964c3 test(core): cache reset hooks + regression-guards from audit
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.
2026-04-24 14:40:18 +08:00

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
});