mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-27 18:21:00 -07:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f7658cb17 | |||
| 76c96cc4be | |||
| 6880859fdb | |||
| bb80f5fd67 | |||
| ccfd2a48ad | |||
| aabc27c12d | |||
| 6362e3cc15 | |||
| 8751910572 | |||
| 708d0e7016 | |||
| d68cc3a88f | |||
| 6510587725 | |||
| 1d86b6504b | |||
| 1dc55b278b | |||
| 2727a871c3 | |||
| 8fcb18996a | |||
| f46a1c7e8b | |||
| 8b7321ea8d | |||
| 931b80206b |
@@ -83,6 +83,7 @@ import {
|
||||
ProjectIdRequiredError,
|
||||
CoreToolCallStatus,
|
||||
buildUserSteeringHintPrompt,
|
||||
formatBackgroundCompletionForModel,
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
type InjectionSource,
|
||||
@@ -1078,6 +1079,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const pendingHintsRef = useRef<string[]>([]);
|
||||
const [pendingHintCount, setPendingHintCount] = useState(0);
|
||||
const pendingBgCompletionsRef = useRef<string[]>([]);
|
||||
const [pendingBgCompletionCount, setPendingBgCompletionCount] = useState(0);
|
||||
|
||||
const consumePendingHints = useCallback(() => {
|
||||
if (pendingHintsRef.current.length === 0) {
|
||||
@@ -1090,16 +1093,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hintListener = (text: string, source: InjectionSource) => {
|
||||
if (source !== 'user_steering') {
|
||||
return;
|
||||
const injectionListener = (text: string, source: InjectionSource) => {
|
||||
if (source === 'user_steering') {
|
||||
pendingHintsRef.current.push(text);
|
||||
setPendingHintCount((prev) => prev + 1);
|
||||
} else if (source === 'background_completion') {
|
||||
pendingBgCompletionsRef.current.push(text);
|
||||
setPendingBgCompletionCount((prev) => prev + 1);
|
||||
}
|
||||
pendingHintsRef.current.push(text);
|
||||
setPendingHintCount((prev) => prev + 1);
|
||||
};
|
||||
config.injectionService.onInjection(hintListener);
|
||||
config.injectionService.onInjection(injectionListener);
|
||||
return () => {
|
||||
config.injectionService.offInjection(hintListener);
|
||||
config.injectionService.offInjection(injectionListener);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -2135,6 +2140,29 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingHintCount,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isConfigInitialized ||
|
||||
streamingState !== StreamingState.Idle ||
|
||||
!isMcpReady ||
|
||||
pendingBgCompletionsRef.current.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bgText = pendingBgCompletionsRef.current.join('\n');
|
||||
pendingBgCompletionsRef.current = [];
|
||||
setPendingBgCompletionCount(0);
|
||||
|
||||
void submitQuery([{ text: formatBackgroundCompletionForModel(bgText) }]);
|
||||
}, [
|
||||
isConfigInitialized,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
submitQuery,
|
||||
pendingBgCompletionCount,
|
||||
]);
|
||||
|
||||
const allToolCalls = useMemo(
|
||||
() =>
|
||||
pendingHistoryItems
|
||||
|
||||
@@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() =>
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleSubscribe = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleOnExit = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
(
|
||||
pid: number,
|
||||
callback: (exitCode: number, signal?: number) => void,
|
||||
) => () => void
|
||||
>(() => vi.fn()),
|
||||
);
|
||||
const mockLifecycleKill = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
|
||||
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
subscribe: mockShellSubscribe,
|
||||
onExit: mockShellOnExit,
|
||||
},
|
||||
ExecutionLifecycleService: {
|
||||
subscribe: mockLifecycleSubscribe,
|
||||
onExit: mockLifecycleOnExit,
|
||||
kill: mockLifecycleKill,
|
||||
background: mockLifecycleBackground,
|
||||
onBackground: mockLifecycleOnBackground,
|
||||
offBackground: mockLifecycleOffBackground,
|
||||
},
|
||||
isBinary: mockIsBinary,
|
||||
};
|
||||
});
|
||||
@@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => {
|
||||
output: 'initial',
|
||||
}),
|
||||
);
|
||||
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
|
||||
expect(mockShellSubscribe).toHaveBeenCalledWith(
|
||||
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
|
||||
1001,
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -823,7 +851,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'No background shells are currently active.',
|
||||
text: 'No background tasks are currently active.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -841,7 +869,7 @@ describe('useShellCommandProcessor', () => {
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
});
|
||||
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
expect(result.current.backgroundShells.has(1001)).toBe(false);
|
||||
});
|
||||
@@ -891,7 +919,7 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(result.current.activeShellPtyId).toBeNull();
|
||||
});
|
||||
|
||||
it('should persist background shell on successful exit and mark as exited', async () => {
|
||||
it('should auto-dismiss background task on successful exit', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
@@ -899,7 +927,7 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
|
||||
// Find the exit callback registered
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
(call) => call[0] === 888,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT be removed, but updated
|
||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
||||
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
|
||||
const shell = result.current.backgroundShells.get(888);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(0);
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
expect(result.current.backgroundShells.has(888)).toBe(false);
|
||||
});
|
||||
|
||||
it('should persist background shell on failed exit', async () => {
|
||||
it('should auto-dismiss background task on failed exit', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.registerBackgroundShell(999, 'fail-exit', '');
|
||||
});
|
||||
|
||||
const exitCallback = mockShellOnExit.mock.calls.find(
|
||||
const exitCallback = mockLifecycleOnExit.mock.calls.find(
|
||||
(call) => call[0] === 999,
|
||||
)?.[1];
|
||||
expect(exitCallback).toBeDefined();
|
||||
@@ -936,17 +961,9 @@ describe('useShellCommandProcessor', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT be removed, but updated
|
||||
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
|
||||
const shell = result.current.backgroundShells.get(999);
|
||||
expect(shell?.status).toBe('exited');
|
||||
expect(shell?.exitCode).toBe(1);
|
||||
|
||||
// Now dismiss it
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(999);
|
||||
});
|
||||
// Should be auto-dismissed from the panel
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
expect(result.current.backgroundShells.has(999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT trigger re-render on background shell output when visible', async () => {
|
||||
@@ -963,7 +980,7 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -989,7 +1006,7 @@ describe('useShellCommandProcessor', () => {
|
||||
// Ensure background shells are hidden (default)
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
@@ -1019,7 +1036,7 @@ describe('useShellCommandProcessor', () => {
|
||||
|
||||
const initialRenderCount = getRenderCount();
|
||||
|
||||
const subscribeCallback = mockShellSubscribe.mock.calls.find(
|
||||
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
|
||||
(call) => call[0] === 1001,
|
||||
)?.[1];
|
||||
expect(subscribeCallback).toBeDefined();
|
||||
|
||||
@@ -9,10 +9,16 @@ import type {
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
||||
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
Config,
|
||||
GeminiClient,
|
||||
CompletionBehavior,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
isBinary,
|
||||
ShellExecutionService,
|
||||
ExecutionLifecycleService,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
@@ -144,7 +150,7 @@ export const useShellCommandProcessor = (
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Unsubscribe from all background shell events on unmount
|
||||
// Unsubscribe from all background task events on unmount
|
||||
for (const unsubscribe of m.subscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -176,7 +182,7 @@ export const useShellCommandProcessor = (
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'No background shells are currently active.',
|
||||
text: 'No background tasks are currently active.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -191,12 +197,19 @@ export const useShellCommandProcessor = (
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const backgroundCurrentShell = useCallback(() => {
|
||||
const backgroundCurrentExecution = useCallback(() => {
|
||||
const pidToBackground =
|
||||
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
||||
if (pidToBackground) {
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
m.backgroundedPids.add(pidToBackground);
|
||||
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
|
||||
// fall back to ExecutionLifecycleService for non-shell executions
|
||||
// (e.g. remote agents, MCP tools, local agents).
|
||||
if (state.activeShellPtyId) {
|
||||
m.backgroundedPids.add(pidToBackground);
|
||||
ShellExecutionService.background(pidToBackground);
|
||||
} else {
|
||||
ExecutionLifecycleService.background(pidToBackground);
|
||||
}
|
||||
// Ensure backgrounding is silent and doesn't trigger restoration
|
||||
m.wasVisibleBeforeForeground = false;
|
||||
if (m.restoreTimeout) {
|
||||
@@ -206,12 +219,14 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
const dismissBackgroundTask = useCallback(
|
||||
async (pid: number) => {
|
||||
const shell = state.backgroundShells.get(pid);
|
||||
if (shell) {
|
||||
if (shell.status === 'running') {
|
||||
await ShellExecutionService.kill(pid);
|
||||
// ExecutionLifecycleService.kill handles both shell and non-shell
|
||||
// executions. For shells, ShellExecutionService.kill delegates to it.
|
||||
ExecutionLifecycleService.kill(pid);
|
||||
}
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
m.backgroundedPids.delete(pid);
|
||||
@@ -227,37 +242,69 @@ export const useShellCommandProcessor = (
|
||||
[state.backgroundShells, dispatch, m],
|
||||
);
|
||||
|
||||
const registerBackgroundShell = useCallback(
|
||||
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
|
||||
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
|
||||
const registerBackgroundTask = useCallback(
|
||||
(
|
||||
pid: number,
|
||||
command: string,
|
||||
initialOutput: string | AnsiOutput,
|
||||
completionBehavior?: CompletionBehavior,
|
||||
) => {
|
||||
dispatch({
|
||||
type: 'REGISTER_SHELL',
|
||||
pid,
|
||||
command,
|
||||
initialOutput,
|
||||
completionBehavior,
|
||||
});
|
||||
|
||||
// Subscribe to process exit directly
|
||||
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
|
||||
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
|
||||
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
pid,
|
||||
update: { status: 'exited', exitCode: code },
|
||||
});
|
||||
// Auto-dismiss for inject/notify (output was delivered to conversation).
|
||||
// Silent tasks stay in the UI until manually dismissed.
|
||||
if (completionBehavior !== 'silent') {
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
}
|
||||
const unsub = m.subscriptions.get(pid);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
m.subscriptions.delete(pid);
|
||||
}
|
||||
m.backgroundedPids.delete(pid);
|
||||
});
|
||||
|
||||
// Subscribe to future updates (data only)
|
||||
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
|
||||
if (event.type === 'data') {
|
||||
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
|
||||
} else if (event.type === 'binary_detected') {
|
||||
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
|
||||
} else if (event.type === 'binary_progress') {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
pid,
|
||||
update: {
|
||||
isBinary: true,
|
||||
binaryBytesReceived: event.bytesReceived,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
|
||||
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
|
||||
pid,
|
||||
(event) => {
|
||||
if (event.type === 'data') {
|
||||
dispatch({
|
||||
type: 'APPEND_SHELL_OUTPUT',
|
||||
pid,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
} else if (event.type === 'binary_detected') {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
pid,
|
||||
update: { isBinary: true },
|
||||
});
|
||||
} else if (event.type === 'binary_progress') {
|
||||
dispatch({
|
||||
type: 'UPDATE_SHELL',
|
||||
pid,
|
||||
update: {
|
||||
isBinary: true,
|
||||
binaryBytesReceived: event.bytesReceived,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
m.subscriptions.set(pid, () => {
|
||||
exitUnsubscribe();
|
||||
@@ -267,6 +314,34 @@ export const useShellCommandProcessor = (
|
||||
[dispatch, m],
|
||||
);
|
||||
|
||||
// Auto-register any execution that gets backgrounded, regardless of type.
|
||||
// This is the agnostic hook: any tool that calls
|
||||
// ExecutionLifecycleService.createExecution() or attachExecution()
|
||||
// automatically gets Ctrl+B support — no UI changes needed per tool.
|
||||
useEffect(() => {
|
||||
const listener = (info: {
|
||||
executionId: number;
|
||||
label: string;
|
||||
output: string;
|
||||
completionBehavior: CompletionBehavior;
|
||||
}) => {
|
||||
// Skip if already registered (e.g. shells register via their own flow)
|
||||
if (m.backgroundedPids.has(info.executionId)) {
|
||||
return;
|
||||
}
|
||||
registerBackgroundTask(
|
||||
info.executionId,
|
||||
info.label,
|
||||
info.output,
|
||||
info.completionBehavior,
|
||||
);
|
||||
};
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
return () => {
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
};
|
||||
}, [registerBackgroundTask, m]);
|
||||
|
||||
const handleShellCommand = useCallback(
|
||||
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
||||
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
||||
@@ -439,7 +514,12 @@ export const useShellCommandProcessor = (
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
if (result.backgrounded && result.pid) {
|
||||
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
|
||||
registerBackgroundTask(
|
||||
result.pid,
|
||||
rawQuery,
|
||||
cumulativeStdout,
|
||||
'notify',
|
||||
);
|
||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||
}
|
||||
|
||||
@@ -531,7 +611,7 @@ export const useShellCommandProcessor = (
|
||||
setShellInputFocused,
|
||||
terminalHeight,
|
||||
terminalWidth,
|
||||
registerBackgroundShell,
|
||||
registerBackgroundTask,
|
||||
m,
|
||||
dispatch,
|
||||
],
|
||||
@@ -548,9 +628,9 @@ export const useShellCommandProcessor = (
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible: state.isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
registerBackgroundShell,
|
||||
dismissBackgroundShell,
|
||||
backgroundCurrentShell: backgroundCurrentExecution,
|
||||
registerBackgroundShell: registerBackgroundTask,
|
||||
dismissBackgroundShell: dismissBackgroundTask,
|
||||
backgroundShells: state.backgroundShells,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
|
||||
|
||||
export interface BackgroundShell {
|
||||
pid: number;
|
||||
@@ -14,6 +14,7 @@ export interface BackgroundShell {
|
||||
binaryBytesReceived: number;
|
||||
status: 'running' | 'exited';
|
||||
exitCode?: number;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
}
|
||||
|
||||
export interface ShellState {
|
||||
@@ -33,6 +34,7 @@ export type ShellAction =
|
||||
pid: number;
|
||||
command: string;
|
||||
initialOutput: string | AnsiOutput;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
}
|
||||
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
|
||||
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
|
||||
@@ -72,6 +74,7 @@ export function shellReducer(
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
completionBehavior: action.completionBehavior,
|
||||
});
|
||||
return { ...state, backgroundShells: nextShells };
|
||||
}
|
||||
|
||||
@@ -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 { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
|
||||
|
||||
// Mock A2AClientManager
|
||||
vi.mock('./a2a-client-manager.js', () => ({
|
||||
@@ -58,6 +59,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ExecutionLifecycleService.resetForTest();
|
||||
(A2AClientManager.getInstance as Mock).mockReturnValue(mockClientManager);
|
||||
(
|
||||
RemoteAgentInvocation as unknown as {
|
||||
@@ -587,6 +589,264 @@ describe('RemoteAgentInvocation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle Integration', () => {
|
||||
it('should call setExecutionIdCallback with lifecycle execution ID', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Response' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const callback = vi.fn();
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
undefined,
|
||||
callback,
|
||||
);
|
||||
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(expect.any(Number));
|
||||
});
|
||||
|
||||
it('should feed output deltas to lifecycle service subscribers', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
};
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello World' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const receivedChunks: string[] = [];
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute(
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
undefined,
|
||||
(id) => {
|
||||
// Subscribe immediately when we get the execution ID
|
||||
ExecutionLifecycleService.subscribe(id, (event) => {
|
||||
if (event.type === 'data' && typeof event.chunk === 'string') {
|
||||
receivedChunks.push(event.chunk);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Lifecycle subscribers should have received output deltas
|
||||
expect(receivedChunks.length).toBeGreaterThan(0);
|
||||
expect(receivedChunks.join('')).toContain('Hello');
|
||||
expect(receivedChunks.join('')).toContain('World');
|
||||
});
|
||||
|
||||
it('should support backgrounding via lifecycle service (Ctrl+B)', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
|
||||
// Create a controllable stream that blocks between chunks
|
||||
let resolveStream!: () => void;
|
||||
const streamBlocked = new Promise<void>((r) => {
|
||||
resolveStream = r;
|
||||
});
|
||||
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Working...' }],
|
||||
};
|
||||
await streamBlocked;
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-2',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Done' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let capturedId: number | undefined;
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
// Start execution (don't await — we need to background mid-stream)
|
||||
const resultPromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
undefined,
|
||||
(id) => {
|
||||
capturedId = id;
|
||||
},
|
||||
);
|
||||
|
||||
// setExecutionIdCallback is called synchronously before first await
|
||||
expect(capturedId).toBeDefined();
|
||||
|
||||
// Flush microtasks so processStream processes the first chunk
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Background the execution (simulates Ctrl+B)
|
||||
ExecutionLifecycleService.background(capturedId!);
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
// Should return backgrounded result with execution data
|
||||
expect(result.data).toBeDefined();
|
||||
expect((result.data as Record<string, unknown>)['pid']).toBe(capturedId);
|
||||
expect(result.returnDisplay).toContain('background');
|
||||
expect((result.llmContent as Array<{ text: string }>)[0].text).toContain(
|
||||
'background',
|
||||
);
|
||||
|
||||
// Let the stream finish cleanly in the background
|
||||
resolveStream();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it('should abort stream when killed via lifecycle service', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
|
||||
let resolveStream!: () => void;
|
||||
const streamBlocked = new Promise<void>((r) => {
|
||||
resolveStream = r;
|
||||
});
|
||||
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Working...' }],
|
||||
};
|
||||
await streamBlocked;
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-2',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Should not reach' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let capturedId: number | undefined;
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const resultPromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
undefined,
|
||||
(id) => {
|
||||
capturedId = id;
|
||||
},
|
||||
);
|
||||
|
||||
// Flush microtasks so processStream processes first chunk
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Kill via lifecycle service
|
||||
ExecutionLifecycleService.kill(capturedId!);
|
||||
|
||||
// Unblock stream so processStream can finish cleanup
|
||||
resolveStream();
|
||||
|
||||
const result = await resultPromise;
|
||||
|
||||
// Kill produces an error result
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toContain('cancelled');
|
||||
|
||||
// Give processStream time to finish cleanup
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it('should report execution as active while stream is running', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
|
||||
let resolveStream!: () => void;
|
||||
const streamBlocked = new Promise<void>((r) => {
|
||||
resolveStream = r;
|
||||
});
|
||||
|
||||
mockClientManager.sendMessageStream.mockImplementation(
|
||||
async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Running' }],
|
||||
};
|
||||
await streamBlocked;
|
||||
},
|
||||
);
|
||||
|
||||
let capturedId: number | undefined;
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const resultPromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
undefined,
|
||||
(id) => {
|
||||
capturedId = id;
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// While stream is running, execution should be active
|
||||
expect(ExecutionLifecycleService.isActive(capturedId!)).toBe(true);
|
||||
|
||||
// Complete the stream
|
||||
resolveStream();
|
||||
// Wait for processStream to complete — it calls completeExecution
|
||||
// which emits 'exit' and cleans up. Need to let the for-await-of
|
||||
// loop process the generator's {done: true} and the catch/finally
|
||||
// blocks to run. Multiple microtask ticks may be needed.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await resultPromise;
|
||||
|
||||
// After completion, execution should no longer be active
|
||||
expect(ExecutionLifecycleService.isActive(capturedId!)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confirmations', () => {
|
||||
it('should return info confirmation details', async () => {
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ToolConfirmationOutcome,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type BackgroundExecutionData,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
@@ -28,6 +29,8 @@ import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { A2AAgentError } from './a2a-errors.js';
|
||||
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
|
||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||
|
||||
/**
|
||||
* A tool invocation that proxies to a remote A2A agent.
|
||||
@@ -116,13 +119,115 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: string | AnsiOutput) => void,
|
||||
_shellExecutionConfig?: ShellExecutionConfig,
|
||||
setExecutionIdCallback?: (executionId: 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.
|
||||
// Create an AbortController for lifecycle kill support.
|
||||
// Parent abort and lifecycle kill both funnel through this controller.
|
||||
const executionAbortController = new AbortController();
|
||||
if (signal.aborted) {
|
||||
executionAbortController.abort();
|
||||
} else {
|
||||
signal.addEventListener('abort', () => executionAbortController.abort(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Register with lifecycle service as a virtual execution so this
|
||||
// invocation can be backgrounded, subscribed to, and killed.
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
() => executionAbortController.abort(),
|
||||
'remote_agent',
|
||||
);
|
||||
// createExecution always produces a valid numeric ID
|
||||
const executionId = handle.pid!;
|
||||
|
||||
if (setExecutionIdCallback) {
|
||||
setExecutionIdCallback(executionId);
|
||||
}
|
||||
|
||||
// Guard: stop calling updateOutput after backgrounding since the
|
||||
// tool call has already returned from the scheduler's perspective.
|
||||
let backgrounded = false;
|
||||
|
||||
// Fire-and-forget: stream processing runs concurrently and settles the
|
||||
// lifecycle execution on completion or error.
|
||||
const streamingPromise = this.processStream(
|
||||
executionId,
|
||||
executionAbortController.signal,
|
||||
(output) => {
|
||||
if (!backgrounded && updateOutput) {
|
||||
updateOutput(output);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Errors are handled internally via completeExecution; prevent
|
||||
// unhandled-rejection noise.
|
||||
streamingPromise.catch(() => {});
|
||||
|
||||
// Resolves when either: (a) processStream completes/errors, or
|
||||
// (b) the execution is backgrounded externally.
|
||||
const result = await handle.result;
|
||||
|
||||
if (result.backgrounded) {
|
||||
backgrounded = true;
|
||||
const agentLabel = this.definition.displayName ?? this.definition.name;
|
||||
const data: BackgroundExecutionData = {
|
||||
pid: executionId,
|
||||
command: `Remote agent: ${agentLabel}`,
|
||||
initialOutput: result.output,
|
||||
};
|
||||
return {
|
||||
llmContent: [
|
||||
{
|
||||
text: `Remote agent '${agentLabel}' moved to background (ID: ${executionId}). Use subscribe to view output.`,
|
||||
},
|
||||
],
|
||||
returnDisplay: `Remote agent moved to background (ID: ${executionId}).`,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
// Error path — the lifecycle result carries the original Error instance.
|
||||
if (result.error) {
|
||||
const errorMessage = this.formatExecutionError(result.error);
|
||||
const fullDisplay = result.output
|
||||
? `${result.output}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
return {
|
||||
llmContent: [{ text: fullDisplay }],
|
||||
returnDisplay: fullDisplay,
|
||||
error: { message: errorMessage },
|
||||
};
|
||||
}
|
||||
|
||||
// Normal completion.
|
||||
const finalOutput = result.output;
|
||||
debugLogger.debug(
|
||||
`[RemoteAgent] Final output from ${this.definition.name}: ${finalOutput.substring(0, 200)}`,
|
||||
);
|
||||
return {
|
||||
llmContent: [{ text: finalOutput }],
|
||||
returnDisplay: safeJsonToMarkdown(finalOutput),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the A2A stream, feeding output deltas into the lifecycle service.
|
||||
* On completion (or error) it settles the lifecycle execution so
|
||||
* {@link execute}'s `handle.result` resolves.
|
||||
*/
|
||||
private async processStream(
|
||||
executionId: number,
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: string | AnsiOutput) => void,
|
||||
): Promise<void> {
|
||||
const reassembler = new A2AResultReassembler();
|
||||
let previousOutputLength = 0;
|
||||
|
||||
try {
|
||||
const priorState = RemoteAgentInvocation.sessionState.get(
|
||||
this.definition.name,
|
||||
@@ -150,21 +255,30 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
{
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
signal: _signal,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
let finalResponse: SendMessageResult | undefined;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (_signal.aborted) {
|
||||
if (signal.aborted) {
|
||||
throw new Error('Operation aborted');
|
||||
}
|
||||
finalResponse = chunk;
|
||||
reassembler.update(chunk);
|
||||
|
||||
// Compute delta so lifecycle subscribers see incremental chunks.
|
||||
const currentOutput = reassembler.toString();
|
||||
const delta = currentOutput.substring(previousOutputLength);
|
||||
previousOutputLength = currentOutput.length;
|
||||
|
||||
if (delta) {
|
||||
ExecutionLifecycleService.appendOutput(executionId, delta);
|
||||
}
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(reassembler.toString());
|
||||
updateOutput(currentOutput);
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -184,33 +298,22 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
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)}`,
|
||||
);
|
||||
|
||||
return {
|
||||
llmContent: [{ text: finalOutput }],
|
||||
returnDisplay: safeJsonToMarkdown(finalOutput),
|
||||
};
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
} catch (error: unknown) {
|
||||
const partialOutput = reassembler.toString();
|
||||
// Surface structured, user-friendly error messages.
|
||||
const errorMessage = this.formatExecutionError(error);
|
||||
const fullDisplay = partialOutput
|
||||
? `${partialOutput}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
return {
|
||||
llmContent: [{ text: fullDisplay }],
|
||||
returnDisplay: fullDisplay,
|
||||
error: { message: errorMessage },
|
||||
};
|
||||
ExecutionLifecycleService.completeExecution(executionId, {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
} finally {
|
||||
// Persist state even on partial failures or aborts to maintain conversational continuity.
|
||||
// Persist conversational state. On abort/kill the task was interrupted
|
||||
// so clear taskId (next invocation starts a fresh task), but keep
|
||||
// contextId to maintain the conversation with the remote agent.
|
||||
RemoteAgentInvocation.sessionState.set(this.definition.name, {
|
||||
contextId: this.contextId,
|
||||
taskId: this.taskId,
|
||||
taskId: signal.aborted ? undefined : this.taskId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ describe('SubAgentInvocation', () => {
|
||||
expect(mockInnerInvocation.execute).toHaveBeenCalledWith(
|
||||
abortSignal,
|
||||
updateOutput,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(runInDevTraceSpan).toHaveBeenCalledWith(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ToolCallConfirmationDetails,
|
||||
isTool,
|
||||
type ToolLiveOutput,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
@@ -161,6 +162,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
|
||||
async execute(
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: ToolLiveOutput) => void,
|
||||
options?: ExecuteOptions,
|
||||
): Promise<ToolResult> {
|
||||
const validationError = SchemaValidator.validate(
|
||||
this.definition.inputConfig.inputSchema,
|
||||
@@ -188,7 +190,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
|
||||
},
|
||||
async ({ metadata }) => {
|
||||
metadata.input = this.params;
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
const result = await invocation.execute(signal, updateOutput, options);
|
||||
metadata.output = result;
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -162,6 +162,12 @@ export * from './services/executionLifecycleService.js';
|
||||
// Export Injection Service
|
||||
export * from './config/injectionService.js';
|
||||
|
||||
// Export Execution Lifecycle Service
|
||||
export * from './services/executionLifecycleService.js';
|
||||
|
||||
// Export Injection Service
|
||||
export * from './config/injectionService.js';
|
||||
|
||||
// Export base tool definitions
|
||||
export * from './tools/tools.js';
|
||||
export * from './tools/tool-error.js';
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ExecutionHandle,
|
||||
type ExecutionResult,
|
||||
} from './executionLifecycleService.js';
|
||||
import { InjectionService } from '../config/injectionService.js';
|
||||
|
||||
function createResult(
|
||||
overrides: Partial<ExecutionResult> = {},
|
||||
@@ -296,6 +297,81 @@ describe('ExecutionLifecycleService', () => {
|
||||
}).toThrow('Execution 4324 is already attached.');
|
||||
});
|
||||
|
||||
describe('Background Start Listeners', () => {
|
||||
it('fires onBackground when an execution is backgrounded', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'remote_agent',
|
||||
undefined,
|
||||
'My Remote Agent',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.appendOutput(executionId, 'some output');
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.executionId).toBe(executionId);
|
||||
expect(info.executionMethod).toBe('remote_agent');
|
||||
expect(info.label).toBe('My Remote Agent');
|
||||
expect(info.output).toBe('some output');
|
||||
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
});
|
||||
|
||||
it('uses fallback label when none is provided', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.label).toContain('none');
|
||||
expect(info.label).toContain(String(executionId));
|
||||
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
});
|
||||
|
||||
it('does not fire onBackground for non-backgrounded completions', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution();
|
||||
ExecutionLifecycleService.completeExecution(handle.pid!);
|
||||
await handle.result;
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
});
|
||||
|
||||
it('offBackground removes the listener', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
ExecutionLifecycleService.offBackground(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution();
|
||||
ExecutionLifecycleService.background(handle.pid!);
|
||||
await handle.result;
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Completion Listeners', () => {
|
||||
it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => {
|
||||
const listener = vi.fn();
|
||||
@@ -327,6 +403,7 @@ describe('ExecutionLifecycleService', () => {
|
||||
expect(info.output).toBe('agent output');
|
||||
expect(info.error).toBeNull();
|
||||
expect(info.injectionText).toBe('[Agent completed]\nagent output');
|
||||
expect(info.completionBehavior).toBe('inject');
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
@@ -358,7 +435,7 @@ describe('ExecutionLifecycleService', () => {
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('sets injectionText to null when no formatInjection callback is provided', async () => {
|
||||
it('sets injectionText to null and completionBehavior to silent when no formatInjection is provided', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
@@ -377,6 +454,7 @@ describe('ExecutionLifecycleService', () => {
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][0].injectionText).toBeNull();
|
||||
expect(listener.mock.calls[0][0].completionBehavior).toBe('silent');
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
@@ -443,5 +521,167 @@ describe('ExecutionLifecycleService', () => {
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('explicit notify behavior includes injectionText and auto-dismiss signal', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'child_process',
|
||||
() => '[Command completed. Output saved to /tmp/bg.log]',
|
||||
undefined,
|
||||
'notify',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.completionBehavior).toBe('notify');
|
||||
expect(info.injectionText).toBe(
|
||||
'[Command completed. Output saved to /tmp/bg.log]',
|
||||
);
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('explicit silent behavior skips injection even when formatInjection is provided', async () => {
|
||||
const formatFn = vi.fn().mockReturnValue('should not appear');
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
formatFn,
|
||||
undefined,
|
||||
'silent',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.completionBehavior).toBe('silent');
|
||||
expect(info.injectionText).toBeNull();
|
||||
expect(formatFn).not.toHaveBeenCalled();
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('includes completionBehavior in BackgroundStartInfo', async () => {
|
||||
const bgStartListener = vi.fn();
|
||||
ExecutionLifecycleService.onBackground(bgStartListener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'remote_agent',
|
||||
() => 'text',
|
||||
'test-label',
|
||||
'inject',
|
||||
);
|
||||
|
||||
ExecutionLifecycleService.background(handle.pid!);
|
||||
await handle.result;
|
||||
|
||||
expect(bgStartListener).toHaveBeenCalledTimes(1);
|
||||
expect(bgStartListener.mock.calls[0][0].completionBehavior).toBe(
|
||||
'inject',
|
||||
);
|
||||
|
||||
ExecutionLifecycleService.offBackground(bgStartListener);
|
||||
});
|
||||
|
||||
it('completionBehavior flows through attachExecution', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.attachExecution(9999, {
|
||||
executionMethod: 'child_process',
|
||||
formatInjection: () => '[notify message]',
|
||||
completionBehavior: 'notify',
|
||||
});
|
||||
|
||||
ExecutionLifecycleService.background(9999);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeWithResult(
|
||||
9999,
|
||||
createResult({ pid: 9999, executionMethod: 'child_process' }),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.completionBehavior).toBe('notify');
|
||||
expect(info.injectionText).toBe('[notify message]');
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('injects directly into InjectionService when wired via setInjectionService', async () => {
|
||||
const injectionService = new InjectionService(() => true);
|
||||
ExecutionLifecycleService.setInjectionService(injectionService);
|
||||
|
||||
const injectionListener = vi.fn();
|
||||
injectionService.onInjection(injectionListener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'remote_agent',
|
||||
(output) => `[Completed] ${output}`,
|
||||
undefined,
|
||||
'inject',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.appendOutput(executionId, 'agent output');
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(injectionListener).toHaveBeenCalledWith(
|
||||
'[Completed] agent output',
|
||||
'background_completion',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not inject into InjectionService for silent behavior', async () => {
|
||||
const injectionService = new InjectionService(() => true);
|
||||
ExecutionLifecycleService.setInjectionService(injectionService);
|
||||
|
||||
const injectionListener = vi.fn();
|
||||
injectionService.onInjection(injectionListener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
() => 'should not inject',
|
||||
undefined,
|
||||
'silent',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(injectionListener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,12 +59,16 @@ export interface ExecutionCompletionOptions {
|
||||
|
||||
export interface ExternalExecutionRegistration {
|
||||
executionMethod: ExecutionMethod;
|
||||
/** Human-readable label for the background task UI (e.g. the command string). */
|
||||
label?: string;
|
||||
initialOutput?: string;
|
||||
getBackgroundOutput?: () => string;
|
||||
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
|
||||
writeInput?: (input: string) => void;
|
||||
kill?: () => void;
|
||||
isActive?: () => boolean;
|
||||
formatInjection?: FormatInjectionFn;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,15 +81,41 @@ export type FormatInjectionFn = (
|
||||
error: Error | null,
|
||||
) => string | null;
|
||||
|
||||
/**
|
||||
* Controls what happens when a backgrounded execution completes:
|
||||
* - `'inject'` — full formatted output is injected into the conversation; task auto-dismisses from UI.
|
||||
* - `'notify'` — a short pointer (e.g. "output saved to /tmp/...") is injected; task auto-dismisses from UI.
|
||||
* - `'silent'` — nothing is injected; task stays in the UI until manually dismissed.
|
||||
*
|
||||
* The distinction between `inject` and `notify` is semantic for now (both inject + dismiss),
|
||||
* but enables the system to treat them differently in the future (e.g. LLM-decided injection).
|
||||
*/
|
||||
export type CompletionBehavior = 'inject' | 'notify' | 'silent';
|
||||
|
||||
interface ManagedExecutionBase {
|
||||
executionMethod: ExecutionMethod;
|
||||
label?: string;
|
||||
output: string;
|
||||
backgrounded?: boolean;
|
||||
formatInjection?: FormatInjectionFn;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
getBackgroundOutput?: () => string;
|
||||
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload emitted when an execution is moved to the background.
|
||||
*/
|
||||
export interface BackgroundStartInfo {
|
||||
executionId: number;
|
||||
executionMethod: ExecutionMethod;
|
||||
label: string;
|
||||
output: string;
|
||||
completionBehavior: CompletionBehavior;
|
||||
}
|
||||
|
||||
export type BackgroundStartListener = (info: BackgroundStartInfo) => void;
|
||||
|
||||
/**
|
||||
* Payload emitted when a previously-backgrounded execution settles.
|
||||
*/
|
||||
@@ -96,6 +126,7 @@ export interface BackgroundCompletionInfo {
|
||||
error: Error | null;
|
||||
/** Pre-formatted injection text from the execution creator, or `null` if skipped. */
|
||||
injectionText: string | null;
|
||||
completionBehavior: CompletionBehavior;
|
||||
}
|
||||
|
||||
export type BackgroundCompletionListener = (
|
||||
@@ -124,6 +155,16 @@ const NON_PROCESS_EXECUTION_ID_START = 2_000_000_000;
|
||||
export class ExecutionLifecycleService {
|
||||
private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000;
|
||||
private static nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
|
||||
private static injectionService: InjectionService | null = null;
|
||||
|
||||
/**
|
||||
* Connects the lifecycle service to the injection service so that
|
||||
* backgrounded executions are reinjected into the model conversation
|
||||
* directly from the backend — no UI hop needed.
|
||||
*/
|
||||
static setInjectionService(service: InjectionService): void {
|
||||
this.injectionService = service;
|
||||
}
|
||||
|
||||
private static activeExecutions = new Map<number, ManagedExecutionState>();
|
||||
private static activeResolvers = new Map<
|
||||
@@ -140,14 +181,22 @@ export class ExecutionLifecycleService {
|
||||
>();
|
||||
private static backgroundCompletionListeners =
|
||||
new Set<BackgroundCompletionListener>();
|
||||
private static injectionService: InjectionService | null = null;
|
||||
|
||||
private static backgroundStartListeners = new Set<BackgroundStartListener>();
|
||||
|
||||
/**
|
||||
* Wires a singleton InjectionService so that backgrounded executions
|
||||
* can inject their output directly without routing through the UI layer.
|
||||
* Registers a listener that fires when any execution is moved to the background.
|
||||
* This is the hook for the UI to automatically discover backgrounded executions.
|
||||
*/
|
||||
static setInjectionService(service: InjectionService): void {
|
||||
this.injectionService = service;
|
||||
static onBackground(listener: BackgroundStartListener): void {
|
||||
this.backgroundStartListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a background start listener.
|
||||
*/
|
||||
static offBackground(listener: BackgroundStartListener): void {
|
||||
this.backgroundStartListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +271,7 @@ export class ExecutionLifecycleService {
|
||||
this.exitedExecutionInfo.clear();
|
||||
this.backgroundCompletionListeners.clear();
|
||||
this.injectionService = null;
|
||||
this.backgroundStartListeners.clear();
|
||||
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
|
||||
}
|
||||
|
||||
@@ -239,6 +289,7 @@ export class ExecutionLifecycleService {
|
||||
|
||||
this.activeExecutions.set(executionId, {
|
||||
executionMethod: registration.executionMethod,
|
||||
label: registration.label,
|
||||
output: registration.initialOutput ?? '',
|
||||
kind: 'external',
|
||||
getBackgroundOutput: registration.getBackgroundOutput,
|
||||
@@ -246,6 +297,8 @@ export class ExecutionLifecycleService {
|
||||
writeInput: registration.writeInput,
|
||||
kill: registration.kill,
|
||||
isActive: registration.isActive,
|
||||
formatInjection: registration.formatInjection,
|
||||
completionBehavior: registration.completionBehavior,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -259,15 +312,19 @@ export class ExecutionLifecycleService {
|
||||
onKill?: () => void,
|
||||
executionMethod: ExecutionMethod = 'none',
|
||||
formatInjection?: FormatInjectionFn,
|
||||
label?: string,
|
||||
completionBehavior?: CompletionBehavior,
|
||||
): ExecutionHandle {
|
||||
const executionId = this.allocateExecutionId();
|
||||
|
||||
this.activeExecutions.set(executionId, {
|
||||
executionMethod,
|
||||
label,
|
||||
output: initialOutput,
|
||||
kind: 'virtual',
|
||||
onKill,
|
||||
formatInjection,
|
||||
completionBehavior,
|
||||
getBackgroundOutput: () => {
|
||||
const state = this.activeExecutions.get(executionId);
|
||||
return state?.output ?? initialOutput;
|
||||
@@ -325,19 +382,15 @@ export class ExecutionLifecycleService {
|
||||
|
||||
// Fire background completion listeners if this was a backgrounded execution.
|
||||
if (execution.backgrounded && !result.aborted) {
|
||||
const injectionText = execution.formatInjection
|
||||
? execution.formatInjection(result.output, result.error)
|
||||
: null;
|
||||
const info: BackgroundCompletionInfo = {
|
||||
executionId,
|
||||
executionMethod: execution.executionMethod,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
injectionText,
|
||||
};
|
||||
const behavior =
|
||||
execution.completionBehavior ??
|
||||
(execution.formatInjection ? 'inject' : 'silent');
|
||||
const injectionText =
|
||||
behavior !== 'silent' && execution.formatInjection
|
||||
? execution.formatInjection(result.output, result.error)
|
||||
: null;
|
||||
|
||||
// Inject directly into the model conversation if injection text is
|
||||
// available and the injection service has been wired up.
|
||||
// Inject directly into the model conversation from the backend.
|
||||
if (injectionText && this.injectionService) {
|
||||
this.injectionService.addInjection(
|
||||
injectionText,
|
||||
@@ -345,6 +398,15 @@ export class ExecutionLifecycleService {
|
||||
);
|
||||
}
|
||||
|
||||
const info: BackgroundCompletionInfo = {
|
||||
executionId,
|
||||
executionMethod: execution.executionMethod,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
injectionText,
|
||||
completionBehavior: behavior,
|
||||
};
|
||||
|
||||
for (const listener of this.backgroundCompletionListeners) {
|
||||
try {
|
||||
listener(info);
|
||||
@@ -434,6 +496,21 @@ export class ExecutionLifecycleService {
|
||||
|
||||
this.activeResolvers.delete(executionId);
|
||||
execution.backgrounded = true;
|
||||
|
||||
// Notify listeners that an execution was moved to the background.
|
||||
const info: BackgroundStartInfo = {
|
||||
executionId,
|
||||
executionMethod: execution.executionMethod,
|
||||
label:
|
||||
execution.label ?? `${execution.executionMethod} (ID: ${executionId})`,
|
||||
output,
|
||||
completionBehavior:
|
||||
execution.completionBehavior ??
|
||||
(execution.formatInjection ? 'inject' : 'silent'),
|
||||
};
|
||||
for (const listener of this.backgroundStartListeners) {
|
||||
listener(info);
|
||||
}
|
||||
}
|
||||
|
||||
static subscribe(
|
||||
|
||||
@@ -488,6 +488,14 @@ export class ShellExecutionService {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
formatInjection: (_output, error) => {
|
||||
const logPath = ShellExecutionService.getLogFilePath(child.pid!);
|
||||
const status = error
|
||||
? `with error: ${error.message}`
|
||||
: 'successfully';
|
||||
return `[Background command completed ${status}. Output saved to ${logPath}]`;
|
||||
},
|
||||
completionBehavior: 'inject',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -844,6 +852,14 @@ export class ShellExecutionService {
|
||||
);
|
||||
return bufferData.length > 0 ? bufferData : undefined;
|
||||
},
|
||||
formatInjection: (_output, error) => {
|
||||
const logPath = ShellExecutionService.getLogFilePath(ptyPid);
|
||||
const status = error
|
||||
? `with error: ${error.message}`
|
||||
: 'successfully';
|
||||
return `[Background command completed ${status}. Output saved to ${logPath}]`;
|
||||
},
|
||||
completionBehavior: 'inject',
|
||||
}).result;
|
||||
|
||||
let processingChain = Promise.resolve();
|
||||
|
||||
Reference in New Issue
Block a user