mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 19:14:33 -07:00
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* @license
|
||
|
|
* Copyright 2025 Google LLC
|
||
|
|
* SPDX-License-Identifier: Apache-2.0
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
|
|
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||
|
|
import {
|
||
|
|
ListBackgroundProcessesTool,
|
||
|
|
ReadBackgroundOutputTool,
|
||
|
|
} from './shellBackgroundTools.js';
|
||
|
|
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||
|
|
|
||
|
|
describe('Background Tools', () => {
|
||
|
|
let listTool: ListBackgroundProcessesTool;
|
||
|
|
let readTool: ReadBackgroundOutputTool;
|
||
|
|
const bus = createMockMessageBus();
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.restoreAllMocks();
|
||
|
|
const mockContext = {
|
||
|
|
config: { getSessionId: () => 'default' },
|
||
|
|
} as unknown as AgentLoopContext;
|
||
|
|
listTool = new ListBackgroundProcessesTool(mockContext, bus);
|
||
|
|
readTool = new ReadBackgroundOutputTool(mockContext, bus);
|
||
|
|
|
||
|
|
// Clear history to avoid state leakage from previous runs
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.clear();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('list_background_processes should return empty message when no processes', async () => {
|
||
|
|
const invocation = listTool.build({});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
expect(result.llmContent).toBe('No background processes found.');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('list_background_processes should list processes after they are backgrounded', async () => {
|
||
|
|
const pid = 99999 + Math.floor(Math.random() * 1000);
|
||
|
|
|
||
|
|
// Simulate adding to history
|
||
|
|
// Since background method relies on activePtys/activeChildProcesses,
|
||
|
|
// we should probably mock those or just call the history add logic if we can't easily trigger background.
|
||
|
|
// Wait, ShellExecutionService.background() reads from activePtys/activeChildProcesses!
|
||
|
|
// So we MUST populate them or mock them!
|
||
|
|
// Let's use vi.spyOn or populate the map if accessible?
|
||
|
|
// activePtys is private static.
|
||
|
|
// Mock active process map to provide sessionId
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).activeChildProcesses.set(pid, {
|
||
|
|
process: {},
|
||
|
|
state: { output: '' },
|
||
|
|
command: 'unknown command',
|
||
|
|
sessionId: 'default',
|
||
|
|
});
|
||
|
|
|
||
|
|
ShellExecutionService.background(pid, 'default', 'unknown command');
|
||
|
|
|
||
|
|
const invocation = listTool.build({});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.llmContent).toContain(
|
||
|
|
`[PID ${pid}] RUNNING: \`unknown command\``,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('list_background_processes should show exited status with code or signal', async () => {
|
||
|
|
const pid = 98989;
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'exited command',
|
||
|
|
status: 'exited',
|
||
|
|
exitCode: 1,
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
const invocation = listTool.build({});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.llmContent).toContain(
|
||
|
|
`- [PID ${pid}] EXITED: \`exited command\` (Exit Code: 1)`,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should return error if log file does not exist', async () => {
|
||
|
|
const pid = 12345 + Math.floor(Math.random() * 1000);
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'unknown command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
expect(result.error).toBeDefined();
|
||
|
|
expect(result.llmContent).toContain('No output log found');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should read content from log file', async () => {
|
||
|
|
const pid = 88888 + Math.floor(Math.random() * 1000);
|
||
|
|
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||
|
|
const logDir = ShellExecutionService.getLogDir();
|
||
|
|
|
||
|
|
// Ensure dir exists
|
||
|
|
// Add to history to pass access check
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'unknown command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Ensure dir exists
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
|
||
|
|
// Write mock log
|
||
|
|
fs.writeFileSync(logPath, 'line 1\nline 2\nline 3\n');
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid, lines: 2 });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.llmContent).toContain('Showing last 2 of 3 lines');
|
||
|
|
expect(result.llmContent).toContain('line 2\nline 3');
|
||
|
|
|
||
|
|
// Cleanup
|
||
|
|
fs.unlinkSync(logPath);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should return Access Denied for processes in other sessions', async () => {
|
||
|
|
const pid = 77777;
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'other command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'other-session',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } }; // Asking for PID from another session
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.error).toBeDefined();
|
||
|
|
expect(result.llmContent).toContain('Access denied');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should handle empty log files', async () => {
|
||
|
|
const pid = 66666;
|
||
|
|
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||
|
|
const logDir = ShellExecutionService.getLogDir();
|
||
|
|
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'empty output command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
fs.writeFileSync(logPath, '');
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.llmContent).toContain('Log is empty');
|
||
|
|
|
||
|
|
fs.unlinkSync(logPath);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should handle direct tool errors gracefully', async () => {
|
||
|
|
const pid = 55555;
|
||
|
|
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||
|
|
const logDir = ShellExecutionService.getLogDir();
|
||
|
|
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'fail command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
fs.writeFileSync(logPath, 'dummy content');
|
||
|
|
|
||
|
|
// Mock open to throw to hit catch block
|
||
|
|
vi.spyOn(fs.promises, 'open').mockRejectedValue(
|
||
|
|
new Error('Simulated read error'),
|
||
|
|
);
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.error).toBeDefined();
|
||
|
|
expect(result.llmContent).toContain('Error reading background log');
|
||
|
|
|
||
|
|
fs.unlinkSync(logPath);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should deny access if log is a symbolic link', async () => {
|
||
|
|
const pid = 66666;
|
||
|
|
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||
|
|
const logDir = ShellExecutionService.getLogDir();
|
||
|
|
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'symlink command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
fs.writeFileSync(logPath, 'dummy content');
|
||
|
|
|
||
|
|
// Mock open to throw ELOOP error for symbolic link
|
||
|
|
const mockError = new Error('ELOOP: too many symbolic links encountered');
|
||
|
|
Object.assign(mockError, { code: 'ELOOP' });
|
||
|
|
vi.spyOn(fs.promises, 'open').mockRejectedValue(mockError);
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.llmContent).toContain('Access is denied');
|
||
|
|
expect(result.error?.message).toContain('Symbolic link detected');
|
||
|
|
|
||
|
|
fs.unlinkSync(logPath);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('read_background_output should tail reading trailing logic correctly', async () => {
|
||
|
|
const pid = 77777;
|
||
|
|
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||
|
|
const logDir = ShellExecutionService.getLogDir();
|
||
|
|
|
||
|
|
const history = new Map();
|
||
|
|
history.set(pid, {
|
||
|
|
command: 'tail command',
|
||
|
|
status: 'running',
|
||
|
|
startTime: Date.now(),
|
||
|
|
});
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||
|
|
'default',
|
||
|
|
history,
|
||
|
|
);
|
||
|
|
|
||
|
|
fs.mkdirSync(logDir, { recursive: true });
|
||
|
|
// Write 5 lines
|
||
|
|
fs.writeFileSync(logPath, 'line1\nline2\nline3\nline4\nline5');
|
||
|
|
|
||
|
|
const invocation = readTool.build({ pid, lines: 2 });
|
||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
|
|
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||
|
|
const result = await invocation.execute(new AbortController().signal);
|
||
|
|
|
||
|
|
expect(result.llmContent).toContain('line4\nline5');
|
||
|
|
expect(result.llmContent).not.toContain('line1');
|
||
|
|
|
||
|
|
fs.unlinkSync(logPath);
|
||
|
|
});
|
||
|
|
});
|