Add chat recording toggle (CLI + settings) and disable recording in tests

This commit is contained in:
tanzhenxin
2025-12-15 13:48:38 +08:00
parent 4cbb57a793
commit b9a2cc7bdf
13 changed files with 128 additions and 59 deletions

View File

@@ -13,8 +13,6 @@ import { TestRig } from './test-helper.js';
const REQUEST_TIMEOUT_MS = 60_000; const REQUEST_TIMEOUT_MS = 60_000;
const INITIAL_PROMPT = 'Create a quick note (smoke test).'; const INITIAL_PROMPT = 'Create a quick note (smoke test).';
const RESUME_PROMPT = 'Continue the note after reload.';
const LIST_SIZE = 5;
const IS_SANDBOX = const IS_SANDBOX =
process.env['GEMINI_SANDBOX'] && process.env['GEMINI_SANDBOX'] &&
process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false'; process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false';
@@ -97,10 +95,14 @@ function setupAcpTest(
const permissionHandler = const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' })); options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], { const agent = spawn(
cwd: rig.testDir!, 'node',
stdio: ['pipe', 'pipe', 'pipe'], [rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
}); {
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
},
);
agent.stderr?.on('data', (chunk) => { agent.stderr?.on('data', (chunk) => {
stderr.push(chunk.toString()); stderr.push(chunk.toString());
@@ -264,11 +266,11 @@ function setupAcpTest(
} }
(IS_SANDBOX ? describe.skip : describe)('acp integration', () => { (IS_SANDBOX ? describe.skip : describe)('acp integration', () => {
it('creates, lists, loads, and resumes a session', async () => { it('basic smoke test', async () => {
const rig = new TestRig(); const rig = new TestRig();
rig.setup('acp load session'); rig.setup('acp load session');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig); const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
try { try {
const initResult = await sendRequest('initialize', { const initResult = await sendRequest('initialize', {
@@ -294,34 +296,6 @@ function setupAcpTest(
prompt: [{ type: 'text', text: INITIAL_PROMPT }], prompt: [{ type: 'text', text: INITIAL_PROMPT }],
}); });
expect(promptResult).toBeDefined(); expect(promptResult).toBeDefined();
await delay(500);
const listResult = (await sendRequest('session/list', {
cwd: rig.testDir!,
size: LIST_SIZE,
})) as { items?: Array<{ sessionId: string }> };
expect(Array.isArray(listResult.items)).toBe(true);
expect(listResult.items?.length ?? 0).toBeGreaterThan(0);
const sessionToLoad = listResult.items![0].sessionId;
await sendRequest('session/load', {
cwd: rig.testDir!,
sessionId: sessionToLoad,
mcpServers: [],
});
const resumeResult = await sendRequest('session/prompt', {
sessionId: sessionToLoad,
prompt: [{ type: 'text', text: RESUME_PROMPT }],
});
expect(resumeResult).toBeDefined();
const sessionsWithUpdates = sessionUpdates
.map((update) => update.sessionId)
.filter(Boolean);
expect(sessionsWithUpdates).toContain(sessionToLoad);
} catch (e) { } catch (e) {
if (stderr.length) { if (stderr.length) {
console.error('Agent stderr:', stderr.join('')); console.error('Agent stderr:', stderr.join(''));

View File

@@ -438,12 +438,8 @@ describe('Configuration Options (E2E)', () => {
} }
}); });
// Skip in containerized sandbox environments - qwen-oauth requires user interaction // Skip - qwen-oauth requires user interaction which is not possible in CI environments
// which is not possible in Docker/Podman CI environments it.skip('should accept authType: qwen-oauth', async () => {
it.skipIf(
process.env['SANDBOX'] === 'sandbox:docker' ||
process.env['SANDBOX'] === 'sandbox:podman',
)('should accept authType: qwen-oauth', async () => {
// Note: qwen-oauth requires credentials in ~/.qwen and user interaction // Note: qwen-oauth requires credentials in ~/.qwen and user interaction
// Without credentials, the auth process will timeout waiting for user // Without credentials, the auth process will timeout waiting for user
// This test verifies the option is accepted and passed correctly to CLI // This test verifies the option is accepted and passed correctly to CLI

View File

@@ -73,15 +73,26 @@ export class SDKTestHelper {
await mkdir(this.testDir, { recursive: true }); await mkdir(this.testDir, { recursive: true });
// Optionally create .qwen/settings.json for CLI configuration // Optionally create .qwen/settings.json for CLI configuration
if (options.createQwenConfig) { if (options.createQwenConfig !== false) {
const qwenDir = join(this.testDir, '.qwen'); const qwenDir = join(this.testDir, '.qwen');
await mkdir(qwenDir, { recursive: true }); await mkdir(qwenDir, { recursive: true });
const optionsSettings = options.settings ?? {};
const generalSettings =
typeof optionsSettings['general'] === 'object' &&
optionsSettings['general'] !== null
? (optionsSettings['general'] as Record<string, unknown>)
: {};
const settings = { const settings = {
...optionsSettings,
telemetry: { telemetry: {
enabled: false, // SDK tests don't need telemetry enabled: false, // SDK tests don't need telemetry
}, },
...options.settings, general: {
...generalSettings,
chatRecording: false, // SDK tests don't need chat recording
},
}; };
await writeFile( await writeFile(

View File

@@ -31,9 +31,7 @@ describe('Tool Control Parameters (E2E)', () => {
beforeEach(async () => { beforeEach(async () => {
helper = new SDKTestHelper(); helper = new SDKTestHelper();
testDir = await helper.setup('tool-control', { testDir = await helper.setup('tool-control');
createQwenConfig: false,
});
}); });
afterEach(async () => { afterEach(async () => {

View File

@@ -218,8 +218,8 @@ export class TestRig {
process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true'; process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true';
const command = isNpmReleaseTest ? 'qwen' : 'node'; const command = isNpmReleaseTest ? 'qwen' : 'node';
const initialArgs = isNpmReleaseTest const initialArgs = isNpmReleaseTest
? extraInitialArgs ? ['--no-chat-recording', ...extraInitialArgs]
: [this.bundlePath, ...extraInitialArgs]; : [this.bundlePath, '--no-chat-recording', ...extraInitialArgs];
return { command, initialArgs }; return { command, initialArgs };
} }

View File

@@ -130,6 +130,11 @@ export interface CliArgs {
inputFormat?: string | undefined; inputFormat?: string | undefined;
outputFormat: string | undefined; outputFormat: string | undefined;
includePartialMessages?: boolean; includePartialMessages?: boolean;
/**
* If chat recording is disabled, the chat history would not be recorded,
* so --continue and --resume would not take effect.
*/
chatRecording: boolean | undefined;
/** Resume the most recent session for the current project */ /** Resume the most recent session for the current project */
continue: boolean | undefined; continue: boolean | undefined;
/** Resume a specific session by its ID */ /** Resume a specific session by its ID */
@@ -233,6 +238,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'proxy', 'proxy',
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.', 'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
) )
.option('chat-recording', {
type: 'boolean',
description:
'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.',
})
.command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) => .command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) =>
yargsInstance yargsInstance
.positional('query', { .positional('query', {
@@ -996,6 +1006,11 @@ export async function loadCliConfig(
format: outputSettingsFormat, format: outputSettingsFormat,
}, },
channel: argv.channel, channel: argv.channel,
// Precedence: explicit CLI flag > settings file > default(true).
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
}); });
} }

View File

@@ -204,6 +204,16 @@ const SETTINGS_SCHEMA = {
'Play terminal bell sound when response completes or needs approval.', 'Play terminal bell sound when response completes or needs approval.',
showInDialog: true, showInDialog: true,
}, },
chatRecording: {
type: 'boolean',
label: 'Chat Recording',
category: 'General',
requiresRestart: true,
default: true,
description:
'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.',
showInDialog: false,
},
}, },
}, },
output: { output: {

View File

@@ -486,6 +486,7 @@ describe('gemini.tsx main function kitty protocol', () => {
authType: undefined, authType: undefined,
maxSessionTurns: undefined, maxSessionTurns: undefined,
channel: undefined, channel: undefined,
chatRecording: undefined,
}); });
await main(); await main();

View File

@@ -15,6 +15,7 @@ import type {
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
// Mock child components // Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({ vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -22,7 +23,9 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
})); }));
describe('<HistoryItemDisplay />', () => { describe('<HistoryItemDisplay />', () => {
const mockConfig = {} as unknown as Config; const mockConfig = {
getChatRecordingService: () => undefined,
} as unknown as Config;
const baseItem = { const baseItem = {
id: 1, id: 1,
timestamp: 12345, timestamp: 12345,
@@ -133,9 +136,11 @@ describe('<HistoryItemDisplay />', () => {
duration: '1s', duration: '1s',
}; };
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<SessionStatsProvider> <ConfigContext.Provider value={mockConfig as never}>
<HistoryItemDisplay {...baseItem} item={item} /> <SessionStatsProvider>
</SessionStatsProvider>, <HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>
</ConfigContext.Provider>,
); );
expect(lastFrame()).toContain('Agent powering down. Goodbye!'); expect(lastFrame()).toContain('Agent powering down. Goodbye!');
}); });

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js'; import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js'; import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => { vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>(); const actual = await importOriginal<typeof SessionContext>();
@@ -24,6 +25,7 @@ const renderWithMockedStats = (
metrics: SessionMetrics, metrics: SessionMetrics,
sessionId: string = 'test-session-id-12345', sessionId: string = 'test-session-id-12345',
promptCount: number = 5, promptCount: number = 5,
chatRecordingEnabled: boolean = true,
) => { ) => {
useSessionStatsMock.mockReturnValue({ useSessionStatsMock.mockReturnValue({
stats: { stats: {
@@ -38,7 +40,17 @@ const renderWithMockedStats = (
startNewPrompt: vi.fn(), startNewPrompt: vi.fn(),
}); });
return render(<SessionSummaryDisplay duration="1h 23m 45s" />); const mockConfig = {
getChatRecordingService: vi.fn(() =>
chatRecordingEnabled ? ({} as never) : undefined,
),
};
return render(
<ConfigContext.Provider value={mockConfig as never}>
<SessionSummaryDisplay duration="1h 23m 45s" />
</ConfigContext.Provider>,
);
}; };
describe('<SessionSummaryDisplay />', () => { describe('<SessionSummaryDisplay />', () => {
@@ -109,4 +121,34 @@ describe('<SessionSummaryDisplay />', () => {
expect(output).not.toContain('To continue this session, run'); expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume'); expect(output).not.toContain('qwen --resume');
}); });
it('does not show resume message when chat recording is disabled', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
5,
false,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
}); });

View File

@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { StatsDisplay } from './StatsDisplay.js'; import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
@@ -18,10 +19,13 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration, duration,
}) => { }) => {
const config = useConfig();
const { stats } = useSessionStats(); const { stats } = useSessionStats();
// Only show the resume message if there were messages in the session // Only show the resume message if there were messages in the session AND
// chat recording is enabled (otherwise there is nothing to resume).
const hasMessages = stats.promptCount > 0; const hasMessages = stats.promptCount > 0;
const canResume = !!config.getChatRecordingService();
return ( return (
<> <>
@@ -29,7 +33,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
title={t('Agent powering down. Goodbye!')} title={t('Agent powering down. Goodbye!')}
duration={duration} duration={duration}
/> />
{hasMessages && ( {hasMessages && canResume && (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{t('To continue this session, run')}{' '} {t('To continue this session, run')}{' '}

View File

@@ -318,6 +318,7 @@ export interface ConfigParameters {
generationConfig?: Partial<ContentGeneratorConfig>; generationConfig?: Partial<ContentGeneratorConfig>;
cliVersion?: string; cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean; loadMemoryFromIncludeDirectories?: boolean;
chatRecording?: boolean;
// Web search providers // Web search providers
webSearch?: { webSearch?: {
provider: Array<{ provider: Array<{
@@ -457,6 +458,7 @@ export class Config {
| undefined; | undefined;
private readonly cliVersion?: string; private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false; private readonly experimentalZedIntegration: boolean = false;
private readonly chatRecordingEnabled: boolean;
private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly webSearch?: { private readonly webSearch?: {
provider: Array<{ provider: Array<{
@@ -572,6 +574,8 @@ export class Config {
._generationConfig as ContentGeneratorConfig; ._generationConfig as ContentGeneratorConfig;
this.cliVersion = params.cliVersion; this.cliVersion = params.cliVersion;
this.chatRecordingEnabled = params.chatRecording ?? true;
this.loadMemoryFromIncludeDirectories = this.loadMemoryFromIncludeDirectories =
params.loadMemoryFromIncludeDirectories ?? false; params.loadMemoryFromIncludeDirectories ?? false;
this.chatCompression = params.chatCompression; this.chatCompression = params.chatCompression;
@@ -618,7 +622,9 @@ export class Config {
setGlobalDispatcher(new ProxyAgent(this.getProxy() as string)); setGlobalDispatcher(new ProxyAgent(this.getProxy() as string));
} }
this.geminiClient = new GeminiClient(this); this.geminiClient = new GeminiClient(this);
this.chatRecordingService = new ChatRecordingService(this); this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;
} }
/** /**
@@ -738,7 +744,9 @@ export class Config {
startNewSession(sessionId?: string): string { startNewSession(sessionId?: string): string {
this.sessionId = sessionId ?? randomUUID(); this.sessionId = sessionId ?? randomUUID();
this.sessionData = undefined; this.sessionData = undefined;
this.chatRecordingService = new ChatRecordingService(this); this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;
if (this.initialized) { if (this.initialized) {
logStartSession(this, new StartSessionEvent(this)); logStartSession(this, new StartSessionEvent(this));
} }
@@ -1267,7 +1275,10 @@ export class Config {
/** /**
* Returns the chat recording service. * Returns the chat recording service.
*/ */
getChatRecordingService(): ChatRecordingService { getChatRecordingService(): ChatRecordingService | undefined {
if (!this.chatRecordingEnabled) {
return undefined;
}
if (!this.chatRecordingService) { if (!this.chatRecordingService) {
this.chatRecordingService = new ChatRecordingService(this); this.chatRecordingService = new ChatRecordingService(this);
} }

View File

@@ -69,6 +69,8 @@ async function createMockConfig(
targetDir: '.', targetDir: '.',
debugMode: false, debugMode: false,
cwd: process.cwd(), cwd: process.cwd(),
// Avoid writing any chat recording records from tests (e.g. via tool-call telemetry).
chatRecording: false,
}; };
const config = new Config(configParams); const config = new Config(configParams);
await config.initialize(); await config.initialize();