Compare commits

...

18 Commits

Author SHA1 Message Date
Adam Weidman 6f7658cb17 feat(core): integrate remote agents with ExecutionLifecycleService for backgrounding
Wire RemoteAgentInvocation to use ExecutionLifecycleService.createExecution()
so remote agents can be backgrounded (Ctrl+B), subscribed to, and killed
through the same lifecycle system as shell commands.

- Create virtual execution on execute(), report ID via setExecutionIdCallback
- Stream output deltas via appendOutput() so lifecycle subscribers see live data
- Separate streaming into detached processStream() that settles the lifecycle
- Support backgrounding: handle.result resolves with backgrounded=true,
  stream continues running, returns BackgroundExecutionData
- Support kill: lifecycle onKill aborts the stream via AbortController
2026-03-17 10:03:00 -04:00
Adam Weidman 76c96cc4be feat(core): enable shell background completion injection
With the idle wake-up listener in place, shell background
completions can now use 'inject' to feed output back into the
model conversation when the agent is idle.
2026-03-17 09:56:42 -04:00
Adam Weidman 6880859fdb feat(cli): auto-restart agent on background task completion
When the agent is idle and a backgrounded execution completes, the
completion output is now automatically submitted as a new turn
instead of being silently dropped. Uses the same InjectionService
listener pattern as user steering hints.
2026-03-17 09:56:41 -04:00
Adam Weidman bb80f5fd67 Merge remote-tracking branch 'origin/main' into afw/agnostic-background-ui
# Conflicts:
#	packages/a2a-server/src/commands/memory.test.ts
#	packages/a2a-server/src/commands/memory.ts
#	packages/cli/src/acp/commands/memory.ts
#	packages/core/src/services/executionLifecycleService.test.ts
#	packages/core/src/services/executionLifecycleService.ts
2026-03-17 09:56:20 -04:00
Adam Weidman ccfd2a48ad fix(cli): only add shell PTYs to backgroundedPids set
Non-shell executions rely on the onBackground listener to register
in the UI panel. Adding their PID to backgroundedPids before firing
the listener caused them to be skipped and disappear from the UI.
2026-03-16 18:17:37 -04:00
Adam Weidman aabc27c12d fix(core): set shell background completionBehavior to silent
Shell background completions are set to silent until the idle
wake-up listener is in place, otherwise injections get buffered
but never consumed when the agent is idle.
2026-03-16 17:56:58 -04:00
Adam Weidman 6362e3cc15 fix: remove duplicate rebase artifacts in executionLifecycleService
Remove duplicated import, field, method, and injection call that
were introduced by rebase conflict resolution.
2026-03-16 12:04:39 -04:00
Adam Weidman 8751910572 refactor(core): move background injection from UI to ExecutionLifecycleService
settleExecution now calls injectionService.addInjection() directly
when a backgrounded execution completes, removing the bridge useEffect
from AppContainer. The UI just wires the injection service once at init
via setInjectionService().
2026-03-16 12:04:39 -04:00
Adam Weidman 708d0e7016 feat(core): add CompletionBehavior for background task injection control
Introduce inject/notify/silent completion behaviors that control what
happens when a backgrounded execution completes:
- inject: full output injected into conversation, auto-dismiss from UI
- notify: short pointer message injected (e.g. log file path), auto-dismiss
- silent: nothing injected, stays in Ctrl+B until manually dismissed

Shell commands use 'notify' (output saved to log file), remote agents
will use 'inject' (full output reinjected). Default is 'silent' when
no formatInjection callback is provided.
2026-03-16 12:04:39 -04:00
Adam Weidman d68cc3a88f feat(cli): make background task UI agnostic to execution type
Add onBackground event to ExecutionLifecycleService that fires when any
execution is moved to the background. The CLI subscribes to this event
and automatically registers background tasks in the UI — no per-tool
changes needed.

