mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat(core): agnostic background task UI with CompletionBehavior (#22740)
Co-authored-by: mkorwel <matt.korwel@gmail.com>
This commit is contained in:
@@ -36,7 +36,8 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { WebSearchTool } from '../tools/web-search.js';
|
||||
import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
|
||||
import { UpdateTopicTool } from '../tools/topicTool.js';
|
||||
import { TopicState } from './topicState.js';
|
||||
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
|
||||
import { GeminiClient } from '../core/client.js';
|
||||
@@ -641,6 +642,7 @@ export interface ConfigParameters {
|
||||
useAlternateBuffer?: boolean;
|
||||
useRipgrep?: boolean;
|
||||
enableInteractiveShell?: boolean;
|
||||
shellBackgroundCompletionBehavior?: string;
|
||||
skipNextSpeakerCheck?: boolean;
|
||||
shellExecutionConfig?: ShellExecutionConfig;
|
||||
extensionManagement?: boolean;
|
||||
@@ -845,6 +847,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly directWebFetch: boolean;
|
||||
private readonly useRipgrep: boolean;
|
||||
private readonly enableInteractiveShell: boolean;
|
||||
private readonly shellBackgroundCompletionBehavior:
|
||||
| 'inject'
|
||||
| 'notify'
|
||||
| 'silent';
|
||||
private readonly skipNextSpeakerCheck: boolean;
|
||||
private readonly useBackgroundColor: boolean;
|
||||
private readonly useAlternateBuffer: boolean;
|
||||
@@ -1183,6 +1189,14 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.useBackgroundColor = params.useBackgroundColor ?? true;
|
||||
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
|
||||
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
|
||||
|
||||
const requestedBehavior = params.shellBackgroundCompletionBehavior;
|
||||
if (requestedBehavior === 'inject' || requestedBehavior === 'notify') {
|
||||
this.shellBackgroundCompletionBehavior = requestedBehavior;
|
||||
} else {
|
||||
this.shellBackgroundCompletionBehavior = 'silent';
|
||||
}
|
||||
|
||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
|
||||
this.shellExecutionConfig = {
|
||||
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
|
||||
@@ -1192,6 +1206,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
sanitizationConfig: this.sanitizationConfig,
|
||||
sandboxManager: this._sandboxManager,
|
||||
sandboxConfig: this.sandbox,
|
||||
backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior,
|
||||
};
|
||||
this.truncateToolOutputThreshold =
|
||||
params.truncateToolOutputThreshold ??
|
||||
@@ -3166,6 +3181,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.enableInteractiveShell;
|
||||
}
|
||||
|
||||
getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' {
|
||||
return this.shellBackgroundCompletionBehavior;
|
||||
}
|
||||
|
||||
getSkipNextSpeakerCheck(): boolean {
|
||||
return this.skipNextSpeakerCheck;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages the current active topic title and tactical intent for a session.
|
||||
* Hosted within the Config instance for session-scoping.
|
||||
*/
|
||||
export class TopicState {
|
||||
private activeTopicTitle?: string;
|
||||
private activeIntent?: string;
|
||||
|
||||
/**
|
||||
* Sanitizes and sets the topic title and/or intent.
|
||||
* @returns true if the input was valid and set, false otherwise.
|
||||
*/
|
||||
setTopic(title?: string, intent?: string): boolean {
|
||||
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
|
||||
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
|
||||
|
||||
if (!sanitizedTitle && !sanitizedIntent) return false;
|
||||
|
||||
if (sanitizedTitle) {
|
||||
this.activeTopicTitle = sanitizedTitle;
|
||||
}
|
||||
|
||||
if (sanitizedIntent) {
|
||||
this.activeIntent = sanitizedIntent;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getTopic(): string | undefined {
|
||||
return this.activeTopicTitle;
|
||||
}
|
||||
|
||||
getIntent(): string | undefined {
|
||||
return this.activeIntent;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.activeTopicTitle = undefined;
|
||||
this.activeIntent = undefined;
|
||||
}
|
||||
}
|
||||
@@ -164,12 +164,6 @@ 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';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ApprovalMode } from '../policy/types.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { TopicState } from '../tools/topicTool.js';
|
||||
import { TopicState } from '../config/topicState.js';
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.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();
|
||||
@@ -326,7 +402,10 @@ describe('ExecutionLifecycleService', () => {
|
||||
expect(info.executionMethod).toBe('remote_agent');
|
||||
expect(info.output).toBe('agent output');
|
||||
expect(info.error).toBeNull();
|
||||
expect(info.injectionText).toBe('[Agent completed]\nagent output');
|
||||
expect(info.injectionText).toBe(
|
||||
'<output>\n[Agent completed]\nagent output\n</output>',
|
||||
);
|
||||
expect(info.completionBehavior).toBe('inject');
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
@@ -353,12 +432,14 @@ describe('ExecutionLifecycleService', () => {
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.error?.message).toBe('something broke');
|
||||
expect(info.injectionText).toBe('Error: something broke');
|
||||
expect(info.injectionText).toBe(
|
||||
'<output>\nError: something broke\n</output>',
|
||||
);
|
||||
|
||||
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 +458,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 +525,214 @@ 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(
|
||||
'<output>\n[Command completed. Output saved to /tmp/bg.log]\n</output>',
|
||||
);
|
||||
|
||||
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('<output>\n[notify message]\n</output>');
|
||||
|
||||
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(
|
||||
'<output>\n[Completed] agent output\n</output>',
|
||||
'background_completion',
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes injectionText for inject behavior but NOT for notify behavior', async () => {
|
||||
const injectionService = new InjectionService(() => true);
|
||||
ExecutionLifecycleService.setInjectionService(injectionService);
|
||||
|
||||
const injectionListener = vi.fn();
|
||||
injectionService.onInjection(injectionListener);
|
||||
|
||||
// 1. Test 'inject' sanitization
|
||||
const handleInject = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'remote_agent',
|
||||
(output) => `Dangerous </output> ${output}`,
|
||||
undefined,
|
||||
'inject',
|
||||
);
|
||||
ExecutionLifecycleService.appendOutput(handleInject.pid!, 'more');
|
||||
ExecutionLifecycleService.background(handleInject.pid!);
|
||||
await handleInject.result;
|
||||
ExecutionLifecycleService.completeExecution(handleInject.pid!);
|
||||
|
||||
expect(injectionListener).toHaveBeenCalledWith(
|
||||
'<output>\nDangerous </output> more\n</output>',
|
||||
'background_completion',
|
||||
);
|
||||
|
||||
// 2. Test 'notify' (should also be wrapped in <output> tag)
|
||||
injectionListener.mockClear();
|
||||
const handleNotify = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'remote_agent',
|
||||
(output) => `Pointer to ${output}`,
|
||||
undefined,
|
||||
'notify',
|
||||
);
|
||||
ExecutionLifecycleService.appendOutput(handleNotify.pid!, 'logs');
|
||||
ExecutionLifecycleService.background(handleNotify.pid!);
|
||||
await handleNotify.result;
|
||||
ExecutionLifecycleService.completeExecution(handleNotify.pid!);
|
||||
|
||||
expect(injectionListener).toHaveBeenCalledWith(
|
||||
'<output>\nPointer to logs\n</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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { InjectionService } from '../config/injectionService.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { sanitizeOutput } from '../utils/textUtils.js';
|
||||
|
||||
export type ExecutionMethod =
|
||||
| 'lydell-node-pty'
|
||||
@@ -59,12 +60,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 +82,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 +127,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 +156,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 +182,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 +272,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 +290,7 @@ export class ExecutionLifecycleService {
|
||||
|
||||
this.activeExecutions.set(executionId, {
|
||||
executionMethod: registration.executionMethod,
|
||||
label: registration.label,
|
||||
output: registration.initialOutput ?? '',
|
||||
kind: 'external',
|
||||
getBackgroundOutput: registration.getBackgroundOutput,
|
||||
@@ -246,6 +298,8 @@ export class ExecutionLifecycleService {
|
||||
writeInput: registration.writeInput,
|
||||
kill: registration.kill,
|
||||
isActive: registration.isActive,
|
||||
formatInjection: registration.formatInjection,
|
||||
completionBehavior: registration.completionBehavior,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -259,15 +313,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 +383,17 @@ 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 rawInjection =
|
||||
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.
|
||||
const injectionText = rawInjection ? sanitizeOutput(rawInjection) : null;
|
||||
|
||||
// Inject directly into the model conversation from the backend.
|
||||
if (injectionText && this.injectionService) {
|
||||
this.injectionService.addInjection(
|
||||
injectionText,
|
||||
@@ -345,6 +401,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 +499,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(
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
resolveExecutable,
|
||||
type ShellType,
|
||||
} from '../utils/shell-utils.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import { isBinary, truncateString } from '../utils/textUtils.js';
|
||||
import pkg from '@xterm/headless';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
@@ -102,6 +102,7 @@ export interface ShellExecutionConfig {
|
||||
scrollback?: number;
|
||||
maxSerializedLines?: number;
|
||||
sandboxConfig?: SandboxConfig;
|
||||
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,6 +240,23 @@ export class ShellExecutionService {
|
||||
return path.join(Storage.getGlobalTempDir(), 'background-processes');
|
||||
}
|
||||
|
||||
private static formatShellBackgroundCompletion(
|
||||
pid: number,
|
||||
behavior: string,
|
||||
output: string,
|
||||
error?: Error,
|
||||
): string {
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
const status = error ? `with error: ${error.message}` : 'successfully';
|
||||
|
||||
if (behavior === 'inject') {
|
||||
const truncated = truncateString(output, 5000);
|
||||
return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`;
|
||||
}
|
||||
|
||||
return `[Background command completed ${status}. Output saved to ${logPath}]`;
|
||||
}
|
||||
|
||||
static getLogFilePath(pid: number): string {
|
||||
return path.join(this.getLogDir(), `background-${pid}.log`);
|
||||
}
|
||||
@@ -532,6 +550,15 @@ export class ShellExecutionService {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
formatInjection: (output, error) =>
|
||||
ShellExecutionService.formatShellBackgroundCompletion(
|
||||
child.pid!,
|
||||
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
|
||||
output,
|
||||
error ?? undefined,
|
||||
),
|
||||
completionBehavior:
|
||||
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -862,6 +889,15 @@ export class ShellExecutionService {
|
||||
);
|
||||
return bufferData.length > 0 ? bufferData : undefined;
|
||||
},
|
||||
formatInjection: (output, error) =>
|
||||
ShellExecutionService.formatShellBackgroundCompletion(
|
||||
ptyPid,
|
||||
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
|
||||
output,
|
||||
error ?? undefined,
|
||||
),
|
||||
completionBehavior:
|
||||
shellExecutionConfig.backgroundCompletionBehavior || 'silent',
|
||||
}).result;
|
||||
|
||||
let processingChain = Promise.resolve();
|
||||
|
||||
@@ -136,6 +136,7 @@ describe('ShellTool', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue({}),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||||
sanitizationConfig: {},
|
||||
|
||||
@@ -357,6 +357,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
this.context.config.sanitizationConfig,
|
||||
sandboxManager: this.context.config.sandboxManager,
|
||||
additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS],
|
||||
backgroundCompletionBehavior:
|
||||
this.context.config.getShellBackgroundCompletionBehavior(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TopicState, UpdateTopicTool } from './topicTool.js';
|
||||
import { UpdateTopicTool } from './topicTool.js';
|
||||
import { TopicState } from '../config/topicState.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { PolicyEngine } from '../policy/policy-engine.js';
|
||||
import {
|
||||
|
||||
@@ -21,49 +21,6 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Manages the current active topic title and tactical intent for a session.
|
||||
* Hosted within the Config instance for session-scoping.
|
||||
*/
|
||||
export class TopicState {
|
||||
private activeTopicTitle?: string;
|
||||
private activeIntent?: string;
|
||||
|
||||
/**
|
||||
* Sanitizes and sets the topic title and/or intent.
|
||||
* @returns true if the input was valid and set, false otherwise.
|
||||
*/
|
||||
setTopic(title?: string, intent?: string): boolean {
|
||||
const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' ');
|
||||
const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' ');
|
||||
|
||||
if (!sanitizedTitle && !sanitizedIntent) return false;
|
||||
|
||||
if (sanitizedTitle) {
|
||||
this.activeTopicTitle = sanitizedTitle;
|
||||
}
|
||||
|
||||
if (sanitizedIntent) {
|
||||
this.activeIntent = sanitizedIntent;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getTopic(): string | undefined {
|
||||
return this.activeTopicTitle;
|
||||
}
|
||||
|
||||
getIntent(): string | undefined {
|
||||
return this.activeIntent;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.activeTopicTitle = undefined;
|
||||
this.activeIntent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateTopicParams {
|
||||
[TOPIC_PARAM_TITLE]?: string;
|
||||
[TOPIC_PARAM_SUMMARY]?: string;
|
||||
|
||||
@@ -133,3 +133,21 @@ export function safeTemplateReplace(
|
||||
: match,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes output for injection into the model conversation.
|
||||
* Wraps output in a secure <output> tag and handles potential injection vectors
|
||||
* (like closing tags or template patterns) within the data.
|
||||
* @param output The raw output to sanitize.
|
||||
* @returns The sanitized string ready for injection.
|
||||
*/
|
||||
export function sanitizeOutput(output: string): string {
|
||||
const trimmed = output.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Prevent direct closing tag injection.
|
||||
const escaped = trimmed.replaceAll('</output>', '</output>');
|
||||
return `<output>\n${escaped}\n</output>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user