mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 04:48:09 -07:00
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.
This commit is contained in:
@@ -9,7 +9,12 @@ 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,
|
||||
@@ -238,8 +243,19 @@ export const useShellCommandProcessor = (
|
||||
);
|
||||
|
||||
const registerBackgroundTask = useCallback(
|
||||
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
|
||||
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
|
||||
(
|
||||
pid: number,
|
||||
command: string,
|
||||
initialOutput: string | AnsiOutput,
|
||||
completionBehavior?: CompletionBehavior,
|
||||
) => {
|
||||
dispatch({
|
||||
type: 'REGISTER_SHELL',
|
||||
pid,
|
||||
command,
|
||||
initialOutput,
|
||||
completionBehavior,
|
||||
});
|
||||
|
||||
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
|
||||
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
|
||||
@@ -248,8 +264,11 @@ export const useShellCommandProcessor = (
|
||||
pid,
|
||||
update: { status: 'exited', exitCode: code },
|
||||
});
|
||||
// Auto-dismiss completed tasks from the background panel.
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
// 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();
|
||||
@@ -304,12 +323,18 @@ export const useShellCommandProcessor = (
|
||||
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);
|
||||
registerBackgroundTask(
|
||||
info.executionId,
|
||||
info.label,
|
||||
info.output,
|
||||
info.completionBehavior,
|
||||
);
|
||||
};
|
||||
ExecutionLifecycleService.onBackground(listener);
|
||||
return () => {
|
||||
@@ -489,7 +514,12 @@ export const useShellCommandProcessor = (
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
if (result.backgrounded && result.pid) {
|
||||
registerBackgroundTask(result.pid, rawQuery, cumulativeStdout);
|
||||
registerBackgroundTask(
|
||||
result.pid,
|
||||
rawQuery,
|
||||
cumulativeStdout,
|
||||
'notify',
|
||||
);
|
||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -402,6 +402,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);
|
||||
});
|
||||
@@ -433,7 +434,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);
|
||||
|
||||
@@ -452,6 +453,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);
|
||||
});
|
||||
@@ -518,5 +520,113 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface ExternalExecutionRegistration {
|
||||
writeInput?: (input: string) => void;
|
||||
kill?: () => void;
|
||||
isActive?: () => boolean;
|
||||
formatInjection?: FormatInjectionFn;
|
||||
completionBehavior?: CompletionBehavior;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,12 +81,24 @@ 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;
|
||||
}
|
||||
@@ -97,6 +111,7 @@ export interface BackgroundStartInfo {
|
||||
executionMethod: ExecutionMethod;
|
||||
label: string;
|
||||
output: string;
|
||||
completionBehavior: CompletionBehavior;
|
||||
}
|
||||
|
||||
export type BackgroundStartListener = (info: BackgroundStartInfo) => void;
|
||||
@@ -111,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 = (
|
||||
@@ -280,6 +296,8 @@ export class ExecutionLifecycleService {
|
||||
writeInput: registration.writeInput,
|
||||
kill: registration.kill,
|
||||
isActive: registration.isActive,
|
||||
formatInjection: registration.formatInjection,
|
||||
completionBehavior: registration.completionBehavior,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -294,6 +312,7 @@ export class ExecutionLifecycleService {
|
||||
executionMethod: ExecutionMethod = 'none',
|
||||
formatInjection?: FormatInjectionFn,
|
||||
label?: string,
|
||||
completionBehavior?: CompletionBehavior,
|
||||
): ExecutionHandle {
|
||||
const executionId = this.allocateExecutionId();
|
||||
|
||||
@@ -304,6 +323,7 @@ export class ExecutionLifecycleService {
|
||||
kind: 'virtual',
|
||||
onKill,
|
||||
formatInjection,
|
||||
completionBehavior,
|
||||
getBackgroundOutput: () => {
|
||||
const state = this.activeExecutions.get(executionId);
|
||||
return state?.output ?? initialOutput;
|
||||
@@ -361,15 +381,20 @@ 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 behavior =
|
||||
execution.completionBehavior ??
|
||||
(execution.formatInjection ? 'inject' : 'silent');
|
||||
const injectionText =
|
||||
behavior !== 'silent' && execution.formatInjection
|
||||
? execution.formatInjection(result.output, result.error)
|
||||
: null;
|
||||
const info: BackgroundCompletionInfo = {
|
||||
executionId,
|
||||
executionMethod: execution.executionMethod,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
injectionText,
|
||||
completionBehavior: behavior,
|
||||
};
|
||||
|
||||
// Inject directly into the model conversation if injection text is
|
||||
@@ -478,6 +503,9 @@ export class ExecutionLifecycleService {
|
||||
label:
|
||||
execution.label ?? `${execution.executionMethod} (ID: ${executionId})`,
|
||||
output,
|
||||
completionBehavior:
|
||||
execution.completionBehavior ??
|
||||
(execution.formatInjection ? 'inject' : 'silent'),
|
||||
};
|
||||
for (const listener of this.backgroundStartListeners) {
|
||||
listener(info);
|
||||
|
||||
@@ -448,6 +448,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: 'notify',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -782,6 +790,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: 'notify',
|
||||
}).result;
|
||||
|
||||
let processingChain = Promise.resolve();
|
||||
|
||||
Reference in New Issue
Block a user