diff --git a/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts b/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts index 91759c499..2661934da 100644 --- a/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts +++ b/src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts @@ -10,6 +10,8 @@ interface DialogCompletionNotificationInput { interface DialogCompletionNotificationCopyInput { sessionTitle?: string | null; + success?: boolean | null; + finishReason?: string | null; t: (key: string, options?: Record) => string; } @@ -41,14 +43,23 @@ export function shouldSendDialogCompletionNotification({ export function buildDialogCompletionNotificationCopy({ sessionTitle, + success, + finishReason, t, }: DialogCompletionNotificationCopyInput): { title: string; body: string } { const trimmedTitle = sessionTitle?.trim(); + const failed = success === false; + const options = { + sessionTitle: trimmedTitle, + finishReason, + }; return { - title: t('notify.dialogCompletedTitle'), + title: failed + ? t('notify.dialogFailedTitle') + : t('notify.dialogCompletedTitle'), body: trimmedTitle - ? t('notify.dialogCompletedWithSession', { sessionTitle: trimmedTitle }) - : t('notify.dialogCompletedFallback'), + ? t(failed ? 'notify.dialogFailedWithSession' : 'notify.dialogCompletedWithSession', options) + : t(failed ? 'notify.dialogFailedFallback' : 'notify.dialogCompletedFallback', options), }; } diff --git a/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts b/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts index fdd6d8ad6..39996b8a2 100644 --- a/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts +++ b/src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts @@ -103,6 +103,20 @@ describe('shouldSendDialogCompletionNotification', () => { }), ).toBe(true); }); + + it('allows failed completion notifications in the background', () => { + expect( + shouldSendDialogCompletionNotification({ + event: event({ + success: false, + finishReason: 'empty_round', + }), + session: session(), + isBackground: true, + notificationsEnabled: true, + }), + ).toBe(true); + }); }); describe('buildDialogCompletionNotificationCopy', () => { @@ -111,6 +125,10 @@ describe('buildDialogCompletionNotificationCopy', () => { if (key === 'notify.dialogCompletedWithSession') { return `${options?.sessionTitle} is ready.`; } + if (key === 'notify.dialogFailedTitle') return 'BitFun task stopped'; + if (key === 'notify.dialogFailedWithSession') { + return `${options?.sessionTitle} stopped unexpectedly.`; + } return 'A BitFun session is ready.'; }; @@ -137,4 +155,18 @@ describe('buildDialogCompletionNotificationCopy', () => { body: 'A BitFun session is ready.', }); }); + + it('uses failure copy when the backend completed with an unsuccessful result', () => { + expect( + buildDialogCompletionNotificationCopy({ + sessionTitle: 'Browser control fix', + success: false, + finishReason: 'empty_round', + t, + }), + ).toEqual({ + title: 'BitFun task stopped', + body: 'Browser control fix stopped unexpectedly.', + }); + }); }); diff --git a/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts b/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts index c9cdb0a55..f5f868f1f 100644 --- a/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts +++ b/src/web-ui/src/app/hooks/useDialogCompletionNotify.ts @@ -66,6 +66,8 @@ export const useDialogCompletionNotify = () => { const notificationCopy = buildDialogCompletionNotificationCopy({ sessionTitle: session?.title, + success: event?.success, + finishReason: event?.finishReason ?? event?.finish_reason, t, }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 097079ee3..0a4886daa 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { normalizeSubagentParentInfo } from './subagentParentInfo'; import { formatDialogErrorForNotification, + handleDialogTurnComplete, handleSessionStateChanged, insertSteeringItemIfAbsent, shouldProcessEvent, @@ -15,6 +16,8 @@ import type { FlowChatContext } from './types'; vi.mock('@/infrastructure/i18n/core/I18nService', () => ({ i18nService: { t: (key: string) => ({ + 'errors:ai.unknown.title': 'AI request failed', + 'errors:ai.unknown.message': 'The model stopped before returning a usable response. Try again or switch models.', 'errors:ai.invalidRequest.title': 'Model request invalid', 'errors:ai.invalidRequest.message': 'The provider rejected the request format, parameters, model name, or payload size. Adjust the request or choose another model.', 'errors:ai.actions.copyDiagnostics': 'Copy diagnostics', @@ -22,6 +25,14 @@ vi.mock('@/infrastructure/i18n/core/I18nService', () => ({ }, })); +vi.mock('../../../shared/notification-system/services/NotificationService', () => ({ + notificationService: { + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + }, +})); + describe('normalizeSubagentParentInfo', () => { it('normalizes snake_case subagent parent metadata from backend events', () => { expect( @@ -404,3 +415,38 @@ describe('handleSessionStateChanged', () => { expect(stateMachineManager.getCurrentState('session-1')).toBe(SessionExecutionState.IDLE); }); }); + +describe('handleDialogTurnComplete', () => { + beforeEach(() => { + vi.restoreAllMocks(); + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + afterEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('treats unsuccessful completed events as errors instead of normal completion', async () => { + putFinishingSessionInStore(); + const context = createFlowChatContext(); + await setFinishingMachine(); + + handleDialogTurnComplete(context, { + sessionId: 'session-1', + turnId: 'turn-1', + success: false, + finishReason: 'empty_round', + }, vi.fn()); + + const turn = FlowChatStore.getInstance() + .getState() + .sessions.get('session-1') + ?.dialogTurns[0]; + + expect(turn?.status).toBe('error'); + expect(turn?.error).toContain('empty response'); + expect(stateMachineManager.getCurrentState('session-1')).toBe(SessionExecutionState.IDLE); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 61f7465e3..5487147f6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -1707,7 +1707,17 @@ function handleCompressionFailed(context: FlowChatContext, event: any): void { /** * Handle dialog turn completed event */ -function handleDialogTurnComplete( +function buildUnsuccessfulCompletionError(finishReason?: string): string { + if (finishReason === 'empty_round') { + return 'Model returned an empty response after retrying. finish_reason=empty_round'; + } + + return finishReason + ? `Dialog turn ended without a usable result. finish_reason=${finishReason}` + : 'Dialog turn ended without a usable result.'; +} + +export function handleDialogTurnComplete( context: FlowChatContext, event: any, _onTodoWriteResult: (sessionId: string, turnId: string, result: any) => void @@ -1723,7 +1733,17 @@ function handleDialogTurnComplete( if (subagentParentInfo) { if (sessionId) { attachSubagentSessionToParentTool(subagentParentInfo, sessionId); - settleSubagentItems(context, subagentParentInfo, sessionId, 'completed'); + if (success === false) { + settleSubagentItems( + context, + subagentParentInfo, + sessionId, + 'error', + buildUnsuccessfulCompletionError(finishReason), + ); + } else { + settleSubagentItems(context, subagentParentInfo, sessionId, 'completed'); + } } return; } @@ -1733,6 +1753,16 @@ function handleDialogTurnComplete( return; } + if (success === false) { + handleDialogTurnFailed(context, { + ...event, + sessionId, + turnId, + error: event?.error || buildUnsuccessfulCompletionError(finishReason), + }); + return; + } + // P1-11: Idempotent terminal-event handling. The backend may emit // DialogTurnCompleted only once for a turn, but if a future change adds a // duplicate emit path, we want this handler to be a no-op the second time. diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index b30d063d5..d21b205bc 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1038,6 +1038,9 @@ "dialogCompleted": "AI agent has finished the task.", "dialogCompletedTitle": "BitFun task finished", "dialogCompletedWithSession": "\"{{sessionTitle}}\" is ready. Return to BitFun to review the result.", - "dialogCompletedFallback": "A BitFun session is ready. Return to BitFun to review the result." + "dialogCompletedFallback": "A BitFun session is ready. Return to BitFun to review the result.", + "dialogFailedTitle": "BitFun task stopped", + "dialogFailedWithSession": "\"{{sessionTitle}}\" stopped unexpectedly. Return to BitFun to review the reason.", + "dialogFailedFallback": "A BitFun session stopped unexpectedly. Return to BitFun to review the reason." } } diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 32f58d97f..6ea2fe94e 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1038,6 +1038,9 @@ "dialogCompleted": "AI 助手已完成任务。", "dialogCompletedTitle": "BitFun 任务已完成", "dialogCompletedWithSession": "“{{sessionTitle}}” 已完成,可以回到 BitFun 查看结果。", - "dialogCompletedFallback": "有一个 BitFun 会话已完成,可以回到应用查看结果。" + "dialogCompletedFallback": "有一个 BitFun 会话已完成,可以回到应用查看结果。", + "dialogFailedTitle": "BitFun 任务已中断", + "dialogFailedWithSession": "“{{sessionTitle}}” 已异常停止,可以回到 BitFun 查看原因。", + "dialogFailedFallback": "有一个 BitFun 会话已异常停止,可以回到应用查看原因。" } } diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 350a0f4c0..2c4b033d1 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1038,6 +1038,9 @@ "dialogCompleted": "AI 助手已完成任務。", "dialogCompletedTitle": "BitFun 任務已完成", "dialogCompletedWithSession": "「{{sessionTitle}}」已完成,可以回到 BitFun 查看結果。", - "dialogCompletedFallback": "有一個 BitFun 會話已完成,可以回到應用查看結果。" + "dialogCompletedFallback": "有一個 BitFun 會話已完成,可以回到應用查看結果。", + "dialogFailedTitle": "BitFun 任務已中斷", + "dialogFailedWithSession": "「{{sessionTitle}}」已異常停止,可以回到 BitFun 查看原因。", + "dialogFailedFallback": "有一個 BitFun 會話已異常停止,可以回到應用查看原因。" } }