Compare commits

...

30 Commits

Author SHA1 Message Date
Spencer 0203ab0a1c Merge branch 'main' into memory_usage3 2026-04-16 13:02:06 -04:00
Spencer cdf5c265d8 Merge branch 'main' into memory_usage3 2026-04-15 16:15:59 -04:00
Spencer d0272a6436 Merge branch 'main' into memory_usage3 2026-04-15 11:26:52 -04:00
Spencer d14205a4ce Merge branch 'main' into memory_usage3 2026-04-14 16:31:05 -04:00
Spencer 20ca623cb8 Merge branch 'main' into memory_usage3 2026-04-14 13:31:08 -04:00
Spencer 7ea1654706 test: use rig.log instead of console.error in file stream tests 2026-04-13 22:07:40 +00:00
Spencer 4cde103712 Merge branch 'main' into memory_usage3 2026-04-13 17:57:12 -04:00
Spencer ee635bb0e9 fix(core): remove unused getPathIdentity import in shell.ts 2026-04-13 21:29:52 +00:00
Spencer 7181242f69 Merge branch 'main' into memory_usage3 2026-04-13 16:50:07 -04:00
Spencer 44abbdf56e test(cli): fix duplicate getSessionId in config mock 2026-04-13 20:25:20 +00:00
Spencer 152806379c test(cli): fix flaky InputPrompt SVGs failing in test:ci suite due to styling leaks 2026-04-13 20:25:20 +00:00
Spencer 03efe65dd5 test(cli): update InputPrompt svg snapshots 2026-04-13 20:25:20 +00:00
Spencer 9e3a48a3b6 test(cli): fix flaky InputPrompt snapshot test caused by polling inside waitFor 2026-04-13 20:25:20 +00:00
Spencer e064cfe043 fix(core): ensure childProcessFallback also streams file_data unconditionally without truncation 2026-04-13 20:25:20 +00:00
Spencer 63a6211fe0 fix(core): ensure binary shell output files are still written to disk for 20MB files, and wait for stream close 2026-04-13 20:25:20 +00:00
Spencer c2a17ae257 test: revert to scanning tmpdir because readToolLogs does not expose tool result metadata 2026-04-13 20:25:20 +00:00
Spencer c1297436b9 fix(core): ensure file stream output dir exists and test uses explicit tool output path 2026-04-13 20:25:19 +00:00
Spencer 3d1c2e849c test: fix tests failing due to close event changes in childProcessFallback and add missing config mocks 2026-04-13 20:25:19 +00:00
Spencer cfac19e772 fix: resolve childProcessFallback on close instead of exit to prevent stdout truncation, and fix recursive file search in tests 2026-04-13 20:25:19 +00:00
Spencer ad28dc83c3 test: fix failing tests in core due to shell stream logic changes 2026-04-13 20:25:19 +00:00
Spencer bcd0acae4f fix: preserve fullOutputFilePath when content is summarized and flush pending lines on exit 2026-04-13 20:25:19 +00:00
Spencer 1755678cf9 fix: properly serialize initial AnsiOutput for background processes 2026-04-13 20:25:19 +00:00
Spencer 886025f6b9 chore: finish truncation and stream logging logic 2026-04-13 20:25:19 +00:00
Spencer 7cdfaaa6bd chore: fix truncation logic and test duplications 2026-04-13 20:25:19 +00:00
A.K.M. Adib ea4dd5ae35 lint passes and failing test passes 2026-04-13 20:25:19 +00:00
A.K.M. Adib 8df9a80cc4 bring new functiontruncateOutputIfNeeded without changes to current branch in there 2026-04-13 20:25:19 +00:00
A.K.M. Adib e3baad48c8 tests pass 2026-04-13 20:25:18 +00:00
A.K.M. Adib 0ca2e4e2ae fix build 2026-04-13 20:25:18 +00:00
jacob314 30b4dcecbc Code review fix. 2026-04-13 20:25:18 +00:00
jacob314 954835123f Checkpoint of shell optimization
fix(cli): Write shell command output to a file and limit memory buffered in UI

Fixes.

Checkpoint.

fix(core, cli): await outputStream.end() to prevent race conditions

This commit fixes a critical race condition where
was called synchronously without being awaited. This led to potential file
truncation or EBUSY errors on Windows when attempting to manipulate the file
immediately after the  call.

Additionally, this change removes fixed wait times (`setTimeout`) that
were previously used in test files as a band-aid.

fix(core): stream processed xterm output to file to remove spurious escape codes

