diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 90b275eba..ab3c1eb34 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -798,19 +798,6 @@ export const AppContainer = (props: AppContainerProps) => { // so it always sees the latest state even between renders. midTurnDrainRef.current = drainQueue; - // Bridge background agent notifications to the message queue. - // When a background agent completes, its notification is injected as a - // queued message so it's submitted to the model between turns. - useEffect(() => { - const registry = config.getBackgroundTaskRegistry(); - registry.setNotificationCallback((message: string) => { - addMessage(message); - }); - return () => { - registry.setNotificationCallback(() => {}); - }; - }, [config, addMessage]); - // Callback for handling final submit (must be after addMessage from useMessageQueue) const handleFinalSubmit = useCallback( (submittedValue: string) => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 3f18d1479..abff9283b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -41,8 +41,6 @@ import { ApiCancelEvent, isSupportedImageMimeType, getUnsupportedImageFormatWarning, - BACKGROUND_NOTIFICATION_PREFIX, - BACKGROUND_NOTIFICATION_SEPARATOR, } from '@qwen-code/qwen-code-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { @@ -487,6 +485,7 @@ export const useGeminiStream = ( userMessageTimestamp: number, abortSignal: AbortSignal, prompt_id: string, + submitType: SendMessageType, ): Promise<{ queryToSend: PartListUnion | null; shouldProceed: boolean; @@ -503,42 +502,23 @@ export const useGeminiStream = ( if (typeof query === 'string') { const trimmedQuery = query.trim(); - // Background agent notifications carry a prefix so the UI can - // render them differently from user-typed messages. - const isNotification = trimmedQuery.startsWith( - BACKGROUND_NOTIFICATION_PREFIX, - ); - let displayText: string; - let modelText: string; - if (isNotification) { - const body = trimmedQuery.slice( - BACKGROUND_NOTIFICATION_PREFIX.length, + // Notification messages (e.g. background agent completions) are + // pre-processed by the notification drain loop which already + // added the display item to history. Just pass the model text + // through to the API. + if (submitType === SendMessageType.Notification) { + onDebugMessage( + `Received notification (${trimmedQuery.length} chars)`, ); - const sepIdx = body.indexOf(BACKGROUND_NOTIFICATION_SEPARATOR); - if (sepIdx !== -1) { - displayText = body.slice(0, sepIdx); - modelText = body.slice( - sepIdx + BACKGROUND_NOTIFICATION_SEPARATOR.length, - ); - } else { - displayText = body; - modelText = body; - } - } else { - displayText = trimmedQuery; - modelText = trimmedQuery; + return { queryToSend: trimmedQuery, shouldProceed: true }; } - onDebugMessage(`Received user query (${displayText.length} chars)`); - // Don't log notifications as user messages — they pollute - // the prompt history shown when pressing up-arrow. - if (!isNotification) { - await logger?.logMessage(MessageSenderType.USER, displayText); - } + onDebugMessage(`Received user query (${trimmedQuery.length} chars)`); + await logger?.logMessage(MessageSenderType.USER, trimmedQuery); // Handle UI-only commands first - const slashCommandResult = isSlashCommand(displayText) - ? await handleSlashCommand(displayText) + const slashCommandResult = isSlashCommand(trimmedQuery) + ? await handleSlashCommand(trimmedQuery) : false; if (slashCommandResult) { @@ -575,26 +555,21 @@ export const useGeminiStream = ( } } - if (shellModeActive && handleShellCommand(displayText, abortSignal)) { + if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) { return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = modelText; + localQueryToSendToGemini = trimmedQuery; addItem( - isNotification - ? { - type: 'notification' as const, - text: displayText, - } - : { type: MessageType.USER, text: modelText }, + { type: MessageType.USER, text: trimmedQuery }, userMessageTimestamp, ); // Handle @-commands (which might involve tool calls) - if (isAtCommand(modelText)) { + if (isAtCommand(trimmedQuery)) { const atCommandResult = await handleAtCommand({ - query: modelText, + query: trimmedQuery, config, onDebugMessage, messageId: userMessageTimestamp, @@ -1274,6 +1249,7 @@ export const useGeminiStream = ( userMessageTimestamp, abortSignal, prompt_id!, + submitType, ); if (!shouldProceed || queryToSend === null) { @@ -1810,6 +1786,40 @@ export const useGeminiStream = ( } }, [streamingState, submitQuery, cronTrigger]); + // ─── Background agent notification queue ─────────────────── + const notificationQueueRef = useRef< + Array<{ displayText: string; modelText: string }> + >([]); + const [notificationTrigger, setNotificationTrigger] = useState(0); + + useEffect(() => { + const registry = config.getBackgroundTaskRegistry(); + registry.setNotificationCallback( + (displayText: string, modelText: string) => { + notificationQueueRef.current.push({ displayText, modelText }); + setNotificationTrigger((n) => n + 1); + }, + ); + return () => { + registry.setNotificationCallback(() => {}); + }; + }, [config]); + + // When idle, drain the notification queue one item at a time + useEffect(() => { + if ( + streamingState === StreamingState.Idle && + notificationQueueRef.current.length > 0 + ) { + const item = notificationQueueRef.current.shift()!; + addItem( + { type: 'notification' as const, text: item.displayText }, + Date.now(), + ); + submitQuery(item.modelText, SendMessageType.Notification); + } + }, [streamingState, submitQuery, notificationTrigger, addItem]); + return { streamingState, submitQuery, diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 4c99fe605..456dd27b5 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -5,11 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - BackgroundTaskRegistry, - BACKGROUND_NOTIFICATION_PREFIX, - BACKGROUND_NOTIFICATION_SEPARATOR, -} from './background-tasks.js'; +import { BackgroundTaskRegistry } from './background-tasks.js'; describe('BackgroundTaskRegistry', () => { let registry: BackgroundTaskRegistry; @@ -50,21 +46,13 @@ describe('BackgroundTaskRegistry', () => { expect(entry.result).toBe('The result text'); expect(entry.endTime).toBeDefined(); expect(callback).toHaveBeenCalledOnce(); - const msg = callback.mock.calls[0][0] as string; - expect(msg.startsWith(BACKGROUND_NOTIFICATION_PREFIX)).toBe(true); - const body = msg.slice(BACKGROUND_NOTIFICATION_PREFIX.length); - // Display part (before separator) should be a short summary - const sepIdx = body.indexOf(BACKGROUND_NOTIFICATION_SEPARATOR); - expect(sepIdx).toBeGreaterThan(0); - const displayPart = body.slice(0, sepIdx); - const modelPart = body.slice( - sepIdx + BACKGROUND_NOTIFICATION_SEPARATOR.length, - ); - expect(displayPart).toContain('completed'); - expect(displayPart).toContain('test agent'); - expect(displayPart).not.toContain('The result text'); - // Model part should include the result for the LLM - expect(modelPart).toContain('The result text'); + const [displayText, modelText] = callback.mock.calls[0] as [string, string]; + // Display text: short summary without the full result + expect(displayText).toContain('completed'); + expect(displayText).toContain('test agent'); + expect(displayText).not.toContain('The result text'); + // Model text: full details including result for the LLM + expect(modelText).toContain('The result text'); }); it('fails a background agent and sends notification', () => { @@ -85,7 +73,8 @@ describe('BackgroundTaskRegistry', () => { expect(entry.status).toBe('failed'); expect(entry.error).toBe('Something went wrong'); expect(callback).toHaveBeenCalledOnce(); - expect(callback.mock.calls[0][0]).toContain('failed'); + const [displayText] = callback.mock.calls[0] as [string, string]; + expect(displayText).toContain('failed'); }); it('cancels a running background agent', () => { diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index c2787979a..e7b57c44a 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -16,18 +16,6 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('BACKGROUND_TASKS'); -/** - * Prefix prepended to notification messages so the UI layer can distinguish - * background-agent notifications from regular user input. - */ -export const BACKGROUND_NOTIFICATION_PREFIX = '\x00__BG_NOTIFY__\x00'; - -/** - * Separator between the display summary (shown in the UI) and the full - * model-facing content (sent to the LLM) within a notification message. - */ -export const BACKGROUND_NOTIFICATION_SEPARATOR = '\x00__BG_SEP__\x00'; - const MAX_DESCRIPTION_LENGTH = 40; export type BackgroundAgentStatus = @@ -49,7 +37,10 @@ export interface BackgroundAgentEntry { name?: string; } -export type BackgroundNotificationCallback = (message: string) => void; +export type BackgroundNotificationCallback = ( + displayText: string, + modelText: string, +) => void; export class BackgroundTaskRegistry { private readonly agents = new Map(); @@ -201,12 +192,7 @@ export class BackgroundTaskRegistry { } try { - this.notificationCallback( - BACKGROUND_NOTIFICATION_PREFIX + - displayLine + - BACKGROUND_NOTIFICATION_SEPARATOR + - modelLines.join('\n'), - ); + this.notificationCallback(displayLine, modelLines.join('\n')); } catch (error) { debugLogger.error('Failed to emit background notification:', error); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 13fc86aaa..63a1a62df 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -100,6 +100,8 @@ export enum SendMessageType { Hook = 'hook', /** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */ Cron = 'cron', + /** Background agent notification. Display item is added by the drain loop. */ + Notification = 'notification', } export interface SendMessageOptions {