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
1 change: 1 addition & 0 deletions src/crates/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
total_tools: 1,
duration_ms: outcome.duration_ms,
subagent_parent_info: None,
partial_recovery_reason: None,
})
.await;

Expand Down
7 changes: 7 additions & 0 deletions src/crates/core/src/agentic/execution/execution_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,7 @@ impl ExecutionEngine {
let mut round_index = 0;
let mut completed_rounds = 0usize;
let mut total_tools = 0;
let mut last_partial_recovery_reason: Option<String> = None;
let mut last_assistant_message = Message::assistant("".to_string());
let mut finalization_reason: Option<&'static str> = None;
let mut consecutive_compression_failures: u32 = 0;
Expand Down Expand Up @@ -1565,6 +1566,11 @@ impl ExecutionEngine {

total_tools += round_result.tool_calls.len();

// Track partial recovery reason from the last round
if round_result.partial_recovery_reason.is_some() {
last_partial_recovery_reason = round_result.partial_recovery_reason.clone();
}

// P0: Consecutive same-tool-call loop detection
if !round_result.tool_calls.is_empty() {
let mut sigs: Vec<String> = round_result
Expand Down Expand Up @@ -1754,6 +1760,7 @@ impl ExecutionEngine {
total_tools,
duration_ms,
subagent_parent_info: event_subagent_parent_info,
partial_recovery_reason: last_partial_recovery_reason,
},
None,
)
Expand Down
21 changes: 21 additions & 0 deletions src/crates/core/src/agentic/execution/round_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@ impl RoundExecutor {
}

let no_effective_output = !result.has_effective_output;
let is_partial_recovery = result.partial_recovery_reason.is_some();

if no_effective_output && attempt_index < max_attempts - 1 {
let delay_ms = Self::retry_delay_ms(attempt_index);
warn!(
Expand All @@ -222,6 +224,23 @@ impl RoundExecutor {
attempt_index += 1;
continue;
}

if is_partial_recovery && attempt_index < max_attempts - 1 {
let delay_ms = Self::retry_delay_ms(attempt_index);
warn!(
"Retrying stream after partial recovery: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, reason={}",
context.session_id,
round_id,
attempt_index + 1,
max_attempts,
delay_ms,
result.partial_recovery_reason.as_deref().unwrap_or("unknown")
);
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
attempt_index += 1;
continue;
}

break result;
}
Err(stream_err) => {
Expand Down Expand Up @@ -362,6 +381,7 @@ impl RoundExecutor {
finish_reason: FinishReason::Complete,
usage: stream_result.usage.clone(),
provider_metadata: stream_result.provider_metadata.clone(),
partial_recovery_reason: stream_result.partial_recovery_reason.clone(),
});
}

Expand Down Expand Up @@ -567,6 +587,7 @@ impl RoundExecutor {
},
usage: stream_result.usage.clone(),
provider_metadata: stream_result.provider_metadata.clone(),
partial_recovery_reason: stream_result.partial_recovery_reason.clone(),
})
}

Expand Down
3 changes: 3 additions & 0 deletions src/crates/core/src/agentic/execution/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ pub struct RoundResult {
pub usage: Option<crate::util::types::ai::GeminiUsage>,
/// Provider-specific metadata returned by the model.
pub provider_metadata: Option<Value>,
/// When set, this round's stream was partially recovered (aborted mid-way
/// but some output was already received). Contains a human-readable reason.
pub partial_recovery_reason: Option<String>,
}

