mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
# 🚀 Sync Gemini CLI v0.2.1 - Major Feature Update (#483)
This commit is contained in:
367
packages/core/src/services/chatRecordingService.test.ts
Normal file
367
packages/core/src/services/chatRecordingService.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
433
packages/core/src/services/chatRecordingService.ts
Normal file
433
packages/core/src/services/chatRecordingService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
packages/core/src/services/fileSystemService.test.ts
Normal file
59
packages/core/src/services/fileSystemService.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
packages/core/src/services/fileSystemService.ts
Normal file
41
packages/core/src/services/fileSystemService.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user