Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/web-ui/src/app/hooks/dialogCompletionNotifyPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface DialogCompletionNotificationInput {

interface DialogCompletionNotificationCopyInput {
sessionTitle?: string | null;
success?: boolean | null;
finishReason?: string | null;
t: (key: string, options?: Record<string, unknown>) => string;
}

Expand Down Expand Up @@ -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),
};
}
32 changes: 32 additions & 0 deletions src/web-ui/src/app/hooks/useDialogCompletionNotify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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.';
};

Expand All @@ -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.',
});
});
});
2 changes: 2 additions & 0 deletions src/web-ui/src/app/hooks/useDialogCompletionNotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export const useDialogCompletionNotify = () => {

const notificationCopy = buildDialogCompletionNotificationCopy({
sessionTitle: session?.title,
success: event?.success,
finishReason: event?.finishReason ?? event?.finish_reason,
t,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { normalizeSubagentParentInfo } from './subagentParentInfo';
import {
formatDialogErrorForNotification,
handleDialogTurnComplete,
handleSessionStateChanged,
insertSteeringItemIfAbsent,
shouldProcessEvent,
Expand All @@ -15,13 +16,23 @@ 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',
}[key] ?? key),
},
}));

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(
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/web-ui/src/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
5 changes: 4 additions & 1 deletion src/web-ui/src/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,9 @@
"dialogCompleted": "AI 助手已完成任务。",
"dialogCompletedTitle": "BitFun 任务已完成",
"dialogCompletedWithSession": "“{{sessionTitle}}” 已完成,可以回到 BitFun 查看结果。",
"dialogCompletedFallback": "有一个 BitFun 会话已完成,可以回到应用查看结果。"
"dialogCompletedFallback": "有一个 BitFun 会话已完成,可以回到应用查看结果。",
"dialogFailedTitle": "BitFun 任务已中断",
"dialogFailedWithSession": "“{{sessionTitle}}” 已异常停止,可以回到 BitFun 查看原因。",
"dialogFailedFallback": "有一个 BitFun 会话已异常停止,可以回到应用查看原因。"
}
}
5 changes: 4 additions & 1 deletion src/web-ui/src/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,9 @@
"dialogCompleted": "AI 助手已完成任務。",
"dialogCompletedTitle": "BitFun 任務已完成",
"dialogCompletedWithSession": "「{{sessionTitle}}」已完成,可以回到 BitFun 查看結果。",
"dialogCompletedFallback": "有一個 BitFun 會話已完成,可以回到應用查看結果。"
"dialogCompletedFallback": "有一個 BitFun 會話已完成,可以回到應用查看結果。",
"dialogFailedTitle": "BitFun 任務已中斷",
"dialogFailedWithSession": "「{{sessionTitle}}」已異常停止,可以回到 BitFun 查看原因。",
"dialogFailedFallback": "有一個 BitFun 會話已異常停止,可以回到應用查看原因。"
}
}
Loading