# 🚀 Sync Gemini CLI v0.2.1 - Major Feature Update (#483)

This commit is contained in:
tanzhenxin
2025-09-01 14:48:55 +08:00
committed by GitHub
parent 1610c1586e
commit 2572faf726
292 changed files with 19401 additions and 5941 deletions

View File

@@ -0,0 +1,367 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
expect,
it,
describe,
vi,
beforeEach,
afterEach,
MockInstance,
} from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
ChatRecordingService,
ConversationRecord,
ToolCallRecord,
} from './chatRecordingService.js';
import { Config } from '../config/config.js';
import { getProjectHash } from '../utils/paths.js';
vi.mock('node:fs');
vi.mock('node:path');
vi.mock('node:crypto');
vi.mock('../utils/paths.js');
describe('ChatRecordingService', () => {
let chatRecordingService: ChatRecordingService;
let mockConfig: Config;
let mkdirSyncSpy: MockInstance<typeof fs.mkdirSync>;
let writeFileSyncSpy: MockInstance<typeof fs.writeFileSync>;
beforeEach(() => {
mockConfig = {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
getProjectTempDir: vi
.fn()
.mockReturnValue('/test/project/root/.gemini/tmp'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
vi.mocked(getProjectHash).mockReturnValue('test-project-hash');
vi.mocked(randomUUID).mockReturnValue('this-is-a-test-uuid');
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
chatRecordingService = new ChatRecordingService(mockConfig);
mkdirSyncSpy = vi
.spyOn(fs, 'mkdirSync')
.mockImplementation(() => undefined);
writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initialize', () => {
it('should create a new session if none is provided', () => {
chatRecordingService.initialize();
expect(mkdirSyncSpy).toHaveBeenCalledWith(
'/test/project/root/.gemini/tmp/chats',
{ recursive: true },
);
expect(writeFileSyncSpy).not.toHaveBeenCalled();
});
it('should resume from an existing session if provided', () => {
const readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
sessionId: 'old-session-id',
projectHash: 'test-project-hash',
messages: [],
}),
);
const writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
chatRecordingService.initialize({
filePath: '/test/project/root/.gemini/tmp/chats/session.json',
conversation: {
sessionId: 'old-session-id',
} as ConversationRecord,
});
expect(mkdirSyncSpy).not.toHaveBeenCalled();
expect(readFileSyncSpy).toHaveBeenCalled();
expect(writeFileSyncSpy).not.toHaveBeenCalled();
});
});
describe('recordMessage', () => {
beforeEach(() => {
chatRecordingService.initialize();
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
sessionId: 'test-session-id',
projectHash: 'test-project-hash',
messages: [],
}),
);
});
it('should record a new message', () => {
const writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
chatRecordingService.recordMessage({ type: 'user', content: 'Hello' });
expect(mkdirSyncSpy).toHaveBeenCalled();
expect(writeFileSyncSpy).toHaveBeenCalled();
const conversation = JSON.parse(
writeFileSyncSpy.mock.calls[0][1] as string,
) as ConversationRecord;
expect(conversation.messages).toHaveLength(1);
expect(conversation.messages[0].content).toBe('Hello');
expect(conversation.messages[0].type).toBe('user');
});
it('should append to the last message if append is true and types match', () => {
const writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
const initialConversation = {
sessionId: 'test-session-id',
projectHash: 'test-project-hash',
messages: [
{
id: '1',
type: 'user',
content: 'Hello',
timestamp: new Date().toISOString(),
},
],
};
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify(initialConversation),
);
chatRecordingService.recordMessage({
type: 'user',
content: ' World',
append: true,
});
expect(mkdirSyncSpy).toHaveBeenCalled();
expect(writeFileSyncSpy).toHaveBeenCalled();
const conversation = JSON.parse(
writeFileSyncSpy.mock.calls[0][1] as string,
) as ConversationRecord;
expect(conversation.messages).toHaveLength(1);
expect(conversation.messages[0].content).toBe('Hello World');
});
});
describe('recordThought', () => {
it('should queue a thought', () => {
chatRecordingService.initialize();
chatRecordingService.recordThought({
subject: 'Thinking',
description: 'Thinking...',
});
// @ts-expect-error private property
expect(chatRecordingService.queuedThoughts).toHaveLength(1);
// @ts-expect-error private property
expect(chatRecordingService.queuedThoughts[0].subject).toBe('Thinking');
// @ts-expect-error private property
expect(chatRecordingService.queuedThoughts[0].description).toBe(
'Thinking...',
);
});
});
describe('recordMessageTokens', () => {
beforeEach(() => {
chatRecordingService.initialize();
});
it('should update the last message with token info', () => {
const writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
const initialConversation = {
sessionId: 'test-session-id',
projectHash: 'test-project-hash',
messages: [
{
id: '1',
type: 'gemini',
content: 'Response',
timestamp: new Date().toISOString(),
},
],
};
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify(initialConversation),
);
chatRecordingService.recordMessageTokens({
input: 1,
output: 2,
total: 3,
cached: 0,
});
expect(mkdirSyncSpy).toHaveBeenCalled();
expect(writeFileSyncSpy).toHaveBeenCalled();
const conversation = JSON.parse(
writeFileSyncSpy.mock.calls[0][1] as string,
) as ConversationRecord;
expect(conversation.messages[0]).toEqual({
...initialConversation.messages[0],
tokens: { input: 1, output: 2, total: 3, cached: 0 },
});
});
it('should queue token info if the last message already has tokens', () => {
const initialConversation = {
sessionId: 'test-session-id',
projectHash: 'test-project-hash',
messages: [
{
id: '1',
type: 'gemini',
content: 'Response',
timestamp: new Date().toISOString(),
tokens: { input: 1, output: 1, total: 2, cached: 0 },
},
],
};
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify(initialConversation),
);
chatRecordingService.recordMessageTokens({
input: 2,
output: 2,
total: 4,
cached: 0,
});
// @ts-expect-error private property
expect(chatRecordingService.queuedTokens).toEqual({
input: 2,
output: 2,
total: 4,
cached: 0,
});
});
});
describe('recordToolCalls', () => {
beforeEach(() => {
chatRecordingService.initialize();
});
it('should add new tool calls to the last message', () => {
const writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
const initialConversation = {
sessionId: 'test-session-id',
projectHash: 'test-project-hash',
messages: [
{
id: '1',
type: 'gemini',
content: '',
timestamp: new Date().toISOString(),
},
],
};
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify(initialConversation),
);
const toolCall: ToolCallRecord = {
id: 'tool-1',
name: 'testTool',
args: {},
status: 'awaiting_approval',
timestamp: new Date().toISOString(),
};
chatRecordingService.recordToolCalls([toolCall]);
expect(mkdirSyncSpy).toHaveBeenCalled();
expect(writeFileSyncSpy).toHaveBeenCalled();
const conversation = JSON.parse(
writeFileSyncSpy.mock.calls[0][1] as string,
) as ConversationRecord;
expect(conversation.messages[0]).toEqual({
...initialConversation.messages[0],
toolCalls: [toolCall],
});
});
it('should create a new message if the last message is not from gemini', () => {
const writeFileSyncSpy = vi
.spyOn(fs, 'writeFileSync')
.mockImplementation(() => undefined);
const initialConversation = {
sessionId: 'test-session-id',
projectHash: 'test-project-hash',
messages: [
{
id: 'a-uuid',
type: 'user',
content: 'call a tool',
timestamp: new Date().toISOString(),
},
],
};
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify(initialConversation),
);
const toolCall: ToolCallRecord = {
id: 'tool-1',
name: 'testTool',
args: {},
status: 'awaiting_approval',
timestamp: new Date().toISOString(),
};
chatRecordingService.recordToolCalls([toolCall]);
expect(mkdirSyncSpy).toHaveBeenCalled();
expect(writeFileSyncSpy).toHaveBeenCalled();
const conversation = JSON.parse(
writeFileSyncSpy.mock.calls[0][1] as string,
) as ConversationRecord;
expect(conversation.messages).toHaveLength(2);
expect(conversation.messages[1]).toEqual({
...conversation.messages[1],
id: 'this-is-a-test-uuid',
model: 'gemini-pro',
type: 'gemini',
thoughts: [],
content: '',
toolCalls: [toolCall],
});
});
});
describe('deleteSession', () => {
it('should delete the session file', () => {
const unlinkSyncSpy = vi
.spyOn(fs, 'unlinkSync')
.mockImplementation(() => undefined);
chatRecordingService.deleteSession('test-session-id');
expect(unlinkSyncSpy).toHaveBeenCalledWith(
'/test/project/root/.gemini/tmp/chats/test-session-id.json',
);
});
});
});

