Compare commits

...

3 Commits

7 changed files with 236 additions and 7 deletions
@@ -8,7 +8,16 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('Interactive Mode', () => {
// Skip on macOS: every interactive test in this file is chronically flaky
// because the captured pty buffer contains the CLI's startup escape
// sequences (`q4;?m...true color warning`) instead of the streamed output,
// causing `expectText(...)` to time out. Reproducible across unrelated
// runs on `main` (24740161950, 24739323404) and on consecutive merge-queue
// gates for #25753 (24743605639, 24747624513) — different tests in the
// same describe fail on different runs. Not specific to any model.
const skipOnDarwin = process.platform === 'darwin';
describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
let rig: TestRig;
beforeEach(() => {
+3 -1
View File
@@ -134,7 +134,9 @@ describe('file-system', () => {
).toBeTruthy();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('hello');
// Trim to tolerate models that idiomatically append a trailing newline.
// This test is about path-with-spaces handling, not whitespace fidelity.
expect(newFileContent.trim()).toBe('hello');
});
it('should perform a read-then-write sequence', async () => {
+9 -2
View File
@@ -81,7 +81,10 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan.md in the plans directory.',
args:
'Create a file called plan.md in the plans directory with the ' +
'content "# Plan". Treat this as a Directive and write the file ' +
'immediately without proposing strategy or asking for confirmation.',
});
const toolLogs = rig.readToolLogs();
@@ -194,7 +197,11 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan-no-session.md in the plans directory.',
args:
'Create a file called plan-no-session.md in the plans directory ' +
'with the content "# Plan". Treat this as a Directive and write ' +
'the file immediately without proposing strategy or asking for ' +
'confirmation.',
});
const toolLogs = rig.readToolLogs();
@@ -42,6 +42,7 @@ import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { noteCommand } from '../ui/commands/noteCommand.js';
import { oncallCommand } from '../ui/commands/oncallCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { planCommand } from '../ui/commands/planCommand.js';
@@ -184,7 +185,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [mcpCommand]),
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
...(this.config?.getFolderTrust()
? [permissionsCommand, noteCommand]
: []),
...(this.config?.isPlanEnabled() ? [planCommand] : []),
policiesCommand,
privacyCommand,
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { noteCommand } from './noteCommand.js';
import * as fsPromises from 'node:fs/promises';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as path from 'node:path';
import type { MessageActionReturn } from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
describe('noteCommand', () => {
const mockContext = createMockCommandContext();
const workspaceRoot = process.cwd();
const notesFile = path.join(workspaceRoot, 'notes.md');
beforeEach(() => {
vi.clearAllMocks();
});
describe('append action', () => {
it('should append a note to notes.md when args are provided', async () => {
const noteText = 'This is a test note';
vi.mocked(fsPromises.appendFile).mockResolvedValue(undefined);
const result = (await noteCommand.action!(
mockContext,
noteText,
)) as MessageActionReturn;
expect(fsPromises.appendFile).toHaveBeenCalledWith(
notesFile,
expect.stringContaining(noteText),
);
expect(fsPromises.appendFile).toHaveBeenCalledWith(
notesFile,
expect.stringContaining('## '),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Note saved to ${notesFile}`,
});
});
it('should return a usage message when no args are provided', async () => {
const result = (await noteCommand.action!(
mockContext,
' ',
)) as MessageActionReturn;
expect(fsPromises.appendFile).not.toHaveBeenCalled();
expect(result.content).toContain('Please provide a note to save');
});
it('should return an error message when appendFile fails', async () => {
vi.mocked(fsPromises.appendFile).mockRejectedValue(new Error('FS Error'));
const result = (await noteCommand.action!(
mockContext,
'some note',
)) as MessageActionReturn;
expect(result.content).toContain('Failed to save note: FS Error');
});
});
describe('view subcommand', () => {
const viewSubcommand = noteCommand.subCommands?.find(
(s) => s.name === 'view',
);
it('should read and display notes from notes.md', async () => {
const notesContent = '## 4/21/2026\n\nTest note content';
vi.mocked(fsPromises.readFile).mockResolvedValue(notesContent);
const result = (await viewSubcommand!.action!(
mockContext,
'',
)) as MessageActionReturn;
expect(fsPromises.readFile).toHaveBeenCalledWith(notesFile, 'utf8');
expect(result.content).toContain('### Current Notes');
expect(result.content).toContain(notesContent);
});
it('should return "No notes found" if notes.md does not exist', async () => {
const error = new Error('Not found');
Object.assign(error, { code: 'ENOENT' });
vi.mocked(fsPromises.readFile).mockRejectedValue(error);
const result = (await viewSubcommand!.action!(
mockContext,
'',
)) as MessageActionReturn;
expect(result.content).toContain('No notes found in this workspace.');
});
it('should return an error message when readFile fails with other error', async () => {
vi.mocked(fsPromises.readFile).mockRejectedValue(new Error('Read Error'));
const result = (await viewSubcommand!.action!(
mockContext,
'',
)) as MessageActionReturn;
expect(result.content).toContain('Failed to read notes: Read Error');
});
});
});
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import * as fsPromises from 'node:fs/promises';
import * as path from 'node:path';
export const noteCommand: SlashCommand = {
name: 'note',
description: 'Manage workspace notes (append or view)',
kind: CommandKind.BUILT_IN,
autoExecute: true,
subCommands: [
{
name: 'view',
description: 'View the current workspace notes',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async () => {
const workspaceRoot = process.cwd();
const notesFile = path.join(workspaceRoot, 'notes.md');
try {
const content = await fsPromises.readFile(notesFile, 'utf8');
return {
type: 'message',
messageType: 'info',
content: `### Current Notes\n\n${content}`,
};
} catch (err: unknown) {
if (
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
) {
return {
type: 'message',
messageType: 'info',
content: 'No notes found in this workspace.',
};
}
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Failed to read notes: ${errorMessage}`,
};
}
},
},
],
action: async (context, args) => {
if (!args.trim()) {
return {
type: 'message',
messageType: 'info',
content:
'Please provide a note to save or use `/note view` to see your notes. Example: `/note This is a useful thought.`',
};
}
const workspaceRoot = process.cwd();
const notesFile = path.join(workspaceRoot, 'notes.md');
const timestamp = new Date().toLocaleString();
const noteEntry = `\n## ${timestamp}\n\n${args.trim()}\n`;
try {
await fsPromises.appendFile(notesFile, noteEntry);
return {
type: 'message',
messageType: 'info',
content: `Note saved to ${notesFile}`,
};
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Failed to save note: ${errorMessage}`,
};
}
},
};
+5 -2
View File
@@ -11,7 +11,10 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { PREVIEW_GEMINI_MODEL, GEMINI_DIR } from '@google/gemini-cli-core';
import {
PREVIEW_GEMINI_FLASH_MODEL,
GEMINI_DIR,
} from '@google/gemini-cli-core';
export { GEMINI_DIR };
import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
@@ -475,7 +478,7 @@ export class TestRig {
...(env['GEMINI_TEST_TYPE'] === 'integration'
? {
model: {
name: PREVIEW_GEMINI_MODEL,
name: PREVIEW_GEMINI_FLASH_MODEL,
},
}
: {}),