refactor(background): replace prefix/separator protocol with typed notification queue

Replace the stringly-typed \x00__BG_NOTIFY__\x00 prefix/separator
encoding with a typed notification path using SendMessageType.Notification.

- Add SendMessageType.Notification to the enum
- Change BackgroundNotificationCallback to emit (displayText, modelText)
- Move notification queue from AppContainer into useGeminiStream (mirrors
  the cron queue pattern): register on registry, queue structured items,
  drain on idle via submitQuery
- prepareQueryForGemini short-circuits for Notification type (skips slash
  commands, shell mode, @-commands, prompt history logging)
- Remove BACKGROUND_NOTIFICATION_PREFIX/SEPARATOR constants
This commit is contained in:
tanzhenxin 2026-04-10 08:34:28 +00:00
parent 9f836e1f03
commit f5067bc24e
5 changed files with 70 additions and 96 deletions

View file

@ -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,