test(core): update shell regression tests to use file_data events
2026-04-13 20:25:18 +00:00
19 changed files with 1319 additions and 208 deletions
@@ -0,0 +1,275 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('run_shell_command streaming to file regression', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should stream large outputs to a file and verify full content presence', async () => {
await rig.setup(
'should stream large outputs to a file and verify full content presence',
{
settings: { tools: { core: ['run_shell_command'] } },
},
);
const numLines = 20000;
const testFileName = 'large_output_test.txt';
const testFilePath = path.join(rig.testDir!, testFileName);
// Create a ~20MB file with unique content at start and end
const startMarker = 'START_OF_FILE_MARKER';
const endMarker = 'END_OF_FILE_MARKER';
const stream = fs.createWriteStream(testFilePath);
stream.write(startMarker + '\n');
for (let i = 0; i < numLines; i++) {
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
}
stream.write(endMarker + '\n');
await new Promise((resolve) => stream.end(resolve));
const fileSize = fs.statSync(testFilePath).size;
expect(fileSize).toBeGreaterThan(20000000);
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
} else if (file.isFile() && file.name.endsWith('.log')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const p of files) {
try {
const stat = fs.statSync(p);
if (stat.size >= 20000000) {
savedFilePath = p;
break;
}
} catch {
// ignore
}
}
if (!savedFilePath) {
const fileStats = files.map((p) => {
try {
return { p, size: fs.statSync(p).size };
} catch {
return { p, size: 'error' };
}
});
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
}
}
expect(
savedFilePath,
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
).toBeTruthy();
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
expect(savedContent).toContain(startMarker);
expect(savedContent).toContain(endMarker);
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
fs.unlinkSync(savedFilePath);
}, 120000);
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
await rig.setup(
'should stream very large (50MB) outputs to a file and verify full content presence',
{
settings: { tools: { core: ['run_shell_command'] } },
},
);
const numLines = 1000000;
const testFileName = 'very_large_output_test.txt';
const testFilePath = path.join(rig.testDir!, testFileName);
// Create a ~50MB file with unique content at start and end
const startMarker = 'START_OF_FILE_MARKER';
const endMarker = 'END_OF_FILE_MARKER';
const stream = fs.createWriteStream(testFilePath);
stream.write(startMarker + '\n');
for (let i = 0; i < numLines; i++) {
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
}
stream.write(endMarker + '\n');
await new Promise((resolve) => stream.end(resolve));
const fileSize = fs.statSync(testFilePath).size;
expect(fileSize).toBeGreaterThan(45000000);
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
} else if (file.isFile() && file.name.endsWith('.log')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const p of files) {
try {
const stat = fs.statSync(p);
if (stat.size >= 20000000) {
savedFilePath = p;
break;
}
} catch {
// ignore
}
}
if (!savedFilePath) {
const fileStats = files.map((p) => {
try {
return { p, size: fs.statSync(p).size };
} catch {
return { p, size: 'error' };
}
});
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
}
}
expect(
savedFilePath,
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
).toBeTruthy();
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
expect(savedContent).toContain(startMarker);
expect(savedContent).toContain(endMarker);
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
fs.unlinkSync(savedFilePath);
}, 120000);
it('should produce clean output resolving carriage returns and backspaces', async () => {
await rig.setup(
'should produce clean output resolving carriage returns and backspaces',
{
settings: {
tools: { core: ['run_shell_command'] },
},
},
);
const script = `
import sys
import time
# Fill buffer to force file streaming/truncation
# 45000 chars to be safe (default threshold is 40000)
print('A' * 45000)
sys.stdout.flush()
# Test sequence
print('XXXXX', end='', flush=True)
time.sleep(0.5)
print('\\rYYYYY', end='', flush=True)
time.sleep(0.5)
print('\\nNext Line', end='', flush=True)
`;
const scriptPath = path.join(rig.testDir!, 'test_script.py');
fs.writeFileSync(scriptPath, script);
const prompt = `run_shell_command python3 "${scriptPath}"`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
if (files.length > 0) {
savedFilePath = files[0];
}
}
expect(savedFilePath, 'Output file should exist').toBeTruthy();
const content = fs.readFileSync(savedFilePath, 'utf8');
// Verify it contains the large chunk
expect(content).toContain('AAAA');
// Verify cleanup logic:
// 1. The final text "YYYYY" should be present.
expect(content).toContain('YYYYY');
// 2. The next line should be present.
expect(content).toContain('Next Line');
// 3. Verify overwrite happened.
// In raw output, we would have "XXXXX...YYYYY".
// In processed output, "YYYYY" overwrites "XXXXX".
// We confirm that escape codes are stripped (processed text).
// 4. Check for ANSI escape codes (like \\x1b) just in case
expect(content).not.toContain('\x1b');
}, 60000);
});
+23 -20
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import React from 'react';
import { Box, Text } from 'ink';
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
@@ -53,23 +53,26 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
);
};
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
export const AnsiLineText = React.memo<{ line: AnsiLine }>(
({ line }: { line: AnsiLine }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
),
);
AnsiLineText.displayName = 'AnsiLineText';
@@ -3877,8 +3877,9 @@ describe('InputPrompt', () => {
// 1. Verify initial placeholder
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
});
expect(stdout.lastFrame()).toMatchSnapshot();
// Simulate double-click to expand
await simulateClick(5, 2);
@@ -3886,8 +3887,9 @@ describe('InputPrompt', () => {
// 2. Verify expanded content is visible
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('line10');
});
expect(stdout.lastFrame()).toMatchSnapshot();
// Simulate double-click to collapse
await simulateClick(5, 2);
@@ -3895,8 +3897,9 @@ describe('InputPrompt', () => {
// 3. Verify placeholder is restored
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
@@ -156,7 +156,16 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
> line1
line2
line3
line4
line5
line6
line7
line8
line9
line10
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -76,7 +76,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mocked = {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
createWriteStream: vi.fn(),
promises: {
...actual.promises,
unlink: vi.fn().mockResolvedValue(undefined),
},
};
return {
...mocked,
default: mocked,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const mocked = {
@@ -144,6 +160,10 @@ describe('useExecutionLifecycle', () => {
enableEnvironmentVariableRedaction: false,
},
}),
getTruncateToolOutputThreshold: () => 40000,
storage: {
getProjectTempDir: () => '/tmp/project',
},
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
@@ -155,6 +175,16 @@ describe('useExecutionLifecycle', () => {
mockIsBinary.mockReturnValue(false);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.createWriteStream).mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
bytesWritten: 0,
closed: false,
} as unknown as fs.WriteStream);
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return Promise.resolve({
@@ -646,7 +676,7 @@ describe('useExecutionLifecycle', () => {
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
// Verify that the temporary file was cleaned up
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
});
@@ -674,7 +704,7 @@ describe('useExecutionLifecycle', () => {
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
"WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
);
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
});
it('should NOT show a warning if the directory does not change', async () => {
@@ -20,6 +20,8 @@ import {
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
moveToolOutputToFile,
debugLogger,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -39,16 +41,15 @@ export { type BackgroundTask };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
geminiClient: GeminiClient,
rawQuery: string,
resultText: string,
maxOutputLength: number,
) {
const modelContent =
resultText.length > MAX_OUTPUT_LENGTH
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
maxOutputLength > 0 && resultText.length > maxOutputLength
? resultText.substring(0, maxOutputLength) + '\n... (truncated)'
: resultText;
// Escape backticks to prevent prompt injection breakouts
@@ -424,6 +425,9 @@ export const useExecutionLifecycle = (
let shouldUpdate = false;
switch (event.type) {
case 'raw_data':
case 'file_data':
break;
case 'data':
if (isBinaryStream) break;
if (typeof event.chunk === 'string') {
@@ -533,6 +537,24 @@ export const useExecutionLifecycle = (
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
if (result.fullOutputFilePath) {
const { outputFile: savedPath } = await moveToolOutputToFile(
result.fullOutputFilePath,
SHELL_COMMAND_NAME,
callId,
config.storage.getProjectTempDir(),
config.getSessionId(),
);
const warning = `[Full command output saved to: ${savedPath}]`;
mainContent = mainContent.includes(
'[GEMINI_CLI_WARNING: Output truncated.',
)
? mainContent.replace(
/\[GEMINI_CLI_WARNING: Output truncated\..*?\]/,
warning,
)
: `${mainContent}\n\n${warning}`;
}
}
let finalOutput: string | AnsiOutput =
@@ -617,7 +639,12 @@ export const useExecutionLifecycle = (
);
}
addShellCommandToGeminiHistory(geminiClient, rawQuery, mainContent);
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
mainContent,
config.getTruncateToolOutputThreshold(),
);
} catch (err) {
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -630,8 +657,13 @@ export const useExecutionLifecycle = (
);
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
if (pwdFilePath) {
fs.promises.unlink(pwdFilePath).catch((err) => {
debugLogger.warn(
`Failed to cleanup pwd file: ${pwdFilePath}`,
err,
);
});
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
@@ -661,4 +661,82 @@ describe('ToolOutputMaskingService', () => {
)['output'],
).toContain(MASKING_INDICATOR_TAG);
});
it('should use existing outputFile if available in the tool response', async () => {
// Setup: Create a large history to trigger masking
const largeContent = 'a'.repeat(60000);
const existingOutputFile = path.join(testTempDir, 'truly_full_output.txt');
await fs.promises.writeFile(existingOutputFile, 'truly full content');
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Old turn' }],
},
{
role: 'model',
parts: [
{
functionResponse: {
name: 'shell',
id: 'call-1',
response: {
output: largeContent,
outputFile: existingOutputFile,
},
},
},
],
},
// Protection buffer
{
role: 'user',
parts: [
{
functionResponse: {
name: 'padding',
response: { output: 'B'.repeat(60000) },
},
},
],
},
{
role: 'user',
parts: [{ text: 'Newest turn' }],
},
];
mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => {
const resp = parts[0].functionResponse?.response as Record<
string,
unknown
>;
const content = (resp?.['output'] as string) ?? JSON.stringify(resp);
if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100;
const name = parts[0].functionResponse?.name;
if (name === 'shell') return 60000;
if (name === 'padding') return 60000;
return 10;
});
// Trigger masking
const result = await service.mask(history, mockConfig);
expect(result.maskedCount).toBe(2);
const maskedPart = result.newHistory[1].parts![0];
const maskedResponse = maskedPart.functionResponse?.response as Record<
string,
unknown
>;
const maskedOutput = maskedResponse['output'] as string;
// Verify the masked snippet points to the existing file
expect(maskedOutput).toContain(
`Full output available at: ${existingOutputFile}`,
);
// Verify the path in maskedOutput is exactly the one we provided
expect(maskedOutput).toContain(existingOutputFile);
});
});
@@ -48,6 +48,18 @@ export interface MaskingResult {
tokensSaved: number;
}
interface HasOutputFile {
outputFile: string;
}
function hasOutputFile(obj: unknown): obj is HasOutputFile {
if (typeof obj !== 'object' || obj === null || !('outputFile' in obj)) {
return false;
}
const val = (obj as Record<string, unknown>)['outputFile'];
return typeof val === 'string';
}
/**
* Service to manage context window efficiency by masking bulky tool outputs (Tool Output Masking).
*
@@ -182,25 +194,44 @@ export class ToolOutputMaskingService {
const toolName = part.functionResponse.name || 'unknown_tool';
const callId = part.functionResponse.id || Date.now().toString();
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
.toString(36)
.substring(7)}.txt`;
const filePath = path.join(toolOutputsDir, fileName);
await fsPromises.writeFile(filePath, content, 'utf-8');
const originalResponse =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(part.functionResponse.response as Record<string, unknown>) || {};
const totalLines = content.split('\n').length;
const fileSizeMB = (
Buffer.byteLength(content, 'utf8') /
1024 /
1024
).toFixed(2);
let filePath = '';
let fileSizeMB = '0.00';
let totalLines = 0;
if (hasOutputFile(originalResponse) && originalResponse.outputFile) {
filePath = originalResponse.outputFile;
try {
const stats = await fsPromises.stat(filePath);
fileSizeMB = (stats.size / 1024 / 1024).toFixed(2);
// For truly full files, we don't count lines as it's too slow.
// We just indicate it's the full file.
totalLines = -1;
} catch {
// Fallback if file is gone
filePath = '';
}
}
if (!filePath) {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
.toString(36)
.substring(7)}.txt`;
filePath = path.join(toolOutputsDir, fileName);
await fsPromises.writeFile(filePath, content, 'utf-8');
totalLines = content.split('\n').length;
fileSizeMB = (Buffer.byteLength(content, 'utf8') / 1024 / 1024).toFixed(
2,
);
}
let preview = '';
if (toolName === SHELL_TOOL_NAME) {
+228 -67
View File
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fsPromises from 'node:fs/promises';
import { ToolExecutor } from './tool-executor.js';
import {
type Config,
@@ -32,6 +33,7 @@ import {
vi.mock('../utils/fileUtils.js', () => ({
saveTruncatedToolOutput: vi.fn(),
formatTruncatedToolOutput: vi.fn(),
moveToolOutputToFile: vi.fn(),
}));
// Mock executeToolWithHooks
@@ -436,73 +438,10 @@ describe('ToolExecutor', () => {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
expect(response).toEqual({ output: 'TruncatedContent...' });
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
}
});
it('should truncate large MCP tool output with single text Part', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
const mcpToolName = 'get_big_text';
const messageBus = createMockMessageBus();
const mcpTool = new DiscoveredMCPTool(
{} as CallableTool,
'my-server',
'get_big_text',
'A test MCP tool',
{},
messageBus,
);
const invocation = mcpTool.build({});
const longText = 'This is a very long MCP output that should be truncated.';
// 2. Mock execution returning Part[] with single text Part
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: [{ text: longText }],
returnDisplay: longText,
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-mcp-trunc',
name: mcpToolName,
args: { query: 'test' },
isClientInitiated: false,
prompt_id: 'prompt-mcp-trunc',
},
tool: mcpTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify Truncation Logic
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
longText,
mcpToolName,
'call-mcp-trunc',
expect.any(String),
'test-session-id',
);
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
longText,
'/tmp/truncated_output.txt',
10,
);
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
expect(response).toEqual({
output: 'TruncatedContent...',
outputFile: '/tmp/truncated_output.txt',
});
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
}
});
@@ -597,6 +536,228 @@ describe('ToolExecutor', () => {
expect(result.status).toBe(CoreToolCallStatus.Success);
});
it('should truncate large output and move file when fullOutputFilePath is provided', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
outputFile: '/tmp/moved_output.txt',
});
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const longOutput = 'This is a very long output that should be truncated.';
// 2. Mock execution returning long content AND fullOutputFilePath
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: longOutput,
returnDisplay: longOutput,
fullOutputFilePath: '/tmp/temp_full_output.txt',
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-trunc-full',
name: SHELL_TOOL_NAME,
args: { command: 'echo long' },
isClientInitiated: false,
prompt_id: 'prompt-trunc-full',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify Truncation Logic
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
'/tmp/temp_full_output.txt',
SHELL_TOOL_NAME,
'call-trunc-full',
expect.any(String), // temp dir
'test-session-id', // session id from makeFakeConfig
);
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
longOutput,
'/tmp/moved_output.txt',
10, // threshold (maxChars)
);
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
expect(response).toEqual({
output: 'TruncatedContent...',
outputFile: '/tmp/moved_output.txt',
});
expect(result.response.outputFile).toBe('/tmp/moved_output.txt');
}
});
it('should preserve temporary file when fullOutputFilePath is provided but output is not truncated', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(100);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
vi.spyOn(config, 'getSessionId').mockReturnValue('test-session-id');
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
outputFile: '/tmp/moved_output_short.txt',
});
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const shortOutput = 'Short';
// 2. Mock execution returning short content AND fullOutputFilePath
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: shortOutput,
returnDisplay: shortOutput,
fullOutputFilePath: '/tmp/temp_full_output_short.txt',
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-short-full',
name: SHELL_TOOL_NAME,
args: { command: 'echo short' },
isClientInitiated: false,
prompt_id: 'prompt-short-full',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify file preservation
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
'/tmp/temp_full_output_short.txt',
SHELL_TOOL_NAME,
'call-short-full',
expect.any(String),
'test-session-id',
);
expect(unlinkSpy).not.toHaveBeenCalled();
expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled();
// We should save it since fullOutputFilePath was provided
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response).toEqual({
output: 'Short',
outputFile: '/tmp/moved_output_short.txt',
});
expect(result.response.outputFile).toBe('/tmp/moved_output_short.txt');
}
unlinkSpy.mockRestore();
});
it('should delete temporary file on error if fullOutputFilePath is provided', async () => {
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: 'failTool' });
const invocation = mockTool.build({});
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: 'partial',
returnDisplay: 'partial',
fullOutputFilePath: '/tmp/temp_error.txt',
error: { message: 'Tool Failed' },
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-err',
name: 'failTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-err',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_error.txt');
expect(result.status).toBe(CoreToolCallStatus.Error);
unlinkSpy.mockRestore();
});
it('should delete temporary file on abort if fullOutputFilePath is provided', async () => {
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: 'slowTool' });
const invocation = mockTool.build({});
const controller = new AbortController();
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async () => {
controller.abort();
return {
llmContent: 'partial',
returnDisplay: 'partial',
fullOutputFilePath: '/tmp/temp_abort.txt',
};
},
);
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-abort',
name: 'slowTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-abort',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_abort.txt');
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
unlinkSpy.mockRestore();
});
it('should report execution ID updates for backgroundable tools', async () => {
// 1. Setup ShellToolInvocation
const messageBus = createMockMessageBus();
+113 -22
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fsPromises from 'node:fs/promises';
import { debugLogger } from '../utils/debugLogger.js';
import {
ToolErrorType,
ToolOutputTruncatedEvent,
@@ -24,6 +26,7 @@ import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
import {
saveTruncatedToolOutput,
formatTruncatedToolOutput,
moveToolOutputToFile,
} from '../utils/fileUtils.js';
import { convertToFunctionResponse } from '../utils/generateContentResponseUtilities.js';
import {
@@ -138,6 +141,16 @@ export class ToolExecutor {
}
if (signal.aborted) {
if (toolResult.fullOutputFilePath) {
await fsPromises
.unlink(toolResult.fullOutputFilePath)
.catch((error) => {
debugLogger.warn(
`Failed to delete temporary tool output file on abort: ${toolResult.fullOutputFilePath}`,
error,
);
});
}
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
@@ -149,6 +162,16 @@ export class ToolExecutor {
toolResult,
);
} else {
if (toolResult.fullOutputFilePath) {
await fsPromises
.unlink(toolResult.fullOutputFilePath)
.catch((error) => {
debugLogger.warn(
`Failed to delete temporary tool output file on error: ${toolResult.fullOutputFilePath}`,
error,
);
});
}
const displayText =
typeof toolResult.returnDisplay === 'string'
? toolResult.returnDisplay
@@ -197,33 +220,80 @@ export class ToolExecutor {
private async truncateOutputIfNeeded(
call: ToolCall,
content: PartListUnion,
fullOutputFilePath?: string,
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
const toolName = call.request.name;
const callId = call.request.callId;
if (this.config.isContextManagementEnabled()) {
const distiller = new ToolOutputDistillationService(
this.config,
this.context.geminiClient,
this.context.promptId,
);
return distiller.distill(call.request.name, call.request.callId, content);
const result = await distiller.distill(
call.request.name,
call.request.callId,
content,
);
let finalOutputFile = result.outputFile;
if (fullOutputFilePath) {
const { outputFile: movedPath } = await moveToolOutputToFile(
fullOutputFilePath,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
);
finalOutputFile = movedPath;
if (result.outputFile) {
try {
await fsPromises.unlink(result.outputFile);
} catch (error) {
debugLogger.warn(
`Failed to delete distiller's temporary tool output file: ${result.outputFile}`,
error,
);
}
}
}
return {
truncatedContent: result.truncatedContent,
outputFile: finalOutputFile,
};
}
const toolName = call.request.name;
const callId = call.request.callId;
let outputFile: string | undefined;
if (fullOutputFilePath) {
const { outputFile: movedPath } = await moveToolOutputToFile(
fullOutputFilePath,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
);
outputFile = movedPath;
}
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
const threshold = this.config.getTruncateToolOutputThreshold();
if (threshold > 0 && content.length > threshold) {
const originalContentLength = content.length;
const { outputFile: savedPath } = await saveTruncatedToolOutput(
content,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = savedPath;
if (!outputFile) {
const { outputFile: writtenPath } = await saveTruncatedToolOutput(
content,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = writtenPath;
}
const truncatedContent = formatTruncatedToolOutput(
content,
outputFile,
@@ -255,14 +325,16 @@ export class ToolExecutor {
if (threshold > 0 && textContent.length > threshold) {
const originalContentLength = textContent.length;
const { outputFile: savedPath } = await saveTruncatedToolOutput(
textContent,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = savedPath;
if (!outputFile) {
const { outputFile: savedPath } = await saveTruncatedToolOutput(
textContent,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.context.promptId,
);
outputFile = savedPath;
}
const truncatedText = formatTruncatedToolOutput(
textContent,
outputFile,
@@ -314,7 +386,11 @@ export class ToolExecutor {
// Attempt to truncate and save output if we have content, even in cancellation case
// This is to handle cases where the tool may have produced output before cancellation
const { truncatedContent: output, outputFile: truncatedOutputFile } =
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
await this.truncateOutputIfNeeded(
call,
toolResult.llmContent,
toolResult.fullOutputFilePath,
);
outputFile = truncatedOutputFile;
responseParts = convertToFunctionResponse(
@@ -323,6 +399,7 @@ export class ToolExecutor {
output,
this.config.getActiveModel(),
this.config,
outputFile,
);
// Inject the cancellation error into the response object
@@ -332,6 +409,16 @@ export class ToolExecutor {
respObj['error'] = errorMessage;
}
} else {
if (toolResult?.fullOutputFilePath) {
try {
await fsPromises.unlink(toolResult.fullOutputFilePath);
} catch (error) {
debugLogger.warn(
`Failed to delete temporary tool output file: ${toolResult.fullOutputFilePath}`,
error,
);
}
}
responseParts = [
{
functionResponse: {
@@ -369,8 +456,11 @@ export class ToolExecutor {
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
const { truncatedContent: content, outputFile } =
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
await this.truncateOutputIfNeeded(
call,
toolResult.llmContent,
toolResult.fullOutputFilePath,
);
const toolName = call.request.originalRequestName || call.request.name;
const callId = call.request.callId;
@@ -380,6 +470,7 @@ export class ToolExecutor {
content,
this.config.getActiveModel(),
this.config,
outputFile,
);
const successResponse: ToolCallResponseInfo = {
@@ -27,6 +27,7 @@ export interface ExecutionResult {
pid: number | undefined;
executionMethod: ExecutionMethod;
backgrounded?: boolean;
fullOutputFilePath?: string;
}
export interface ExecutionHandle {
@@ -35,6 +36,14 @@ export interface ExecutionHandle {
}
export type ExecutionOutputEvent =
| {
type: 'raw_data';
chunk: string;
}
| {
type: 'file_data';
chunk: string;
}
| {
type: 'data';
chunk: string | AnsiOutput;
@@ -33,7 +33,16 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockHomedir = vi.hoisted(() => vi.fn());
const mockMkdirSync = vi.hoisted(() => vi.fn());
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
const mockCreateWriteStream = vi.hoisted(() =>
vi.fn().mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb?: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
closed: false,
}),
);
const mockGetPty = vi.hoisted(() => vi.fn());
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
const mockResolveExecutable = vi.hoisted(() => vi.fn());
@@ -92,6 +101,7 @@ vi.mock('node:os', () => ({
default: {
platform: mockPlatform,
homedir: mockHomedir,
tmpdir: () => '/tmp',
constants: {
signals: {
SIGTERM: 15,
@@ -208,6 +218,15 @@ describe('ShellExecutionService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateWriteStream.mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb?: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
closed: false,
on: vi.fn(),
});
ExecutionLifecycleService.resetForTest();
ShellExecutionService.resetForTest();
mockSerializeTerminalToObject.mockReturnValue([]);
@@ -489,6 +508,7 @@ describe('ShellExecutionService', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
headlessTerminal: mockHeadlessTerminal as any,
command: 'some-command',
lastCommittedLine: -1,
});
});
@@ -620,7 +640,7 @@ describe('ShellExecutionService', () => {
});
it('should handle a synchronous spawn error', async () => {
mockGetPty.mockImplementation(() => null);
mockGetPty.mockResolvedValue(null);
mockCpSpawn.mockImplementation(() => {
throw new Error('Simulated PTY spawn error');
@@ -724,7 +744,13 @@ describe('ShellExecutionService', () => {
});
describe('Backgrounding', () => {
let mockWriteStream: { write: Mock; end: Mock; on: Mock };
let mockWriteStream: {
write: Mock;
end: Mock;
on: Mock;
destroy: Mock;
closed: boolean;
};
let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;
beforeEach(async () => {
@@ -732,6 +758,8 @@ describe('ShellExecutionService', () => {
write: vi.fn(),
end: vi.fn().mockImplementation((cb) => cb?.()),
on: vi.fn(),
destroy: vi.fn(),
closed: false,
};
mockMkdirSync.mockReturnValue(undefined);
@@ -989,6 +1017,8 @@ describe('ShellExecutionService', () => {
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
// We don't check result here because result is not available in the test body as written
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
@@ -1301,7 +1331,7 @@ describe('ShellExecutionService child_process fallback', () => {
const { result, handle } = await simulateExecution('ls -l', (cp) => {
cp.stdout?.emit('data', Buffer.from('file1.txt\n'));
cp.stderr?.emit('data', Buffer.from('a warning'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
@@ -1338,7 +1368,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should strip ANSI color codes from output', async () => {
const { result } = await simulateExecution('ls --color=auto', (cp) => {
cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
@@ -1359,7 +1389,7 @@ describe('ShellExecutionService child_process fallback', () => {
const multiByteChar = Buffer.from('你好', 'utf-8');
cp.stdout?.emit('data', multiByteChar.slice(0, 2));
cp.stdout?.emit('data', multiByteChar.slice(2));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
expect(result.output.trim()).toBe('你好');
@@ -1367,7 +1397,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should handle commands with no output', async () => {
const { result } = await simulateExecution('touch file', (cp) => {
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
cp.emit('close', 0, null);
});
@@ -1389,7 +1419,7 @@ describe('ShellExecutionService child_process fallback', () => {
cp.stdout?.emit('data', Buffer.from(chunk1));
cp.stdout?.emit('data', Buffer.from(chunk2));
cp.stdout?.emit('data', Buffer.from(chunk3));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
const truncationMessage =
@@ -1414,7 +1444,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should capture a non-zero exit code and format output correctly', async () => {
const { result } = await simulateExecution('a-bad-command', (cp) => {
cp.stderr?.emit('data', Buffer.from('command not found'));
cp.emit('exit', 127, null);
cp.emit('close', 127, null);
cp.emit('close', 127, null);
});
@@ -1425,7 +1455,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should capture a termination signal', async () => {
const { result } = await simulateExecution('long-process', (cp) => {
cp.emit('exit', null, 'SIGTERM');
cp.emit('close', null, 'SIGTERM');
cp.emit('close', null, 'SIGTERM');
});
@@ -1437,7 +1467,7 @@ describe('ShellExecutionService child_process fallback', () => {
const spawnError = new Error('spawn EACCES');
const { result } = await simulateExecution('protected-cmd', (cp) => {
cp.emit('error', spawnError);
cp.emit('exit', 1, null);
cp.emit('close', 1, null);
cp.emit('close', 1, null);
});
@@ -1483,11 +1513,11 @@ describe('ShellExecutionService child_process fallback', () => {
await new Promise(process.nextTick);
await new Promise(process.nextTick);
if (expectedExit.signal) {
cp.emit('exit', null, expectedExit.signal);
cp.emit('close', null, expectedExit.signal);
cp.emit('close', null, expectedExit.signal);
}
if (typeof expectedExit.code === 'number') {
cp.emit('exit', expectedExit.code, null);
cp.emit('close', expectedExit.code, null);
cp.emit('close', expectedExit.code, null);
}
},
@@ -1556,7 +1586,7 @@ describe('ShellExecutionService child_process fallback', () => {
);
// Finally, simulate the process exiting and await the result
mockChildProcess.emit('exit', null, 'SIGKILL');
mockChildProcess.emit('close', null, 'SIGKILL');
mockChildProcess.emit('close', null, 'SIGKILL');
const result = await handle.result;
@@ -1576,9 +1606,11 @@ describe('ShellExecutionService child_process fallback', () => {
await simulateExecution('cat image.png', (cp) => {
cp.stdout?.emit('data', binaryChunk1);
cp.stdout?.emit('data', binaryChunk2);
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
// We don't check result here because result is not available in the test body as written
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
@@ -1604,7 +1636,6 @@ describe('ShellExecutionService child_process fallback', () => {
await simulateExecution('cat mixed_file', (cp) => {
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
cp.stdout?.emit('data', Buffer.from('more text'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
@@ -1640,7 +1671,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should use powershell.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (cp) => {
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
expect(mockCpSpawn).toHaveBeenCalledWith(
@@ -1657,7 +1688,7 @@ describe('ShellExecutionService child_process fallback', () => {
it('should use bash and detached process group on Linux', async () => {
mockPlatform.mockReturnValue('linux');
await simulateExecution('ls "foo bar"', (cp) => {
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
expect(mockCpSpawn).toHaveBeenCalledWith(
@@ -1771,7 +1802,7 @@ describe('ShellExecutionService execution method selection', () => {
);
// Simulate exit to allow promise to resolve
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
const result = await handle.result;
expect(mockGetPty).not.toHaveBeenCalled();
@@ -1794,7 +1825,7 @@ describe('ShellExecutionService execution method selection', () => {
);
// Simulate exit to allow promise to resolve
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
const result = await handle.result;
expect(mockGetPty).toHaveBeenCalled();
@@ -1860,10 +1891,6 @@ describe('ShellExecutionService environment variables', () => {
// Small delay to allow async ops to complete
setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0);
});
mockChildProcess.on('exit', (code, signal) => {
// Small delay to allow async ops to complete
setTimeout(() => mockChildProcess.emit('close', code, signal), 0);
});
});
afterEach(() => {
@@ -1926,7 +1953,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
@@ -1986,7 +2013,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
@@ -2034,7 +2061,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
});
@@ -2091,7 +2118,7 @@ describe('ShellExecutionService environment variables', () => {
);
// Clean up
mockChild.emit('exit', 0, null);
mockChild.emit('close', 0, null);
mockChild.emit('close', 0, null);
await handle.result;
});
@@ -2140,7 +2167,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
@@ -2180,7 +2207,7 @@ describe('ShellExecutionService environment variables', () => {
expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
// Ensure child_process exits
mockChildProcess.emit('exit', 0, null);
mockChildProcess.emit('close', 0, null);
mockChildProcess.emit('close', 0, null);
await new Promise(process.nextTick);
@@ -12,6 +12,7 @@ import type { Writable } from 'node:stream';
import os from 'node:os';
import fs, { mkdirSync } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import {
@@ -120,6 +121,8 @@ interface ActivePty {
maxSerializedLines?: number;
command: string;
sessionId?: string;
lastSerializedOutput?: AnsiOutput;
lastCommittedLine: number;
}
interface ActiveChildProcess {
@@ -148,6 +151,48 @@ const findLastContentLine = (
return -1;
};
const emitPendingLines = (
activePty: ActivePty,
pid: number,
onOutputEvent: (event: ShellOutputEvent) => void,
forceAll = false,
) => {
const buffer = activePty.headlessTerminal.buffer.active;
const limit = forceAll ? buffer.length : buffer.baseY;
let chunks = '';
for (let i = activePty.lastCommittedLine + 1; i < limit; i++) {
const line = buffer.getLine(i);
if (!line) continue;
let trimRight = true;
let isNextLineWrapped = false;
if (i + 1 < buffer.length) {
const nextLine = buffer.getLine(i + 1);
if (nextLine?.isWrapped) {
isNextLineWrapped = true;
trimRight = false;
}
}
const lineContent = line.translateToString(trimRight);
chunks += lineContent;
if (!isNextLineWrapped) {
chunks += '\n';
}
}
if (chunks.length > 0) {
const event: ShellOutputEvent = {
type: 'file_data',
chunk: chunks,
};
onOutputEvent(event);
ExecutionLifecycleService.emitEvent(pid, event);
activePty.lastCommittedLine = limit - 1;
}
};
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
const buffer = terminal.buffer.active;
const lines: string[] = [];
@@ -326,32 +371,110 @@ export class ShellExecutionService {
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
): Promise<ShellExecutionHandle> {
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
const outputFilePath = path.join(os.tmpdir(), outputFileName);
const outputStream = fs.createWriteStream(outputFilePath);
let totalBytesWritten = 0;
const interceptedOnOutputEvent = (event: ShellOutputEvent) => {
switch (event.type) {
case 'raw_data':
break;
case 'file_data':
outputStream.write(event.chunk);
totalBytesWritten += Buffer.byteLength(event.chunk);
break;
case 'binary_detected':
case 'binary_progress':
break;
default:
break;
}
onOutputEvent(event);
};
let handlePromise: Promise<ShellExecutionHandle>;
if (shouldUseNodePty) {
const ptyInfo = await getPty();
if (ptyInfo) {
try {
return await this.executeWithPty(
handlePromise = getPty().then((ptyInfo) => {
if (ptyInfo) {
return this.executeWithPty(
commandToExecute,
cwd,
onOutputEvent,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
ptyInfo,
).catch(() =>
this.childProcessFallback(
commandToExecute,
cwd,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
),
);
} catch {
// Fallback to child_process
}
}
return this.childProcessFallback(
commandToExecute,
cwd,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
);
});
} else {
handlePromise = this.childProcessFallback(
commandToExecute,
cwd,
interceptedOnOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
);
}
return this.childProcessFallback(
commandToExecute,
cwd,
onOutputEvent,
abortSignal,
shellExecutionConfig,
shouldUseNodePty,
);
const handle = await handlePromise;
const wrappedResultPromise = handle.result
.then(async (result) => {
await new Promise<void>((resolve) => {
outputStream.end(resolve);
});
// The threshold logic is handled later by ToolExecutor/caller, so we just return the full file path if anything was written
if (
totalBytesWritten > 0 &&
!result.backgrounded &&
!abortSignal.aborted &&
!result.error
) {
return {
...result,
fullOutputFilePath: outputFilePath,
};
} else {
if (!outputStream.closed) {
outputStream.destroy();
}
await fs.promises.unlink(outputFilePath).catch(() => undefined);
return result;
}
})
.catch(async (err) => {
if (!outputStream.closed) {
outputStream.destroy();
}
await fs.promises.unlink(outputFilePath).catch(() => undefined);
throw err;
});
return {
pid: handle.pid,
result: wrappedResultPromise,
};
}
private static appendAndTruncate(
@@ -661,6 +784,24 @@ export class ShellExecutionService {
}
if (decodedChunk) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: decodedChunk,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(decodedChunk),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: decodedChunk,
@@ -772,7 +913,7 @@ export class ShellExecutionService {
abortSignal.addEventListener('abort', abortHandler, { once: true });
child.on('exit', (code, signal) => {
child.on('close', (code, signal) => {
handleExit(code, signal);
});
@@ -784,6 +925,15 @@ export class ShellExecutionService {
if (remaining) {
state.output += remaining;
if (isStreamingRawContent) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: remaining,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
@@ -792,6 +942,15 @@ export class ShellExecutionService {
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, event);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(remaining),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
}
}
}
@@ -800,6 +959,15 @@ export class ShellExecutionService {
if (remaining) {
state.output += remaining;
if (isStreamingRawContent) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: remaining,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
@@ -808,6 +976,15 @@ export class ShellExecutionService {
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, event);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(remaining),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
}
}
}
@@ -934,6 +1111,7 @@ export class ShellExecutionService {
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
command: shellExecutionConfig.originalCommand ?? commandToExecute,
sessionId: shellExecutionConfig.sessionId,
lastCommittedLine: -1,
});
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
@@ -1099,10 +1277,38 @@ export class ShellExecutionService {
}, 68);
};
headlessTerminal.onScroll(() => {
let lastYdisp = 0;
let hasReachedMax = false;
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
headlessTerminal.onScroll((ydisp) => {
if (!isWriting) {
render();
}
if (
ydisp === scrollbackLimit &&
lastYdisp === scrollbackLimit &&
hasReachedMax
) {
const activePty = this.activePtys.get(ptyPid);
if (activePty) {
activePty.lastCommittedLine--;
}
}
if (
ydisp === scrollbackLimit &&
headlessTerminal.buffer.active.length === scrollbackLimit + rows
) {
hasReachedMax = true;
}
lastYdisp = ydisp;
const activePtyForEmit = this.activePtys.get(ptyPid);
if (activePtyForEmit) {
emitPendingLines(activePtyForEmit, ptyPid, onOutputEvent);
}
});
const handleOutput = (data: Buffer) => {
@@ -1187,6 +1393,11 @@ export class ShellExecutionService {
render(true);
cmdCleanup?.();
const activePty = ShellExecutionService.activePtys.get(ptyPid);
if (activePty && isStreamingRawContent) {
emitPendingLines(activePty, ptyPid, onOutputEvent, true);
}
const event: ShellOutputEvent = {
type: 'exit',
exitCode,
@@ -1490,6 +1701,7 @@ export class ShellExecutionService {
startLine,
endLine,
);
activePty.lastSerializedOutput = bufferData;
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
ExecutionLifecycleService.emitEvent(pid, event);
}
+1
View File
@@ -119,6 +119,7 @@ describe('ShellTool', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
getWorkspaceContext: vi
.fn()
.mockReturnValue(new WorkspaceContext(tempRootDir)),
+107 -20
View File
@@ -9,7 +9,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import { debugLogger } from '../utils/debugLogger.js';
import { type SandboxPermissions } from '../services/sandboxManager.js';
import { ToolErrorType } from './tool-error.js';
import {
@@ -34,7 +34,10 @@ import {
type ShellOutputEvent,
} from '../services/shellExecutionService.js';
import { formatBytes } from '../utils/formatters.js';
import type { AnsiOutput } from '../utils/terminalSerializer.js';
import {
serializeAnsiOutputToText,
type AnsiOutput,
} from '../utils/terminalSerializer.js';
import {
getCommandRoots,
initializeShellParsers,
@@ -464,6 +467,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
const onAbort = () => combinedController.abort();
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
const projectTempDir = this.context.config.storage.getProjectTempDir();
fs.mkdirSync(projectTempDir, { recursive: true });
const outputFilePath = path.join(projectTempDir, outputFileName);
const outputStream = fs.createWriteStream(outputFilePath);
let fullOutputReturned = false;
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = this.wrapCommandForPgrep(
@@ -490,6 +501,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
let cumulativeOutput: string | AnsiOutput = '';
let lastUpdateTime = Date.now();
let isBinaryStream = false;
let totalBytesWritten = 0;
const resetTimeout = () => {
if (timeoutMs <= 0) {
@@ -515,31 +527,65 @@ export class ShellToolInvocation extends BaseToolInvocation<
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
let shouldUpdate = false;
switch (event.type) {
case 'raw_data':
// We do not write raw data to the file to avoid spurious escape codes.
// We rely on 'file_data' for the clean output stream.
break;
case 'file_data':
totalBytesWritten += Buffer.byteLength(event.chunk);
outputStream.write(event.chunk);
break;
case 'data':
if (isBinaryStream) break;
cumulativeOutput = event.chunk;
shouldUpdate = true;
if (typeof event.chunk === 'string') {
if (typeof cumulativeOutput === 'string') {
// Accumulate string chunks, capped at 16MB (same as ShellExecutionService)
const MAX_BUFFER = 16 * 1024 * 1024;
if (
cumulativeOutput.length + event.chunk.length >
MAX_BUFFER
) {
cumulativeOutput = (cumulativeOutput + event.chunk).slice(
-MAX_BUFFER,
);
} else {
cumulativeOutput += event.chunk;
}
} else {
// In case of mode switch (unlikely)
cumulativeOutput = event.chunk;
}
} else {
// Snapshots (AnsiOutput) always replace
cumulativeOutput = event.chunk;
}
if (updateOutput && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
break;
case 'binary_detected':
isBinaryStream = true;
cumulativeOutput =
'[Binary output detected. Halting stream...]';
shouldUpdate = true;
if (updateOutput && !this.params.is_background) {
updateOutput(cumulativeOutput);
}
break;
case 'binary_progress':
isBinaryStream = true;
cumulativeOutput = `[Receiving binary output... ${formatBytes(
event.bytesReceived,
)} received]`;
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
shouldUpdate = true;
if (
updateOutput &&
!this.params.is_background &&
Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS
) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
break;
case 'exit':
@@ -548,11 +594,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
},
combinedController.signal,
this.context.config.getEnableInteractiveShell(),
@@ -615,9 +656,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
await new Promise((resolve) => setTimeout(resolve, delay));
if (!completed) {
const initialOutputText =
typeof cumulativeOutput === 'string'
? cumulativeOutput
: serializeAnsiOutputToText(cumulativeOutput);
// Return early with initial output if still running
return {
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${cumulativeOutput}`,
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${initialOutputText}`,
returnDisplay: `Background process started with PID ${pid}.`,
};
}
@@ -625,6 +671,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
const result = await resultPromise;
await new Promise<void>((resolve, reject) => {
outputStream.on('error', reject);
outputStream.end(resolve);
});
// Ensure the stream is fully closed before we proceed
if (!outputStream.closed) {
await new Promise<void>((resolve) => outputStream.on('close', resolve));
}
const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') {
@@ -918,21 +973,46 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.geminiClient,
signal,
);
return {
const threshold = this.context.config.getTruncateToolOutputThreshold();
const fullOutputFilePath =
threshold > 0 && totalBytesWritten >= threshold
? outputFilePath
: undefined;
const toolResult: ToolResult = {
llmContent: summary,
returnDisplay,
fullOutputFilePath,
...executionError,
};
if (toolResult.fullOutputFilePath) {
fullOutputReturned = true;
}
return toolResult;
}
return {
const threshold = this.context.config.getTruncateToolOutputThreshold();
const fullOutputFilePath =
threshold > 0 && totalBytesWritten >= threshold
? outputFilePath
: undefined;
const toolResult: ToolResult = {
llmContent,
returnDisplay,
data,
fullOutputFilePath,
...executionError,
};
if (toolResult.fullOutputFilePath) {
fullOutputReturned = true;
}
return toolResult;
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
if (!outputStream.closed) {
outputStream.destroy();
}
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
try {
@@ -940,6 +1020,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
} catch {
// Ignore errors during unlink
}
if (!fullOutputReturned) {
try {
await fsPromises.unlink(outputFilePath);
} catch {
// Ignore errors during unlink
}
}
}
}
}
+7
View File
@@ -776,6 +776,13 @@ export interface ToolResult {
name: string;
args: Record<string, unknown>;
};
/**
* Optional path to a file containing the full, non-truncated output of the tool.
* If provided, the scheduler may use this file for long-term storage and
* reference it in the conversation history if the output is truncated.
*/
fullOutputFilePath?: string;
}
/**
+42
View File
@@ -677,6 +677,48 @@ ${head}
${tail}`;
}
/**
* Moves tool output from a source path to a temporary file for later retrieval.
*/
export async function moveToolOutputToFile(
sourcePath: string,
toolName: string,
id: string | number, // Accept string (callId) or number (truncationId)
projectTempDir: string,
sessionId?: string,
): Promise<{ outputFile: string }> {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeId = sanitizeFilenamePart(id.toString()).toLowerCase();
const fileName = safeId.startsWith(safeToolName)
? `${safeId}.txt`
: `${safeToolName}_${safeId}.txt`;
let toolOutputDir = path.join(projectTempDir, TOOL_OUTPUTS_DIR);
if (sessionId) {
const safeSessionId = sanitizeFilenamePart(sessionId);
toolOutputDir = path.join(toolOutputDir, `session-${safeSessionId}`);
}
const outputFile = path.join(toolOutputDir, fileName);
await fsPromises.mkdir(toolOutputDir, { recursive: true });
try {
// Attempt rename (efficient if on the same filesystem)
await fsPromises.rename(sourcePath, outputFile);
} catch (error: unknown) {
// If rename fails (e.g. cross-filesystem), copy and then delete
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((error as { code?: string }).code === 'EXDEV') {
await fsPromises.copyFile(sourcePath, outputFile);
await fsPromises.unlink(sourcePath);
} else {
throw error;
}
}
return { outputFile };
}
/**
* Saves tool output to a temporary file for later retrieval.
*/
@@ -22,12 +22,13 @@ function createFunctionResponsePart(
callId: string,
toolName: string,
output: string,
outputFile?: string,
): Part {
return {
functionResponse: {
id: callId,
name: toolName,
response: { output },
response: { output, outputFile },
},
};
}
@@ -50,9 +51,12 @@ export function convertToFunctionResponse(
llmContent: PartListUnion,
model: string,
config?: Config,
outputFile?: string,
): Part[] {
if (typeof llmContent === 'string') {
return [createFunctionResponsePart(callId, toolName, llmContent)];
return [
createFunctionResponsePart(callId, toolName, llmContent, outputFile),
];
}
const parts = toParts(llmContent);
@@ -94,7 +98,10 @@ export function convertToFunctionResponse(
functionResponse: {
id: callId,
name: toolName,
response: textParts.length > 0 ? { output: textParts.join('\n') } : {},
response: {
...(textParts.length > 0 ? { output: textParts.join('\n') } : {}),
outputFile,
},
},
};
@@ -250,6 +250,12 @@ export function serializeTerminalToObject(
return result;
}
export function serializeAnsiOutputToText(output: AnsiOutput): string {
return output
.map((line) => line.map((segment) => segment.text).join(''))
.join('\n');
}
// ANSI color palette from https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit
const ANSI_COLORS = [
'#000000',