mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
30 Commits
v0.46.0
...
memory_usage3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0203ab0a1c | |||
| cdf5c265d8 | |||
| d0272a6436 | |||
| d14205a4ce | |||
| 20ca623cb8 | |||
| 7ea1654706 | |||
| 4cde103712 | |||
| ee635bb0e9 | |||
| 7181242f69 | |||
| 44abbdf56e | |||
| 152806379c | |||
| 03efe65dd5 | |||
| 9e3a48a3b6 | |||
| e064cfe043 | |||
| 63a6211fe0 | |||
| c2a17ae257 | |||
| c1297436b9 | |||
| 3d1c2e849c | |||
| cfac19e772 | |||
| ad28dc83c3 | |||
| bcd0acae4f | |||
| 1755678cf9 | |||
| 886025f6b9 | |||
| 7cdfaaa6bd | |||
| ea4dd5ae35 | |||
| 8df9a80cc4 | |||
| e3baad48c8 | |||
| 0ca2e4e2ae | |||
| 30b4dcecbc | |||
| 954835123f |
@@ -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);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user