diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 948dca919c..7eb30f1cc3 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -3227,6 +3227,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status: TurnStatus::Completed, }; @@ -3309,6 +3311,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status: TurnStatus::Completed, }]; @@ -3372,6 +3376,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status: TurnStatus::Completed, }]; diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index 6ec58cb7c1..016f22a35b 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -363,6 +363,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: Some(true), + error: None, + error_detail: None, status: TurnStatus::Completed, } } diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 8cb4561254..0668883f5c 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -2701,6 +2701,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status: TurnStatus::Completed, } } diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index ccb407375f..22ee348e98 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -2109,6 +2109,8 @@ mod tests { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status, } } diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index a7ea7b05e7..9d9e5cad53 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1,7 +1,7 @@ //! Types for session persistence use bitfun_core_types::ToolImageAttachment; -use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; +use bitfun_core_types::{AiErrorDetail, SessionContinuationPolicy, SessionKind}; use bitfun_events::ModelRoundAttemptDiagnostic; use serde::{Deserialize, Serialize}; @@ -420,6 +420,18 @@ pub struct DialogTurnData { )] pub has_final_response: Option, + /// Terminal error message when the turn failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + + /// Structured provider diagnostics for a failed turn. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "error_detail" + )] + pub error_detail: Option, + /// Turn status pub status: TurnStatus, } @@ -1003,6 +1015,8 @@ impl DialogTurnData { token_usage: None, finish_reason: None, has_final_response: None, + error: None, + error_detail: None, status: TurnStatus::InProgress, } } diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.scss b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.scss new file mode 100644 index 0000000000..693526ed08 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.scss @@ -0,0 +1,177 @@ +.turn-failure-notice { + display: flex; + gap: 8px; + align-items: flex-start; + margin: + 8px + var(--flowchat-content-inline-pad) + calc(var(--flowchat-turn-gap) + 0.18rem) + var(--flowchat-content-inline-pad); + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-error) 45%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--color-error) 10%, var(--color-bg-secondary)); + color: var(--color-text-primary); + font-size: 12px; + line-height: 1.45; + + &--warning { + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); + background: color-mix(in srgb, var(--color-warning) 10%, var(--color-bg-secondary)); + } +} + +.turn-failure-notice__icon { + display: inline-flex; + flex: 0 0 auto; + margin-top: 4px; + color: var(--color-error); + + .turn-failure-notice--warning & { + color: var(--color-warning); + } +} + +.turn-failure-notice__content { + min-width: 0; + flex: 1 1 auto; +} + +.turn-failure-notice__header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.turn-failure-notice__summary { + display: flex; + flex: 1 1 auto; + gap: 8px; + align-items: baseline; + min-width: 0; +} + +.turn-failure-notice__title { + flex: 0 0 auto; + font-weight: 600; + white-space: nowrap; +} + +.turn-failure-notice__message { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: color-mix(in srgb, var(--color-text-primary) 78%, transparent); +} + +.turn-failure-notice__details-toggle { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + gap: 3px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--color-text-primary) 8%, transparent); + color: var(--color-text-primary); + } +} + +.turn-failure-notice__details { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.turn-failure-notice__facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px 12px; + margin: 0; +} + +.turn-failure-notice__fact { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px; + min-width: 0; + + dt { + color: var(--color-text-secondary); + } + + dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-family: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + } +} + +.turn-failure-notice__raw-error { + min-width: 0; +} + +.turn-failure-notice__raw-error-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--color-text-secondary); +} + +.turn-failure-notice__copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--color-text-primary) 8%, transparent); + color: var(--color-text-primary); + } +} + +.turn-failure-notice__raw-error pre { + max-height: 300px; + margin: 4px 0 0; + overflow: auto; + padding: 8px; + border-radius: 4px; + background: color-mix(in srgb, var(--color-bg-primary) 75%, transparent); + color: var(--color-text-primary); + font-family: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +@media (max-width: 900px) { + .turn-failure-notice { + margin-left: var(--flowchat-content-inline-pad-mobile); + margin-right: var(--flowchat-content-inline-pad-mobile); + } + + .turn-failure-notice__facts { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.test.tsx new file mode 100644 index 0000000000..5d830e1161 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TurnFailureNoticeItem } from './TurnFailureNoticeItem'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const TRANSLATIONS: Record = { + 'errors:ai.authError': 'API authentication failed', + 'errors:ai.authErrorSuggestion': 'The API key is invalid or expired. Check the model configuration.', + 'turnFailure.showDetails': 'Show technical details', + 'turnFailure.hideDetails': 'Hide technical details', + 'turnFailure.provider': 'Provider', + 'turnFailure.errorCode': 'Error code', + 'turnFailure.httpStatus': 'HTTP status', + 'turnFailure.requestId': 'Request ID', + 'turnFailure.providerError': 'Provider error', + 'turnFailure.copy': 'Copy error', + 'turnFailure.copied': 'Copied', +}; + +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ + t: (key: string) => TRANSLATIONS[key] ?? key, + }), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ content, children }: { content: string; children: React.ReactElement }) => ( + {children} + ), +})); + +describe('TurnFailureNoticeItem', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it('keeps the failure summary on one row and exposes details through an icon button', () => { + act(() => { + root.render( + , + ); + }); + + const header = container.querySelector('.turn-failure-notice__header'); + const summary = container.querySelector('.turn-failure-notice__summary'); + const toggle = container.querySelector('.turn-failure-notice__details-toggle'); + expect(header).not.toBeNull(); + expect(summary?.textContent).toBe( + 'API authentication failedThe API key is invalid or expired. Check the model configuration.', + ); + expect(toggle?.textContent).toBe(''); + expect(toggle?.getAttribute('aria-label')).toBe('Show technical details'); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain('Provider error'); + }); + + it('expands raw diagnostics and copies the original error', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + await act(async () => { + root.render( + , + ); + }); + + const toggle = container.querySelector('.turn-failure-notice__details-toggle'); + act(() => toggle?.click()); + + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(toggle?.getAttribute('aria-label')).toBe('Hide technical details'); + expect(container.querySelector('.turn-failure-notice__raw-error pre')?.textContent).toBe('Invalid API key'); + + const copyButton = container.querySelector('.turn-failure-notice__copy'); + await act(async () => { + copyButton?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith('Invalid API key'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.tsx b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.tsx new file mode 100644 index 0000000000..0b4c837118 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useId, useMemo, useState } from 'react'; +import { AlertCircle, Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'; +import { Tooltip } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { + getAiErrorPresentation, + normalizeAiErrorDetail, + type AiErrorDetail, +} from '@/shared/ai-errors/aiErrorPresenter'; +import './TurnFailureNoticeItem.scss'; + +interface TurnFailureNoticeItemProps { + error: string; + errorDetail?: AiErrorDetail; +} + +export const TurnFailureNoticeItem: React.FC = ({ error, errorDetail }) => { + const { t } = useI18n(['flow-chat', 'errors']); + const [isOpen, setIsOpen] = useState(false); + const [copied, setCopied] = useState(false); + const detail = useMemo( + () => normalizeAiErrorDetail(errorDetail ?? { rawMessage: error }, error), + [error, errorDetail], + ); + const presentation = useMemo(() => getAiErrorPresentation(detail), [detail]); + const rawError = detail.rawMessage ?? error; + const detailsId = useId(); + const facts = [ + { label: t('turnFailure.provider'), value: detail.provider }, + { label: t('turnFailure.errorCode'), value: detail.providerCode }, + { label: t('turnFailure.httpStatus'), value: detail.httpStatus?.toString() }, + { label: t('turnFailure.requestId'), value: detail.requestId }, + ].filter((fact): fact is { label: string; value: string } => Boolean(fact.value)); + + const copyRawError = useCallback(async () => { + if (!rawError) return; + try { + await navigator.clipboard.writeText(rawError); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard access is best-effort and does not affect the visible diagnostic. + } + }, [rawError]); + + return ( +
+ +
+
+
+
{t(presentation.titleKey)}
+
{t(presentation.messageKey)}
+
+ + {(facts.length > 0 || rawError) && ( + + + + )} +
+ + {isOpen && ( +
+ {facts.length > 0 && ( +
+ {facts.map(fact => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+ )} + {rawError && ( +
+
+ {t('turnFailure.providerError')} + + + +
+
{rawError}
+
+ )} +
+ )} +
+
+ ); +}; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index 8048f15dea..6f00b76a91 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -12,6 +12,7 @@ import { ExploreGroupRenderer } from './ExploreGroupRenderer'; import { CompactToolCard, CompactToolCardHeader } from '../../tool-cards/CompactToolCard'; import { useFlowChatContext } from './FlowChatContext'; import { TurnCompletionNoticeItem } from './TurnCompletionNoticeItem'; +import { TurnFailureNoticeItem } from './TurnFailureNoticeItem'; import './VirtualItemRenderer.scss'; interface VirtualItemRendererProps { @@ -68,6 +69,9 @@ export const VirtualItemRenderer = React.memo( case 'turn-completion-notice': return ; + case 'turn-failure-notice': + return ; + case 'image-analyzing': return (
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index f573ea5ade..ce0017af2c 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -323,6 +323,8 @@ function getVirtualItemStableKey(item: VirtualItem): string { return `${item.type}:${item.turnId}:${item.data.groupId}`; case 'turn-completion-notice': return `${item.type}:${item.turnId}:${item.data.reasonCode}`; + case 'turn-failure-notice': + return `${item.type}:${item.turnId}`; case 'image-analyzing': return `${item.type}:${item.turnId}`; } @@ -4455,6 +4457,7 @@ const VirtualMessageListSession = forwardRef ({ - 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(), @@ -359,24 +347,59 @@ describe('shouldProcessEvent', () => { }); }); -describe('formatDialogErrorForNotification', () => { - it('shows friendly copy while preserving raw error details for diagnostics', () => { - const rawError = 'Provider error: code=invalid_request_error, request_id=req-1, message=bad payload'; - const formatted = formatDialogErrorForNotification(rawError, { - category: 'invalid_request', - provider: 'openai', - providerCode: 'invalid_request_error', - requestId: 'req-1', - rawMessage: rawError, +describe('handleDialogTurnFailed', () => { + beforeEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('keeps a zero-round turn and records the terminal provider error', () => { + createSessionWithTurn({ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'Initial request', + timestamp: 900, + }, + modelRounds: [], + status: 'processing', + startTime: 900, + }); + const context = createFlowChatContext(); + + __test_only__.handleDialogTurnFailed(context, { + sessionId: 'session-1', + turnId: 'turn-1', + error: 'OpenAI Streaming API failed after 10 attempts: connection refused', + errorDetail: { + category: 'network', + provider: 'openai', + }, }); - expect(formatted.type).toBe('error'); - expect(formatted.title).toBe('Model request invalid'); - expect(formatted.message).not.toContain('Provider error'); - expect(formatted.rawError).toBe(rawError); - expect(formatted.metadata?.aiError?.rawError).toBe(rawError); - expect(formatted.metadata?.aiError?.diagnostics).toContain('code=invalid_request_error'); - expect(formatted.actions?.map((action) => action.label)).toContain('Copy diagnostics'); + const turn = FlowChatStore.getInstance() + .getState() + .sessions.get('session-1') + ?.dialogTurns.find(item => item.id === 'turn-1'); + + expect(turn).toMatchObject({ + status: 'error', + error: 'OpenAI Streaming API failed after 10 attempts: connection refused', + errorDetail: { + category: 'network', + provider: 'openai', + }, + modelRounds: [], + }); + expect(notificationService.error).not.toHaveBeenCalled(); + expect(notificationService.warning).not.toHaveBeenCalled(); }); }); 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 a4d56f7d6a..36107df403 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 @@ -20,7 +20,6 @@ import { type ParamsPartialToolEvent } from '../EventBatcher'; import { notificationService } from '../../../shared/notification-system/services/NotificationService'; -import type { NotificationAction } from '../../../shared/notification-system/types'; import { createLogger } from '@/shared/utils/logger'; import { handleThreadGoalUpdated } from '../threadGoalEventService'; import { resolveThreadGoalUserMessageDisplay } from '../../utils/threadGoalDisplay'; @@ -36,15 +35,12 @@ import type { SessionModelAutoMigratedEvent, SubagentSessionLinkedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; -import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import { MCPAPI } from '@/infrastructure/api/service-api/MCPAPI'; import { ACPClientAPI, type AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; import { globalEventBus } from '@/infrastructure/event-bus'; import type { FlowChatContext, DialogTurn, ModelRound, FlowToolItem } from './types'; import { - getAiErrorPresentation, normalizeAiErrorDetail, - type AiErrorPresentation, type AiErrorDetail, } from '@/shared/ai-errors/aiErrorPresenter'; import { useReviewActionBarStore } from '../../store/deepReviewActionBarStore'; @@ -60,7 +56,6 @@ import { immediateSaveDialogTurn, saveDialogTurnToDisk, cleanupSaveState, - updateSessionMetadata, } from './PersistenceModule'; import { processNormalTextChunkInternal, @@ -68,7 +63,6 @@ import { completeActiveTextItems, cleanupSessionBuffers } from './TextChunkModule'; -import { pendingQueueManager } from './PendingQueueModule'; import { processToolEvent, processToolParamsPartialInternal, @@ -157,6 +151,7 @@ export const __test_only__ = { resolveDialogTurnDisplayContent, mergeParamsPartialEventData, findSubagentParentInfoByRound, + handleDialogTurnFailed, }; function shouldMarkUnreadCompletion(sessionId: string): boolean { @@ -2294,13 +2289,6 @@ export function handleDialogTurnComplete( beginTurnCompletion(context, sessionId, turnId, partialRecoveryReason); } -/** - * Handle dialog turn failed event - */ -/** - * Format a raw dialog error string into a user-friendly notification. - * Returns a title, a short message with actionable advice, and the original error for diagnostics. - */ function normalizeDialogErrorDetail(event: any): AiErrorDetail { const rawCategory = typeof event.errorCategory === 'string' ? event.errorCategory : undefined; const detail = event.errorDetail && typeof event.errorDetail === 'object' @@ -2310,87 +2298,6 @@ function normalizeDialogErrorDetail(event: any): AiErrorDetail { return normalizeAiErrorDetail(detail, event.error); } -export interface DialogErrorNotification { - type: 'error' | 'warning'; - title: string; - message: string; - detail: string; - rawError: string; - diagnostics: string; - actions?: NotificationAction[]; - metadata?: Record; -} - -export function formatDialogErrorForNotification( - rawError: string, - errorDetail?: AiErrorDetail -): DialogErrorNotification { - const raw = rawError || ''; - const normalizedDetail = normalizeAiErrorDetail(errorDetail ?? { rawMessage: raw }, raw); - const presentation = getAiErrorPresentation(normalizedDetail); - const title = i18nService.t(presentation.titleKey); - const message = i18nService.t(presentation.messageKey); - const diagnostics = buildDialogErrorDiagnostics(presentation, raw, normalizedDetail); - - return { - type: presentation.severity, - title, - message, - detail: diagnostics || raw, - rawError: raw, - diagnostics, - actions: buildDialogErrorActions(diagnostics), - metadata: { - aiError: { - category: presentation.category, - retryable: presentation.retryable, - diagnostics, - rawError: raw, - detail: normalizedDetail, - }, - }, - }; -} - -function buildDialogErrorDiagnostics( - presentation: AiErrorPresentation, - rawError: string, - detail: AiErrorDetail -): string { - const lines = [ - presentation.diagnostics, - detail.providerMessage ? `provider_message=${detail.providerMessage}` : null, - rawError ? `raw_error=${rawError}` : null, - ].filter(Boolean); - - return lines.join('\n'); -} - -function buildDialogErrorActions(diagnostics: string): NotificationAction[] | undefined { - if (!diagnostics) { - return undefined; - } - - return [ - { - label: i18nService.t('errors:ai.actions.copyDiagnostics'), - variant: 'secondary', - onClick: () => { - const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined; - if (!clipboard?.writeText) { - return; - } - - void clipboard.writeText(diagnostics).then(() => { - notificationService.success(i18nService.t('flow-chat:deepReviewActionBar.diagnosticsCopied'), { - duration: 2500, - }); - }); - }, - }, - ]; -} - function handleDialogTurnFailed(context: FlowChatContext, event: any): void { const { sessionId, turnId, error } = event; const errorDetail = normalizeDialogErrorDetail(event); @@ -2430,9 +2337,10 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { context.flowChatStore.markSessionFinished(sessionId); const dialogTurn = session.dialogTurns.find(turn => turn.id === turnId); - const hasSuccessfulModelRounds = dialogTurn && dialogTurn.modelRounds.length > 0; - - if (hasSuccessfulModelRounds) { + if (dialogTurn) { + const terminalError = typeof error === 'string' && error.trim() + ? error + : errorDetail.rawMessage || errorDetail.providerMessage || 'Execution failed'; context.flowChatStore.updateDialogTurn(sessionId, turnId, turn => { const updatedModelRounds = turn.modelRounds.map((round) => { if (round.isStreaming) { @@ -2451,7 +2359,8 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { ...turn, modelRounds: updatedModelRounds, status: 'error' as const, - error: error || 'Execution failed', + error: terminalError, + errorDetail, endTime: Date.now() }; }); @@ -2459,35 +2368,6 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { saveDialogTurnToDisk(context, sessionId, turnId).catch(err => { log.warn('Failed to save failed dialog turn', { sessionId, turnId, error: err }); }); - } else { - if (dialogTurn?.userMessage?.content) { - try { - // B-policy: restore the failed turn's user content into the pending - // queue exactly once, marked `failed` and `retryCount=1`. The auto-drain - // listener skips items with `retryCount > 0`, so the user must - // explicitly edit / send-now / delete to clear the entry. This prevents - // the previous behaviour where a hard error (auth, rate-limit, bad - // tool args) would auto-resend in a tight loop. - pendingQueueManager.enqueue({ - sessionId, - content: dialogTurn.userMessage.content, - displayMessage: dialogTurn.userMessage.content, - retryCount: 1, - initialStatus: 'failed', - }); - } catch (err) { - log.warn('Failed to restore failed turn into pending queue', { - sessionId, - turnId, - err, - }); - } - } - - context.flowChatStore.deleteDialogTurn(sessionId, turnId); - updateSessionMetadata(context, sessionId).catch(err => { - log.warn('Failed to update failed session metadata', { sessionId, error: err }); - }); } reconcileBackgroundSubagentSession(sessionId); @@ -2503,20 +2383,6 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { }); } - const formatted = formatDialogErrorForNotification(error, errorDetail); - const options = { - title: formatted.title, - duration: 8000, - actions: formatted.actions, - metadata: formatted.metadata, - }; - - if (formatted.type === 'warning') { - notificationService.warning(formatted.message, options); - } else { - notificationService.error(formatted.message, options); - } - if (shouldMarkUnreadCompletion(sessionId)) { context.flowChatStore.markSessionUnreadCompletion(sessionId, 'error'); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index 341b5fe8dd..159829a059 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -155,6 +155,28 @@ describe('PersistenceModule', () => { expect(persisted.hasFinalResponse).toBe(false); }); + it('persists terminal error diagnostics for failed turns', () => { + const turn = createDialogTurn('error'); + turn.error = 'OpenAI Streaming API failed after 10 attempts: connection refused'; + turn.errorDetail = { + category: 'network', + provider: 'openai', + requestId: 'req-1', + }; + + const persisted = convertDialogTurnToBackendFormat(turn, 0); + + expect(persisted).toMatchObject({ + error: 'OpenAI Streaming API failed after 10 attempts: connection refused', + errorDetail: { + category: 'network', + provider: 'openai', + requestId: 'req-1', + }, + status: 'error', + }); + }); + it('persists ACP permission metadata for pending confirmation tools', () => { const turn = createDialogTurn('processing'); turn.modelRounds[0].items = [ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 4103cbc220..f14bc74a3f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -496,6 +496,8 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd : undefined, finishReason: dialogTurn.finishReason, hasFinalResponse: dialogTurn.hasFinalResponse, + error: dialogTurn.error, + errorDetail: dialogTurn.errorDetail, status: dialogTurn.status === 'completed' ? 'completed' : dialogTurn.status === 'error' ? 'error' : dialogTurn.status === 'cancelled' ? 'cancelled' : 'inprogress', diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index d873592fb2..414a9c4cc2 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -5062,6 +5062,8 @@ export class FlowChatStore { : typeof turn.has_final_response === 'boolean' ? turn.has_final_response : undefined, + error: typeof turn.error === 'string' ? turn.error : undefined, + errorDetail: turn.errorDetail ?? turn.error_detail, startTime: turn.startTime, endTime: turn.endTime, tokenUsage: rawTokenUsage diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index b65a4e9060..708d97bd2e 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -331,6 +331,43 @@ describe('sessionToVirtualItems explore grouping', () => { expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); }); + it('appends a terminal failure notice even when no model round was created', () => { + const session = makeSession({ + dialogTurns: [{ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [], + status: 'error', + error: 'OpenAI Streaming API failed after 10 attempts: connection refused', + errorDetail: { + category: 'network', + provider: 'openai', + }, + startTime: 900, + endTime: 1200, + }], + }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'turn-failure-notice']); + expect(items[1]).toMatchObject({ + type: 'turn-failure-notice', + data: { + error: 'OpenAI Streaming API failed after 10 attempts: connection refused', + errorDetail: { + category: 'network', + provider: 'openai', + }, + }, + }); + }); + it('keeps trailing explore groups expanded while the turn is still processing', () => { const session = makeSession({ dialogTurns: [{ diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 1a64e5757d..5a435f1032 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -81,6 +81,14 @@ export type VirtualItem = } | { type: 'explore-group'; data: ExploreGroupData; turnId: string } | { type: 'turn-completion-notice'; data: TurnCompletionNotice; turnId: string } + | { + type: 'turn-failure-notice'; + data: { + error: string; + errorDetail?: DialogTurn['errorDetail']; + }; + turnId: string; + } | { type: 'image-analyzing'; turnId: string }; /** @@ -606,6 +614,17 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { }); } + if (turn.status === 'error' && (turn.error || turn.errorDetail)) { + items.push({ + type: 'turn-failure-notice', + turnId: turn.id, + data: { + error: turn.error ?? turn.errorDetail?.providerMessage ?? '', + errorDetail: turn.errorDetail, + }, + }); + } + if (isStableTurnProjection(turn)) { cachedTurnItems.set(turn, items.slice(turnItemStart)); } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 261b0d2a48..8316382231 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -8,6 +8,7 @@ import type { SessionKind, SessionTitleSource, } from '@/shared/types/session-history'; +import type { AiErrorDetail } from '@/shared/ai-errors/aiErrorPresenter'; import type { ReviewTargetEvidence, ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; export type ModelRoundAttemptDiagnostic = import('@/shared/types/session-history').ModelRoundAttemptDiagnostic; @@ -258,6 +259,7 @@ export interface DialogTurn { startTime: number; endTime?: number; error?: string; + errorDetail?: AiErrorDetail; tokenUsage?: TokenUsage; todos?: TodoItem[]; backendTurnIndex?: number; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 531e2fce38..5823278e8b 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1236,6 +1236,17 @@ "exporting": "Exporting...", "exportToImage": "Export as Image" }, + "turnFailure": { + "showDetails": "Show technical details", + "hideDetails": "Hide technical details", + "provider": "Provider", + "errorCode": "Error code", + "httpStatus": "HTTP status", + "requestId": "Request ID", + "providerError": "Provider error", + "copy": "Copy", + "copied": "Copied" + }, "modelRound": { "copyDialog": "Copy dialog", "copiedDialog": "Copied!", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 33e7e21a15..dd8fdd33e2 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1236,6 +1236,17 @@ "exporting": "正在导出...", "exportToImage": "导出为图片" }, + "turnFailure": { + "showDetails": "显示技术详情", + "hideDetails": "隐藏技术详情", + "provider": "服务商", + "errorCode": "错误码", + "httpStatus": "HTTP 状态", + "requestId": "请求 ID", + "providerError": "服务商错误", + "copy": "复制", + "copied": "已复制" + }, "modelRound": { "copyDialog": "复制对话", "copiedDialog": "已复制!", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index ad350175a7..6a123898e3 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1236,6 +1236,17 @@ "exporting": "正在導出...", "exportToImage": "導出為圖片" }, + "turnFailure": { + "showDetails": "顯示技術詳情", + "hideDetails": "隱藏技術詳情", + "provider": "服務商", + "errorCode": "錯誤碼", + "httpStatus": "HTTP 狀態", + "requestId": "請求 ID", + "providerError": "服務商錯誤", + "copy": "複製", + "copied": "已複製" + }, "modelRound": { "copyDialog": "複製對話", "copiedDialog": "已複製!", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 36a6fc9da9..8ac79abdbe 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -5,6 +5,7 @@ */ import type { ReviewTargetEvidence, ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; +import type { AiErrorDetail } from '@/shared/ai-errors/aiErrorPresenter'; export type SessionKind = 'normal' | 'btw' | 'review' | 'deep_review' | 'miniapp' | 'subagent'; export type PersistedSessionKind = 'standard' | 'subagent'; @@ -169,6 +170,8 @@ export interface DialogTurnData { status: TurnStatus; finishReason?: string; hasFinalResponse?: boolean; + error?: string; + errorDetail?: AiErrorDetail; } export interface DialogTurnTokenUsageData {