Any tool that calls ExecutionLifecycleService.createExecution() or
attachExecution() now automatically gets Ctrl+B support. Shell-specific
concerns (PTY log files) stay in ShellExecutionService.

Forward setExecutionIdCallback through SubAgentInvocation so agents
can expose their execution ID to the scheduler for backgrounding.

Route registerBackgroundTask and dismissBackgroundTask through
ExecutionLifecycleService instead of ShellExecutionService for
agnostic subscribe/onExit/kill support.
2026-03-16 12:04:39 -04:00
Adam Weidman 6510587725 refactor(core): replace positional execute params with ExecuteOptions bag
Collapse shellExecutionConfig and setExecutionIdCallback into a single
optional ExecuteOptions object on ToolInvocation.execute(). This avoids
forcing every tool implementation to accept shell-specific parameters
just to reach later positional args.
2026-03-16 12:01:39 -04:00
Adam Weidman 1d86b6504b test(core): add integration tests for background completion injection flow
Tests cover XML tag wrapping with safety instruction, ordering
(background completions before user hints), and source filtering
to prevent background output from leaking into user hint getters.
2026-03-16 11:57:36 -04:00
Adam Weidman 1dc55b278b fix(core): harden injection safety and listener resilience
Wrap background completion output in <background_output> XML tags with
inline instructions to treat as data, consistent with <user_input> tags
used for user steering hints.

Guard listener iteration in InjectionService.addInjection and
ExecutionLifecycleService.settleExecution with try/catch so a throwing
listener doesn't block subsequent listeners or crash the caller.
2026-03-16 11:00:52 -04:00
Adam Weidman 2727a871c3 fix(core): source-aware injection getters, fix unshift ordering, remove dead code
Rename getUserHints/getUserHintsAfter/getLatestHintIndex to
getInjections/getInjectionsAfter/getLatestInjectionIndex with optional
source filter so bg completions don't get formatted as user hints.

Swap unshift ordering so bg completions appear before user hints in the
message — the model sees context before the user's reaction to it.

Remove unused getLastUserHintAt().
2026-03-15 22:16:38 -04:00
Adam Weidman 8fcb18996a refactor(core): unify InjectionService API to single onInjection interface
Remove legacy onUserHint/offUserHint/addUserHint methods. All callers
now use addInjection(text, source) and onInjection/offInjection with
source-based filtering where needed.
2026-03-15 21:38:11 -04:00
Adam Weidman f46a1c7e8b refactor(core): move background completion consumption from UI to agent loop
The agent loop in local-executor now listens via onInjection (all sources)
instead of onUserHint (steering only), picking up background completions
between turns. This removes the separate bg completion useEffect, refs,
state, and callback from AppContainer entirely.
2026-03-15 15:43:34 -04:00
Adam Weidman 8b7321ea8d refactor(core): move background injection wiring from UI to backend
Wire ExecutionLifecycleService.setInjectionService() in Config constructor
so backgrounded executions inject directly via settleExecution instead of
routing through a useEffect bridge in AppContainer.
2026-03-15 15:11:13 -04:00
Adam Weidman 931b80206b refactor(core): rename UserHintService to InjectionService and add background completion support
Rename UserHintService to InjectionService as a generic, source-agnostic
injection mechanism. InjectionService supports typed sources ('user_steering'
and 'background_completion') with source-specific gating — user_steering
respects the model steering toggle while background_completion always fires.

Add background completion lifecycle to ExecutionLifecycleService: tracks
backgrounded executions, fires onBackgroundComplete listeners when they
settle, and supports FormatInjectionFn callbacks so execution creators
control how their output is formatted for reinjection.

Wire AppContainer to route background completions through InjectionService
and submit them to the model when idle, independent of model steering.
2026-03-12 13:23:52 -04:00
12 changed files with 948 additions and 115 deletions
+35 -7
View File
@@ -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 -1
View File
@@ -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(
+129 -26
View File
@@ -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(
+3 -1
View File
@@ -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;
},
+6
View File
@@ -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();