mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-21 16:57:08 -07:00
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.
This commit is contained in:
@@ -611,7 +611,7 @@ export class AppRig {
|
||||
async addUserHint(hint: string) {
|
||||
if (!this.config) throw new Error('AppRig not initialized');
|
||||
await act(async () => {
|
||||
this.config!.userHintService.addUserHint(hint);
|
||||
this.config!.injectionService.addUserHint(hint);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
logBillingEvent,
|
||||
ApiKeyUpdatedEvent,
|
||||
ExecutionLifecycleService,
|
||||
type BackgroundCompletionInfo,
|
||||
type InjectionSource,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
@@ -1077,6 +1080,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const pendingHintsRef = useRef<string[]>([]);
|
||||
const [pendingHintCount, setPendingHintCount] = useState(0);
|
||||
const pendingBackgroundCompletionsRef = useRef<string[]>([]);
|
||||
const [pendingBgCompletionCount, setPendingBgCompletionCount] = useState(0);
|
||||
|
||||
const consumePendingHints = useCallback(() => {
|
||||
if (pendingHintsRef.current.length === 0) {
|
||||
@@ -1088,14 +1093,51 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return hint;
|
||||
}, []);
|
||||
|
||||
const consumePendingBackgroundCompletions = useCallback(() => {
|
||||
if (pendingBackgroundCompletionsRef.current.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const output = pendingBackgroundCompletionsRef.current.join('\n');
|
||||
pendingBackgroundCompletionsRef.current = [];
|
||||
setPendingBgCompletionCount(0);
|
||||
return output;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hintListener = (hint: string) => {
|
||||
pendingHintsRef.current.push(hint);
|
||||
setPendingHintCount((prev) => prev + 1);
|
||||
const injectionListener = (text: string, source: InjectionSource) => {
|
||||
if (source === 'user_steering') {
|
||||
pendingHintsRef.current.push(text);
|
||||
setPendingHintCount((prev) => prev + 1);
|
||||
} else if (source === 'background_completion') {
|
||||
pendingBackgroundCompletionsRef.current.push(text);
|
||||
setPendingBgCompletionCount((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
config.userHintService.onUserHint(hintListener);
|
||||
config.injectionService.onInjection(injectionListener);
|
||||
return () => {
|
||||
config.userHintService.offUserHint(hintListener);
|
||||
config.injectionService.offInjection(injectionListener);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// Wire background completion events from ExecutionLifecycleService into the
|
||||
// injection service so completed backgrounded executions are reinjected.
|
||||
useEffect(() => {
|
||||
const bgListener = (info: BackgroundCompletionInfo) => {
|
||||
// Use the execution creator's custom injection text if provided.
|
||||
let text = info.injectionText;
|
||||
if (text === null || text === undefined) {
|
||||
// Fallback: generic format for executions without a custom formatter.
|
||||
const header = info.error
|
||||
? `[Background execution (ID: ${info.executionId}) completed with error: ${info.error.message}]`
|
||||
: `[Background execution (ID: ${info.executionId}) completed]`;
|
||||
const body = info.output ? `\n${info.output}` : '';
|
||||
text = `${header}${body}`;
|
||||
}
|
||||
config.injectionService.addInjection(text, 'background_completion');
|
||||
};
|
||||
ExecutionLifecycleService.onBackgroundComplete(bgListener);
|
||||
return () => {
|
||||
ExecutionLifecycleService.offBackgroundComplete(bgListener);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -1259,7 +1301,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
config.userHintService.addUserHint(trimmed);
|
||||
config.injectionService.addUserHint(trimmed);
|
||||
// Render hints with a distinct style.
|
||||
historyManager.addItem({
|
||||
type: 'hint',
|
||||
@@ -2130,6 +2172,38 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingHintCount,
|
||||
]);
|
||||
|
||||
// Reinject completed background execution output into the model conversation.
|
||||
// Unlike user steering hints, this is NOT gated on model steering being enabled.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isConfigInitialized ||
|
||||
streamingState !== StreamingState.Idle ||
|
||||
!isMcpReady ||
|
||||
isToolAwaitingConfirmation(pendingHistoryItems)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bgOutput = consumePendingBackgroundCompletions();
|
||||
if (!bgOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
void submitQuery([
|
||||
{
|
||||
text: `Background execution update:\n${bgOutput}\n\nThe above background execution has completed. Review the output and continue your work accordingly.`,
|
||||
},
|
||||
]);
|
||||
}, [
|
||||
isConfigInitialized,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
submitQuery,
|
||||
consumePendingBackgroundCompletions,
|
||||
pendingHistoryItems,
|
||||
pendingBgCompletionCount,
|
||||
]);
|
||||
|
||||
const allToolCalls = useMemo(
|
||||
() =>
|
||||
pendingHistoryItems
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('clearCommand', () => {
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
userHintService: {
|
||||
injectionService: {
|
||||
clear: mockHintClear,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ export const clearCommand: SlashCommand = {
|
||||
}
|
||||
|
||||
// Reset user steering hints
|
||||
config?.userHintService.clear();
|
||||
config?.injectionService.clear();
|
||||
|
||||
// Start a new conversation recording with a new session ID
|
||||
// We MUST do this before calling resetChat() so the new ChatRecordingService
|
||||
|
||||
@@ -2105,7 +2105,7 @@ describe('LocalAgentExecutor', () => {
|
||||
// Give the loop a chance to start and register the listener
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
configWithHints.userHintService.addUserHint('Initial Hint');
|
||||
configWithHints.injectionService.addUserHint('Initial Hint');
|
||||
|
||||
// Resolve the tool call to complete Turn 1
|
||||
resolveToolCall!([
|
||||
@@ -2151,7 +2151,7 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
it('should NOT inject legacy hints added before executor was created', async () => {
|
||||
const definition = createTestDefinition();
|
||||
configWithHints.userHintService.addUserHint('Legacy Hint');
|
||||
configWithHints.injectionService.addUserHint('Legacy Hint');
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
@@ -2218,7 +2218,7 @@ describe('LocalAgentExecutor', () => {
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
// Add the hint while the tool call is pending
|
||||
configWithHints.userHintService.addUserHint('Corrective Hint');
|
||||
configWithHints.injectionService.addUserHint('Corrective Hint');
|
||||
|
||||
// Now resolve the tool call to complete Turn 1
|
||||
resolveToolCall!([
|
||||
|
||||
@@ -532,12 +532,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
// Capture the index of the last hint before starting to avoid re-injecting old hints.
|
||||
// NOTE: Hints added AFTER this point will be broadcast to all currently running
|
||||
// local agents via the listener below.
|
||||
const startIndex = this.config.userHintService.getLatestHintIndex();
|
||||
this.config.userHintService.onUserHint(hintListener);
|
||||
const startIndex = this.config.injectionService.getLatestHintIndex();
|
||||
this.config.injectionService.onUserHint(hintListener);
|
||||
|
||||
try {
|
||||
const initialHints =
|
||||
this.config.userHintService.getUserHintsAfter(startIndex);
|
||||
this.config.injectionService.getUserHintsAfter(startIndex);
|
||||
const formattedInitialHints = formatUserHintsForModel(initialHints);
|
||||
|
||||
let currentMessage: Content = formattedInitialHints
|
||||
@@ -598,7 +598,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.config.userHintService.offUserHint(hintListener);
|
||||
this.config.injectionService.offUserHint(hintListener);
|
||||
}
|
||||
|
||||
// === UNIFIED RECOVERY BLOCK ===
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('SubAgentInvocation', () => {
|
||||
describe('withUserHints', () => {
|
||||
it('should NOT modify query for local agents', async () => {
|
||||
mockConfig = makeFakeConfig({ modelSteering: true });
|
||||
mockConfig.userHintService.addUserHint('Test Hint');
|
||||
mockConfig.injectionService.addUserHint('Test Hint');
|
||||
|
||||
const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus);
|
||||
const params = { query: 'original query' };
|
||||
@@ -229,7 +229,7 @@ describe('SubAgentInvocation', () => {
|
||||
|
||||
it('should NOT modify query for remote agents if model steering is disabled', async () => {
|
||||
mockConfig = makeFakeConfig({ modelSteering: false });
|
||||
mockConfig.userHintService.addUserHint('Test Hint');
|
||||
mockConfig.injectionService.addUserHint('Test Hint');
|
||||
|
||||
const tool = new SubagentTool(
|
||||
testRemoteDefinition,
|
||||
@@ -276,8 +276,8 @@ describe('SubAgentInvocation', () => {
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const invocation = tool.createInvocation(params, mockMessageBus);
|
||||
|
||||
mockConfig.userHintService.addUserHint('Hint 1');
|
||||
mockConfig.userHintService.addUserHint('Hint 2');
|
||||
mockConfig.injectionService.addUserHint('Hint 1');
|
||||
mockConfig.injectionService.addUserHint('Hint 2');
|
||||
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const hintedParams = invocation.withUserHints(params);
|
||||
@@ -289,7 +289,7 @@ describe('SubAgentInvocation', () => {
|
||||
|
||||
it('should NOT include legacy hints added before the invocation was created', async () => {
|
||||
mockConfig = makeFakeConfig({ modelSteering: true });
|
||||
mockConfig.userHintService.addUserHint('Legacy Hint');
|
||||
mockConfig.injectionService.addUserHint('Legacy Hint');
|
||||
|
||||
const tool = new SubagentTool(
|
||||
testRemoteDefinition,
|
||||
@@ -308,7 +308,7 @@ describe('SubAgentInvocation', () => {
|
||||
expect(hintedParams.query).toBe('original query');
|
||||
|
||||
// Add a new hint after creation
|
||||
mockConfig.userHintService.addUserHint('New Hint');
|
||||
mockConfig.injectionService.addUserHint('New Hint');
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
hintedParams = invocation.withUserHints(params);
|
||||
|
||||
@@ -318,7 +318,7 @@ describe('SubAgentInvocation', () => {
|
||||
|
||||
it('should NOT modify query if query is missing or not a string', async () => {
|
||||
mockConfig = makeFakeConfig({ modelSteering: true });
|
||||
mockConfig.userHintService.addUserHint('Hint');
|
||||
mockConfig.injectionService.addUserHint('Hint');
|
||||
|
||||
const tool = new SubagentTool(
|
||||
testRemoteDefinition,
|
||||
|
||||
@@ -137,7 +137,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
|
||||
_toolName ?? definition.name,
|
||||
_toolDisplayName ?? definition.displayName ?? definition.name,
|
||||
);
|
||||
this.startIndex = context.config.userHintService.getLatestHintIndex();
|
||||
this.startIndex = context.config.injectionService.getLatestHintIndex();
|
||||
}
|
||||
|
||||
private get config(): Config {
|
||||
@@ -200,7 +200,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
|
||||
return agentArgs;
|
||||
}
|
||||
|
||||
const userHints = this.config.userHintService.getUserHintsAfter(
|
||||
const userHints = this.config.injectionService.getUserHintsAfter(
|
||||
this.startIndex,
|
||||
);
|
||||
const formattedHints = formatUserHintsForModel(userHints);
|
||||
|
||||
@@ -147,7 +147,7 @@ import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
|
||||
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
|
||||
import { UserHintService } from './userHintService.js';
|
||||
import { InjectionService } from './injectionService.js';
|
||||
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
|
||||
import { loadPoliciesFromToml } from '../policy/toml-loader.js';
|
||||
|
||||
@@ -842,7 +842,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private remoteAdminSettings: AdminControlsSettings | undefined;
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
private lastModeSwitchTime: number = performance.now();
|
||||
readonly userHintService: UserHintService;
|
||||
readonly injectionService: InjectionService;
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
@@ -935,7 +935,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.modelSteering = params.modelSteering ?? false;
|
||||
this.userHintService = new UserHintService(() =>
|
||||
this.injectionService = new InjectionService(() =>
|
||||
this.isModelSteeringEnabled(),
|
||||
);
|
||||
this.toolOutputMasking = {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { InjectionService } from './injectionService.js';
|
||||
|
||||
describe('InjectionService', () => {
|
||||
it('is disabled by default and ignores user_steering injections', () => {
|
||||
const service = new InjectionService(() => false);
|
||||
service.addUserHint('this hint should be ignored');
|
||||
expect(service.getUserHints()).toEqual([]);
|
||||
expect(service.getLatestHintIndex()).toBe(-1);
|
||||
});
|
||||
|
||||
it('stores trimmed injections and exposes them via indexing when enabled', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
|
||||
service.addUserHint(' first hint ');
|
||||
service.addUserHint('second hint');
|
||||
service.addUserHint(' ');
|
||||
|
||||
expect(service.getUserHints()).toEqual(['first hint', 'second hint']);
|
||||
expect(service.getLatestHintIndex()).toBe(1);
|
||||
expect(service.getUserHintsAfter(-1)).toEqual([
|
||||
'first hint',
|
||||
'second hint',
|
||||
]);
|
||||
expect(service.getUserHintsAfter(0)).toEqual(['second hint']);
|
||||
expect(service.getUserHintsAfter(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('tracks the last injection timestamp', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
|
||||
expect(service.getLastUserHintAt()).toBeNull();
|
||||
service.addUserHint('hint');
|
||||
|
||||
const timestamp = service.getLastUserHintAt();
|
||||
expect(timestamp).not.toBeNull();
|
||||
expect(typeof timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('notifies user hint listeners when a user_steering injection is added', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onUserHint(listener);
|
||||
|
||||
service.addUserHint('new hint');
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('new hint');
|
||||
});
|
||||
|
||||
it('does NOT notify user hint listeners after they are unregistered', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onUserHint(listener);
|
||||
service.offUserHint(listener);
|
||||
|
||||
service.addUserHint('ignored hint');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear all injections', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
service.addUserHint('hint 1');
|
||||
service.addUserHint('hint 2');
|
||||
expect(service.getUserHints()).toHaveLength(2);
|
||||
|
||||
service.clear();
|
||||
expect(service.getUserHints()).toHaveLength(0);
|
||||
expect(service.getLatestHintIndex()).toBe(-1);
|
||||
});
|
||||
|
||||
describe('typed injection API', () => {
|
||||
it('notifies typed listeners with source for user_steering', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onInjection(listener);
|
||||
|
||||
service.addUserHint('steering hint');
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('steering hint', 'user_steering');
|
||||
});
|
||||
|
||||
it('notifies typed listeners with source for background_completion', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onInjection(listener);
|
||||
|
||||
service.addInjection('bg output', 'background_completion');
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
'bg output',
|
||||
'background_completion',
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT notify user hint listeners for background_completion', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
const userListener = vi.fn();
|
||||
const typedListener = vi.fn();
|
||||
service.onUserHint(userListener);
|
||||
service.onInjection(typedListener);
|
||||
|
||||
service.addInjection('bg output', 'background_completion');
|
||||
|
||||
expect(typedListener).toHaveBeenCalledWith(
|
||||
'bg output',
|
||||
'background_completion',
|
||||
);
|
||||
expect(userListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts background_completion even when model steering is disabled', () => {
|
||||
const service = new InjectionService(() => false);
|
||||
const listener = vi.fn();
|
||||
service.onInjection(listener);
|
||||
|
||||
service.addInjection('bg output', 'background_completion');
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
'bg output',
|
||||
'background_completion',
|
||||
);
|
||||
expect(service.getUserHints()).toEqual(['bg output']);
|
||||
});
|
||||
|
||||
it('rejects user_steering when model steering is disabled', () => {
|
||||
const service = new InjectionService(() => false);
|
||||
const listener = vi.fn();
|
||||
service.onInjection(listener);
|
||||
|
||||
service.addInjection('steering hint', 'user_steering');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(service.getUserHints()).toEqual([]);
|
||||
});
|
||||
|
||||
it('unregisters typed listeners correctly', () => {
|
||||
const service = new InjectionService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onInjection(listener);
|
||||
service.offInjection(listener);
|
||||
|
||||
service.addInjection('bg output', 'background_completion');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Source of an injection into the model conversation.
|
||||
* - `user_steering`: Interactive guidance from the user (gated on model steering).
|
||||
* - `background_completion`: Output from a backgrounded execution that has finished.
|
||||
*/
|
||||
export type InjectionSource = 'user_steering' | 'background_completion';
|
||||
|
||||
/**
|
||||
* Typed listener that receives both the injection text and its source.
|
||||
*/
|
||||
export type InjectionListener = (text: string, source: InjectionSource) => void;
|
||||
|
||||
/**
|
||||
* Service for managing injections into the model conversation.
|
||||
*
|
||||
* Multiple sources (user steering, background execution completions, etc.)
|
||||
* can feed into this service. Consumers register typed listeners via
|
||||
* {@link onInjection} to receive injections with source information, or use the
|
||||
* legacy {@link onUserHint} API for backward compatibility.
|
||||
*/
|
||||
export class InjectionService {
|
||||
private readonly injections: Array<{
|
||||
text: string;
|
||||
source: InjectionSource;
|
||||
timestamp: number;
|
||||
}> = [];
|
||||
private readonly injectionListeners: Set<InjectionListener> = new Set();
|
||||
private readonly userHintListeners: Set<(hint: string) => void> = new Set();
|
||||
|
||||
constructor(private readonly isEnabled: () => boolean) {}
|
||||
|
||||
/**
|
||||
* Adds an injection from any source.
|
||||
*
|
||||
* `user_steering` injections are gated on model steering being enabled.
|
||||
* Other sources (e.g. `background_completion`) are always accepted.
|
||||
*/
|
||||
addInjection(text: string, source: InjectionSource): void {
|
||||
if (source === 'user_steering' && !this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.injections.push({ text: trimmed, source, timestamp: Date.now() });
|
||||
|
||||
// Fire typed listeners (new API)
|
||||
for (const listener of this.injectionListeners) {
|
||||
listener(trimmed, source);
|
||||
}
|
||||
|
||||
// Fire legacy listeners (user_steering only)
|
||||
if (source === 'user_steering') {
|
||||
for (const listener of this.userHintListeners) {
|
||||
listener(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new steering hint from the user.
|
||||
* Convenience wrapper around {@link addInjection} with `user_steering` source.
|
||||
*/
|
||||
addUserHint(hint: string): void {
|
||||
this.addInjection(hint, 'user_steering');
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a typed listener for injections from any source.
|
||||
*/
|
||||
onInjection(listener: InjectionListener): void {
|
||||
this.injectionListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a typed injection listener.
|
||||
*/
|
||||
offInjection(listener: InjectionListener): void {
|
||||
this.injectionListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener for user steering hints only.
|
||||
*/
|
||||
onUserHint(listener: (hint: string) => void): void {
|
||||
this.userHintListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a user steering hint listener.
|
||||
*/
|
||||
offUserHint(listener: (hint: string) => void): void {
|
||||
this.userHintListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all collected injection texts (all sources).
|
||||
*/
|
||||
getUserHints(): string[] {
|
||||
return this.injections.map((h) => h.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns injection texts added after a specific index.
|
||||
*/
|
||||
getUserHintsAfter(index: number): string[] {
|
||||
if (index < 0) {
|
||||
return this.getUserHints();
|
||||
}
|
||||
return this.injections.slice(index + 1).map((h) => h.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the latest injection.
|
||||
*/
|
||||
getLatestHintIndex(): number {
|
||||
return this.injections.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timestamp of the last injection.
|
||||
*/
|
||||
getLastUserHintAt(): number | null {
|
||||
if (this.injections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.injections[this.injections.length - 1].timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all collected injections.
|
||||
*/
|
||||
clear(): void {
|
||||
this.injections.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { UserHintService } from './userHintService.js';
|
||||
|
||||
describe('UserHintService', () => {
|
||||
it('is disabled by default and ignores hints', () => {
|
||||
const service = new UserHintService(() => false);
|
||||
service.addUserHint('this hint should be ignored');
|
||||
expect(service.getUserHints()).toEqual([]);
|
||||
expect(service.getLatestHintIndex()).toBe(-1);
|
||||
});
|
||||
|
||||
it('stores trimmed hints and exposes them via indexing when enabled', () => {
|
||||
const service = new UserHintService(() => true);
|
||||
|
||||
service.addUserHint(' first hint ');
|
||||
service.addUserHint('second hint');
|
||||
service.addUserHint(' ');
|
||||
|
||||
expect(service.getUserHints()).toEqual(['first hint', 'second hint']);
|
||||
expect(service.getLatestHintIndex()).toBe(1);
|
||||
expect(service.getUserHintsAfter(-1)).toEqual([
|
||||
'first hint',
|
||||
'second hint',
|
||||
]);
|
||||
expect(service.getUserHintsAfter(0)).toEqual(['second hint']);
|
||||
expect(service.getUserHintsAfter(1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('tracks the last hint timestamp', () => {
|
||||
const service = new UserHintService(() => true);
|
||||
|
||||
expect(service.getLastUserHintAt()).toBeNull();
|
||||
service.addUserHint('hint');
|
||||
|
||||
const timestamp = service.getLastUserHintAt();
|
||||
expect(timestamp).not.toBeNull();
|
||||
expect(typeof timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('notifies listeners when a hint is added', () => {
|
||||
const service = new UserHintService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onUserHint(listener);
|
||||
|
||||
service.addUserHint('new hint');
|
||||
|
||||
expect(listener).toHaveBeenCalledWith('new hint');
|
||||
});
|
||||
|
||||
it('does NOT notify listeners after they are unregistered', () => {
|
||||
const service = new UserHintService(() => true);
|
||||
const listener = vi.fn();
|
||||
service.onUserHint(listener);
|
||||
service.offUserHint(listener);
|
||||
|
||||
service.addUserHint('ignored hint');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear all hints', () => {
|
||||
const service = new UserHintService(() => true);
|
||||
service.addUserHint('hint 1');
|
||||
service.addUserHint('hint 2');
|
||||
expect(service.getUserHints()).toHaveLength(2);
|
||||
|
||||
service.clear();
|
||||
expect(service.getUserHints()).toHaveLength(0);
|
||||
expect(service.getLatestHintIndex()).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service for managing user steering hints during a session.
|
||||
*/
|
||||
export class UserHintService {
|
||||
private readonly userHints: Array<{ text: string; timestamp: number }> = [];
|
||||
private readonly userHintListeners: Set<(hint: string) => void> = new Set();
|
||||
|
||||
constructor(private readonly isEnabled: () => boolean) {}
|
||||
|
||||
/**
|
||||
* Adds a new steering hint from the user.
|
||||
*/
|
||||
addUserHint(hint: string): void {
|
||||
if (!this.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
const trimmed = hint.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.userHints.push({ text: trimmed, timestamp: Date.now() });
|
||||
for (const listener of this.userHintListeners) {
|
||||
listener(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a listener for new user hints.
|
||||
*/
|
||||
onUserHint(listener: (hint: string) => void): void {
|
||||
this.userHintListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a listener for new user hints.
|
||||
*/
|
||||
offUserHint(listener: (hint: string) => void): void {
|
||||
this.userHintListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all collected hints.
|
||||
*/
|
||||
getUserHints(): string[] {
|
||||
return this.userHints.map((h) => h.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns hints added after a specific index.
|
||||
*/
|
||||
getUserHintsAfter(index: number): string[] {
|
||||
if (index < 0) {
|
||||
return this.getUserHints();
|
||||
}
|
||||
return this.userHints.slice(index + 1).map((h) => h.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the latest hint.
|
||||
*/
|
||||
getLatestHintIndex(): number {
|
||||
return this.userHints.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timestamp of the last user hint.
|
||||
*/
|
||||
getLastUserHintAt(): number | null {
|
||||
if (this.userHints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.userHints[this.userHints.length - 1].timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all collected hints.
|
||||
*/
|
||||
clear(): void {
|
||||
this.userHints.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,12 @@ export * from './ide/types.js';
|
||||
// Export Shell Execution Service
|
||||
export * from './services/shellExecutionService.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';
|
||||
|
||||
@@ -295,4 +295,153 @@ describe('ExecutionLifecycleService', () => {
|
||||
});
|
||||
}).toThrow('Execution 4324 is already attached.');
|
||||
});
|
||||
|
||||
describe('Background Completion Listeners', () => {
|
||||
it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'remote_agent',
|
||||
(output, error) => {
|
||||
const header = error
|
||||
? `[Agent error: ${error.message}]`
|
||||
: '[Agent completed]';
|
||||
return output ? `${header}\n${output}` : header;
|
||||
},
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.appendOutput(executionId, 'agent output');
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const info = listener.mock.calls[0][0];
|
||||
expect(info.executionId).toBe(executionId);
|
||||
expect(info.executionMethod).toBe('remote_agent');
|
||||
expect(info.output).toBe('agent output');
|
||||
expect(info.error).toBeNull();
|
||||
expect(info.injectionText).toBe('[Agent completed]\nagent output');
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('passes error to formatInjection when backgrounded execution fails', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
(output, error) => error ? `Error: ${error.message}` : output,
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId, {
|
||||
error: new Error('something broke'),
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('sets injectionText to null when no formatInjection callback is provided', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.appendOutput(executionId, 'output');
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][0].injectionText).toBeNull();
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('does not fire onBackgroundComplete for non-backgrounded executions', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
() => 'text',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
await handle.result;
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('does not fire onBackgroundComplete when execution is killed (aborted)', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
() => 'text',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.kill(executionId);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
});
|
||||
|
||||
it('offBackgroundComplete removes the listener', async () => {
|
||||
const listener = vi.fn();
|
||||
ExecutionLifecycleService.onBackgroundComplete(listener);
|
||||
ExecutionLifecycleService.offBackgroundComplete(listener);
|
||||
|
||||
const handle = ExecutionLifecycleService.createExecution(
|
||||
'',
|
||||
undefined,
|
||||
'none',
|
||||
() => 'text',
|
||||
);
|
||||
const executionId = handle.pid!;
|
||||
|
||||
ExecutionLifecycleService.background(executionId);
|
||||
await handle.result;
|
||||
|
||||
ExecutionLifecycleService.completeExecution(executionId);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,13 +65,41 @@ export interface ExternalExecutionRegistration {
|
||||
isActive?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback that an execution creator provides to control how its output
|
||||
* is formatted when reinjected into the model conversation after backgrounding.
|
||||
* Return `null` to skip injection entirely.
|
||||
*/
|
||||
export type FormatInjectionFn = (
|
||||
output: string,
|
||||
error: Error | null,
|
||||
) => string | null;
|
||||
|
||||
interface ManagedExecutionBase {
|
||||
executionMethod: ExecutionMethod;
|
||||
output: string;
|
||||
backgrounded?: boolean;
|
||||
formatInjection?: FormatInjectionFn;
|
||||
getBackgroundOutput?: () => string;
|
||||
getSubscriptionSnapshot?: () => string | AnsiOutput | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload emitted when a previously-backgrounded execution settles.
|
||||
*/
|
||||
export interface BackgroundCompletionInfo {
|
||||
executionId: number;
|
||||
executionMethod: ExecutionMethod;
|
||||
output: string;
|
||||
error: Error | null;
|
||||
/** Pre-formatted injection text from the execution creator, or `null` if skipped. */
|
||||
injectionText: string | null;
|
||||
}
|
||||
|
||||
export type BackgroundCompletionListener = (
|
||||
info: BackgroundCompletionInfo,
|
||||
) => void;
|
||||
|
||||
interface VirtualExecutionState extends ManagedExecutionBase {
|
||||
kind: 'virtual';
|
||||
onKill?: () => void;
|
||||
@@ -108,6 +136,23 @@ export class ExecutionLifecycleService {
|
||||
number,
|
||||
{ exitCode: number; signal?: number }
|
||||
>();
|
||||
private static backgroundCompletionListeners =
|
||||
new Set<BackgroundCompletionListener>();
|
||||
|
||||
/**
|
||||
* Registers a listener that fires when a previously-backgrounded
|
||||
* execution settles (completes or errors).
|
||||
*/
|
||||
static onBackgroundComplete(listener: BackgroundCompletionListener): void {
|
||||
this.backgroundCompletionListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a background completion listener.
|
||||
*/
|
||||
static offBackgroundComplete(listener: BackgroundCompletionListener): void {
|
||||
this.backgroundCompletionListeners.delete(listener);
|
||||
}
|
||||
|
||||
private static storeExitInfo(
|
||||
executionId: number,
|
||||
@@ -164,6 +209,7 @@ export class ExecutionLifecycleService {
|
||||
this.activeResolvers.clear();
|
||||
this.activeListeners.clear();
|
||||
this.exitedExecutionInfo.clear();
|
||||
this.backgroundCompletionListeners.clear();
|
||||
this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START;
|
||||
}
|
||||
|
||||
@@ -200,6 +246,7 @@ export class ExecutionLifecycleService {
|
||||
initialOutput = '',
|
||||
onKill?: () => void,
|
||||
executionMethod: ExecutionMethod = 'none',
|
||||
formatInjection?: FormatInjectionFn,
|
||||
): ExecutionHandle {
|
||||
const executionId = this.allocateExecutionId();
|
||||
|
||||
@@ -208,6 +255,7 @@ export class ExecutionLifecycleService {
|
||||
output: initialOutput,
|
||||
kind: 'virtual',
|
||||
onKill,
|
||||
formatInjection,
|
||||
getBackgroundOutput: () => {
|
||||
const state = this.activeExecutions.get(executionId);
|
||||
return state?.output ?? initialOutput;
|
||||
@@ -258,10 +306,28 @@ export class ExecutionLifecycleService {
|
||||
executionId: number,
|
||||
result: ExecutionResult,
|
||||
): void {
|
||||
if (!this.activeExecutions.has(executionId)) {
|
||||
const execution = this.activeExecutions.get(executionId);
|
||||
if (!execution) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
for (const listener of this.backgroundCompletionListeners) {
|
||||
listener(info);
|
||||
}
|
||||
}
|
||||
|
||||
this.resolvePending(executionId, result);
|
||||
this.emitEvent(executionId, {
|
||||
type: 'exit',
|
||||
@@ -341,6 +407,7 @@ export class ExecutionLifecycleService {
|
||||
});
|
||||
|
||||
this.activeResolvers.delete(executionId);
|
||||
execution.backgrounded = true;
|
||||
}
|
||||
|
||||
static subscribe(
|
||||
|
||||
Reference in New Issue
Block a user