mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 02:20:42 -07:00
feat(core): extract execution lifecycle facade
Extracts ExecutionLifecycleService to decouple background task tracking from ShellExecutionService, enabling Ctrl+B backgrounding for any tool (including remote agents).
This commit is contained in:
committed by
Adam Weidman
parent
d012929a28
commit
a367a1724c
@@ -80,7 +80,7 @@ export const useShellCommandProcessor = (
|
||||
setShellInputFocused: (value: boolean) => void,
|
||||
terminalWidth?: number,
|
||||
terminalHeight?: number,
|
||||
activeToolPtyId?: number,
|
||||
activeBackgroundExecutionId?: number,
|
||||
isWaitingForConfirmation?: boolean,
|
||||
) => {
|
||||
const [state, dispatch] = useReducer(shellReducer, initialState);
|
||||
@@ -103,7 +103,8 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
const m = manager.current;
|
||||
|
||||
const activePtyId = state.activeShellPtyId || activeToolPtyId;
|
||||
const activePtyId =
|
||||
state.activeShellPtyId ?? activeBackgroundExecutionId ?? undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
||||
@@ -191,7 +192,8 @@ export const useShellCommandProcessor = (
|
||||
]);
|
||||
|
||||
const backgroundCurrentShell = useCallback(() => {
|
||||
const pidToBackground = state.activeShellPtyId || activeToolPtyId;
|
||||
const pidToBackground =
|
||||
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
||||
if (pidToBackground) {
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
m.backgroundedPids.add(pidToBackground);
|
||||
@@ -202,7 +204,7 @@ export const useShellCommandProcessor = (
|
||||
m.restoreTimeout = null;
|
||||
}
|
||||
}
|
||||
}, [state.activeShellPtyId, activeToolPtyId, m]);
|
||||
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
(pid: number) => {
|
||||
|
||||
@@ -96,6 +96,25 @@ const MockedUserPromptEvent = vi.hoisted(() =>
|
||||
vi.fn().mockImplementation(() => {}),
|
||||
);
|
||||
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
|
||||
const mockIsBackgroundExecutionData = vi.hoisted(
|
||||
() =>
|
||||
(data: unknown): data is { pid?: number } => {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
}
|
||||
const value = data as {
|
||||
pid?: unknown;
|
||||
command?: unknown;
|
||||
initialOutput?: unknown;
|
||||
};
|
||||
return (
|
||||
(value.pid === undefined || typeof value.pid === 'number') &&
|
||||
(value.command === undefined || typeof value.command === 'string') &&
|
||||
(value.initialOutput === undefined ||
|
||||
typeof value.initialOutput === 'string')
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const MockValidationRequiredError = vi.hoisted(
|
||||
() =>
|
||||
@@ -121,6 +140,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actualCoreModule = (await importOriginal()) as any;
|
||||
return {
|
||||
...actualCoreModule,
|
||||
isBackgroundExecutionData: mockIsBackgroundExecutionData,
|
||||
GitService: vi.fn(),
|
||||
GeminiClient: MockedGeminiClientClass,
|
||||
UserPromptEvent: MockedUserPromptEvent,
|
||||
@@ -599,6 +619,35 @@ describe('useGeminiStream', () => {
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled(); // submitQuery uses this
|
||||
});
|
||||
|
||||
it('should expose activePtyId for non-shell executing tools that report an execution ID', () => {
|
||||
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' }];
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -94,10 +95,10 @@ type ToolResponseWithParts = ToolCallResponseInfo & {
|
||||
llmContent?: PartListUnion;
|
||||
};
|
||||
|
||||
interface ShellToolData {
|
||||
pid?: number;
|
||||
command?: string;
|
||||
initialOutput?: string;
|
||||
interface BackgroundedToolInfo {
|
||||
pid: number;
|
||||
command: string;
|
||||
initialOutput: string;
|
||||
}
|
||||
|
||||
enum StreamProcessingStatus {
|
||||
@@ -111,15 +112,32 @@ 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 {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return false;
|
||||
function getBackgroundedToolInfo(
|
||||
toolCall: TrackedCompletedToolCall | TrackedCancelledToolCall,
|
||||
): BackgroundedToolInfo | undefined {
|
||||
const response = toolCall.response as ToolResponseWithParts;
|
||||
const rawData: unknown = response?.data;
|
||||
if (!isBackgroundExecutionData(rawData)) {
|
||||
return undefined;
|
||||
}
|
||||
const d = data as Partial<ShellToolData>;
|
||||
|
||||
if (rawData.pid === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
pid: rawData.pid,
|
||||
command: rawData.command ?? toolCall.request.name,
|
||||
initialOutput: rawData.initialOutput ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function isBackgroundableExecutingToolCall(
|
||||
toolCall: TrackedToolCall,
|
||||
): toolCall is TrackedExecutingToolCall {
|
||||
return (
|
||||
(d.pid === undefined || typeof d.pid === 'number') &&
|
||||
(d.command === undefined || typeof d.command === 'string') &&
|
||||
(d.initialOutput === undefined || typeof d.initialOutput === 'string')
|
||||
toolCall.status === CoreToolCallStatus.Executing &&
|
||||
typeof toolCall.pid === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,13 +329,11 @@ export const useGeminiStream = (
|
||||
getPreferredEditor,
|
||||
);
|
||||
|
||||
const activeToolPtyId = useMemo(() => {
|
||||
const executingShellTool = toolCalls.find(
|
||||
(tc) =>
|
||||
tc.status === 'executing' && tc.request.name === 'run_shell_command',
|
||||
const activeBackgroundExecutionId = useMemo(() => {
|
||||
const executingBackgroundableTool = toolCalls.find(
|
||||
isBackgroundableExecutingToolCall,
|
||||
);
|
||||
// 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>) => {
|
||||
@@ -347,7 +363,7 @@ export const useGeminiStream = (
|
||||
setShellInputFocused,
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
activeToolPtyId,
|
||||
activeBackgroundExecutionId,
|
||||
);
|
||||
|
||||
const streamingState = useMemo(
|
||||
@@ -525,7 +541,8 @@ export const useGeminiStream = (
|
||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
} | null>(null);
|
||||
|
||||
const activePtyId = activeShellPtyId || activeToolPtyId;
|
||||
const activePtyId =
|
||||
activeShellPtyId ?? activeBackgroundExecutionId ?? undefined;
|
||||
|
||||
const prevActiveShellPtyIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -1651,26 +1668,16 @@ export const useGeminiStream = (
|
||||
!processedMemoryToolsRef.current.has(t.request.callId),
|
||||
);
|
||||
|
||||
// Handle backgrounded shell tools
|
||||
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 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) ?? '';
|
||||
|
||||
registerBackgroundShell(pid, command, initialOutput);
|
||||
for (const toolCall of completedAndReadyToSubmitTools) {
|
||||
const backgroundedTool = getBackgroundedToolInfo(toolCall);
|
||||
if (backgroundedTool) {
|
||||
registerBackgroundShell(
|
||||
backgroundedTool.pid,
|
||||
backgroundedTool.command,
|
||||
backgroundedTool.initialOutput,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newSuccessfulMemorySaves.length > 0) {
|
||||
// Perform the refresh only if there are new ones.
|
||||
|
||||
Reference in New Issue
Block a user