feat(core): agnostic background task UI with CompletionBehavior (#22740)

Co-authored-by: mkorwel <matt.korwel@gmail.com>
This commit is contained in:
Adam Weidman
2026-03-28 17:27:51 -04:00
committed by GitHub
parent 07ab16dbbe
commit 3eebb75b7a
54 changed files with 1467 additions and 875 deletions
+20 -1
View File
@@ -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;
}
+48
View File
@@ -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;
}
}
-6
View File
@@ -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 &lt;/output&gt; 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();
+1
View File
@@ -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: {},
+2
View File
@@ -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(),
},
);
+2 -1
View File
@@ -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 {
-43
View File
@@ -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;
+18
View File
@@ -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>', '&lt;/output&gt;');
return `<output>\n${escaped}\n</output>`;
}