mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c7fe1367 | |||
| ba90cc2538 |
@@ -71,30 +71,4 @@ describe('interactive_commands', () => {
|
||||
).toMatch(/(?:^|\s)(--yes|-y|--template\s+\S+)(?:\s|$|\\|")/);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates that the agent does not hang when creating a vite app.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should not hang when creating a vite app',
|
||||
prompt: 'create a hello world app with vite',
|
||||
files: {
|
||||
//'.npmrc': 'cache=./.npm-cache',
|
||||
},
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall('run_shell_command');
|
||||
const logs = rig.readToolLogs();
|
||||
const viteCall = logs.find(
|
||||
(l) =>
|
||||
l.toolRequest.name === 'run_shell_command' &&
|
||||
l.toolRequest.args.toLowerCase().includes('vite'),
|
||||
);
|
||||
|
||||
expect(viteCall, 'Agent should have called vite').toBeDefined();
|
||||
expect(
|
||||
viteCall?.toolRequest.success,
|
||||
'Vite tool call should finish successfully without hanging',
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,6 +599,35 @@ describe('useGeminiStream', () => {
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled(); // submitQuery uses this
|
||||
});
|
||||
|
||||
it('should expose activePtyId for non-shell executing tools that report pid', () => {
|
||||
const remoteExecutingTool: TrackedExecutingToolCall = {
|
||||
request: {
|
||||
callId: 'remote-call-1',
|
||||
name: 'remote_agent_call',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-remote',
|
||||
},
|
||||
status: CoreToolCallStatus.Executing,
|
||||
responseSubmittedToGemini: false,
|
||||
tool: {
|
||||
name: 'remote_agent_call',
|
||||
displayName: 'Remote Agent',
|
||||
description: 'Remote agent execution',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
invocation: {
|
||||
getDescription: () => 'Calling remote agent',
|
||||
} as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
liveOutput: 'working...',
|
||||
pid: 4242,
|
||||
};
|
||||
|
||||
const { result } = renderTestHook([remoteExecutingTool]);
|
||||
expect(result.current.activePtyId).toBe(4242);
|
||||
});
|
||||
|
||||
it('should submit tool responses when all tool calls are completed and ready', async () => {
|
||||
const toolCall1ResponseParts: Part[] = [{ text: 'tool 1 final response' }];
|
||||
const toolCall2ResponseParts: Part[] = [{ text: 'tool 2 final response' }];
|
||||
|
||||
@@ -94,7 +94,7 @@ type ToolResponseWithParts = ToolCallResponseInfo & {
|
||||
llmContent?: PartListUnion;
|
||||
};
|
||||
|
||||
interface ShellToolData {
|
||||
interface BackgroundToolData {
|
||||
pid?: number;
|
||||
command?: string;
|
||||
initialOutput?: string;
|
||||
@@ -111,11 +111,11 @@ const SUPPRESSED_TOOL_ERRORS_NOTE =
|
||||
const LOW_VERBOSITY_FAILURE_NOTE =
|
||||
'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';
|
||||
|
||||
function isShellToolData(data: unknown): data is ShellToolData {
|
||||
function isBackgroundToolData(data: unknown): data is BackgroundToolData {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
const d = data as Partial<ShellToolData>;
|
||||
const d = data as Partial<BackgroundToolData>;
|
||||
return (
|
||||
(d.pid === undefined || typeof d.pid === 'number') &&
|
||||
(d.command === undefined || typeof d.command === 'string') &&
|
||||
@@ -312,12 +312,12 @@ export const useGeminiStream = (
|
||||
);
|
||||
|
||||
const activeToolPtyId = useMemo(() => {
|
||||
const executingShellTool = toolCalls.find(
|
||||
(tc) =>
|
||||
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
||||
const executingBackgroundableTool = toolCalls.find(
|
||||
(toolCall): toolCall is TrackedExecutingToolCall =>
|
||||
toolCall.status === CoreToolCallStatus.Executing &&
|
||||
typeof toolCall.pid === 'number',
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
|
||||
return executingBackgroundableTool?.pid;
|
||||
}, [toolCalls]);
|
||||
|
||||
const onExec = useCallback(async (done: Promise<void>) => {
|
||||
@@ -1651,22 +1651,17 @@ export const useGeminiStream = (
|
||||
!processedMemoryToolsRef.current.has(t.request.callId),
|
||||
);
|
||||
|
||||
// Handle backgrounded shell tools
|
||||
// Handle tools moved to the background (shell + remote agents).
|
||||
completedAndReadyToSubmitTools.forEach((t) => {
|
||||
const isShell = t.request.name === 'run_shell_command';
|
||||
// Access result from the tracked tool call response
|
||||
const response = t.response as ToolResponseWithParts;
|
||||
const rawData = response?.data;
|
||||
const data = isShellToolData(rawData) ? rawData : undefined;
|
||||
|
||||
// Use data.pid for shell commands moved to the background.
|
||||
const data = isBackgroundToolData(rawData) ? rawData : undefined;
|
||||
const pid = data?.pid;
|
||||
|
||||
if (isShell && pid) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const command = (data?.['command'] as string) ?? 'shell';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const initialOutput = (data?.['initialOutput'] as string) ?? '';
|
||||
if (pid) {
|
||||
const command = data.command ?? t.request.name;
|
||||
const initialOutput = data.initialOutput ?? '';
|
||||
|
||||
registerBackgroundShell(pid, command, initialOutput);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { RemoteAgentDefinition } from './types.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import type { A2AAuthProvider } from './auth-provider/types.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
|
||||
// Mock A2AClientManager
|
||||
vi.mock('./a2a-client-manager.js', () => ({
|
||||
@@ -583,6 +584,88 @@ describe('RemoteAgentInvocation', () => {
|
||||
'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support Ctrl+B backgrounding through ShellExecutionService', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
|
||||
let releaseSecondChunk: (() => void) | undefined;
|
||||
const secondChunkGate = new Promise<void>((resolve) => {
|
||||
releaseSecondChunk = resolve;
|
||||
});
|
||||
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Chunk 1' }],
|
||||
};
|
||||
await secondChunkGate;
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-2',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Chunk 2' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let pid: number | undefined;
|
||||
const onExit = vi.fn();
|
||||
let unsubscribeOnExit: (() => void) | undefined;
|
||||
const streamedOutputChunks: string[] = [];
|
||||
let unsubscribeStream: (() => void) | undefined;
|
||||
|
||||
const updateOutput = vi.fn((output: unknown) => {
|
||||
if (output === 'Chunk 1' && pid) {
|
||||
ShellExecutionService.background(pid);
|
||||
unsubscribeStream = ShellExecutionService.subscribe(pid, (event) => {
|
||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||
streamedOutputChunks.push(event.chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'long task' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const resultPromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
updateOutput,
|
||||
undefined,
|
||||
(newPid) => {
|
||||
pid = newPid;
|
||||
unsubscribeOnExit = ShellExecutionService.onExit(newPid, onExit);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(pid).toBeDefined();
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Remote agent moved to background',
|
||||
);
|
||||
expect(result.data).toMatchObject({
|
||||
pid,
|
||||
initialOutput: 'Chunk 1',
|
||||
});
|
||||
|
||||
releaseSecondChunk?.();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(streamedOutputChunks.join('')).toContain('Chunk 2');
|
||||
});
|
||||
|
||||
unsubscribeStream?.();
|
||||
unsubscribeOnExit?.();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confirmations', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
|
||||
/**
|
||||
* Authentication handler implementation using Google Application Default Credentials (ADC).
|
||||
@@ -145,102 +146,192 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
private publishBackgroundDelta(
|
||||
pid: number,
|
||||
previousOutput: string,
|
||||
nextOutput: string,
|
||||
): string {
|
||||
if (nextOutput.length === 0 || nextOutput === previousOutput) {
|
||||
return previousOutput;
|
||||
}
|
||||
|
||||
if (nextOutput.startsWith(previousOutput)) {
|
||||
ShellExecutionService.appendVirtualOutput(
|
||||
pid,
|
||||
nextOutput.slice(previousOutput.length),
|
||||
);
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
// If the reassembled output changes non-monotonically, resync by appending
|
||||
// the full latest snapshot with a clear separator.
|
||||
ShellExecutionService.appendVirtualOutput(
|
||||
pid,
|
||||
`\n\n[Output updated]\n${nextOutput}`,
|
||||
);
|
||||
return nextOutput;
|
||||
}
|
||||
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
updateOutput?: (output: string | AnsiOutput) => void,
|
||||
_shellExecutionConfig?: unknown,
|
||||
setPidCallback?: (pid: number) => void,
|
||||
): Promise<ToolResult> {
|
||||
// 1. Ensure the agent is loaded (cached by manager)
|
||||
// We assume the user has provided an access token via some mechanism (TODO),
|
||||
// or we rely on ADC.
|
||||
const reassembler = new A2AResultReassembler();
|
||||
try {
|
||||
const priorState = RemoteAgentInvocation.sessionState.get(
|
||||
this.definition.name,
|
||||
);
|
||||
if (priorState) {
|
||||
this.contextId = priorState.contextId;
|
||||
this.taskId = priorState.taskId;
|
||||
}
|
||||
const executionController = new AbortController();
|
||||
const onAbort = () => executionController.abort();
|
||||
_signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const authHandler = await this.getAuthHandler();
|
||||
const { pid, result } = ShellExecutionService.createVirtualExecution(
|
||||
'',
|
||||
() => executionController.abort(),
|
||||
);
|
||||
if (pid === undefined) {
|
||||
_signal.removeEventListener('abort', onAbort);
|
||||
return {
|
||||
llmContent: [
|
||||
{ text: 'Error calling remote agent: missing execution pid.' },
|
||||
],
|
||||
returnDisplay: 'Error calling remote agent: missing execution pid.',
|
||||
error: {
|
||||
message: 'Error calling remote agent: missing execution pid.',
|
||||
},
|
||||
};
|
||||
}
|
||||
const backgroundPid = pid;
|
||||
setPidCallback?.(backgroundPid);
|
||||
|
||||
if (!this.clientManager.getClient(this.definition.name)) {
|
||||
await this.clientManager.loadAgent(
|
||||
const run = async () => {
|
||||
let lastOutput = '';
|
||||
try {
|
||||
const priorState = RemoteAgentInvocation.sessionState.get(
|
||||
this.definition.name,
|
||||
this.definition.agentCardUrl,
|
||||
authHandler,
|
||||
);
|
||||
}
|
||||
if (priorState) {
|
||||
this.contextId = priorState.contextId;
|
||||
this.taskId = priorState.taskId;
|
||||
}
|
||||
|
||||
const message = this.params.query;
|
||||
const authHandler = await this.getAuthHandler();
|
||||
|
||||
const stream = this.clientManager.sendMessageStream(
|
||||
this.definition.name,
|
||||
message,
|
||||
{
|
||||
if (!this.clientManager.getClient(this.definition.name)) {
|
||||
await this.clientManager.loadAgent(
|
||||
this.definition.name,
|
||||
this.definition.agentCardUrl,
|
||||
authHandler,
|
||||
);
|
||||
}
|
||||
|
||||
const stream = this.clientManager.sendMessageStream(
|
||||
this.definition.name,
|
||||
this.params.query,
|
||||
{
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
signal: executionController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
let finalResponse: SendMessageResult | undefined;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (executionController.signal.aborted) {
|
||||
throw new Error('Operation aborted');
|
||||
}
|
||||
finalResponse = chunk;
|
||||
reassembler.update(chunk);
|
||||
|
||||
const currentOutput = reassembler.toString();
|
||||
lastOutput = this.publishBackgroundDelta(
|
||||
backgroundPid,
|
||||
lastOutput,
|
||||
currentOutput,
|
||||
);
|
||||
if (updateOutput) {
|
||||
updateOutput(currentOutput);
|
||||
}
|
||||
|
||||
const {
|
||||
contextId: newContextId,
|
||||
taskId: newTaskId,
|
||||
clearTaskId,
|
||||
} = extractIdsFromResponse(chunk);
|
||||
|
||||
if (newContextId) {
|
||||
this.contextId = newContextId;
|
||||
}
|
||||
|
||||
this.taskId = clearTaskId ? undefined : (newTaskId ?? this.taskId);
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('No response from remote agent.');
|
||||
}
|
||||
|
||||
debugLogger.debug(
|
||||
`[RemoteAgent] Final response from ${this.definition.name}:\n${JSON.stringify(finalResponse, null, 2)}`,
|
||||
);
|
||||
|
||||
ShellExecutionService.completeVirtualExecution(backgroundPid, {
|
||||
exitCode: 0,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const partialOutput = reassembler.toString();
|
||||
lastOutput = this.publishBackgroundDelta(
|
||||
backgroundPid,
|
||||
lastOutput,
|
||||
partialOutput,
|
||||
);
|
||||
const errorMessage = `Error calling remote agent: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
ShellExecutionService.completeVirtualExecution(backgroundPid, {
|
||||
error: new Error(errorMessage),
|
||||
aborted: executionController.signal.aborted,
|
||||
exitCode: executionController.signal.aborted ? 130 : 1,
|
||||
});
|
||||
} finally {
|
||||
_signal.removeEventListener('abort', onAbort);
|
||||
// Persist state even on partial failures or aborts to maintain conversational continuity.
|
||||
RemoteAgentInvocation.sessionState.set(this.definition.name, {
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
signal: _signal,
|
||||
},
|
||||
);
|
||||
|
||||
let finalResponse: SendMessageResult | undefined;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (_signal.aborted) {
|
||||
throw new Error('Operation aborted');
|
||||
}
|
||||
finalResponse = chunk;
|
||||
reassembler.update(chunk);
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(reassembler.toString());
|
||||
}
|
||||
|
||||
const {
|
||||
contextId: newContextId,
|
||||
taskId: newTaskId,
|
||||
clearTaskId,
|
||||
} = extractIdsFromResponse(chunk);
|
||||
|
||||
if (newContextId) {
|
||||
this.contextId = newContextId;
|
||||
}
|
||||
|
||||
this.taskId = clearTaskId ? undefined : (newTaskId ?? this.taskId);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error('No response from remote agent.');
|
||||
}
|
||||
|
||||
const finalOutput = reassembler.toString();
|
||||
|
||||
debugLogger.debug(
|
||||
`[RemoteAgent] Final response from ${this.definition.name}:\n${JSON.stringify(finalResponse, null, 2)}`,
|
||||
);
|
||||
void run();
|
||||
const executionResult = await result;
|
||||
|
||||
if (executionResult.backgrounded) {
|
||||
const command = `${this.getDescription()}: ${this.params.query}`;
|
||||
const backgroundMessage = `Remote agent moved to background (PID: ${backgroundPid}). Output hidden. Press Ctrl+B to view.`;
|
||||
return {
|
||||
llmContent: [{ text: finalOutput }],
|
||||
returnDisplay: finalOutput,
|
||||
llmContent: [{ text: backgroundMessage }],
|
||||
returnDisplay: backgroundMessage,
|
||||
data: {
|
||||
pid: backgroundPid,
|
||||
command,
|
||||
initialOutput: executionResult.output,
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const partialOutput = reassembler.toString();
|
||||
const errorMessage = `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
|
||||
const fullDisplay = partialOutput
|
||||
? `${partialOutput}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
}
|
||||
|
||||
if (executionResult.error) {
|
||||
const fullDisplay = executionResult.output
|
||||
? `${executionResult.output}\n\n${executionResult.error.message}`
|
||||
: executionResult.error.message;
|
||||
return {
|
||||
llmContent: [{ text: fullDisplay }],
|
||||
returnDisplay: fullDisplay,
|
||||
error: { message: errorMessage },
|
||||
error: { message: executionResult.error.message },
|
||||
};
|
||||
} finally {
|
||||
// Persist state even on partial failures or aborts to maintain conversational continuity.
|
||||
RemoteAgentInvocation.sessionState.set(this.definition.name, {
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: [{ text: executionResult.output }],
|
||||
returnDisplay: executionResult.output,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
type AnyDeclarativeTool,
|
||||
type ToolLiveOutput,
|
||||
} from '../tools/tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { HookSystem } from '../hooks/hookSystem.js';
|
||||
@@ -37,6 +38,30 @@ class MockInvocation extends BaseToolInvocation<{ key?: string }, ToolResult> {
|
||||
}
|
||||
}
|
||||
|
||||
class MockPidInvocation extends BaseToolInvocation<
|
||||
{ key?: string },
|
||||
ToolResult
|
||||
> {
|
||||
constructor(params: { key?: string }, messageBus: MessageBus) {
|
||||
super(params, messageBus);
|
||||
}
|
||||
getDescription() {
|
||||
return 'mock-pid';
|
||||
}
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
_updateOutput?: (output: ToolLiveOutput) => void,
|
||||
_shellExecutionConfig?: unknown,
|
||||
setPidCallback?: (pid: number) => void,
|
||||
) {
|
||||
setPidCallback?.(4242);
|
||||
return {
|
||||
llmContent: 'pid',
|
||||
returnDisplay: 'pid',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('executeToolWithHooks', () => {
|
||||
let messageBus: MessageBus;
|
||||
let mockTool: AnyDeclarativeTool;
|
||||
@@ -258,4 +283,26 @@ describe('executeToolWithHooks', () => {
|
||||
expect(invocation.params.key).toBe('original');
|
||||
expect(mockTool.build).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass pid callback through for non-shell invocations', async () => {
|
||||
const invocation = new MockPidInvocation({}, messageBus);
|
||||
const abortSignal = new AbortController().signal;
|
||||
const setPidCallback = vi.fn();
|
||||
|
||||
vi.mocked(mockHookSystem.fireBeforeToolEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(mockHookSystem.fireAfterToolEvent).mockResolvedValue(undefined);
|
||||
|
||||
await executeToolWithHooks(
|
||||
invocation,
|
||||
'test_tool',
|
||||
abortSignal,
|
||||
mockTool,
|
||||
undefined,
|
||||
undefined,
|
||||
setPidCallback,
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(setPidCallback).toHaveBeenCalledWith(4242);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { ShellExecutionConfig } from '../index.js';
|
||||
import { ShellToolInvocation } from '../tools/shell.js';
|
||||
import type { ShellToolInvocation } from '../tools/shell.js';
|
||||
import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js';
|
||||
|
||||
/**
|
||||
@@ -154,22 +154,23 @@ export async function executeToolWithHooks(
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the actual tool
|
||||
let toolResult: ToolResult;
|
||||
if (setPidCallback && invocation instanceof ShellToolInvocation) {
|
||||
toolResult = await invocation.execute(
|
||||
signal,
|
||||
liveOutputCallback,
|
||||
shellExecutionConfig,
|
||||
setPidCallback,
|
||||
);
|
||||
} else {
|
||||
toolResult = await invocation.execute(
|
||||
signal,
|
||||
liveOutputCallback,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
}
|
||||
// Execute the actual tool. Some tools (not just shell) can optionally expose
|
||||
// a PID-like handle via a fourth parameter.
|
||||
const invocationWithPidSupport = invocation as AnyToolInvocation & {
|
||||
execute(
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (outputChunk: ToolLiveOutput) => void,
|
||||
shellExecutionConfig?: ShellExecutionConfig,
|
||||
setPidCallback?: (pid: number) => void,
|
||||
): Promise<ToolResult>;
|
||||
};
|
||||
|
||||
const toolResult: ToolResult = await invocationWithPidSupport.execute(
|
||||
signal,
|
||||
liveOutputCallback,
|
||||
shellExecutionConfig,
|
||||
setPidCallback,
|
||||
);
|
||||
|
||||
// Append notification if parameters were modified
|
||||
if (inputWasModified) {
|
||||
|
||||
@@ -214,9 +214,6 @@ Use the following guidelines to optimize your search and read patterns.
|
||||
- **Navigating:** read the minimum required to not require additional turns spent reading the file.
|
||||
</examples>
|
||||
|
||||
## Shell Usage
|
||||
- **Non-Interactive Mandate:** You MUST ALWAYS use non-interactive flags (e.g., \`--yes\`, \`-y\`, \`--no-interactive\`) with any command that might prompt for user input or installation confirmation. This is especially CRITICAL for \`npm create\`, \`npm init\`, and \`npx\`. **Placement is key:** For \`npm create\` and \`npm init\`, you MUST place the non-interactive flag (\`--yes\`) BEFORE any \`--\` argument separator, AND you MUST include tool-specific non-interactive flags like \`--no-interactive\` AFTER the separator to avoid modern prompts (e.g., \`npm create vite@latest --yes -- --template react-ts --no-interactive\`). Failure to include BOTH flags correctly will cause the environment to hang indefinitely.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in ${formattedFilenames} files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -691,7 +688,7 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string {
|
||||
- **Mobile:** Compose Multiplatform or Flutter.
|
||||
- **Games:** HTML/CSS/JS (Three.js for 3D).
|
||||
- **CLIs:** Python or Go.
|
||||
3. **Implementation:** Autonomously implement each feature per the approved plan. When starting, scaffold the application using ${formatToolName(SHELL_TOOL_NAME)}. For interactive scaffolding tools (like create-react-app, create-vite, or npm create), you MUST use BOTH the non-interactive installation flag (\`--yes\` or \`-y\`) AND any tool-specific non-interactive flags (like \`--no-interactive\`). **Place \`--yes\` BEFORE the \`--\` separator** and any tool flags like \`--no-interactive\` AFTER it (e.g. \`npm create vite@latest --yes -- --template react-ts --no-interactive\`) to prevent the environment from hanging waiting for user input. For visual assets, utilize **platform-native primitives** (e.g., stylized shapes, gradients, icons). Never link to external services or assume local paths for assets that have not been created.
|
||||
3. **Implementation:** Autonomously implement each feature per the approved plan. When starting, scaffold the application using ${formatToolName(SHELL_TOOL_NAME)}. For interactive scaffolding tools (like create-react-app, create-vite, or npm create), you MUST use the corresponding non-interactive flag (e.g. '--yes', '-y', or specific template flags) to prevent the environment from hanging waiting for user input. For visual assets, utilize **platform-native primitives** (e.g., stylized shapes, gradients, icons). Never link to external services or assume local paths for assets that have not been created.
|
||||
4. **Verify:** Review work against the original request. Fix bugs and deviations. **Build the application and ensure there are no compile errors.**`.trim();
|
||||
}
|
||||
|
||||
@@ -701,17 +698,15 @@ function toolUsageInteractive(
|
||||
): string {
|
||||
if (interactive) {
|
||||
const focusHint = interactiveShellEnabled
|
||||
? ' If you choose to execute an interactive command consider letting the user know they can press `ctrl + f` to focus into the shell to provide input.'
|
||||
? ' If you choose to execute an interactive command consider letting the user know they can press `tab` to focus into the shell to provide input.'
|
||||
: '';
|
||||
return `
|
||||
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}
|
||||
- **Proactive Non-Interaction:** Always use non-interactive flags (e.g., \`--yes\` or \`-y\`) with tools that might prompt for confirmation, such as \`npm create\`, \`npm init\`, or \`npx\`. **CRITICAL:** Failure to use \`--yes\` with these tools will cause the environment to hang indefinitely. Do not rely on \`--help\` as even help commands can prompt for installation.`;
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).${focusHint}`;
|
||||
}
|
||||
return `
|
||||
- **Background Processes:** To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
|
||||
- **Proactive Non-Interaction:** Always use non-interactive flags (e.g., \`--yes\` or \`-y\`) with tools that might prompt for confirmation, such as \`npm create\`, \`npm init\`, or \`npx\`. **CRITICAL:** Failure to use \`--yes\` with these tools will cause the environment to hang indefinitely. Do not rely on \`--help\` as even help commands can prompt for installation.`;
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).`;
|
||||
}
|
||||
|
||||
function toolUsageRememberingFacts(
|
||||
|
||||
@@ -534,6 +534,51 @@ describe('ToolExecutor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should report PID updates for non-shell tools that support backgrounding', async () => {
|
||||
const mockTool = new MockTool({
|
||||
name: 'remote_agent_call',
|
||||
description: 'Remote agent call',
|
||||
});
|
||||
const invocation = mockTool.build({});
|
||||
|
||||
const testPid = 67890;
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
|
||||
async (_inv, _name, _sig, _tool, _liveCb, _shellCfg, setPidCallback) => {
|
||||
setPidCallback?.(testPid);
|
||||
return { llmContent: 'done', returnDisplay: 'done' };
|
||||
},
|
||||
);
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-remote-pid',
|
||||
name: 'remote_agent_call',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-remote-pid',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const onUpdateToolCall = vi.fn();
|
||||
|
||||
await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall,
|
||||
});
|
||||
|
||||
expect(onUpdateToolCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: CoreToolCallStatus.Executing,
|
||||
pid: testPid,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return cancelled result with partial output when signal is aborted', async () => {
|
||||
const mockTool = new MockTool({
|
||||
name: 'slowTool',
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type ToolLiveOutput,
|
||||
} from '../index.js';
|
||||
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { ShellToolInvocation } from '../tools/shell.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
|
||||
import {
|
||||
@@ -89,43 +88,28 @@ export class ToolExecutor {
|
||||
let completedToolCall: CompletedToolCall;
|
||||
|
||||
try {
|
||||
let promise: Promise<ToolResult>;
|
||||
if (invocation instanceof ShellToolInvocation) {
|
||||
const setPidCallback = (pid: number) => {
|
||||
const executingCall: ExecutingToolCall = {
|
||||
...call,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
tool,
|
||||
invocation,
|
||||
pid,
|
||||
startTime: 'startTime' in call ? call.startTime : undefined,
|
||||
};
|
||||
onUpdateToolCall(executingCall);
|
||||
const setPidCallback = (pid: number) => {
|
||||
const executingCall: ExecutingToolCall = {
|
||||
...call,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
tool,
|
||||
invocation,
|
||||
pid,
|
||||
startTime: 'startTime' in call ? call.startTime : undefined,
|
||||
};
|
||||
promise = executeToolWithHooks(
|
||||
invocation,
|
||||
toolName,
|
||||
signal,
|
||||
tool,
|
||||
liveOutputCallback,
|
||||
shellExecutionConfig,
|
||||
setPidCallback,
|
||||
this.config,
|
||||
request.originalRequestName,
|
||||
);
|
||||
} else {
|
||||
promise = executeToolWithHooks(
|
||||
invocation,
|
||||
toolName,
|
||||
signal,
|
||||
tool,
|
||||
liveOutputCallback,
|
||||
shellExecutionConfig,
|
||||
undefined,
|
||||
this.config,
|
||||
request.originalRequestName,
|
||||
);
|
||||
}
|
||||
onUpdateToolCall(executingCall);
|
||||
};
|
||||
const promise = executeToolWithHooks(
|
||||
invocation,
|
||||
toolName,
|
||||
signal,
|
||||
tool,
|
||||
liveOutputCallback,
|
||||
shellExecutionConfig,
|
||||
setPidCallback,
|
||||
this.config,
|
||||
request.originalRequestName,
|
||||
);
|
||||
|
||||
const toolResult: ToolResult = await promise;
|
||||
|
||||
|
||||
@@ -432,13 +432,21 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
describe('pty interaction', () => {
|
||||
let activePtysGetSpy: { mockRestore: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ShellExecutionService['activePtys'], 'get').mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ptyProcess: mockPtyProcess as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
});
|
||||
activePtysGetSpy = vi
|
||||
.spyOn(ShellExecutionService['activePtys'], 'get')
|
||||
.mockReturnValue({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ptyProcess: mockPtyProcess as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
activePtysGetSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should write to the pty and trigger a render', async () => {
|
||||
@@ -1633,3 +1641,85 @@ describe('ShellExecutionService environment variables', () => {
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShellExecutionService virtual executions', () => {
|
||||
it('completes a virtual execution in the foreground', async () => {
|
||||
const { pid, result } = ShellExecutionService.createVirtualExecution();
|
||||
if (pid === undefined) {
|
||||
throw new Error('Expected virtual pid to be defined.');
|
||||
}
|
||||
const onExit = vi.fn();
|
||||
const unsubscribe = ShellExecutionService.onExit(pid, onExit);
|
||||
|
||||
ShellExecutionService.appendVirtualOutput(pid, 'Hello');
|
||||
ShellExecutionService.appendVirtualOutput(pid, ' World');
|
||||
ShellExecutionService.completeVirtualExecution(pid, { exitCode: 0 });
|
||||
|
||||
const executionResult = await result;
|
||||
|
||||
expect(executionResult.output).toBe('Hello World');
|
||||
expect(executionResult.backgrounded).toBeUndefined();
|
||||
expect(executionResult.exitCode).toBe(0);
|
||||
expect(executionResult.error).toBeNull();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('supports backgrounding virtual executions and streaming additional output', async () => {
|
||||
const { pid, result } = ShellExecutionService.createVirtualExecution();
|
||||
if (pid === undefined) {
|
||||
throw new Error('Expected virtual pid to be defined.');
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
const onExit = vi.fn();
|
||||
|
||||
const unsubscribeStream = ShellExecutionService.subscribe(pid, (event) => {
|
||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||
chunks.push(event.chunk);
|
||||
}
|
||||
});
|
||||
const unsubscribeExit = ShellExecutionService.onExit(pid, onExit);
|
||||
|
||||
ShellExecutionService.appendVirtualOutput(pid, 'Chunk 1');
|
||||
ShellExecutionService.background(pid);
|
||||
|
||||
const backgroundResult = await result;
|
||||
expect(backgroundResult.backgrounded).toBe(true);
|
||||
expect(backgroundResult.output).toBe('Chunk 1');
|
||||
|
||||
ShellExecutionService.appendVirtualOutput(pid, '\nChunk 2');
|
||||
ShellExecutionService.completeVirtualExecution(pid, { exitCode: 0 });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(chunks.join('')).toContain('Chunk 2');
|
||||
expect(onExit).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
|
||||
unsubscribeStream();
|
||||
unsubscribeExit();
|
||||
});
|
||||
|
||||
it('kills virtual executions via the existing kill API', async () => {
|
||||
const onKill = vi.fn();
|
||||
const { pid, result } = ShellExecutionService.createVirtualExecution(
|
||||
'',
|
||||
onKill,
|
||||
);
|
||||
if (pid === undefined) {
|
||||
throw new Error('Expected virtual pid to be defined.');
|
||||
}
|
||||
|
||||
ShellExecutionService.appendVirtualOutput(pid, 'work');
|
||||
ShellExecutionService.kill(pid);
|
||||
|
||||
const killResult = await result;
|
||||
expect(onKill).toHaveBeenCalledTimes(1);
|
||||
expect(killResult.aborted).toBe(true);
|
||||
expect(killResult.exitCode).toBe(130);
|
||||
expect(killResult.error?.message).toContain('Operation cancelled by user');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,22 @@ interface ActiveChildProcess {
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveVirtualProcessState {
|
||||
output: string;
|
||||
onKill?: () => void;
|
||||
}
|
||||
|
||||
type ActiveManagedProcess =
|
||||
| {
|
||||
kind: 'child';
|
||||
process: ChildProcess;
|
||||
state: ActiveChildProcess['state'];
|
||||
}
|
||||
| {
|
||||
kind: 'virtual';
|
||||
state: ActiveVirtualProcessState;
|
||||
};
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
@@ -197,7 +213,7 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
|
||||
export class ShellExecutionService {
|
||||
private static activePtys = new Map<number, ActivePty>();
|
||||
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
|
||||
private static activeProcesses = new Map<number, ActiveManagedProcess>();
|
||||
private static exitedPtyInfo = new Map<
|
||||
number,
|
||||
{ exitCode: number; signal?: number }
|
||||
@@ -210,6 +226,7 @@ export class ShellExecutionService {
|
||||
number,
|
||||
Set<(event: ShellOutputEvent) => void>
|
||||
>();
|
||||
private static nextVirtualPid = 2_000_000_000;
|
||||
/**
|
||||
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
|
||||
*
|
||||
@@ -292,6 +309,119 @@ export class ShellExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
private static getActiveChildProcess(
|
||||
pid: number,
|
||||
): ActiveChildProcess | undefined {
|
||||
const activeProcess = this.activeProcesses.get(pid);
|
||||
if (activeProcess?.kind !== 'child') {
|
||||
return undefined;
|
||||
}
|
||||
return { process: activeProcess.process, state: activeProcess.state };
|
||||
}
|
||||
|
||||
private static getActiveVirtualProcess(
|
||||
pid: number,
|
||||
): ActiveVirtualProcessState | undefined {
|
||||
const activeProcess = this.activeProcesses.get(pid);
|
||||
if (activeProcess?.kind !== 'virtual') {
|
||||
return undefined;
|
||||
}
|
||||
return activeProcess.state;
|
||||
}
|
||||
|
||||
private static allocateVirtualPid(): number {
|
||||
let pid = ++this.nextVirtualPid;
|
||||
while (this.activePtys.has(pid) || this.activeProcesses.has(pid)) {
|
||||
pid = ++this.nextVirtualPid;
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
|
||||
static createVirtualExecution(
|
||||
initialOutput = '',
|
||||
onKill?: () => void,
|
||||
): ShellExecutionHandle {
|
||||
const pid = this.allocateVirtualPid();
|
||||
this.activeProcesses.set(pid, {
|
||||
kind: 'virtual',
|
||||
state: {
|
||||
output: initialOutput,
|
||||
onKill,
|
||||
},
|
||||
});
|
||||
|
||||
const result = new Promise<ShellExecutionResult>((resolve) => {
|
||||
this.activeResolvers.set(pid, resolve);
|
||||
});
|
||||
|
||||
return { pid, result };
|
||||
}
|
||||
|
||||
static appendVirtualOutput(pid: number, chunk: string): void {
|
||||
const virtual = this.getActiveVirtualProcess(pid);
|
||||
if (!virtual || chunk.length === 0) {
|
||||
return;
|
||||
}
|
||||
virtual.output += chunk;
|
||||
this.emitEvent(pid, { type: 'data', chunk });
|
||||
}
|
||||
|
||||
static completeVirtualExecution(
|
||||
pid: number,
|
||||
options?: {
|
||||
exitCode?: number | null;
|
||||
signal?: number | null;
|
||||
error?: Error | null;
|
||||
aborted?: boolean;
|
||||
},
|
||||
): void {
|
||||
const virtual = this.getActiveVirtualProcess(pid);
|
||||
if (!virtual) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
error = null,
|
||||
aborted = false,
|
||||
exitCode = error ? 1 : 0,
|
||||
signal = null,
|
||||
} = options ?? {};
|
||||
|
||||
const resolve = this.activeResolvers.get(pid);
|
||||
if (resolve) {
|
||||
resolve({
|
||||
rawOutput: Buffer.from(virtual.output, 'utf8'),
|
||||
output: virtual.output,
|
||||
exitCode,
|
||||
signal,
|
||||
error,
|
||||
aborted,
|
||||
pid,
|
||||
executionMethod: 'none',
|
||||
});
|
||||
this.activeResolvers.delete(pid);
|
||||
}
|
||||
|
||||
this.emitEvent(pid, {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
signal,
|
||||
});
|
||||
this.activeListeners.delete(pid);
|
||||
this.activeProcesses.delete(pid);
|
||||
|
||||
this.exitedPtyInfo.set(pid, {
|
||||
exitCode: exitCode ?? 0,
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
setTimeout(
|
||||
() => {
|
||||
this.exitedPtyInfo.delete(pid);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
).unref();
|
||||
}
|
||||
|
||||
private static childProcessFallback(
|
||||
commandToExecute: string,
|
||||
cwd: string,
|
||||
@@ -328,7 +458,8 @@ export class ShellExecutionService {
|
||||
};
|
||||
|
||||
if (child.pid) {
|
||||
this.activeChildProcesses.set(child.pid, {
|
||||
this.activeProcesses.set(child.pid, {
|
||||
kind: 'child',
|
||||
process: child,
|
||||
state,
|
||||
});
|
||||
@@ -438,7 +569,7 @@ export class ShellExecutionService {
|
||||
onOutputEvent(event);
|
||||
ShellExecutionService.emitEvent(child.pid, event);
|
||||
|
||||
this.activeChildProcesses.delete(child.pid);
|
||||
this.activeProcesses.delete(child.pid);
|
||||
this.activeResolvers.delete(child.pid);
|
||||
this.activeListeners.delete(child.pid);
|
||||
}
|
||||
@@ -914,11 +1045,9 @@ export class ShellExecutionService {
|
||||
* @param input The string to write to the terminal.
|
||||
*/
|
||||
static writeToPty(pid: number, input: string): void {
|
||||
if (this.activeChildProcesses.has(pid)) {
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
if (activeChild) {
|
||||
activeChild.process.stdin?.write(input);
|
||||
}
|
||||
const activeChild = this.getActiveChildProcess(pid);
|
||||
if (activeChild) {
|
||||
activeChild.process.stdin?.write(input);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -933,7 +1062,11 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
static isPtyActive(pid: number): boolean {
|
||||
if (this.activeChildProcesses.has(pid)) {
|
||||
if (this.getActiveVirtualProcess(pid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.getActiveChildProcess(pid)) {
|
||||
try {
|
||||
return process.kill(pid, 0);
|
||||
} catch {
|
||||
@@ -971,8 +1104,10 @@ export class ShellExecutionService {
|
||||
},
|
||||
);
|
||||
return () => disposable.dispose();
|
||||
} else if (this.activeChildProcesses.has(pid)) {
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
}
|
||||
|
||||
const activeChild = this.getActiveChildProcess(pid);
|
||||
if (activeChild) {
|
||||
const listener = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
let signalNumber: number | undefined;
|
||||
if (signal) {
|
||||
@@ -984,14 +1119,25 @@ export class ShellExecutionService {
|
||||
return () => {
|
||||
activeChild?.process.removeListener('exit', listener);
|
||||
};
|
||||
} else {
|
||||
// Check if it already exited recently
|
||||
const exitedInfo = this.exitedPtyInfo.get(pid);
|
||||
if (exitedInfo) {
|
||||
callback(exitedInfo.exitCode, exitedInfo.signal);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (this.getActiveVirtualProcess(pid)) {
|
||||
const listener = (event: ShellOutputEvent) => {
|
||||
if (event.type === 'exit') {
|
||||
callback(event.exitCode ?? 0, event.signal ?? undefined);
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
const unsubscribe = this.subscribe(pid, listener);
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
// Check if it already exited recently
|
||||
const exitedInfo = this.exitedPtyInfo.get(pid);
|
||||
if (exitedInfo) {
|
||||
callback(exitedInfo.exitCode, exitedInfo.signal);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1001,11 +1147,20 @@ export class ShellExecutionService {
|
||||
*/
|
||||
static kill(pid: number): void {
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
const activeChild = this.getActiveChildProcess(pid);
|
||||
const activeVirtual = this.getActiveVirtualProcess(pid);
|
||||
|
||||
if (activeChild) {
|
||||
if (activeVirtual) {
|
||||
activeVirtual.onKill?.();
|
||||
this.completeVirtualExecution(pid, {
|
||||
error: new Error('Operation cancelled by user.'),
|
||||
aborted: true,
|
||||
exitCode: 130,
|
||||
});
|
||||
return;
|
||||
} else if (activeChild) {
|
||||
killProcessGroup({ pid }).catch(() => {});
|
||||
this.activeChildProcesses.delete(pid);
|
||||
this.activeProcesses.delete(pid);
|
||||
} else if (activePty) {
|
||||
killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {});
|
||||
this.activePtys.delete(pid);
|
||||
@@ -1028,7 +1183,8 @@ export class ShellExecutionService {
|
||||
const rawOutput = Buffer.from('');
|
||||
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
const activeChild = this.getActiveChildProcess(pid);
|
||||
const activeVirtual = this.getActiveVirtualProcess(pid);
|
||||
|
||||
if (activePty) {
|
||||
output = getFullBufferText(activePty.headlessTerminal);
|
||||
@@ -1057,6 +1213,19 @@ export class ShellExecutionService {
|
||||
executionMethod: 'child_process',
|
||||
backgrounded: true,
|
||||
});
|
||||
} else if (activeVirtual) {
|
||||
output = activeVirtual.output;
|
||||
resolve({
|
||||
rawOutput,
|
||||
output,
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid,
|
||||
executionMethod: 'none',
|
||||
backgrounded: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.activeResolvers.delete(pid);
|
||||
@@ -1074,7 +1243,8 @@ export class ShellExecutionService {
|
||||
|
||||
// Send current buffer content immediately
|
||||
const activePty = this.activePtys.get(pid);
|
||||
const activeChild = this.activeChildProcesses.get(pid);
|
||||
const activeChild = this.getActiveChildProcess(pid);
|
||||
const activeVirtual = this.getActiveVirtualProcess(pid);
|
||||
|
||||
if (activePty) {
|
||||
// Use serializeTerminalToObject to preserve colors and structure
|
||||
@@ -1096,6 +1266,8 @@ export class ShellExecutionService {
|
||||
if (output) {
|
||||
listener({ type: 'data', chunk: output });
|
||||
}
|
||||
} else if (activeVirtual?.output) {
|
||||
listener({ type: 'data', chunk: activeVirtual.output });
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -557,7 +557,7 @@ export class TestRig {
|
||||
return {
|
||||
...cleanEnv,
|
||||
GEMINI_CLI_HOME: this.homeDir!,
|
||||
GEMINI_PTY_INFO: 'node-pty',
|
||||
GEMINI_PTY_INFO: 'child_process',
|
||||
...extraEnv,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user