View File

@@ -0,0 +1,433 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config } from '../config/config.js';
import { type Status } from '../core/coreToolScheduler.js';
import { type ThoughtSummary } from '../core/turn.js';
import { getProjectHash } from '../utils/paths.js';
import path from 'node:path';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { PartListUnion } from '@google/genai';
/**
* Token usage summary for a message or conversation.
*/
export interface TokensSummary {
input: number; // promptTokenCount
output: number; // candidatesTokenCount
cached: number; // cachedContentTokenCount
thoughts?: number; // thoughtsTokenCount
tool?: number; // toolUsePromptTokenCount
total: number; // totalTokenCount
}
/**
* Base fields common to all messages.
*/
export interface BaseMessageRecord {
id: string;
timestamp: string;
content: string;
}
/**
* Record of a tool call execution within a conversation.
*/
export interface ToolCallRecord {
id: string;
name: string;
args: Record<string, unknown>;
result?: PartListUnion | null;
status: Status;
timestamp: string;
// UI-specific fields for display purposes
displayName?: string;
description?: string;
resultDisplay?: string;
renderOutputAsMarkdown?: boolean;
}
/**
* Message type and message type-specific fields.
*/
export type ConversationRecordExtra =
| {
type: 'user';
}
| {
type: 'gemini';
toolCalls?: ToolCallRecord[];
thoughts?: Array<ThoughtSummary & { timestamp: string }>;
tokens?: TokensSummary | null;
model?: string;
};
/**
* A single message record in a conversation.
*/
export type MessageRecord = BaseMessageRecord & ConversationRecordExtra;
/**
* Complete conversation record stored in session files.
*/
export interface ConversationRecord {
sessionId: string;
projectHash: string;
startTime: string;
lastUpdated: string;
messages: MessageRecord[];
}
/**
* Data structure for resuming an existing session.
*/
export interface ResumedSessionData {
conversation: ConversationRecord;
filePath: string;
}
/**
* Service for automatically recording chat conversations to disk.
*
* This service provides comprehensive conversation recording that captures:
* - All user and assistant messages
* - Tool calls and their execution results
* - Token usage statistics
* - Assistant thoughts and reasoning
*
* Sessions are stored as JSON files in ~/.gemini/tmp/<project_hash>/chats/
*/
export class ChatRecordingService {
private conversationFile: string | null = null;
private cachedLastConvData: string | null = null;
private sessionId: string;
private projectHash: string;
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
private queuedTokens: TokensSummary | null = null;
private config: Config;
constructor(config: Config) {
this.config = config;
this.sessionId = config.getSessionId();
this.projectHash = getProjectHash(config.getProjectRoot());
}
/**
* Initializes the chat recording service: creates a new conversation file and associates it with
* this service instance, or resumes from an existing session if resumedSessionData is provided.
*/
initialize(resumedSessionData?: ResumedSessionData): void {
try {
if (resumedSessionData) {
// Resume from existing session
this.conversationFile = resumedSessionData.filePath;
this.sessionId = resumedSessionData.conversation.sessionId;
// Update the session ID in the existing file
this.updateConversation((conversation) => {
conversation.sessionId = this.sessionId;
});
// Clear any cached data to force fresh reads
this.cachedLastConvData = null;
} else {
// Create new session
const chatsDir = path.join(this.config.getProjectTempDir(), 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const timestamp = new Date()
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `session-${timestamp}-${this.sessionId.slice(
0,
8,
)}.json`;
this.conversationFile = path.join(chatsDir, filename);
this.writeConversation({
sessionId: this.sessionId,
projectHash: this.projectHash,
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
});
}
// Clear any queued data since this is a fresh start
this.queuedThoughts = [];
this.queuedTokens = null;
} catch (error) {
console.error('Error initializing chat recording service:', error);
throw error;
}
}
private getLastMessage(
conversation: ConversationRecord,
): MessageRecord | undefined {
return conversation.messages.at(-1);
}
private newMessage(
type: ConversationRecordExtra['type'],
content: string,
): MessageRecord {
return {
id: randomUUID(),
timestamp: new Date().toISOString(),
type,
content,
};
}
/**
* Records a message in the conversation.
*/
recordMessage(message: {
type: ConversationRecordExtra['type'];
content: string;
append?: boolean;
}): void {
if (!this.conversationFile) return;
try {
this.updateConversation((conversation) => {
if (message.append) {
const lastMsg = this.getLastMessage(conversation);
if (lastMsg && lastMsg.type === message.type) {
lastMsg.content += message.content;
return;
}
}
// We're not appending, or we are appending but the last message's type is not the same as
// the specified type, so just create a new message.
const msg = this.newMessage(message.type, message.content);
if (msg.type === 'gemini') {
// If it's a new Gemini message then incorporate any queued thoughts.
conversation.messages.push({
...msg,
thoughts: this.queuedThoughts,
tokens: this.queuedTokens,
model: this.config.getModel(),
});
this.queuedThoughts = [];
this.queuedTokens = null;
} else {
// Or else just add it.
conversation.messages.push(msg);
}
});
} catch (error) {
console.error('Error saving message:', error);
throw error;
}
}
/**
* Records a thought from the assistant's reasoning process.
*/
recordThought(thought: ThoughtSummary): void {
if (!this.conversationFile) return;
try {
this.queuedThoughts.push({
...thought,
timestamp: new Date().toISOString(),
});
} catch (error) {
if (this.config.getDebugMode()) {
console.error('Error saving thought:', error);
throw error;
}
}
}
/**
* Updates the tokens for the last message in the conversation (which should be by Gemini).
*/
recordMessageTokens(tokens: {
input: number;
output: number;
cached: number;
thoughts?: number;
tool?: number;
total: number;
}): void {
if (!this.conversationFile) return;
try {
this.updateConversation((conversation) => {
const lastMsg = this.getLastMessage(conversation);
// If the last message already has token info, it's because this new token info is for a
// new message that hasn't been recorded yet.
if (lastMsg && lastMsg.type === 'gemini' && !lastMsg.tokens) {
lastMsg.tokens = tokens;
this.queuedTokens = null;
} else {
this.queuedTokens = tokens;
}
});
} catch (error) {
console.error('Error updating message tokens:', error);
throw error;
}
}
/**
* Adds tool calls to the last message in the conversation (which should be by Gemini).
*/
recordToolCalls(toolCalls: ToolCallRecord[]): void {
if (!this.conversationFile) return;
try {
this.updateConversation((conversation) => {
const lastMsg = this.getLastMessage(conversation);
// If a tool call was made, but the last message isn't from Gemini, it's because Gemini is
// calling tools without starting the message with text. So the user submits a prompt, and
// Gemini immediately calls a tool (maybe with some thinking first). In that case, create
// a new empty Gemini message.
// Also if there are any queued thoughts, it means this tool call(s) is from a new Gemini
// message--because it's thought some more since we last, if ever, created a new Gemini
// message from tool calls, when we dequeued the thoughts.
if (
!lastMsg ||
lastMsg.type !== 'gemini' ||
this.queuedThoughts.length > 0
) {
const newMsg: MessageRecord = {
...this.newMessage('gemini' as const, ''),
// This isn't strictly necessary, but TypeScript apparently can't
// tell that the first parameter to newMessage() becomes the
// resulting message's type, and so it thinks that toolCalls may
// not be present. Confirming the type here satisfies it.
type: 'gemini' as const,
toolCalls,
thoughts: this.queuedThoughts,
model: this.config.getModel(),
};
// If there are any queued thoughts join them to this message.
if (this.queuedThoughts.length > 0) {
newMsg.thoughts = this.queuedThoughts;
this.queuedThoughts = [];
}
// If there's any queued tokens info join it to this message.
if (this.queuedTokens) {
newMsg.tokens = this.queuedTokens;
this.queuedTokens = null;
}
conversation.messages.push(newMsg);
} else {
// The last message is an existing Gemini message that we need to update.
// Update any existing tool call entries.
if (!lastMsg.toolCalls) {
lastMsg.toolCalls = [];
}
lastMsg.toolCalls = lastMsg.toolCalls.map((toolCall) => {
// If there are multiple tool calls with the same ID, this will take the first one.
const incomingToolCall = toolCalls.find(
(tc) => tc.id === toolCall.id,
);
if (incomingToolCall) {
// Merge in the new data to keep preserve thoughts, etc., that were assigned to older
// versions of the tool call.
return { ...toolCall, ...incomingToolCall };
} else {
return toolCall;
}
});
// Add any new tools calls that aren't in the message yet.
for (const toolCall of toolCalls) {
const existingToolCall = lastMsg.toolCalls.find(
(tc) => tc.id === toolCall.id,
);
if (!existingToolCall) {
lastMsg.toolCalls.push(toolCall);
}
}
}
});
} catch (error) {
console.error('Error adding tool call to message:', error);
throw error;
}
}
/**
* Loads up the conversation record from disk.
*/
private readConversation(): ConversationRecord {
try {
this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8');
return JSON.parse(this.cachedLastConvData);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error reading conversation file:', error);
throw error;
}
// Placeholder empty conversation if file doesn't exist.
return {
sessionId: this.sessionId,
projectHash: this.projectHash,
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: [],
};
}
}
/**
* Saves the conversation record; overwrites the file.
*/
private writeConversation(conversation: ConversationRecord): void {
try {
if (!this.conversationFile) return;
// Don't write the file yet until there's at least one message.
if (conversation.messages.length === 0) return;
// Only write the file if this change would change the file.
if (this.cachedLastConvData !== JSON.stringify(conversation, null, 2)) {
conversation.lastUpdated = new Date().toISOString();
const newContent = JSON.stringify(conversation, null, 2);
this.cachedLastConvData = newContent;
fs.writeFileSync(this.conversationFile, newContent);
}
} catch (error) {
console.error('Error writing conversation file:', error);
throw error;
}
}
/**
* Convenient helper for updating the conversation without file reading and writing and time
* updating boilerplate.
*/
private updateConversation(
updateFn: (conversation: ConversationRecord) => void,
) {
const conversation = this.readConversation();
updateFn(conversation);
this.writeConversation(conversation);
}
/**
* Deletes a session file by session ID.
*/
deleteSession(sessionId: string): void {
try {
const chatsDir = path.join(this.config.getProjectTempDir(), 'chats');
const sessionPath = path.join(chatsDir, `${sessionId}.json`);
fs.unlinkSync(sessionPath);
} catch (error) {
console.error('Error deleting session:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import { StandardFileSystemService } from './fileSystemService.js';
vi.mock('fs/promises');
describe('StandardFileSystemService', () => {
let fileSystem: StandardFileSystemService;
beforeEach(() => {
vi.resetAllMocks();
fileSystem = new StandardFileSystemService();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readTextFile', () => {
it('should read file content using fs', async () => {
const testContent = 'Hello, World!';
vi.mocked(fs.readFile).mockResolvedValue(testContent);
const result = await fileSystem.readTextFile('/test/file.txt');
expect(fs.readFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8');
expect(result).toBe(testContent);
});
it('should propagate fs.readFile errors', async () => {
const error = new Error('ENOENT: File not found');
vi.mocked(fs.readFile).mockRejectedValue(error);
await expect(fileSystem.readTextFile('/test/file.txt')).rejects.toThrow(
'ENOENT: File not found',
);
});
});
describe('writeTextFile', () => {
it('should write file content using fs', async () => {
vi.mocked(fs.writeFile).mockResolvedValue();
await fileSystem.writeTextFile('/test/file.txt', 'Hello, World!');
expect(fs.writeFile).toHaveBeenCalledWith(
'/test/file.txt',
'Hello, World!',
'utf-8',
);
});
});
});

View File

@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
/**
* Interface for file system operations that may be delegated to different implementations
*/
export interface FileSystemService {
/**
* Read text content from a file
*
* @param filePath - The path to the file to read
* @returns The file content as a string
*/
readTextFile(filePath: string): Promise<string>;
/**
* Write text content to a file
*
* @param filePath - The path to the file to write
* @param content - The content to write
*/
writeTextFile(filePath: string, content: string): Promise<void>;
}
/**
* Standard file system implementation
*/
export class StandardFileSystemService implements FileSystemService {
async readTextFile(filePath: string): Promise<string> {
return fs.readFile(filePath, 'utf-8');
}
async writeTextFile(filePath: string, content: string): Promise<void> {
await fs.writeFile(filePath, content, 'utf-8');
}
}

View File

@@ -367,10 +367,10 @@ Please analyze the conversation history to determine the possibility that the co
return false;
}
if (typeof result.confidence === 'number') {
if (result.confidence > 0.9) {
if (typeof result.reasoning === 'string' && result.reasoning) {
console.warn(result.reasoning);
if (typeof result['confidence'] === 'number') {
if (result['confidence'] > 0.9) {
if (typeof result['reasoning'] === 'string' && result['reasoning']) {
console.warn(result['reasoning']);
}
logLoopDetected(
this.config,
@@ -381,7 +381,7 @@ Please analyze the conversation history to determine the possibility that the co
this.llmCheckInterval = Math.round(
MIN_LLM_CHECK_INTERVAL +
(MAX_LLM_CHECK_INTERVAL - MIN_LLM_CHECK_INTERVAL) *
(1 - result.confidence),
(1 - result['confidence']),
);
}
}

View File

@@ -5,11 +5,6 @@
*/
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
const mockSpawn = vi.hoisted(() => vi.fn());
vi.mock('child_process', () => ({
spawn: mockSpawn,
}));
import EventEmitter from 'events';
import { Readable } from 'stream';
import { type ChildProcess } from 'child_process';
@@ -18,17 +13,43 @@ import {
ShellOutputEvent,
} from './shellExecutionService.js';
// Hoisted Mocks
const mockPtySpawn = vi.hoisted(() => vi.fn());
const mockCpSpawn = vi.hoisted(() => vi.fn());
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockPlatform = vi.hoisted(() => vi.fn());
const mockGetPty = vi.hoisted(() => vi.fn());
// Top-level Mocks
vi.mock('@lydell/node-pty', () => ({
spawn: mockPtySpawn,
}));
vi.mock('child_process', () => ({
spawn: mockCpSpawn,
}));
vi.mock('../utils/textUtils.js', () => ({
isBinary: mockIsBinary,
}));
const mockPlatform = vi.hoisted(() => vi.fn());
vi.mock('os', () => ({
default: {
platform: mockPlatform,
constants: {
signals: {
SIGTERM: 15,
SIGKILL: 9,
},
},
},
platform: mockPlatform,
constants: {
signals: {
SIGTERM: 15,
SIGKILL: 9,
},
},
}));
vi.mock('../utils/getPty.js', () => ({
getPty: mockGetPty,
}));
const mockProcessKill = vi
@@ -36,6 +57,262 @@ const mockProcessKill = vi
.mockImplementation(() => true);
describe('ShellExecutionService', () => {
let mockPtyProcess: EventEmitter & {
pid: number;
kill: Mock;
onData: Mock;
onExit: Mock;
};
let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
beforeEach(() => {
vi.clearAllMocks();
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
mockGetPty.mockResolvedValue({
module: { spawn: mockPtySpawn },
name: 'mock-pty',
});
onOutputEventMock = vi.fn();
mockPtyProcess = new EventEmitter() as EventEmitter & {
pid: number;
kill: Mock;
onData: Mock;
onExit: Mock;
};
mockPtyProcess.pid = 12345;
mockPtyProcess.kill = vi.fn();
mockPtyProcess.onData = vi.fn();
mockPtyProcess.onExit = vi.fn();
mockPtySpawn.mockReturnValue(mockPtyProcess);
});
// Helper function to run a standard execution simulation
const simulateExecution = async (
command: string,
simulation: (
ptyProcess: typeof mockPtyProcess,
ac: AbortController,
) => void,
) => {
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
command,
'/test/dir',
onOutputEventMock,
abortController.signal,
true,
);
await new Promise((resolve) => setImmediate(resolve));
simulation(mockPtyProcess, abortController);
const result = await handle.result;
return { result, handle, abortController };
};
describe('Successful Execution', () => {
it('should execute a command and capture output', async () => {
const { result, handle } = await simulateExecution('ls -l', (pty) => {
pty.onData.mock.calls[0][0]('file1.txt\n');
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
expect(mockPtySpawn).toHaveBeenCalledWith(
'bash',
['-c', 'ls -l'],
expect.any(Object),
);
expect(result.exitCode).toBe(0);
expect(result.signal).toBeNull();
expect(result.error).toBeNull();
expect(result.aborted).toBe(false);
expect(result.output).toBe('file1.txt');
expect(handle.pid).toBe(12345);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'file1.txt',
});
});
it('should strip ANSI codes from output', async () => {
const { result } = await simulateExecution('ls --color=auto', (pty) => {
pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword');
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
expect(result.output).toBe('aredword');
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'aredword',
});
});
it('should correctly decode multi-byte characters split across chunks', async () => {
const { result } = await simulateExecution('echo "你好"', (pty) => {
const multiByteChar = '你好';
pty.onData.mock.calls[0][0](multiByteChar.slice(0, 1));
pty.onData.mock.calls[0][0](multiByteChar.slice(1));
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
expect(result.output).toBe('你好');
});
it('should handle commands with no output', async () => {
const { result } = await simulateExecution('touch file', (pty) => {
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
expect(result.output).toBe('');
expect(onOutputEventMock).not.toHaveBeenCalled();
});
});
describe('Failed Execution', () => {
it('should capture a non-zero exit code', async () => {
const { result } = await simulateExecution('a-bad-command', (pty) => {
pty.onData.mock.calls[0][0]('command not found');
pty.onExit.mock.calls[0][0]({ exitCode: 127, signal: null });
});
expect(result.exitCode).toBe(127);
expect(result.output).toBe('command not found');
expect(result.error).toBeNull();
});
it('should capture a termination signal', async () => {
const { result } = await simulateExecution('long-process', (pty) => {
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: 15 });
});
expect(result.exitCode).toBe(0);
expect(result.signal).toBe(15);
});
it('should handle a synchronous spawn error', async () => {
mockGetPty.mockImplementation(() => null);
mockCpSpawn.mockImplementation(() => {
throw new Error('Simulated PTY spawn error');
});
const handle = await ShellExecutionService.execute(
'any-command',
'/test/dir',
onOutputEventMock,
new AbortController().signal,
true,
);
const result = await handle.result;
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toContain('Simulated PTY spawn error');
expect(result.exitCode).toBe(1);
expect(result.output).toBe('');
expect(handle.pid).toBeUndefined();
});
});
describe('Aborting Commands', () => {
it('should abort a running process and set the aborted flag', async () => {
const { result } = await simulateExecution(
'sleep 10',
(pty, abortController) => {
abortController.abort();
pty.onExit.mock.calls[0][0]({ exitCode: 1, signal: null });
},
);
expect(result.aborted).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalled();
});
});
describe('Binary Output', () => {
it('should detect binary output and switch to progress events', async () => {
mockIsBinary.mockReturnValueOnce(true);
const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]);
const { result } = await simulateExecution('cat image.png', (pty) => {
pty.onData.mock.calls[0][0](binaryChunk1);
pty.onData.mock.calls[0][0](binaryChunk2);
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
expect(result.rawOutput).toEqual(
Buffer.concat([binaryChunk1, binaryChunk2]),
);
expect(onOutputEventMock).toHaveBeenCalledTimes(3);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
});
expect(onOutputEventMock.mock.calls[1][0]).toEqual({
type: 'binary_progress',
bytesReceived: 4,
});
expect(onOutputEventMock.mock.calls[2][0]).toEqual({
type: 'binary_progress',
bytesReceived: 8,
});
});
it('should not emit data events after binary is detected', async () => {
mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));
await simulateExecution('cat mixed_file', (pty) => {
pty.onData.mock.calls[0][0](Buffer.from('some text'));
pty.onData.mock.calls[0][0](Buffer.from([0x00, 0x01, 0x02]));
pty.onData.mock.calls[0][0](Buffer.from('more text'));
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
const eventTypes = onOutputEventMock.mock.calls.map(
(call: [ShellOutputEvent]) => call[0].type,
);
expect(eventTypes).toEqual([
'data',
'binary_detected',
'binary_progress',
'binary_progress',
]);
});
});
describe('Platform-Specific Behavior', () => {
it('should use cmd.exe on Windows', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (pty) =>
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
);
expect(mockPtySpawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'dir "foo bar"'],
expect.any(Object),
);
});
it('should use bash on Linux', async () => {
mockPlatform.mockReturnValue('linux');
await simulateExecution('ls "foo bar"', (pty) =>
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
);
expect(mockPtySpawn).toHaveBeenCalledWith(
'bash',
['-c', 'ls "foo bar"'],
expect.any(Object),
);
});
});
});
describe('ShellExecutionService child_process fallback', () => {
let mockChildProcess: EventEmitter & Partial<ChildProcess>;
let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
@@ -44,23 +321,22 @@ describe('ShellExecutionService', () => {
mockIsBinary.mockReturnValue(false);
mockPlatform.mockReturnValue('linux');
mockGetPty.mockResolvedValue(null);
onOutputEventMock = vi.fn();
mockChildProcess = new EventEmitter() as EventEmitter &
Partial<ChildProcess>;
// FIX: Cast simple EventEmitters to the expected stream type.
mockChildProcess.stdout = new EventEmitter() as Readable;
mockChildProcess.stderr = new EventEmitter() as Readable;
mockChildProcess.kill = vi.fn();
// FIX: Use Object.defineProperty to set the readonly 'pid' property.
Object.defineProperty(mockChildProcess, 'pid', {
value: 12345,
configurable: true,
});
mockSpawn.mockReturnValue(mockChildProcess);
mockCpSpawn.mockReturnValue(mockChildProcess);
});
// Helper function to run a standard execution simulation
@@ -69,11 +345,12 @@ describe('ShellExecutionService', () => {
simulation: (cp: typeof mockChildProcess, ac: AbortController) => void,
) => {
const abortController = new AbortController();
const handle = ShellExecutionService.execute(
const handle = await ShellExecutionService.execute(
command,
'/test/dir',
onOutputEventMock,
abortController.signal,
true,
);
await new Promise((resolve) => setImmediate(resolve));
@@ -90,7 +367,7 @@ describe('ShellExecutionService', () => {
cp.emit('exit', 0, null);
});
expect(mockSpawn).toHaveBeenCalledWith(
expect(mockCpSpawn).toHaveBeenCalledWith(
'ls -l',
[],
expect.objectContaining({ shell: 'bash' }),
@@ -99,19 +376,15 @@ describe('ShellExecutionService', () => {
expect(result.signal).toBeNull();
expect(result.error).toBeNull();
expect(result.aborted).toBe(false);
expect(result.stdout).toBe('file1.txt\n');
expect(result.stderr).toBe('a warning');
expect(result.output).toBe('file1.txt\n\na warning');
expect(result.output).toBe('file1.txt\na warning');
expect(handle.pid).toBe(12345);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
stream: 'stdout',
chunk: 'file1.txt\n',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
stream: 'stderr',
chunk: 'a warning',
});
});
@@ -122,10 +395,9 @@ describe('ShellExecutionService', () => {
cp.emit('exit', 0, null);
});
expect(result.stdout).toBe('aredword');
expect(result.output).toBe('aredword');
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
stream: 'stdout',
chunk: 'aredword',
});
});
@@ -137,7 +409,7 @@ describe('ShellExecutionService', () => {
cp.stdout?.emit('data', multiByteChar.slice(2));
cp.emit('exit', 0, null);
});
expect(result.stdout).toBe('你好');
expect(result.output).toBe('你好');
});
it('should handle commands with no output', async () => {
@@ -145,8 +417,6 @@ describe('ShellExecutionService', () => {
cp.emit('exit', 0, null);
});
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
expect(result.output).toBe('');
expect(onOutputEventMock).not.toHaveBeenCalled();
});
@@ -160,9 +430,7 @@ describe('ShellExecutionService', () => {
});
expect(result.exitCode).toBe(127);
expect(result.stderr).toBe('command not found');
expect(result.stdout).toBe('');
expect(result.output).toBe('\ncommand not found');
expect(result.output).toBe('command not found');
expect(result.error).toBeNull();
});
@@ -172,7 +440,7 @@ describe('ShellExecutionService', () => {
});
expect(result.exitCode).toBeNull();
expect(result.signal).toBe('SIGTERM');
expect(result.signal).toBe(15);
});
it('should handle a spawn error', async () => {
@@ -234,7 +502,7 @@ describe('ShellExecutionService', () => {
expectedSignal,
);
} else {
expect(mockSpawn).toHaveBeenCalledWith(expectedCommand, [
expect(mockCpSpawn).toHaveBeenCalledWith(expectedCommand, [
'/pid',
String(mockChildProcess.pid),
'/f',
@@ -252,11 +520,12 @@ describe('ShellExecutionService', () => {
// Don't await the result inside the simulation block for this specific test.
// We need to control the timeline manually.
const abortController = new AbortController();
const handle = ShellExecutionService.execute(
const handle = await ShellExecutionService.execute(
'unresponsive_process',
'/test/dir',
onOutputEventMock,
abortController.signal,
true,
);
abortController.abort();
@@ -283,7 +552,7 @@ describe('ShellExecutionService', () => {
vi.useRealTimers();
expect(result.aborted).toBe(true);
expect(result.signal).toBe('SIGKILL');
expect(result.signal).toBe(9);
// The individual kill calls were already asserted above.
expect(mockProcessKill).toHaveBeenCalledTimes(2);
});
@@ -328,7 +597,6 @@ describe('ShellExecutionService', () => {
cp.emit('exit', 0, null);
});
// FIX: Provide explicit type for the 'call' parameter in the map function.
const eventTypes = onOutputEventMock.mock.calls.map(
(call: [ShellOutputEvent]) => call[0].type,
);
@@ -348,7 +616,7 @@ describe('ShellExecutionService', () => {
cp.emit('exit', 0, null),
);
expect(mockSpawn).toHaveBeenCalledWith(
expect(mockCpSpawn).toHaveBeenCalledWith(
'dir "foo bar"',
[],
expect.objectContaining({
@@ -362,7 +630,7 @@ describe('ShellExecutionService', () => {
mockPlatform.mockReturnValue('linux');
await simulateExecution('ls "foo bar"', (cp) => cp.emit('exit', 0, null));
expect(mockSpawn).toHaveBeenCalledWith(
expect(mockCpSpawn).toHaveBeenCalledWith(
'ls "foo bar"',
[],
expect.objectContaining({
@@ -373,3 +641,110 @@ describe('ShellExecutionService', () => {
});
});
});
describe('ShellExecutionService execution method selection', () => {
let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>;
let mockPtyProcess: EventEmitter & {
pid: number;
kill: Mock;
onData: Mock;
onExit: Mock;
};
let mockChildProcess: EventEmitter & Partial<ChildProcess>;
beforeEach(() => {
vi.clearAllMocks();
onOutputEventMock = vi.fn();
// Mock for pty
mockPtyProcess = new EventEmitter() as EventEmitter & {
pid: number;
kill: Mock;
onData: Mock;
onExit: Mock;
};
mockPtyProcess.pid = 12345;
mockPtyProcess.kill = vi.fn();
mockPtyProcess.onData = vi.fn();
mockPtyProcess.onExit = vi.fn();
mockPtySpawn.mockReturnValue(mockPtyProcess);
mockGetPty.mockResolvedValue({
module: { spawn: mockPtySpawn },
name: 'mock-pty',
});
// Mock for child_process
mockChildProcess = new EventEmitter() as EventEmitter &
Partial<ChildProcess>;
mockChildProcess.stdout = new EventEmitter() as Readable;
mockChildProcess.stderr = new EventEmitter() as Readable;
mockChildProcess.kill = vi.fn();
Object.defineProperty(mockChildProcess, 'pid', {
value: 54321,
configurable: true,
});
mockCpSpawn.mockReturnValue(mockChildProcess);
});
it('should use node-pty when shouldUseNodePty is true and pty is available', async () => {
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'test command',
'/test/dir',
onOutputEventMock,
abortController.signal,
true, // shouldUseNodePty
);
// Simulate exit to allow promise to resolve
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
const result = await handle.result;
expect(mockGetPty).toHaveBeenCalled();
expect(mockPtySpawn).toHaveBeenCalled();
expect(mockCpSpawn).not.toHaveBeenCalled();
expect(result.executionMethod).toBe('mock-pty');
});
it('should use child_process when shouldUseNodePty is false', async () => {
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'test command',
'/test/dir',
onOutputEventMock,
abortController.signal,
false, // shouldUseNodePty
);
// Simulate exit to allow promise to resolve
mockChildProcess.emit('exit', 0, null);
const result = await handle.result;
expect(mockGetPty).not.toHaveBeenCalled();
expect(mockPtySpawn).not.toHaveBeenCalled();
expect(mockCpSpawn).toHaveBeenCalled();
expect(result.executionMethod).toBe('child_process');
});
it('should fall back to child_process if pty is not available even if shouldUseNodePty is true', async () => {
mockGetPty.mockResolvedValue(null);
const abortController = new AbortController();
const handle = await ShellExecutionService.execute(
'test command',
'/test/dir',
onOutputEventMock,
abortController.signal,
true, // shouldUseNodePty
);
// Simulate exit to allow promise to resolve
mockChildProcess.emit('exit', 0, null);
const result = await handle.result;
expect(mockGetPty).toHaveBeenCalled();
expect(mockPtySpawn).not.toHaveBeenCalled();
expect(mockCpSpawn).toHaveBeenCalled();
expect(result.executionMethod).toBe('child_process');
});
});

View File

@@ -4,35 +4,47 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'child_process';
import { getPty, PtyImplementation } from '../utils/getPty.js';
import { spawn as cpSpawn } from 'child_process';
import { TextDecoder } from 'util';
import os from 'os';
import stripAnsi from 'strip-ansi';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import stripAnsi from 'strip-ansi';
const { Terminal } = pkg;
const SIGKILL_TIMEOUT_MS = 200;
// @ts-expect-error getFullText is not a public API.
const getFullText = (terminal: Terminal) => {
const buffer = terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
lines.push(line ? line.translateToString(true) : '');
}
return lines.join('\n').trim();
};
/** A structured result from a shell command execution. */
export interface ShellExecutionResult {
/** The raw, unprocessed output buffer. */
rawOutput: Buffer;
/** The combined, decoded stdout and stderr as a string. */
/** The combined, decoded output as a string. */
output: string;
/** The decoded stdout as a string. */
stdout: string;
/** The decoded stderr as a string. */
stderr: string;
/** The process exit code, or null if terminated by a signal. */
exitCode: number | null;
/** The signal that terminated the process, if any. */
signal: NodeJS.Signals | null;
signal: number | null;
/** An error object if the process failed to spawn. */
error: Error | null;
/** A boolean indicating if the command was aborted by the user. */
aborted: boolean;
/** The process ID of the spawned shell. */
pid: number | undefined;
/** The method used to execute the shell command. */
executionMethod: 'lydell-node-pty' | 'node-pty' | 'child_process' | 'none';
}
/** A handle for an ongoing shell execution. */
@@ -50,8 +62,6 @@ export type ShellOutputEvent =
| {
/** The event contains a chunk of output data. */
type: 'data';
/** The stream from which the data originated. */
stream: 'stdout' | 'stderr';
/** The decoded string chunk. */
chunk: string;
}
@@ -73,7 +83,7 @@ export type ShellOutputEvent =
*/
export class ShellExecutionService {
/**
* Executes a shell command using `spawn`, capturing all output and lifecycle events.
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
*
* @param commandToExecute The exact command string to run.
* @param cwd The working directory to execute the command in.
@@ -82,172 +92,369 @@ export class ShellExecutionService {
* @returns An object containing the process ID (pid) and a promise that
* resolves with the complete execution result.
*/
static execute(
static async execute(
commandToExecute: string,
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
shouldUseNodePty: boolean,
terminalColumns?: number,
terminalRows?: number,
): Promise<ShellExecutionHandle> {
if (shouldUseNodePty) {
const ptyInfo = await getPty();
if (ptyInfo) {
try {
return this.executeWithPty(
commandToExecute,
cwd,
onOutputEvent,
abortSignal,
terminalColumns,
terminalRows,
ptyInfo,
);
} catch (_e) {
// Fallback to child_process
}
}
}
return this.childProcessFallback(
commandToExecute,
cwd,
onOutputEvent,
abortSignal,
);
}
private static childProcessFallback(
commandToExecute: string,
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
): ShellExecutionHandle {
const isWindows = os.platform() === 'win32';
try {
const isWindows = os.platform() === 'win32';
const child = spawn(commandToExecute, [], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
// Use bash unless in Windows (since it doesn't support bash).
// For windows, just use the default.
shell: isWindows ? true : 'bash',
// Use process groups on non-Windows for robust killing.
// Windows process termination is handled by `taskkill /t`.
detached: !isWindows,
env: {
...process.env,
QWEN_CODE: '1',
},
});
const child = cpSpawn(commandToExecute, [], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
shell: isWindows ? true : 'bash',
detached: !isWindows,
env: {
...process.env,
QWEN_CODE: '1',
TERM: 'xterm-256color',
PAGER: 'cat',
},
});
const result = new Promise<ShellExecutionResult>((resolve) => {
// Use decoders to handle multi-byte characters safely (for streaming output).
let stdoutDecoder: TextDecoder | null = null;
let stderrDecoder: TextDecoder | null = null;
const result = new Promise<ShellExecutionResult>((resolve) => {
let stdoutDecoder: TextDecoder | null = null;
let stderrDecoder: TextDecoder | null = null;
let stdout = '';
let stderr = '';
const outputChunks: Buffer[] = [];
let error: Error | null = null;
let exited = false;
let stdout = '';
let stderr = '';
const outputChunks: Buffer[] = [];
let error: Error | null = null;
let exited = false;
let isStreamingRawContent = true;
const MAX_SNIFF_SIZE = 4096;
let sniffedBytes = 0;
let isStreamingRawContent = true;
const MAX_SNIFF_SIZE = 4096;
let sniffedBytes = 0;
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
if (!stdoutDecoder || !stderrDecoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
stdoutDecoder = new TextDecoder(encoding);
stderrDecoder = new TextDecoder(encoding);
} catch {
// If the encoding is not supported, fall back to utf-8.
// This can happen on some platforms for certain encodings like 'utf-32le'.
stdoutDecoder = new TextDecoder('utf-8');
stderrDecoder = new TextDecoder('utf-8');
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
if (!stdoutDecoder || !stderrDecoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
stdoutDecoder = new TextDecoder(encoding);
stderrDecoder = new TextDecoder(encoding);
} catch {
stdoutDecoder = new TextDecoder('utf-8');
stderrDecoder = new TextDecoder('utf-8');
}
}
}
outputChunks.push(data);
outputChunks.push(data);
// Binary detection logic. This only runs until we've made a determination.
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
// Change state to stop streaming raw content.
isStreamingRawContent = false;
onOutputEvent({ type: 'binary_detected' });
if (isBinary(sniffBuffer)) {
isStreamingRawContent = false;
onOutputEvent({ type: 'binary_detected' });
}
}
const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder;
const decodedChunk = decoder.decode(data, { stream: true });
const strippedChunk = stripAnsi(decodedChunk);
if (stream === 'stdout') {
stdout += strippedChunk;
} else {
stderr += strippedChunk;
}
if (isStreamingRawContent) {
onOutputEvent({ type: 'data', chunk: strippedChunk });
} else {
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
onOutputEvent({
type: 'binary_progress',
bytesReceived: totalBytes,
});
}
};
const handleExit = (
code: number | null,
signal: NodeJS.Signals | null,
) => {
const { finalBuffer } = cleanup();
// Ensure we don't add an extra newline if stdout already ends with one.
const separator = stdout.endsWith('\n') ? '' : '\n';
const combinedOutput =
stdout + (stderr ? (stdout ? separator : '') + stderr : '');
resolve({
rawOutput: finalBuffer,
output: combinedOutput.trim(),
exitCode: code,
signal: signal ? os.constants.signals[signal] : null,
error,
aborted: abortSignal.aborted,
pid: child.pid,
executionMethod: 'child_process',
});
};
child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
child.on('error', (err) => {
error = err;
handleExit(1, null);
});
const abortHandler = async () => {
if (child.pid && !exited) {
if (isWindows) {
cpSpawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
} else {
try {
process.kill(-child.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
process.kill(-child.pid, 'SIGKILL');
}
} catch (_e) {
if (!exited) child.kill('SIGKILL');
}
}
}
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
child.on('exit', (code, signal) => {
handleExit(code, signal);
});
function cleanup() {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
if (stdoutDecoder) {
const remaining = stdoutDecoder.decode();
if (remaining) {
stdout += stripAnsi(remaining);
}
}
if (stderrDecoder) {
const remaining = stderrDecoder.decode();
if (remaining) {
stderr += stripAnsi(remaining);
}
}
const finalBuffer = Buffer.concat(outputChunks);
return { stdout, stderr, finalBuffer };
}
});
const decodedChunk =
stream === 'stdout'
? stdoutDecoder.decode(data, { stream: true })
: stderrDecoder.decode(data, { stream: true });
const strippedChunk = stripAnsi(decodedChunk);
if (stream === 'stdout') {
stdout += strippedChunk;
} else {
stderr += strippedChunk;
}
if (isStreamingRawContent) {
onOutputEvent({ type: 'data', stream, chunk: strippedChunk });
} else {
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
onOutputEvent({ type: 'binary_progress', bytesReceived: totalBytes });
}
};
child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
child.on('error', (err) => {
const { stdout, stderr, finalBuffer } = cleanup();
error = err;
resolve({
return { pid: child.pid, result };
} catch (e) {
const error = e as Error;
return {
pid: undefined,
result: Promise.resolve({
error,
stdout,
stderr,
rawOutput: finalBuffer,
output: stdout + (stderr ? `\n${stderr}` : ''),
rawOutput: Buffer.from(''),
output: '',
exitCode: 1,
signal: null,
aborted: false,
pid: child.pid,
});
});
const abortHandler = async () => {
if (child.pid && !exited) {
if (isWindows) {
spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
} else {
try {
// Kill the entire process group (negative PID).
// SIGTERM first, then SIGKILL if it doesn't die.
process.kill(-child.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
process.kill(-child.pid, 'SIGKILL');
}
} catch (_e) {
// Fall back to killing just the main process if group kill fails.
if (!exited) child.kill('SIGKILL');
}
}
}
pid: undefined,
executionMethod: 'none',
}),
};
}
}
abortSignal.addEventListener('abort', abortHandler, { once: true });
private static executeWithPty(
commandToExecute: string,
cwd: string,
onOutputEvent: (event: ShellOutputEvent) => void,
abortSignal: AbortSignal,
terminalColumns: number | undefined,
terminalRows: number | undefined,
ptyInfo: PtyImplementation | undefined,
): ShellExecutionHandle {
try {
const cols = terminalColumns ?? 80;
const rows = terminalRows ?? 30;
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const args = isWindows
? ['/c', commandToExecute]
: ['-c', commandToExecute];
child.on('exit', (code: number, signal: NodeJS.Signals) => {
const { stdout, stderr, finalBuffer } = cleanup();
resolve({
rawOutput: finalBuffer,
output: stdout + (stderr ? `\n${stderr}` : ''),
stdout,
stderr,
exitCode: code,
signal,
error,
aborted: abortSignal.aborted,
pid: child.pid,
});
const ptyProcess = ptyInfo?.module.spawn(shell, args, {
cwd,
name: 'xterm-color',
cols,
rows,
env: {
...process.env,
QWEN_CODE: '1',
TERM: 'xterm-256color',
PAGER: 'cat',
},
handleFlowControl: true,
});
/**
* Cleans up a process (and it's accompanying state) that is exiting or
* erroring and returns output formatted output buffers and strings
*/
function cleanup() {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
if (stdoutDecoder) {
stdout += stripAnsi(stdoutDecoder.decode());
}
if (stderrDecoder) {
stderr += stripAnsi(stderrDecoder.decode());
}
const result = new Promise<ShellExecutionResult>((resolve) => {
const headlessTerminal = new Terminal({
allowProposedApi: true,
cols,
rows,
});
let processingChain = Promise.resolve();
let decoder: TextDecoder | null = null;
let output = '';
const outputChunks: Buffer[] = [];
const error: Error | null = null;
let exited = false;
const finalBuffer = Buffer.concat(outputChunks);
let isStreamingRawContent = true;
const MAX_SNIFF_SIZE = 4096;
let sniffedBytes = 0;
return { stdout, stderr, finalBuffer };
}
});
const handleOutput = (data: Buffer) => {
processingChain = processingChain.then(
() =>
new Promise<void>((resolve) => {
if (!decoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
decoder = new TextDecoder(encoding);
} catch {
decoder = new TextDecoder('utf-8');
}
}
return { pid: child.pid, result };
outputChunks.push(data);
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
isStreamingRawContent = false;
onOutputEvent({ type: 'binary_detected' });
}
}
if (isStreamingRawContent) {
const decodedChunk = decoder.decode(data, { stream: true });
headlessTerminal.write(decodedChunk, () => {
const newStrippedOutput = getFullText(headlessTerminal);
output = newStrippedOutput;
onOutputEvent({ type: 'data', chunk: newStrippedOutput });
resolve();
});
} else {
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
onOutputEvent({
type: 'binary_progress',
bytesReceived: totalBytes,
});
resolve();
}
}),
);
};
ptyProcess.onData((data: string) => {
const bufferData = Buffer.from(data, 'utf-8');
handleOutput(bufferData);
});
ptyProcess.onExit(
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
processingChain.then(() => {
const finalBuffer = Buffer.concat(outputChunks);
resolve({
rawOutput: finalBuffer,
output,
exitCode,
signal: signal ?? null,
error,
aborted: abortSignal.aborted,
pid: ptyProcess.pid,
executionMethod: ptyInfo?.name ?? 'node-pty',
});
});
},
);
const abortHandler = async () => {
if (ptyProcess.pid && !exited) {
ptyProcess.kill('SIGHUP');
}
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
});
return { pid: ptyProcess.pid, result };
} catch (e) {
const error = e as Error;
return {
pid: undefined,
result: Promise.resolve({
error,
rawOutput: Buffer.from(''),
output: '',
exitCode: 1,
signal: null,
aborted: false,
pid: undefined,
executionMethod: 'none',
}),
};
}
}
}