/// Finish reason
Expand Down
31 changes: 30 additions & 1 deletion src/crates/core/src/agentic/session/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,7 +1042,8 @@ impl SessionManager {

// Reset session state to Idle
// After application restart, previous Processing state is invalid and must be reset
if !matches!(session.state, SessionState::Idle) {
let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle);
if previous_state_was_not_idle {
let old_state = session.state.clone();
session.state = SessionState::Idle;
debug!(
Expand Down Expand Up @@ -1159,6 +1160,34 @@ impl SessionManager {
context_msg_count
);

// Mark session as having unread completion if it was previously running (not Idle).
// This handles both normal app close and abnormal crash scenarios.
if previous_state_was_not_idle {
if let Ok(Some(mut metadata)) = self
.persistence_manager
.load_session_metadata(&session_storage_path, session_id)
.await
{
if metadata.unread_completion.is_none() {
debug!(
"Marking session as having unread completion (was interrupted during restore): session_id={}",
session_id
);
metadata.unread_completion = Some("completed".to_string());
if let Err(e) = self
.persistence_manager
.save_session_metadata(&session_storage_path, &metadata)
.await
{
warn!(
"Failed to save unread_completion metadata: session_id={}, error={}",
session_id, e
);
}
}
}
}

// 4. Add to memory (will overwrite if already exists)
self.sessions
.insert(session_id.to_string(), session.clone());
Expand Down
4 changes: 4 additions & 0 deletions src/crates/events/src/agentic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ pub enum AgenticEvent {
total_tools: usize,
duration_ms: u64,
subagent_parent_info: Option<SubagentParentInfo>,
/// When set, the turn finished but the last model round was a partial
/// recovery (stream aborted mid-way). Contains a human-readable reason.
#[serde(skip_serializing_if = "Option::is_none")]
partial_recovery_reason: Option<String>,
},

DialogTurnCancelled {
Expand Down
2 changes: 2 additions & 0 deletions src/crates/transport/src/adapters/tauri.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ impl TransportAdapter for TauriTransportAdapter {
session_id,
turn_id,
subagent_parent_info,
partial_recovery_reason,
..
} => {
self.app_handle.emit(
Expand All @@ -202,6 +203,7 @@ impl TransportAdapter for TauriTransportAdapter {
"sessionId": session_id,
"turnId": turn_id,
"subagentParentInfo": subagent_parent_info,
"partialRecoveryReason": partial_recovery_reason,
}),
)?;
}
Expand Down
2 changes: 2 additions & 0 deletions src/crates/transport/src/adapters/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,15 @@ impl TransportAdapter for WebSocketTransportAdapter {
session_id,
turn_id,
subagent_parent_info,
partial_recovery_reason,
..
} => {
json!({
"type": "dialog-turn-completed",
"sessionId": session_id,
"turnId": turn_id,
"subagentParentInfo": subagent_parent_info,
"partialRecoveryReason": partial_recovery_reason,
})
}
_ => return Ok(()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@
0 0 0 1px color-mix(in srgb, var(--color-success, #22c55e) 35%, transparent),
0 0 6px color-mix(in srgb, var(--color-success, #22c55e) 42%, transparent);

&.is-error {
&.is-error,
&.is-interrupted {
background: var(--color-error, #ef4444);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--color-error, #ef4444) 35%, transparent),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -532,18 +532,21 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
className={[
'bitfun-nav-panel__inline-item-unread-dot',
attentionKind === 'error' && 'is-error',
attentionKind === 'interrupted' && 'is-interrupted',
attentionKind === 'ask_user' && 'is-ask-user',
attentionKind === 'tool_confirm' && 'is-tool-confirm',
isHighPriority && 'is-high-priority',
].filter(Boolean).join(' ')}
aria-label={
attentionKind === 'error'
? t('nav.sessions.unreadError')
: attentionKind === 'ask_user'
? t('nav.sessions.needsUserInput')
: attentionKind === 'tool_confirm'
? t('nav.sessions.needsToolConfirm')
: t('nav.sessions.unreadCompleted')
: attentionKind === 'interrupted'
? t('nav.sessions.unreadInterrupted')
: attentionKind === 'ask_user'
? t('nav.sessions.needsUserInput')
: attentionKind === 'tool_confirm'
? t('nav.sessions.needsToolConfirm')
: t('nav.sessions.unreadCompleted')
}
/>
) : null}
Expand Down
49 changes: 49 additions & 0 deletions src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,53 @@
&--has-action-bar &__scroll-to-bottom {
bottom: 140px;
}

/* Minimized review action bar indicator */
&__minimized-indicator {
position: absolute;
bottom: 14px;
left: 14px;
z-index: 11;
pointer-events: auto;
}

&__minimized-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 20px;
background: var(--color-bg-secondary);
border: 1px solid var(--border-base);
color: var(--color-text-primary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;

&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

svg {
flex-shrink: 0;
color: var(--color-accent-500);
}
}

&__minimized-text {
white-space: nowrap;
}

&__minimized-count {
padding: 2px 8px;
border-radius: 10px;
background: var(--element-bg-soft);
font-size: 11px;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
Loading
Loading