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
6 changes: 6 additions & 0 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3227,6 +3227,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
};

Expand Down Expand Up @@ -3309,6 +3311,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
}];

Expand Down Expand Up @@ -3372,6 +3376,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
}];

Expand Down
2 changes: 2 additions & 0 deletions src/crates/assembly/core/src/agentic/memories/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/crates/assembly/core/src/service/session_usage/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2701,6 +2701,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/crates/assembly/core/src/service_agent_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
error: None,
error_detail: None,
status,
}
}
Expand Down
16 changes: 15 additions & 1 deletion src/crates/services/services-core/src/session/types.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -420,6 +420,18 @@ pub struct DialogTurnData {
)]
pub has_final_response: Option<bool>,

/// Terminal error message when the turn failed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,

/// Structured provider diagnostics for a failed turn.
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "error_detail"
)]
pub error_detail: Option<AiErrorDetail>,

/// Turn status
pub status: TurnStatus,
}
Expand Down Expand Up @@ -1003,6 +1015,8 @@ impl DialogTurnData {
token_usage: None,
finish_reason: None,
has_final_response: None,
error: None,
error_detail: None,
status: TurnStatus::InProgress,
}
}
Expand Down
177 changes: 177 additions & 0 deletions src/web-ui/src/flow_chat/components/modern/TurnFailureNoticeItem.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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 }) => (
<span data-tooltip={content}>{children}</span>
),
}));

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(
<TurnFailureNoticeItem
error="Invalid API key"
errorDetail={{ category: 'auth', rawMessage: 'Invalid API key' }}
/>,
);
});

const header = container.querySelector('.turn-failure-notice__header');
const summary = container.querySelector('.turn-failure-notice__summary');
const toggle = container.querySelector<HTMLButtonElement>('.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(
<TurnFailureNoticeItem
error="Invalid API key"
errorDetail={{
category: 'auth',
provider: 'OpenAI',
httpStatus: 401,
rawMessage: 'Invalid API key',
}}
/>,
);
});

const toggle = container.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.turn-failure-notice__copy');
await act(async () => {
copyButton?.click();
await Promise.resolve();
});

expect(writeText).toHaveBeenCalledWith('Invalid API key');
});
});
Loading