diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index a9aea95376..921d3ceaf8 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -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); }); } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0bfdeba120..757c458b0b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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([]); const [pendingHintCount, setPendingHintCount] = useState(0); + const pendingBackgroundCompletionsRef = useRef([]); + 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 diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 96c61fe8bd..0072bebf27 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -51,7 +51,7 @@ describe('clearCommand', () => { fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), }), - userHintService: { + injectionService: { clear: mockHintClear, }, }, diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 6d3b14e179..05eb96193f 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -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 diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index f8758cd935..f64e79590c 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -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!([ diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 6a9dfe0151..c761641b55 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -532,12 +532,12 @@ export class LocalAgentExecutor { // 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 { } } } finally { - this.config.userHintService.offUserHint(hintListener); + this.config.injectionService.offUserHint(hintListener); } // === UNIFIED RECOVERY BLOCK === diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index c428fbdba0..761431c2ba 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -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, diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts index d7af2fcc27..dd2115dc3c 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -137,7 +137,7 @@ class SubAgentInvocation extends BaseToolInvocation { _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 { return agentArgs; } - const userHints = this.config.userHintService.getUserHintsAfter( + const userHints = this.config.injectionService.getUserHintsAfter( this.startIndex, ); const formattedHints = formatUserHintsForModel(userHints); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0e8062dfb3..e07d6fce0c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 = { diff --git a/packages/core/src/config/injectionService.test.ts b/packages/core/src/config/injectionService.test.ts new file mode 100644 index 0000000000..4803b49e7d --- /dev/null +++ b/packages/core/src/config/injectionService.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core/src/config/injectionService.ts b/packages/core/src/config/injectionService.ts new file mode 100644 index 0000000000..7991a95bea --- /dev/null +++ b/packages/core/src/config/injectionService.ts @@ -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 = 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; + } +} diff --git a/packages/core/src/config/userHintService.test.ts b/packages/core/src/config/userHintService.test.ts deleted file mode 100644 index faf301c6d1..0000000000 --- a/packages/core/src/config/userHintService.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/core/src/config/userHintService.ts b/packages/core/src/config/userHintService.ts deleted file mode 100644 index 227e54b18c..0000000000 --- a/packages/core/src/config/userHintService.ts +++ /dev/null @@ -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; - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e035dc4502..af975c8fbf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/services/executionLifecycleService.test.ts b/packages/core/src/services/executionLifecycleService.test.ts index 213ad39224..ce485fc823 100644 --- a/packages/core/src/services/executionLifecycleService.test.ts +++ b/packages/core/src/services/executionLifecycleService.test.ts @@ -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(); + }); + }); }); diff --git a/packages/core/src/services/executionLifecycleService.ts b/packages/core/src/services/executionLifecycleService.ts index 6195e516da..0cd4df4c5d 100644 --- a/packages/core/src/services/executionLifecycleService.ts +++ b/packages/core/src/services/executionLifecycleService.ts @@ -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(); + + /** + * 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(