use std::collections::{BTreeMap, VecDeque};
use bcode_client::{BcodeClient, ClientError, MessageAcceptance};
use bcode_ipc::{ComposerDraftScope, PermissionSummary, PromptPlacement};
use bcode_session_models::{
ProjectionWindowAnchor, ProjectionWindowDirection, ProjectionWindowLimits,
ProjectionWindowRequest, ProjectionWindowTarget, SessionHistoryCursor, SessionHistoryPage,
SessionId, SessionProjectionKind, SessionSummary, WorkId,
};
use bcode_session_view::execute_session_view_action;
use bcode_session_view_models::{SessionViewAction, SessionViewActionOutcome};
use bcode_skill_models::SkillId;
use bcode_worktree_models::WorktreeCreateRequest;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use super::{
TuiError, clipboard_image, history_flow,
session_flow::{self, AgentCatalog},
slash_palette,
};
const TRANSCRIPT_PAGE_TARGET_ITEMS: usize = 4;
const TRANSCRIPT_PAGE_MAX_ITEMS: usize = 16;
const TRANSCRIPT_PAGE_TARGET_ROWS: usize = 24;
const TRANSCRIPT_PAGE_MAX_EVENTS_SCANNED: usize = 512;
const TRANSCRIPT_PAGE_MAX_BYTES: usize = 512 * 1024;
async fn load_transcript_history_page(
client: &BcodeClient,
session_id: SessionId,
cursor: SessionHistoryCursor,
width_columns: u16,
older: bool,
) -> Result<
(
SessionHistoryPage,
Vec<bcode_session_models::SubmissionRecord>,
),
ClientError,
> {
let (anchor, direction) = if older {
(
ProjectionWindowAnchor::BeforeSequence(cursor.sequence.saturating_add(1)),
ProjectionWindowDirection::Backward,
)
} else {
(
ProjectionWindowAnchor::AfterSequence(cursor.sequence.saturating_sub(1)),
ProjectionWindowDirection::Forward,
)
};
let page = client
.session_projection_history_page(
session_id,
ProjectionWindowRequest {
projection: SessionProjectionKind::Transcript,
anchor,
direction,
target: ProjectionWindowTarget {
min_items: Some(TRANSCRIPT_PAGE_TARGET_ITEMS),
min_estimated_rows: Some(TRANSCRIPT_PAGE_TARGET_ROWS),
min_bytes: None,
width_columns: Some(width_columns.max(1)),
},
limits: ProjectionWindowLimits {
max_items: TRANSCRIPT_PAGE_MAX_ITEMS,
max_events_scanned: TRANSCRIPT_PAGE_MAX_EVENTS_SCANNED,
max_bytes: TRANSCRIPT_PAGE_MAX_BYTES,
},
},
)
.await?;
let page = projection_page_as_history_page(session_id, cursor, older, page)?;
let records = client
.submissions_for_history(session_id, &page.events)
.await?;
Ok((page, records))
}
fn projection_page_as_history_page(
session_id: SessionId,
cursor: SessionHistoryCursor,
older: bool,
page: bcode_session_models::ProjectionHistoryPage,
) -> Result<SessionHistoryPage, ClientError> {
let has_more = if older {
page.window.has_older
} else {
page.window.has_newer
};
let expected_boundary = page.window.source_range.map(|range| {
if older {
range.end_sequence == cursor.sequence
} else {
range.start_sequence == cursor.sequence
}
});
if page.session_id != session_id
|| page.window.projection != SessionProjectionKind::Transcript
|| page.events.is_empty() && has_more
|| page.window.source_range.is_some() == page.events.is_empty()
|| expected_boundary == Some(false)
|| page.window.source_range.is_some_and(|range| {
page.events.first().map(|event| event.sequence) != Some(range.start_sequence)
|| page.events.last().map(|event| event.sequence) != Some(range.end_sequence)
|| page
.events
.iter()
.any(|event| event.session_id != session_id)
|| page
.events
.windows(2)
.any(|pair| pair[0].sequence >= pair[1].sequence)
})
{
return Err(ClientError::Protocol(
"inconsistent projection history page boundary".to_owned(),
));
}
Ok(SessionHistoryPage {
session_id,
events: page.events,
compatibility_issues: Vec::new(),
next_cursor: None,
has_more,
})
}
pub struct SubmitMessageRequest {
pub submission_id: bcode_session_models::SubmissionId,
pub session_id: Option<SessionId>,
pub launch_working_directory: std::path::PathBuf,
pub message: String,
pub placement: PromptPlacement,
pub provider_plugin_id: Option<String>,
pub model_id: Option<String>,
pub agent_id: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_summary: Option<String>,
pub reasoning_effort_generation: Option<u64>,
pub execution: bcode_session_models::TurnExecutionOptions,
pub event_sender: mpsc::Sender<super::history_flow::SessionStreamUpdate>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillActionKind {
Activate,
Deactivate,
Invoke,
}
pub struct SkillActionRequest {
pub session_id: Option<SessionId>,
pub launch_working_directory: std::path::PathBuf,
pub skill_id: SkillId,
pub action: SkillActionKind,
pub arguments: String,
pub provider_plugin_id: Option<String>,
pub model_id: Option<String>,
pub agent_id: Option<String>,
pub reasoning_effort: Option<String>,
pub reasoning_summary: Option<String>,
pub reasoning_effort_generation: Option<u64>,
pub execution: bcode_session_models::TurnExecutionOptions,
pub event_sender: mpsc::Sender<super::history_flow::SessionStreamUpdate>,
}
pub enum TuiEffect {
OpenSession {
session_id: SessionId,
initial_window_request: ProjectionWindowRequest,
event_sender: mpsc::Sender<super::history_flow::SessionStreamUpdate>,
allow_daemon_start: bool,
},
LoadConfig,
PersistThemeSelection {
name: String,
},
ClearThemeSelection,
PersistStreamingPresentation {
policy: bcode_session_view_models::StreamingPresentationPolicy,
},
ClearStreamingPresentation,
ReconcileAuthSecurity {
config: Box<bcode_config::BcodeConfig>,
},
LoadDraftStatus {
launch_working_directory: std::path::PathBuf,
},
QueueRecovery {
session_id: SessionId,
generation: u64,
action: Box<SessionViewAction>,
},
LoadSessionStatus {
session_id: SessionId,
},
LoadSessionModelStatus { session_id: SessionId },
LoadPluginStatus { session_id: SessionId },
LoadAgentCatalog,
LoadOlderHistory {
session_id: SessionId,
cursor: SessionHistoryCursor,
generation: u64,
width_columns: u16,
},
LoadNewerHistory {
session_id: SessionId,
cursor: SessionHistoryCursor,
generation: u64,
width_columns: u16,
},
ListPermissions,
SaveDraft {
scope: ComposerDraftScope,
text: String,
},
LoadSlashPalette {
provider_plugin_id: Option<String>,
generation: u64,
query: String,
session_id: Option<SessionId>,
},
LoadCommandPalette,
LoadSessionChildren {
parent_id: SessionId,
after: Option<SessionId>,
generation: u64,
},
LoadChildActivity {
session_id: SessionId,
after: Option<SessionId>,
generation: u64,
},
LoadSessionPicker {
generation: u64,
scope: bcode_session_models::SessionDiscoveryScope,
query: bcode_session_models::SessionCatalogQuery,
cursor: Option<bcode_session_models::SessionCatalogCursor>,
},
ImportSession {
source_id: String,
external_session_id: String,
},
RenameSession {
session_id: SessionId,
name: Option<String>,
},
DeleteSession { session_id: SessionId },
SearchSessions {
request: Box<bcode_session_search::SessionSearchRequest>,
policy: bcode_session_search::SessionSearchPlanPolicy,
},
StartBulkMigration {
request: bcode_ipc::SessionBulkMigrationStartRequest,
},
CancelBulkMigration { operation_id: String },
WaitBulkMigration {
operation_id: String,
after_revision: u64,
},
StartSearchIndexing {
request: bcode_session_search::CompleteSessionSearchBackfillRequest,
},
CancelSearchIndexing { operation_id: String },
WaitSearchIndexing {
operation_id: String,
after_revision: u64,
},
ExecuteSlashCommand {
session_id: Option<SessionId>,
working_directory: std::path::PathBuf,
current_agent_id: String,
reasoning_display_mode: bcode_config::TuiThinkingMode,
reasoning_visible: bool,
message: String,
},
RalphAction {
repo_root: std::path::PathBuf,
action: super::ralph_flow::RalphRootAction,
},
RalphStart {
request: super::ralph_flow::RalphStartRequest,
},
OpenPluginSurface {
plugin_id: String,
surface_kind: String,
instance_id: String,
options: serde_json::Value,
working_directory: std::path::PathBuf,
session_id: Option<SessionId>,
},
LoadModelProviders,
LoadAuthPoolPicker,
SetAuthPoolPreference {
pool: String,
profile: Option<String>,
},
CheckSelectedModel {
session_id: Option<SessionId>,
provider_plugin_id: Option<String>,
model_id: String,
generation: u64,
},
LoadModelPicker {
provider_plugin_id: Option<String>,
},
LoadSkillPicker,
DescribeSkill {
skill_id: SkillId,
},
LoadThinkingDialog {
session_id: Option<SessionId>,
focus: super::thinking_dialog::ThinkingDialogFocus,
},
LoadMilestones {
request: super::timeline_dialog::MilestonePageRequest,
},
LoadTimelineJump {
session_id: SessionId,
generation: u64,
sequence: u64,
},
InvokePluginCommand {
plugin_id: String,
command_id: String,
arguments: Option<String>,
working_directory: std::path::PathBuf,
session_id: Option<SessionId>,
},
SubmitMessage {
request: Box<SubmitMessageRequest>,
},
SkillAction {
request: Box<SkillActionRequest>,
},
SetSessionModel {
session_id: SessionId,
provider_plugin_id: Option<String>,
model_id: String,
},
SetSessionReasoning {
session_id: SessionId,
effort: Option<String>,
summary: Option<String>,
effort_generation: Option<u64>,
status: String,
},
AppendPresentationNote {
session_id: SessionId,
source_id: String,
note_id: String,
text: String,
format: bcode_command::CommandTextFormat,
},
CancelRuntimeWork {
session_id: SessionId,
work_id: WorkId,
},
CompactContext {
session_id: SessionId,
},
AttachWorktree {
session_id: SessionId,
path: std::path::PathBuf,
},
CreateWorktree {
operation_id: String,
request: WorktreeCreateRequest,
},
WaitWorktreeCreate {
operation_id: String,
after_revision: u64,
},
CancelTurn { session_id: SessionId },
ResolvePermission {
permission_id: String,
approved: bool,
remember: bool,
apply_to_batch: bool,
batch_id: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaemonObservation {
None,
Success,
Unavailable(String),
Failed(String),
}
impl DaemonObservation {
fn from_client_error(error: &ClientError) -> Self {
if error.is_daemon_unavailable() {
Self::Unavailable(error.to_string())
} else {
Self::Failed(error.to_string())
}
}
fn from_client_result<T>(result: &Result<T, ClientError>) -> Self {
match result {
Ok(_) => Self::Success,
Err(error) => Self::from_client_error(error),
}
}
fn from_tui_result<T>(result: &Result<T, TuiError>) -> Self {
match result {
Ok(_) => Self::Success,
Err(TuiError::Client(error)) => Self::from_client_error(error),
Err(_) => Self::None,
}
}
fn from_optional_error(connected: bool, error: Option<&str>) -> Self {
if connected {
Self::Success
} else if let Some(error) = error {
Self::Unavailable(error.to_owned())
} else {
Self::None
}
}
}
pub enum TuiEffectResult {
QueueRecovery {
session_id: SessionId,
generation: u64,
result: Box<
Result<
(
SessionViewActionOutcome,
bcode_session_models::SubmissionQueueStatus,
),
ClientError,
>,
>,
},
SessionChildrenLoaded {
parent_id: SessionId,
generation: u64,
result: Result<bcode_session_models::SessionChildPage, ClientError>,
},
ChildActivityLoaded {
session_id: SessionId,
generation: u64,
result: Result<bcode_session_models::SessionActivityPage, ClientError>,
},
SessionOpenProgress {
snapshot: bcode_session_models::SessionOpenOperationSnapshot,
},
SessionOpened {
session_id: SessionId,
has_older_history: bool,
result: Box<Result<(bcode_client::AttachedSessionHistory, JoinHandle<()>), TuiError>>,
},
ConfigLoaded {
config: Box<Result<bcode_config::BcodeConfig, String>>,
theme_selection: Result<Option<String>, String>,
streaming_presentation:
Result<Option<bcode_session_view_models::StreamingPresentationPolicy>, String>,
},
ThemeSelectionPersisted {
name: Option<String>,
result: Result<std::path::PathBuf, String>,
},
StreamingPresentationPersisted {
policy: Option<bcode_session_view_models::StreamingPresentationPolicy>,
result: Result<std::path::PathBuf, String>,
},
AuthSecurityReconciled {
status: Option<String>,
},
DraftStatusLoaded {
daemon_connected: bool,
model: Option<bcode_model::SessionModelStatus>,
composer_draft: Option<String>,
error: Option<String>,
},
SessionStatusLoaded {
daemon_connected: bool,
session_id: SessionId,
hydration: Box<SessionStatusHydration>,
},
SessionModelStatusLoaded {
session_id: SessionId,
result: Result<bcode_model::SessionModelStatus, ClientError>,
},
PluginStatusLoaded {
session_id: SessionId,
plugin_status: Vec<bcode_session_view_models::PluginStatusView>,
error: Option<String>,
},
AgentCatalogLoaded {
agents: Result<AgentCatalog, String>,
},
OlderHistoryLoaded {
session_id: SessionId,
cursor: SessionHistoryCursor,
generation: u64,
result: Result<
(
SessionHistoryPage,
Vec<bcode_session_models::SubmissionRecord>,
),
ClientError,
>,
},
NewerHistoryLoaded {
session_id: SessionId,
cursor: SessionHistoryCursor,
generation: u64,
result: Result<
(
SessionHistoryPage,
Vec<bcode_session_models::SubmissionRecord>,
),
ClientError,
>,
},
PermissionList {
result: Result<Vec<PermissionSummary>, ClientError>,
},
SaveDraft {
text: String,
result: Result<(), ClientError>,
},
SlashPaletteLoaded {
generation: u64,
query: String,
palette: slash_palette::SlashPalette,
},
CommandPaletteLoaded {
result: Result<Vec<bcode_command::CommandContribution>, ClientError>,
},
SessionPickerLoaded {
generation: u64,
result: Result<bcode_session_models::SessionCatalogPage, ClientError>,
restart: bool,
},
SessionImported {
result: Result<(SessionSummary, Vec<bcode_ipc::SessionImportWarning>), ClientError>,
},
SessionRenamed {
result: Result<SessionSummary, ClientError>,
},
SessionDeleted {
result: Result<SessionSummary, ClientError>,
},
SessionsSearched {
result: Result<
(
bcode_session_search::FederatedSessionSearchResponse,
Vec<bcode_session_search::HydratedSessionSearchHit>,
),
ClientError,
>,
},
BulkMigrationUpdated {
result: Result<bcode_ipc::SessionBulkMigrationOperationStatus, ClientError>,
},
BulkMigrationWaited {
result: Result<bcode_ipc::SessionBulkMigrationOperationStatus, ClientError>,
},
SearchIndexingStarted {
result: Result<bcode_session_search::StartSessionSearchBackfillResponse, ClientError>,
},
SearchIndexingUpdated {
result: Result<bcode_session_search::SessionSearchBackfillOperationStatus, ClientError>,
},
SearchIndexingWaited {
result: Result<bcode_session_search::SessionSearchBackfillOperationStatus, ClientError>,
},
SlashCommandExecuted {
message: String,
result: Result<super::slash_commands::SlashCommandOutcome, ClientError>,
},
RalphAction {
action: super::ralph_flow::RalphRootAction,
result: Result<super::ralph_flow::RalphRootOutput, TuiError>,
},
RalphStarted {
result: Result<super::ralph_flow::RalphStartOutput, TuiError>,
},
PluginSurfaceOpened {
plugin_id: String,
result: Result<bcode_plugin_sdk::tui::BoxedPluginTuiSurface, TuiError>,
},
ModelProvidersLoaded {
result: Result<Vec<bcode_ipc::PluginServiceSummary>, ClientError>,
},
AuthPoolPickerLoaded {
result: Result<Vec<bcode_provider_auth_models::AuthPoolSummary>, ClientError>,
},
AuthPoolPreferenceSet {
pool: String,
profile: Option<String>,
result: Result<(), ClientError>,
},
SelectedModelChecked {
session_id: Option<SessionId>,
provider_plugin_id: Option<String>,
generation: u64,
result: Result<bcode_model::ModelCheckReport, ClientError>,
models: Result<bcode_model::ModelList, ClientError>,
},
ModelPickerLoaded {
provider_plugin_id: Option<String>,
result: Result<bcode_model::ModelList, ClientError>,
},
SkillPickerLoaded {
result: Result<bcode_skill_models::SkillList, ClientError>,
},
SkillDescribed {
skill_id: SkillId,
result: Result<bcode_skill_models::SkillManifest, ClientError>,
},
ThinkingDialogLoaded {
focus: super::thinking_dialog::ThinkingDialogFocus,
result: Result<bcode_model::SessionModelStatus, ClientError>,
},
MilestonesLoaded {
request: super::timeline_dialog::MilestonePageRequest,
result: Result<bcode_session_models::SessionMilestonePage, ClientError>,
},
TimelineJumpLoaded {
session_id: SessionId,
generation: u64,
sequence: u64,
result: Result<
(
Vec<bcode_session_models::SessionEvent>,
bool,
bool,
Vec<bcode_session_models::SubmissionRecord>,
),
TuiError,
>,
},
PluginCommandInvoked {
plugin_id: String,
result: Result<bcode_command::InvokeCommandResponse, TuiError>,
},
SubmitMessage {
submission_id: bcode_session_models::SubmissionId,
message: String,
result: Box<Result<SubmitMessageResult, ClientError>>,
},
SkillAction {
action: SkillActionKind,
skill_id: SkillId,
result: Box<Result<SkillActionResult, ClientError>>,
},
SetSessionModel {
session_id: SessionId,
provider_plugin_id: Option<String>,
model_id: String,
result: Result<(), ClientError>,
},
SetSessionReasoning {
session_id: SessionId,
effort: Option<String>,
effort_generation: Option<u64>,
status: String,
result: Result<(), ClientError>,
},
AppendPresentationNote {
session_id: SessionId,
result: Result<(), ClientError>,
},
CancelRuntimeWork {
work_id: WorkId,
result: Result<bool, ClientError>,
},
CompactContext {
session_id: SessionId,
result: Result<String, ClientError>,
},
AttachWorktree {
path: std::path::PathBuf,
result: Result<SessionSummary, ClientError>,
},
CreateWorktree {
result: Result<bcode_worktree_models::WorktreeCreateOperationStatus, ClientError>,
},
CancelTurn {
session_id: SessionId,
result: Result<bool, ClientError>,
},
PermissionResolved {
permission_id: String,
approved: bool,
remember: bool,
apply_to_batch: bool,
result: Result<bool, ClientError>,
},
}
#[allow(clippy::match_same_arms)]
impl TuiEffectResult {
#[must_use]
pub fn daemon_observation(&self) -> DaemonObservation {
match self {
Self::QueueRecovery { result, .. } => DaemonObservation::from_client_result(result),
Self::SessionChildrenLoaded { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::ChildActivityLoaded { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::SessionOpenProgress { .. } => DaemonObservation::Success,
Self::SessionOpened { result, .. } => DaemonObservation::from_tui_result(result),
Self::DraftStatusLoaded {
daemon_connected,
error,
..
} => DaemonObservation::from_optional_error(*daemon_connected, error.as_deref()),
Self::SessionStatusLoaded {
daemon_connected,
hydration,
..
} => DaemonObservation::from_optional_error(
*daemon_connected,
hydration.error.as_deref(),
),
Self::SessionModelStatusLoaded { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::PluginStatusLoaded { error, .. } => {
error.as_ref().map_or(DaemonObservation::Success, |error| {
DaemonObservation::Failed(error.clone())
})
}
Self::AgentCatalogLoaded { agents } => match agents {
Ok(_) => DaemonObservation::Success,
Err(error) => DaemonObservation::Unavailable(error.clone()),
},
Self::OlderHistoryLoaded { result, .. } | Self::NewerHistoryLoaded { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::PermissionList { result } => DaemonObservation::from_client_result(result),
Self::SaveDraft { result, .. } => DaemonObservation::from_client_result(result),
Self::SkillAction { result, .. } => DaemonObservation::from_client_result(result),
Self::SetSessionModel { result, .. } => DaemonObservation::from_client_result(result),
Self::SetSessionReasoning { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::AppendPresentationNote { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::SubmitMessage { result, .. } => DaemonObservation::from_client_result(result),
Self::CompactContext { result, .. } => DaemonObservation::from_client_result(result),
Self::CancelRuntimeWork { result, .. } => DaemonObservation::from_client_result(result),
Self::AttachWorktree { result, .. } => DaemonObservation::from_client_result(result),
Self::CreateWorktree { result } => DaemonObservation::from_client_result(result),
Self::CancelTurn { result, .. } | Self::PermissionResolved { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::ConfigLoaded { .. }
| Self::ThemeSelectionPersisted { .. }
| Self::StreamingPresentationPersisted { .. }
| Self::AuthSecurityReconciled { .. }
| Self::SlashPaletteLoaded { .. }
| Self::CommandPaletteLoaded { .. }
| Self::SessionPickerLoaded { .. }
| Self::SessionImported { .. }
| Self::SessionRenamed { .. }
| Self::SessionDeleted { .. }
| Self::SessionsSearched { .. }
| Self::BulkMigrationUpdated { .. }
| Self::BulkMigrationWaited { .. }
| Self::SearchIndexingStarted { .. }
| Self::SearchIndexingUpdated { .. }
| Self::SearchIndexingWaited { .. }
| Self::SlashCommandExecuted { .. }
| Self::ThinkingDialogLoaded { .. }
| Self::MilestonesLoaded { .. }
| Self::TimelineJumpLoaded { .. }
| Self::PluginCommandInvoked { .. } => DaemonObservation::None,
Self::RalphAction { result, .. } => DaemonObservation::from_tui_result(result),
Self::RalphStarted { result } => DaemonObservation::from_tui_result(result),
Self::PluginSurfaceOpened { result, .. } => DaemonObservation::from_tui_result(result),
Self::ModelProvidersLoaded { result } => DaemonObservation::from_client_result(result),
Self::AuthPoolPickerLoaded { result } => DaemonObservation::from_client_result(result),
Self::AuthPoolPreferenceSet { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::SelectedModelChecked { result, .. } => {
DaemonObservation::from_client_result(result)
}
Self::ModelPickerLoaded { result, .. } => DaemonObservation::from_client_result(result),
Self::SkillPickerLoaded { result } => DaemonObservation::from_client_result(result),
Self::SkillDescribed { result, .. } => DaemonObservation::from_client_result(result),
}
}
}
#[derive(Debug)]
pub struct SkillActionResult {
pub session_id: SessionId,
pub created_session: Option<SessionSummary>,
pub event_task: Option<JoinHandle<()>>,
pub acceptance: Option<MessageAcceptance>,
pub committed_agent_id: Option<String>,
pub committed_reasoning_effort_generation: Option<u64>,
pub event_stream_release: Option<oneshot::Sender<()>>,
}
#[derive(Debug)]
pub struct SessionStatusHydration {
pub model: Option<bcode_model::SessionModelStatus>,
pub active_skills: Option<Vec<bcode_skill_models::SkillContextResponse>>,
pub runtime_work: Option<Vec<bcode_session_models::RuntimeWorkSnapshot>>,
pub interactions: Option<Vec<bcode_session_view_models::InteractionViewSummary>>,
pub plugin_status: Vec<bcode_session_view_models::PluginStatusView>,
pub error: Option<String>,
}
#[derive(Debug)]
pub struct SubmitMessageResult {
pub session_id: SessionId,
pub created_session: Option<SessionSummary>,
pub submission: Result<Option<bcode_session_models::SubmissionRecord>, ClientError>,
pub receipt: bcode_session_models::SubmissionReceipt,
pub committed_agent_id: Option<String>,
pub committed_reasoning_effort_generation: Option<u64>,
pub event_task: Option<JoinHandle<()>>,
pub event_stream_release: Option<oneshot::Sender<()>>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum EffectKey {
QueueRecovery,
SessionOpen,
Config,
ThemeSelection,
StreamingPresentation,
AuthSecurity,
DraftStatus,
SessionStatus,
SessionModelStatus,
PluginStatus,
AgentCatalog,
OlderHistory,
NewerHistory,
PermissionList,
DraftSave,
SlashPalette,
CommandPalette,
SessionChildren(SessionId),
ChildActivity(SessionId),
SessionPicker,
ImportSession,
RenameSession(SessionId),
DeleteSession(SessionId),
SearchSessions,
BulkMigration,
SearchIndexing,
SlashCommand,
RalphAction,
RalphStart,
PluginSurface,
ModelProviders,
ModelCheck,
ModelPicker,
AuthPoolPicker,
AuthPoolPreference,
SkillPicker,
SkillDescription,
ThinkingDialog,
TimelineJump,
PluginCommand(String, String),
SubmitMessage(bcode_session_models::SubmissionId),
SkillAction(SkillId),
SetSessionModel(SessionId),
SetSessionReasoning(SessionId),
AppendPresentationNote(SessionId, String),
CancelRuntimeWork(SessionId),
CompactContext(SessionId),
AttachWorktree(SessionId),
CreateWorktree,
CancelTurn(SessionId),
ResolvePermission(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectSchedule {
StartIfIdle,
Replace,
QueueLatest,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EffectDaemonIntent {
Background,
Foreground,
}
impl TuiEffect {
#[allow(clippy::too_many_lines, dead_code)]
fn daemon_start_failed(self, client_error: ClientError) -> TuiEffectResult {
match self {
Self::QueueRecovery {
session_id,
generation,
..
} => TuiEffectResult::QueueRecovery {
generation,
session_id,
result: Box::new(Err(client_error)),
},
Self::LoadSessionChildren {
parent_id,
generation,
..
} => TuiEffectResult::SessionChildrenLoaded {
parent_id,
generation,
result: Err(client_error),
},
Self::LoadChildActivity {
session_id,
generation,
..
} => TuiEffectResult::ChildActivityLoaded {
session_id,
generation,
result: Err(client_error),
},
Self::OpenSession { session_id, .. } => TuiEffectResult::SessionOpened {
session_id,
has_older_history: true,
result: Box::new(Err(TuiError::Client(client_error))),
},
Self::LoadDraftStatus { .. } => TuiEffectResult::DraftStatusLoaded {
daemon_connected: false,
model: None,
composer_draft: None,
error: Some(client_error.to_string()),
},
Self::LoadSessionStatus { session_id } => TuiEffectResult::SessionStatusLoaded {
daemon_connected: false,
session_id,
hydration: Box::new(SessionStatusHydration {
model: None,
active_skills: None,
runtime_work: None,
interactions: None,
plugin_status: Vec::new(),
error: Some(client_error.to_string()),
}),
},
Self::LoadSessionModelStatus { session_id } => {
TuiEffectResult::SessionModelStatusLoaded {
session_id,
result: Err(client_error),
}
}
Self::LoadPluginStatus { session_id } => TuiEffectResult::PluginStatusLoaded {
session_id,
plugin_status: Vec::new(),
error: Some(client_error.to_string()),
},
Self::LoadAgentCatalog => TuiEffectResult::AgentCatalogLoaded {
agents: Err(client_error.to_string()),
},
Self::SubmitMessage { request } => TuiEffectResult::SubmitMessage {
submission_id: request.submission_id,
message: request.message,
result: Box::new(Err(client_error)),
},
Self::SkillAction { request } => TuiEffectResult::SkillAction {
action: request.action,
skill_id: request.skill_id,
result: Box::new(Err(client_error)),
},
Self::SetSessionModel {
session_id,
provider_plugin_id,
model_id,
} => TuiEffectResult::SetSessionModel {
session_id,
provider_plugin_id,
model_id,
result: Err(client_error),
},
Self::SetSessionReasoning {
session_id,
effort,
effort_generation,
status,
..
} => TuiEffectResult::SetSessionReasoning {
session_id,
effort,
effort_generation,
status,
result: Err(client_error),
},
Self::AppendPresentationNote { session_id, .. } => {
TuiEffectResult::AppendPresentationNote {
session_id,
result: Err(client_error),
}
}
Self::CancelRuntimeWork { work_id, .. } => TuiEffectResult::CancelRuntimeWork {
work_id,
result: Err(client_error),
},
Self::CompactContext { session_id } => TuiEffectResult::CompactContext {
session_id,
result: Err(client_error),
},
Self::AttachWorktree { path, .. } => TuiEffectResult::AttachWorktree {
path,
result: Err(client_error),
},
Self::CreateWorktree { .. } | Self::WaitWorktreeCreate { .. } => {
TuiEffectResult::CreateWorktree {
result: Err(client_error),
}
}
Self::CancelTurn { session_id } => TuiEffectResult::CancelTurn {
session_id,
result: Err(client_error),
},
Self::ResolvePermission {
permission_id,
approved,
remember,
apply_to_batch,
..
} => TuiEffectResult::PermissionResolved {
permission_id,
approved,
remember,
apply_to_batch,
result: Err(client_error),
},
Self::LoadConfig
| Self::PersistThemeSelection { .. }
| Self::ClearThemeSelection
| Self::PersistStreamingPresentation { .. }
| Self::ClearStreamingPresentation
| Self::ReconcileAuthSecurity { .. }
| Self::LoadOlderHistory { .. }
| Self::LoadNewerHistory { .. }
| Self::ListPermissions
| Self::SaveDraft { .. }
| Self::LoadSlashPalette { .. }
| Self::LoadCommandPalette
| Self::LoadSessionPicker { .. }
| Self::ImportSession { .. }
| Self::RenameSession { .. }
| Self::DeleteSession { .. }
| Self::SearchSessions { .. }
| Self::StartBulkMigration { .. }
| Self::CancelBulkMigration { .. }
| Self::WaitBulkMigration { .. }
| Self::StartSearchIndexing { .. }
| Self::CancelSearchIndexing { .. }
| Self::WaitSearchIndexing { .. }
| Self::ExecuteSlashCommand { .. }
| Self::RalphAction { .. }
| Self::RalphStart { .. }
| Self::OpenPluginSurface { .. }
| Self::LoadModelProviders
| Self::LoadAuthPoolPicker
| Self::SetAuthPoolPreference { .. }
| Self::CheckSelectedModel { .. }
| Self::LoadModelPicker { .. }
| Self::LoadSkillPicker
| Self::DescribeSkill { .. }
| Self::LoadThinkingDialog { .. }
| Self::LoadMilestones { .. }
| Self::LoadTimelineJump { .. }
| Self::InvokePluginCommand { .. } => {
unreachable!("daemon start failure for non-foreground effect")
}
}
}
const fn daemon_intent(&self) -> EffectDaemonIntent {
match self {
Self::OpenSession {
allow_daemon_start: true,
..
}
| Self::QueueRecovery { .. }
| Self::LoadDraftStatus { .. }
| Self::LoadSessionStatus { .. }
| Self::LoadAgentCatalog
| Self::SubmitMessage { .. }
| Self::SkillAction { .. }
| Self::SetSessionModel { .. }
| Self::SetSessionReasoning { .. }
| Self::AppendPresentationNote { .. }
| Self::CancelRuntimeWork { .. }
| Self::CompactContext { .. }
| Self::AttachWorktree { .. }
| Self::CreateWorktree { .. }
| Self::WaitWorktreeCreate { .. }
| Self::CancelTurn { .. }
| Self::ResolvePermission { .. }
| Self::InvokePluginCommand { .. } => EffectDaemonIntent::Foreground,
Self::OpenSession {
allow_daemon_start: false,
..
}
| Self::LoadSessionChildren { .. }
| Self::LoadChildActivity { .. }
| Self::LoadConfig
| Self::PersistThemeSelection { .. }
| Self::ClearThemeSelection
| Self::PersistStreamingPresentation { .. }
| Self::ClearStreamingPresentation
| Self::ReconcileAuthSecurity { .. }
| Self::LoadSessionModelStatus { .. }
| Self::LoadPluginStatus { .. }
| Self::LoadOlderHistory { .. }
| Self::LoadNewerHistory { .. }
| Self::ListPermissions
| Self::SaveDraft { .. }
| Self::LoadSlashPalette { .. }
| Self::LoadCommandPalette
| Self::LoadSessionPicker { .. }
| Self::ImportSession { .. }
| Self::RenameSession { .. }
| Self::DeleteSession { .. }
| Self::SearchSessions { .. }
| Self::StartBulkMigration { .. }
| Self::CancelBulkMigration { .. }
| Self::WaitBulkMigration { .. }
| Self::StartSearchIndexing { .. }
| Self::CancelSearchIndexing { .. }
| Self::WaitSearchIndexing { .. }
| Self::ExecuteSlashCommand { .. }
| Self::RalphAction { .. }
| Self::RalphStart { .. }
| Self::OpenPluginSurface { .. }
| Self::LoadModelProviders
| Self::LoadAuthPoolPicker
| Self::SetAuthPoolPreference { .. }
| Self::CheckSelectedModel { .. }
| Self::LoadModelPicker { .. }
| Self::LoadSkillPicker
| Self::DescribeSkill { .. }
| Self::LoadThinkingDialog { .. }
| Self::LoadMilestones { .. }
| Self::LoadTimelineJump { .. } => EffectDaemonIntent::Background,
}
}
}
type OrderedPresentationEffects = BTreeMap<SessionId, VecDeque<TuiEffect>>;
type ScheduledEffects = Vec<(EffectSchedule, TuiEffect)>;
#[derive(Default)]
pub struct TuiEffectQueue {
effects: BTreeMap<EffectKey, (EffectSchedule, TuiEffect)>,
}
impl TuiEffectQueue {
pub fn start(&mut self, effect: TuiEffect) {
self.push(effect, EffectSchedule::StartIfIdle);
}
pub fn replace(&mut self, effect: TuiEffect) {
self.push(effect, EffectSchedule::Replace);
}
pub fn queue_latest(&mut self, effect: TuiEffect) {
self.push(effect, EffectSchedule::QueueLatest);
}
pub fn start_ordered(&mut self, effect: TuiEffect) {
let key = effect.key();
debug_assert!(matches!(key, EffectKey::AppendPresentationNote(_, _)));
self.effects
.insert(key, (EffectSchedule::QueueLatest, effect));
}
pub fn cancel(&mut self, effect: TuiEffect) {
self.push(effect, EffectSchedule::Cancel);
}
fn push(&mut self, effect: TuiEffect, schedule: EffectSchedule) {
self.effects.insert(effect.key(), (schedule, effect));
}
#[cfg(test)]
pub fn queued_effect_count(&self) -> usize {
self.effects.len()
}
#[cfg(test)]
pub fn has_open_session(&self, session_id: SessionId) -> bool {
self.effects.values().any(|(_, effect)| {
matches!(
effect,
TuiEffect::OpenSession {
session_id: queued,
..
} if *queued == session_id
)
})
}
#[cfg(test)]
pub fn queued_agent_id(&self) -> Option<&str> {
self.effects.values().find_map(|(_, effect)| match effect {
TuiEffect::SubmitMessage { request } => request.agent_id.as_deref(),
TuiEffect::SkillAction { request } => request.agent_id.as_deref(),
_ => None,
})
}
#[cfg(test)]
pub fn queued_execution_options(&self) -> Option<&bcode_session_models::TurnExecutionOptions> {
self.effects.values().find_map(|(_, effect)| match effect {
TuiEffect::SubmitMessage { request } => Some(&request.execution),
TuiEffect::SkillAction { request } => Some(&request.execution),
_ => None,
})
}
#[cfg(test)]
pub fn open_session_request(&self, session_id: SessionId) -> Option<&ProjectionWindowRequest> {
self.effects.values().find_map(|(_, effect)| match effect {
TuiEffect::OpenSession {
session_id: queued,
initial_window_request,
..
} if *queued == session_id => Some(initial_window_request),
_ => None,
})
}
pub(super) fn contains_effect(&self, effect: &TuiEffect) -> bool {
self.effects.contains_key(&effect.key())
}
pub(super) fn drain_runtime(&mut self) -> (ScheduledEffects, OrderedPresentationEffects) {
let mut effects = Vec::new();
let mut notes = BTreeMap::<SessionId, VecDeque<TuiEffect>>::new();
for (schedule, effect) in std::mem::take(&mut self.effects).into_values() {
if let TuiEffect::AppendPresentationNote { session_id, .. } = &effect {
notes.entry(*session_id).or_default().push_back(effect);
} else {
effects.push((schedule, effect));
}
}
(effects, notes)
}
}
const TUI_EFFECT_STREAM_CAPACITY: usize = 64;
impl TuiEffect {
#[cfg(test)]
pub(super) async fn run_for_test(self, client: BcodeClient) -> TuiEffectResult {
let (streaming_sender, _streaming_receiver) = mpsc::channel(1);
self.run(client, streaming_sender).await
}
pub(super) fn command_key(&self) -> bmux_tui_runtime::CommandKey {
let key = match self {
Self::AppendPresentationNote { session_id, .. } => {
return bmux_tui_runtime::CommandKey::new(format!(
"bcode.effect.AppendPresentationNote({session_id})"
));
}
_ => self.key(),
};
bmux_tui_runtime::CommandKey::new(format!("bcode.effect.{key:?}"))
}
pub(super) fn command(
self,
schedule: EffectSchedule,
foreground_client: &BcodeClient,
passive_client: &BcodeClient,
handle: bmux_tui_runtime::RuntimeHandle<super::root_program::BcodeRuntimeMessage>,
) -> bmux_tui_runtime::Command<super::root_program::BcodeRuntimeMessage> {
let key = self.command_key();
let client = match self.daemon_intent() {
EffectDaemonIntent::Background => passive_client.clone(),
EffectDaemonIntent::Foreground => foreground_client.clone(),
};
let future = async move {
let (streaming_sender, mut streaming_receiver) =
mpsc::channel(TUI_EFFECT_STREAM_CAPACITY);
let effect = Box::pin(self.run(client, streaming_sender));
tokio::pin!(effect);
loop {
tokio::select! {
result = &mut effect => {
break Some(super::root_program::BcodeRuntimeMessage::EffectCompleted(
Box::new(result),
));
}
progress = streaming_receiver.recv() => {
let Some(progress) = progress else {
continue;
};
if handle
.send(super::root_program::BcodeRuntimeMessage::EffectCompleted(
Box::new(progress),
))
.await
.is_err()
{
break None;
}
}
}
}
};
match schedule {
EffectSchedule::StartIfIdle => bmux_tui_runtime::Command::start_if_idle(key, future),
EffectSchedule::Replace => bmux_tui_runtime::Command::replace(key, future),
EffectSchedule::QueueLatest => bmux_tui_runtime::Command::queue_latest(key, future),
EffectSchedule::Cancel => bmux_tui_runtime::Command::cancel(key),
}
}
fn key(&self) -> EffectKey {
match self {
Self::QueueRecovery { .. } => EffectKey::QueueRecovery,
Self::OpenSession { .. } => EffectKey::SessionOpen,
Self::LoadConfig => EffectKey::Config,
Self::PersistThemeSelection { .. } | Self::ClearThemeSelection => {
EffectKey::ThemeSelection
}
Self::PersistStreamingPresentation { .. } | Self::ClearStreamingPresentation => {
EffectKey::StreamingPresentation
}
Self::ReconcileAuthSecurity { .. } => EffectKey::AuthSecurity,
Self::LoadDraftStatus { .. } => EffectKey::DraftStatus,
Self::LoadSessionStatus { .. } => EffectKey::SessionStatus,
Self::LoadSessionModelStatus { .. } => EffectKey::SessionModelStatus,
Self::LoadPluginStatus { .. } => EffectKey::PluginStatus,
Self::LoadAgentCatalog => EffectKey::AgentCatalog,
Self::LoadOlderHistory { .. } => EffectKey::OlderHistory,
Self::LoadNewerHistory { .. } => EffectKey::NewerHistory,
Self::ListPermissions => EffectKey::PermissionList,
Self::SaveDraft { .. } => EffectKey::DraftSave,
Self::LoadSlashPalette { .. } => EffectKey::SlashPalette,
Self::LoadCommandPalette => EffectKey::CommandPalette,
Self::LoadSessionChildren { parent_id, .. } => EffectKey::SessionChildren(*parent_id),
Self::LoadChildActivity { session_id, .. } => EffectKey::ChildActivity(*session_id),
Self::LoadSessionPicker { .. } => EffectKey::SessionPicker,
Self::ImportSession { .. } => EffectKey::ImportSession,
Self::RenameSession { session_id, .. } => EffectKey::RenameSession(*session_id),
Self::DeleteSession { session_id } => EffectKey::DeleteSession(*session_id),
Self::SearchSessions { .. } => EffectKey::SearchSessions,
Self::StartBulkMigration { .. }
| Self::CancelBulkMigration { .. }
| Self::WaitBulkMigration { .. } => EffectKey::BulkMigration,
Self::StartSearchIndexing { .. }
| Self::CancelSearchIndexing { .. }
| Self::WaitSearchIndexing { .. } => EffectKey::SearchIndexing,
Self::ExecuteSlashCommand { .. } => EffectKey::SlashCommand,
Self::RalphAction { .. } => EffectKey::RalphAction,
Self::RalphStart { .. } => EffectKey::RalphStart,
Self::OpenPluginSurface { .. } => EffectKey::PluginSurface,
Self::LoadModelProviders => EffectKey::ModelProviders,
Self::LoadAuthPoolPicker => EffectKey::AuthPoolPicker,
Self::SetAuthPoolPreference { .. } => EffectKey::AuthPoolPreference,
Self::CheckSelectedModel { .. } => EffectKey::ModelCheck,
Self::LoadModelPicker { .. } => EffectKey::ModelPicker,
Self::LoadSkillPicker => EffectKey::SkillPicker,
Self::DescribeSkill { .. } => EffectKey::SkillDescription,
Self::LoadThinkingDialog { .. } => EffectKey::ThinkingDialog,
Self::LoadMilestones { .. } | Self::LoadTimelineJump { .. } => EffectKey::TimelineJump,
Self::InvokePluginCommand {
plugin_id,
command_id,
..
} => EffectKey::PluginCommand(plugin_id.clone(), command_id.clone()),
Self::SubmitMessage { request } => {
EffectKey::SubmitMessage(request.submission_id.clone())
}
Self::SkillAction { request } => EffectKey::SkillAction(request.skill_id.clone()),
Self::SetSessionModel { session_id, .. } => EffectKey::SetSessionModel(*session_id),
Self::SetSessionReasoning { session_id, .. } => {
EffectKey::SetSessionReasoning(*session_id)
}
Self::AppendPresentationNote {
session_id,
note_id,
..
} => EffectKey::AppendPresentationNote(*session_id, note_id.clone()),
Self::CancelRuntimeWork { session_id, .. } => EffectKey::CancelRuntimeWork(*session_id),
Self::CompactContext { session_id } => EffectKey::CompactContext(*session_id),
Self::AttachWorktree { session_id, .. } => EffectKey::AttachWorktree(*session_id),
Self::CreateWorktree { .. } | Self::WaitWorktreeCreate { .. } => {
EffectKey::CreateWorktree
}
Self::CancelTurn { session_id } => EffectKey::CancelTurn(*session_id),
Self::ResolvePermission { permission_id, .. } => {
EffectKey::ResolvePermission(permission_id.clone())
}
}
}
async fn run_session_status_effect(
client: &BcodeClient,
session_id: SessionId,
) -> TuiEffectResult {
Box::pin(load_session_status(client, session_id)).await
}
#[allow(clippy::too_many_lines, clippy::large_stack_frames)]
async fn run(
self,
client: BcodeClient,
streaming_sender: mpsc::Sender<TuiEffectResult>,
) -> TuiEffectResult {
match self {
Self::QueueRecovery {
session_id,
generation,
action,
} => {
let result = async {
let outcome = execute_session_view_action(&client, *action).await?;
let status = client.queue_status(session_id).await?;
Ok((outcome, status))
}
.await;
TuiEffectResult::QueueRecovery {
session_id,
generation,
result: Box::new(result),
}
}
Self::LoadSessionChildren {
parent_id,
after,
generation,
} => TuiEffectResult::SessionChildrenLoaded {
parent_id,
generation,
result: client
.query_child_sessions(bcode_session_models::SessionChildQuery {
parent_id,
after,
limit: 64,
})
.await,
},
Self::LoadChildActivity {
session_id,
after,
generation,
} => TuiEffectResult::ChildActivityLoaded {
session_id,
generation,
result: client
.session_activity(
session_id,
bcode_session_models::SessionActivityQuery {
scope: bcode_session_models::SessionActivityScope::DirectChildren,
after,
limit: 64,
},
)
.await
.map(|mut page| {
if after.is_some()
&& page.coverage
== bcode_session_models::SessionActivityCoverage::Complete
{
page.coverage = bcode_session_models::SessionActivityCoverage::Partial;
}
page
}),
},
Self::OpenSession {
session_id,
initial_window_request,
event_sender,
allow_daemon_start: _,
} => TuiEffectResult::SessionOpened {
session_id,
has_older_history: true,
result: Box::new(
history_flow::attach_session_event_stream_with_window_request(
&client,
session_id,
event_sender,
initial_window_request,
|snapshot| {
let _result =
streaming_sender.try_send(TuiEffectResult::SessionOpenProgress {
snapshot: snapshot.clone(),
});
},
)
.await,
),
},
Self::LoadConfig => TuiEffectResult::ConfigLoaded {
config: Box::new(bcode_config::load_config().map_err(|error| error.to_string())),
theme_selection: bcode_config::load_tui_theme_selection()
.map_err(|error| error.to_string()),
streaming_presentation: bcode_config::load_tui_streaming_presentation_override()
.map_err(|error| error.to_string()),
},
Self::PersistThemeSelection { name } => TuiEffectResult::ThemeSelectionPersisted {
result: bcode_config::set_tui_theme_selection(&name)
.map_err(|error| error.to_string()),
name: Some(name),
},
Self::ClearThemeSelection => TuiEffectResult::ThemeSelectionPersisted {
result: bcode_config::clear_tui_theme_selection()
.map_err(|error| error.to_string()),
name: None,
},
Self::PersistStreamingPresentation { policy } => {
TuiEffectResult::StreamingPresentationPersisted {
result: bcode_config::set_tui_streaming_presentation_override(policy)
.map_err(|error| error.to_string()),
policy: Some(policy.normalized()),
}
}
Self::ClearStreamingPresentation => TuiEffectResult::StreamingPresentationPersisted {
result: bcode_config::clear_tui_streaming_presentation_override()
.map_err(|error| error.to_string()),
policy: None,
},
Self::ReconcileAuthSecurity { config } => TuiEffectResult::AuthSecurityReconciled {
status: session_flow::auth_security_status(&config),
},
Self::LoadDraftStatus {
launch_working_directory,
} => Box::pin(load_draft_status(&client, launch_working_directory)).await,
Self::LoadSessionStatus { session_id } => {
Box::pin(Self::run_session_status_effect(&client, session_id)).await
}
Self::LoadSessionModelStatus { session_id } => {
TuiEffectResult::SessionModelStatusLoaded {
session_id,
result: client.session_model_status(session_id).await,
}
}
Self::LoadPluginStatus { session_id } => {
let (plugin_status, error) = load_plugin_session_status(&client, session_id).await;
TuiEffectResult::PluginStatusLoaded {
session_id,
plugin_status,
error,
}
}
Self::LoadAgentCatalog => TuiEffectResult::AgentCatalogLoaded {
agents: AgentCatalog::load(&client)
.await
.map_err(|error| error.to_string()),
},
Self::LoadOlderHistory {
session_id,
cursor,
generation,
width_columns,
} => TuiEffectResult::OlderHistoryLoaded {
session_id,
cursor,
generation,
result: load_transcript_history_page(
&client,
session_id,
cursor,
width_columns,
true,
)
.await,
},
Self::LoadNewerHistory {
session_id,
cursor,
generation,
width_columns,
} => TuiEffectResult::NewerHistoryLoaded {
session_id,
cursor,
generation,
result: load_transcript_history_page(
&client,
session_id,
cursor,
width_columns,
false,
)
.await,
},
Self::ListPermissions => TuiEffectResult::PermissionList {
result: client.list_permissions().await,
},
Self::SaveDraft { scope, text } => {
let scope = match scope {
ComposerDraftScope::Session { session_id } => {
bcode_session_view_models::ComposerDraftViewScope::Session { session_id }
}
ComposerDraftScope::DraftSession {
launch_working_directory,
} => bcode_session_view_models::ComposerDraftViewScope::DraftSession {
launch_working_directory,
},
};
let result = execute_session_view_action(
&client,
SessionViewAction::UpdateDraft {
scope,
text: text.clone(),
},
)
.await
.map(|_| ());
TuiEffectResult::SaveDraft { text, result }
}
Self::LoadSlashPalette {
query,
session_id,
generation,
provider_plugin_id,
} => {
let palette = slash_palette::SlashPalette::discover(
&client,
session_id,
provider_plugin_id,
&query,
)
.await;
TuiEffectResult::SlashPaletteLoaded {
query,
palette,
generation,
}
}
Self::LoadCommandPalette => TuiEffectResult::CommandPaletteLoaded {
result: client
.plugin_contributions()
.await
.map(|contributions| contributions.command_contributions),
},
Self::LoadSessionPicker {
generation,
scope,
query,
cursor,
} => {
let result = async {
if let bcode_session_models::SessionDiscoveryScope::WorkingDirectory(_) = &scope
{
let mut watcher = client.watch_session_catalog_with_scope(scope).await?;
let mut snapshot = watcher.initial_snapshot().await?;
loop {
let page = bcode_session_models::SessionCatalogPage {
sessions: snapshot.sessions,
catalog_status: snapshot.catalog_status,
catalog_sources: snapshot.catalog_sources,
catalog_revision: snapshot.catalog_revision,
next_cursor: None,
};
if streaming_sender
.send(TuiEffectResult::SessionPickerLoaded {
result: Ok(page),
generation,
restart: false,
})
.await
.is_err()
{
return Err(ClientError::Protocol(
"session picker receiver closed".into(),
));
}
snapshot = watcher.next_snapshot().await?;
}
}
let mut watcher = client
.watch_session_catalog_with_scope(scope.clone())
.await?;
let initial = client
.list_sessions_page_with_scope(
scope.clone(),
query.clone(),
cursor.clone(),
100,
)
.await?;
if streaming_sender
.send(TuiEffectResult::SessionPickerLoaded {
result: Ok(initial),
generation,
restart: false,
})
.await
.is_err()
{
return Err(ClientError::Protocol(
"session picker receiver closed".into(),
));
}
loop {
watcher.next_revision().await?;
let snapshot = client
.list_sessions_page_with_scope(scope.clone(), query.clone(), None, 100)
.await?;
if streaming_sender
.send(TuiEffectResult::SessionPickerLoaded {
result: Ok(snapshot),
generation,
restart: true,
})
.await
.is_err()
{
return Err(ClientError::Protocol(
"session picker receiver closed".into(),
));
}
}
}
.await;
TuiEffectResult::SessionPickerLoaded {
result,
generation,
restart: false,
}
}
Self::ImportSession {
source_id,
external_session_id,
} => TuiEffectResult::SessionImported {
result: client
.import_external_session(source_id, external_session_id)
.await,
},
Self::RenameSession { session_id, name } => TuiEffectResult::SessionRenamed {
result: execute_session_view_action(
&client,
SessionViewAction::RenameSession { session_id, name },
)
.await
.and_then(|outcome| match outcome {
SessionViewActionOutcome::SessionRenamed { session } => Ok(*session),
_ => Err(ClientError::UnexpectedResponse),
}),
},
Self::DeleteSession { session_id } => TuiEffectResult::SessionDeleted {
result: execute_session_view_action(
&client,
SessionViewAction::DeleteSession { session_id },
)
.await
.and_then(|outcome| match outcome {
SessionViewActionOutcome::SessionDeleted { session } => Ok(*session),
_ => Err(ClientError::UnexpectedResponse),
}),
},
Self::SearchSessions { request, policy } => TuiEffectResult::SessionsSearched {
result: client
.session_search(*request, policy, Vec::new(), true)
.await,
},
Self::StartBulkMigration { request } => TuiEffectResult::BulkMigrationUpdated {
result: client.start_session_bulk_migration(request).await,
},
Self::CancelBulkMigration { operation_id } => TuiEffectResult::BulkMigrationUpdated {
result: client.cancel_session_bulk_migration(operation_id).await,
},
Self::WaitBulkMigration {
operation_id,
after_revision,
} => TuiEffectResult::BulkMigrationWaited {
result: client
.wait_session_bulk_migration(operation_id, after_revision, 30_000)
.await,
},
Self::StartSearchIndexing { request } => TuiEffectResult::SearchIndexingStarted {
result: client.session_search_index_all_start(request).await,
},
Self::CancelSearchIndexing { operation_id } => TuiEffectResult::SearchIndexingUpdated {
result: client.session_search_backfill_cancel(operation_id).await,
},
Self::WaitSearchIndexing {
operation_id,
after_revision,
} => TuiEffectResult::SearchIndexingWaited {
result: client
.session_search_backfill_wait(operation_id, after_revision, 30_000)
.await,
},
Self::ExecuteSlashCommand {
session_id,
working_directory,
current_agent_id,
reasoning_display_mode,
reasoning_visible,
message,
} => {
let result = match super::slash_registry::resolve(&client, &message).await {
Ok(resolution) => {
super::slash_commands::execute_resolved(
&client,
session_id,
super::slash_commands::SlashExecutionContext {
working_directory: &working_directory,
current_agent_id: ¤t_agent_id,
reasoning_display_mode,
reasoning_visible,
},
&message,
resolution,
)
.await
}
Err(error) => Err(error),
};
TuiEffectResult::SlashCommandExecuted { message, result }
}
Self::RalphAction { repo_root, action } => TuiEffectResult::RalphAction {
action,
result: super::ralph_flow::execute_root_action(&client, repo_root, action).await,
},
Self::RalphStart { request } => TuiEffectResult::RalphStarted {
result: super::ralph_flow::execute_root_start(&client, request).await,
},
Self::OpenPluginSurface {
plugin_id,
surface_kind,
instance_id,
options,
working_directory,
session_id,
} => {
let result = async {
let options = super::palette_flow::hydrate_root_plugin_surface_options(
&client, session_id, options,
)
.await;
let runtime =
bcode_plugin::PluginRuntimeHost::load_defaults_with_static_bundled(
&bcode_plugin::PluginSelection::all_enabled(),
&crate::static_bundled_plugins(),
)
.map_err(|error| TuiError::PluginService {
code: "plugin_runtime_load_failed".to_owned(),
message: error.to_string(),
})?;
crate::plugin_tui::open_plugin_tui_surface(
&runtime,
&plugin_id,
&surface_kind,
bcode_plugin_sdk::tui::PluginTuiSurfaceOpenRequest {
instance_id,
repo_path: Some(working_directory),
session_id,
target: None,
options,
},
)
.await
.map_err(|error| TuiError::PluginService {
code: "tui_surface_open_failed".to_owned(),
message: error.to_string(),
})
}
.await;
TuiEffectResult::PluginSurfaceOpened { plugin_id, result }
}
Self::LoadModelProviders => TuiEffectResult::ModelProvidersLoaded {
result: client.plugin_services().await.map(|services| {
services
.into_iter()
.filter(|service| {
service.interface_id == bcode_model::MODEL_PROVIDER_INTERFACE_ID
})
.collect()
}),
},
Self::LoadAuthPoolPicker => TuiEffectResult::AuthPoolPickerLoaded {
result: client.auth_pool_list().await,
},
Self::SetAuthPoolPreference { pool, profile } => {
TuiEffectResult::AuthPoolPreferenceSet {
pool: pool.clone(),
profile: profile.clone(),
result: client.set_auth_pool_preference(pool, profile).await,
}
}
Self::CheckSelectedModel {
session_id,
provider_plugin_id,
model_id,
generation,
} => {
let result = client
.check_models(
session_id,
provider_plugin_id.clone(),
vec![model_id],
20,
true,
)
.await;
let models = match session_id {
Some(id) => {
client
.session_model_list_for_session(id, provider_plugin_id.clone())
.await
}
None => client.session_model_list(provider_plugin_id.clone()).await,
};
TuiEffectResult::SelectedModelChecked {
session_id,
provider_plugin_id,
generation,
result,
models,
}
}
Self::LoadModelPicker { provider_plugin_id } => TuiEffectResult::ModelPickerLoaded {
result: client.session_model_list(provider_plugin_id.clone()).await,
provider_plugin_id,
},
Self::LoadSkillPicker => TuiEffectResult::SkillPickerLoaded {
result: client.list_skills().await,
},
Self::DescribeSkill { skill_id } => TuiEffectResult::SkillDescribed {
skill_id: skill_id.clone(),
result: client.describe_skill(skill_id).await,
},
Self::LoadMilestones { request } => TuiEffectResult::MilestonesLoaded {
request,
result: client
.session_milestones(
request.session_id,
bcode_session_models::SessionMilestoneQuery {
after_sequence: request.after_sequence,
limit: 200,
},
)
.await,
},
Self::LoadThinkingDialog { session_id, focus } => {
let result = match session_id {
Some(session_id) => client.session_model_status(session_id).await,
None => client.default_model_status().await,
};
TuiEffectResult::ThinkingDialogLoaded { focus, result }
}
Self::LoadTimelineJump {
session_id,
generation,
sequence,
} => TuiEffectResult::TimelineJumpLoaded {
session_id,
generation,
sequence,
result: super::history_flow::load_timeline_jump_events(
&client, session_id, sequence,
)
.await,
},
Self::InvokePluginCommand {
plugin_id,
command_id,
arguments,
working_directory,
session_id,
} => {
let mut args = BTreeMap::new();
if let Some(arguments) = arguments.filter(|value| !value.is_empty()) {
args.insert("arguments".to_owned(), arguments);
}
let result = async {
let payload = serde_json::to_vec(&bcode_command::InvokeCommandRequest {
command_id,
args,
context: Some(bcode_command::CommandInvocationContext {
session_id,
working_directory,
}),
})?;
let response = client
.invoke_plugin_service(
plugin_id.clone(),
bcode_command::COMMAND_INTERFACE_ID.to_owned(),
bcode_command::OP_INVOKE_COMMAND.to_owned(),
payload,
)
.await?;
if let Some(error) = response.error {
return Err(TuiError::PluginService {
code: error.code,
message: error.message,
});
}
Ok(serde_json::from_slice::<
bcode_command::InvokeCommandResponse,
>(&response.payload)?)
}
.await;
TuiEffectResult::PluginCommandInvoked { plugin_id, result }
}
Self::SubmitMessage { request } => run_submit_message(&client, *request).await,
Self::SkillAction { request } => run_skill_action(&client, *request).await,
Self::SetSessionModel {
session_id,
provider_plugin_id,
model_id,
} => TuiEffectResult::SetSessionModel {
session_id,
provider_plugin_id: provider_plugin_id.clone(),
model_id: model_id.clone(),
result: execute_session_view_action(
&client,
SessionViewAction::SetModel {
session_id,
provider_plugin_id,
model_id,
},
)
.await
.map(|_| ()),
},
Self::SetSessionReasoning {
session_id,
effort,
summary,
effort_generation,
status,
} => TuiEffectResult::SetSessionReasoning {
session_id,
effort: effort.clone(),
effort_generation,
status,
result: execute_session_view_action(
&client,
SessionViewAction::SetReasoning {
session_id,
effort,
summary,
},
)
.await
.map(|_| ()),
},
Self::AppendPresentationNote {
session_id,
source_id,
note_id,
text,
format,
} => TuiEffectResult::AppendPresentationNote {
session_id,
result: client
.append_presentation_note(session_id, source_id, note_id, text, format)
.await,
},
Self::CancelRuntimeWork {
session_id,
work_id,
} => TuiEffectResult::CancelRuntimeWork {
work_id: work_id.clone(),
result: match execute_session_view_action(
&client,
SessionViewAction::CancelRuntimeWork {
session_id,
work_id,
},
)
.await
{
Ok(SessionViewActionOutcome::RuntimeWorkCancellationRequested {
cancelled,
}) => Ok(cancelled),
Ok(_) => Err(ClientError::UnexpectedResponse),
Err(error) => Err(error),
},
},
Self::CompactContext { session_id } => TuiEffectResult::CompactContext {
session_id,
result: match execute_session_view_action(
&client,
SessionViewAction::CompactContext { session_id },
)
.await
{
Ok(SessionViewActionOutcome::ContextCompacted { message }) => Ok(message),
Ok(_) => Err(ClientError::UnexpectedResponse),
Err(error) => Err(error),
},
},
Self::AttachWorktree { session_id, path } => TuiEffectResult::AttachWorktree {
path: path.clone(),
result: match execute_session_view_action(
&client,
SessionViewAction::ChangeWorkingDirectory { session_id, path },
)
.await
{
Ok(SessionViewActionOutcome::WorkingDirectoryChanged { session }) => {
Ok(*session)
}
Ok(_) => Err(ClientError::UnexpectedResponse),
Err(error) => Err(error),
},
},
Self::CreateWorktree {
operation_id,
request,
} => TuiEffectResult::CreateWorktree {
result: client.start_worktree_create(operation_id, request).await,
},
Self::WaitWorktreeCreate {
operation_id,
after_revision,
} => TuiEffectResult::CreateWorktree {
result: client
.wait_worktree_create(operation_id, after_revision, 30_000)
.await,
},
Self::CancelTurn { session_id } => TuiEffectResult::CancelTurn {
session_id,
result: match execute_session_view_action(
&client,
active_turn_cancel_action(session_id),
)
.await
{
Ok(SessionViewActionOutcome::Cancelled { cancelled }) => Ok(cancelled),
Ok(_) => Err(ClientError::UnexpectedResponse),
Err(error) => Err(error),
},
},
Self::ResolvePermission {
permission_id,
approved,
remember,
apply_to_batch,
batch_id,
} => {
let result = if apply_to_batch {
let Some(batch_id) = batch_id else {
return TuiEffectResult::PermissionResolved {
permission_id,
approved,
remember,
apply_to_batch,
result: Err(ClientError::UnexpectedResponse),
};
};
match execute_session_view_action(
&client,
SessionViewAction::ResolvePermissionBatch { batch_id, approved },
)
.await
{
Ok(SessionViewActionOutcome::PermissionBatchResolved {
resolved_count,
}) => Ok(resolved_count > 0),
Ok(_) => Err(ClientError::UnexpectedResponse),
Err(error) => Err(error),
}
} else {
match execute_session_view_action(
&client,
SessionViewAction::ResolvePermission {
permission_id: permission_id.clone(),
approved,
remember,
},
)
.await
{
Ok(SessionViewActionOutcome::PermissionResolved { resolved }) => {
Ok(resolved)
}
Ok(_) => Err(ClientError::UnexpectedResponse),
Err(error) => Err(error),
}
};
TuiEffectResult::PermissionResolved {
permission_id,
approved,
remember,
apply_to_batch,
result,
}
}
}
}
}
async fn ensure_session_for_foreground_action(
client: &BcodeClient,
session_id: Option<SessionId>,
launch_working_directory: std::path::PathBuf,
event_sender: mpsc::Sender<super::history_flow::SessionStreamUpdate>,
) -> Result<
(
SessionId,
Option<SessionSummary>,
Option<JoinHandle<()>>,
Option<oneshot::Sender<()>>,
),
ClientError,
> {
if let Some(session_id) = session_id {
return Ok((session_id, None, None, None));
}
let session = client
.create_session_in_working_directory(None, launch_working_directory.clone())
.await?;
let _ = execute_session_view_action(
client,
SessionViewAction::UpdateDraft {
scope: bcode_session_view_models::ComposerDraftViewScope::DraftSession {
launch_working_directory,
},
text: String::new(),
},
)
.await;
let (attached, task, release) =
history_flow::attach_paused_session_event_stream(client, session.id, event_sender)
.await
.map_err(|error| match error {
TuiError::Client(error) => error,
other => ClientError::Server {
code: "tui_session_attach_failed".to_owned(),
message: other.to_string(),
},
})?;
Ok((
session.id,
Some(attached.session),
Some(task),
Some(release),
))
}
async fn run_skill_action(client: &BcodeClient, request: SkillActionRequest) -> TuiEffectResult {
let action = request.action;
let skill_id = request.skill_id.clone();
TuiEffectResult::SkillAction {
action,
skill_id,
result: Box::new(skill_action(client, request).await),
}
}
async fn skill_action(
client: &BcodeClient,
request: SkillActionRequest,
) -> Result<SkillActionResult, ClientError> {
let SkillActionRequest {
session_id,
launch_working_directory,
skill_id,
action,
arguments,
provider_plugin_id,
model_id,
agent_id,
reasoning_effort,
reasoning_summary,
reasoning_effort_generation,
mut execution,
event_sender,
} = request;
execution.reasoning = Some(Box::new(bcode_session_models::TurnReasoningOptions {
effort: reasoning_effort.clone(),
summary: reasoning_summary.clone(),
}));
execution.agent_profile.clone_from(&agent_id);
let (session_id, created_session, event_task, event_stream_release) =
ensure_session_for_foreground_action(
client,
session_id,
launch_working_directory,
event_sender,
)
.await?;
let acceptance = match action {
SkillActionKind::Activate => {
execute_session_view_action(
client,
SessionViewAction::ActivateSkill {
session_id,
skill_id: skill_id.to_string(),
},
)
.await?;
None
}
SkillActionKind::Deactivate => {
execute_session_view_action(
client,
SessionViewAction::DeactivateSkill {
session_id,
skill_id: skill_id.to_string(),
},
)
.await?;
None
}
SkillActionKind::Invoke => {
apply_submit_runtime_selections(
client,
session_id,
provider_plugin_id,
model_id,
None,
reasoning_effort.clone(),
reasoning_summary.clone(),
)
.await?;
let display_text = if arguments.trim().is_empty() {
format!("Invoke skill {skill_id}")
} else {
format!("Invoke skill {skill_id}: {arguments}")
};
let outcome = execute_session_view_action(
client,
SessionViewAction::InvokeSkill {
session_id,
skill_id: skill_id.to_string(),
arguments,
display_text,
execution: Box::new(execution),
},
)
.await?;
Some(message_acceptance_from_action_outcome(&outcome)?)
}
};
Ok(SkillActionResult {
session_id,
created_session,
event_task,
acceptance,
committed_agent_id: (action == SkillActionKind::Invoke)
.then_some(agent_id)
.flatten(),
committed_reasoning_effort_generation: (action == SkillActionKind::Invoke)
.then_some(reasoning_effort_generation)
.flatten(),
event_stream_release,
})
}
async fn run_submit_message(
client: &BcodeClient,
request: SubmitMessageRequest,
) -> TuiEffectResult {
let message = request.message.clone();
TuiEffectResult::SubmitMessage {
submission_id: request.submission_id.clone(),
message,
result: Box::new(submit_message(client, request).await),
}
}
pub async fn apply_submit_runtime_selections(
client: &BcodeClient,
session_id: SessionId,
provider_plugin_id: Option<String>,
model_id: Option<String>,
agent_id: Option<String>,
reasoning_effort: Option<String>,
reasoning_summary: Option<String>,
) -> Result<(), ClientError> {
if let Some(model_id) = model_id {
execute_session_view_action(
client,
SessionViewAction::SetModel {
session_id,
provider_plugin_id,
model_id,
},
)
.await?;
}
if let Some(agent_id) = agent_id {
execute_session_view_action(
client,
SessionViewAction::SetAgent {
session_id,
agent_id,
},
)
.await?;
}
execute_session_view_action(
client,
SessionViewAction::SetReasoning {
session_id,
effort: reasoning_effort,
summary: reasoning_summary,
},
)
.await?;
Ok(())
}
fn message_acceptance_from_action_outcome(
outcome: &SessionViewActionOutcome,
) -> Result<MessageAcceptance, ClientError> {
let SessionViewActionOutcome::MessageAccepted {
queued,
queue_position,
disposition,
..
} = outcome
else {
return Err(ClientError::UnexpectedResponse);
};
Ok(MessageAcceptance {
queued: *queued,
queue_position: queue_position.and_then(|position| u32::try_from(position).ok()),
disposition: match disposition {
bcode_session_view_models::MessageAcceptanceDispositionView::AppliedSteering => {
bcode_ipc::MessageAcceptanceDisposition::AppliedSteering
}
bcode_session_view_models::MessageAcceptanceDispositionView::QueuedFollowUp => {
bcode_ipc::MessageAcceptanceDisposition::QueuedFollowUp
}
bcode_session_view_models::MessageAcceptanceDispositionView::QueuedTurn => {
bcode_ipc::MessageAcceptanceDisposition::QueuedTurn
}
bcode_session_view_models::MessageAcceptanceDispositionView::StartedTurn => {
bcode_ipc::MessageAcceptanceDisposition::StartedTurn
}
},
})
}
#[allow(clippy::too_many_lines)]
fn submission_stage_error(stage: &'static str, error: ClientError) -> ClientError {
match error {
ClientError::RequestTimeout { timeout } => ClientError::Server {
code: "tui_submission_request_timeout".into(),
message: format!(
"Message submission timed out while {stage} after {timeout:?}. Acceptance is not confirmed; check session history before retrying."
),
},
other => other,
}
}
#[cfg(test)]
mod submission_error_tests {
use super::*;
#[test]
fn timeout_reports_stage_without_claiming_rejection_or_restart() {
let error = submission_stage_error(
"waiting for message acceptance",
ClientError::RequestTimeout {
timeout: std::time::Duration::from_secs(30),
},
);
let ClientError::Server { code, message } = error else {
panic!("stage diagnostic");
};
assert_eq!(code, "tui_submission_request_timeout");
assert!(message.contains("waiting for message acceptance"));
assert!(message.contains("Acceptance is not confirmed"));
let startup = submission_stage_error(
"creating the session",
ClientError::DaemonStartupTimeout {
timeout: std::time::Duration::from_secs(10),
},
);
assert!(matches!(startup, ClientError::DaemonStartupTimeout { .. }));
}
}
fn submission_attach_error(error: TuiError) -> ClientError {
match error {
TuiError::Client(error) => {
submission_stage_error("attaching the session event stream", error)
}
other => ClientError::Server {
code: "tui_session_attach_failed".into(),
message: other.to_string(),
},
}
}
async fn submit_message(
client: &BcodeClient,
request: SubmitMessageRequest,
) -> Result<SubmitMessageResult, ClientError> {
let SubmitMessageRequest {
submission_id,
session_id,
launch_working_directory,
message,
placement,
provider_plugin_id,
model_id,
agent_id,
reasoning_effort,
reasoning_summary,
reasoning_effort_generation,
mut execution,
event_sender,
} = request;
execution.reasoning = Some(Box::new(bcode_session_models::TurnReasoningOptions {
effort: reasoning_effort.clone(),
summary: reasoning_summary.clone(),
}));
execution.agent_profile.clone_from(&agent_id);
let mut message = message;
let mut created_session = None;
let mut event_task = None;
let mut event_stream_release = None;
let session_id = if let Some(session_id) = session_id {
session_id
} else {
let session = client
.create_session_in_working_directory(None, launch_working_directory.clone())
.await
.map_err(|error| submission_stage_error("creating the session", error))?;
let _ = execute_session_view_action(
client,
SessionViewAction::UpdateDraft {
scope: bcode_session_view_models::ComposerDraftViewScope::DraftSession {
launch_working_directory: launch_working_directory.clone(),
},
text: String::new(),
},
)
.await;
let (attached, task, release) =
history_flow::attach_paused_session_event_stream(client, session.id, event_sender)
.await
.map_err(submission_attach_error)?;
let session_id = session.id;
message = clipboard_image::promote_draft_clipboard_images(
&message,
&launch_working_directory,
session_id,
)
.map_err(|error| ClientError::Server {
code: "tui_clipboard_image_promotion_failed".to_owned(),
message: error.to_string(),
})?;
created_session = Some(attached.session);
event_task = Some(task);
event_stream_release = Some(release);
session_id
};
apply_submit_runtime_selections(
client,
session_id,
provider_plugin_id,
model_id,
None,
reasoning_effort.clone(),
reasoning_summary.clone(),
)
.await
.map_err(|error| submission_stage_error("applying model/agent/reasoning selections", error))?;
let outcome = execute_session_view_action(
client,
SessionViewAction::SubmitMessage {
submission_id,
session_id: Some(session_id),
launch_working_directory: None,
text: message,
placement,
execution: Box::new(execution),
},
)
.await
.map_err(|error| submission_stage_error("waiting for message acceptance", error))?;
let receipt = submission_receipt_from_action(outcome)?;
Ok(SubmitMessageResult {
session_id,
created_session,
submission: client
.get_submission(session_id, receipt.submission_id.clone())
.await,
receipt,
committed_agent_id: agent_id,
committed_reasoning_effort_generation: reasoning_effort_generation,
event_task,
event_stream_release,
})
}
fn submission_receipt_from_action(
outcome: SessionViewActionOutcome,
) -> Result<bcode_session_models::SubmissionReceipt, ClientError> {
match outcome {
SessionViewActionOutcome::SubmissionAccepted { receipt, .. } => Ok(receipt),
_ => Err(ClientError::UnexpectedResponse),
}
}
async fn optional_client_result<T>(
future: impl std::future::Future<Output = Result<T, ClientError>>,
) -> (Option<T>, Option<String>) {
match future.await {
Ok(value) => (Some(value), None),
Err(error) => (None, Some(error.to_string())),
}
}
async fn load_draft_status(
client: &BcodeClient,
launch_working_directory: std::path::PathBuf,
) -> TuiEffectResult {
let (model, model_error) =
Box::pin(optional_client_result(client.default_model_status())).await;
let draft_scope = ComposerDraftScope::DraftSession {
launch_working_directory,
};
let (composer_draft, draft_error) =
Box::pin(optional_client_result(client.composer_draft(draft_scope))).await;
TuiEffectResult::DraftStatusLoaded {
daemon_connected: model.is_some() || composer_draft.is_some(),
model,
composer_draft: composer_draft.flatten(),
error: model_error.or(draft_error),
}
}
async fn load_plugin_session_status(
client: &BcodeClient,
session_id: SessionId,
) -> (
Vec<bcode_session_view_models::PluginStatusView>,
Option<String>,
) {
let services = match client.plugin_services().await {
Ok(services) => services,
Err(error) => return (Vec::new(), Some(error.to_string())),
};
let mut contributions = Vec::new();
let mut first_error = None;
for service in services
.into_iter()
.filter(|service| service.interface_id == bcode_plugin_sdk::SESSION_STATUS_INTERFACE_ID)
{
let plugin_id = service.plugin_id.clone();
let payload =
match serde_json::to_vec(&bcode_plugin_sdk::SessionStatusRequest { session_id }) {
Ok(payload) => payload,
Err(error) => {
first_error.get_or_insert_with(|| error.to_string());
continue;
}
};
match client
.invoke_plugin_service(
service.plugin_id,
bcode_plugin_sdk::SESSION_STATUS_INTERFACE_ID.to_owned(),
bcode_plugin_sdk::OP_SESSION_STATUS.to_owned(),
payload,
)
.await
{
Ok(response) if response.error.is_none() => {
match serde_json::from_slice::<bcode_plugin_sdk::SessionStatusResponse>(
&response.payload,
) {
Ok(response) => {
contributions.extend(response.contribution.map(|contribution| {
bcode_session_view_models::PluginStatusView {
plugin_id: plugin_id.clone(),
note_id: contribution.contribution_id,
text: contribution.text,
priority: contribution.priority,
metadata: contribution.metadata,
}
}));
}
Err(error) => {
first_error.get_or_insert_with(|| error.to_string());
}
}
}
Ok(response) => {
if let Some(error) = response.error {
first_error.get_or_insert(error.message);
}
}
Err(error) if error.is_optional_domain_unavailable() => {}
Err(error) => {
first_error.get_or_insert_with(|| error.to_string());
}
}
}
contributions.sort_by_key(|contribution| contribution.priority);
(contributions, first_error)
}
#[cfg(any(
feature = "static-bundled-code-review-plugin",
feature = "static-bundled-filesystem-plugin",
feature = "static-bundled-plugins",
feature = "static-bundled-workflow-plugin"
))]
fn interaction_adapter_for_exchange(
producer_id: &str,
schema: &str,
schema_version: u32,
platform_id: &str,
) -> Option<bcode_plugin_sdk::interaction::PluginInteractionAdapterCapability> {
bcode_bundled_plugins::interaction_adapter(producer_id, schema, schema_version, platform_id)
}
#[cfg(not(any(
feature = "static-bundled-code-review-plugin",
feature = "static-bundled-filesystem-plugin",
feature = "static-bundled-plugins",
feature = "static-bundled-workflow-plugin"
)))]
const fn interaction_adapter_for_exchange(
_producer_id: &str,
_schema: &str,
_schema_version: u32,
_platform_id: &str,
) -> Option<bcode_plugin_sdk::interaction::PluginInteractionAdapterCapability> {
None
}
pub async fn load_pending_interactions(
client: &BcodeClient,
session_id: SessionId,
) -> Result<Vec<bcode_session_view_models::InteractionViewSummary>, ClientError> {
let mut interactions = Vec::new();
for request in client
.list_pending_tool_exchanges()
.await?
.into_iter()
.filter(|request| request.is_addressed_to(session_id))
{
let exchange = request.request;
let interaction_id = exchange.exchange_id.clone();
let producer_id = exchange.producer_id.clone();
let exchange_schema = exchange.schema.clone();
let exchange_schema_version = exchange.schema_version;
let snapshot = exchange.payload;
let adapter = interaction_adapter_for_exchange(
&exchange.producer_id,
&exchange.schema,
exchange.schema_version,
"tui",
);
let kind = adapter.as_ref().map_or_else(
|| exchange.schema.clone(),
|adapter| adapter.interaction_kind.clone(),
);
interactions.push(bcode_session_view_models::InteractionViewSummary {
interaction_id,
producer_id: Some(producer_id),
exchange_schema: Some(exchange_schema),
exchange_schema_version: Some(exchange_schema_version),
kind,
tool_call_id: Some(exchange.invocation_id),
title: Some(exchange.producer_id),
required: exchange.response_policy
== bcode_session_models::ToolExchangeResponsePolicy::Required,
snapshot: Some(snapshot),
state: bcode_session_view_models::InteractionViewState::Pending,
status_detail: None,
resolved: false,
resolution: None,
});
}
Ok(interactions)
}
const fn active_turn_cancel_action(session_id: SessionId) -> SessionViewAction {
SessionViewAction::CancelTurn {
session_id,
clear_queue: false,
}
}
async fn load_session_status(client: &BcodeClient, session_id: SessionId) -> TuiEffectResult {
let (model, model_error) = Box::pin(optional_client_result(
client.session_model_status(session_id),
))
.await;
let (
(active_skills, skills_error),
(runtime_work, runtime_work_error),
(interactions, interactions_error),
(plugin_status, plugin_error),
) = tokio::join!(
optional_client_result(client.active_skills(session_id)),
optional_client_result(client.list_runtime_work(session_id)),
optional_client_result(load_pending_interactions(client, session_id)),
load_plugin_session_status(client, session_id),
);
TuiEffectResult::SessionStatusLoaded {
daemon_connected: model.is_some() || active_skills.is_some() || runtime_work.is_some(),
session_id,
hydration: Box::new(SessionStatusHydration {
model,
active_skills,
runtime_work,
interactions,
plugin_status,
error: model_error
.or(skills_error)
.or(runtime_work_error)
.or(interactions_error)
.or(plugin_error),
}),
}
}
#[cfg(test)]
mod projection_paging_tests {
#[test]
fn active_turn_cancel_preserves_all_pending_queue_items() {
let session_id = SessionId::new();
assert_eq!(
active_turn_cancel_action(session_id),
SessionViewAction::CancelTurn {
session_id,
clear_queue: false
}
);
}
use super::*;
use bcode_session_models::{
CURRENT_SESSION_EVENT_SCHEMA_VERSION, ClientId, ProjectionSourceRange, ProjectionWindow,
SessionEvent, SessionEventKind,
};
fn projection_page(
session_id: SessionId,
start: u64,
end: u64,
has_more: bool,
) -> bcode_session_models::ProjectionHistoryPage {
bcode_session_models::ProjectionHistoryPage {
session_id,
window: ProjectionWindow {
projection: SessionProjectionKind::Transcript,
transcript_items: Vec::new(),
source_range: Some(ProjectionSourceRange {
start_sequence: start,
end_sequence: end,
}),
has_older: has_more,
has_newer: has_more,
scanned_events: usize::try_from(end - start + 1).expect("small test page"),
},
events: (start..=end)
.map(|sequence| SessionEvent {
schema_version: CURRENT_SESSION_EVENT_SCHEMA_VERSION,
sequence,
timestamp_ms: sequence,
session_id,
provenance: None,
kind: SessionEventKind::ClientAttached {
client_id: ClientId::new(),
},
})
.collect(),
}
}
fn queue_test_hello() -> bcode_ipc::ResponsePayload {
bcode_ipc::ResponsePayload::Hello {
protocol_version: bcode_ipc::ProtocolVersion::current(),
client_id: ClientId::new(),
daemon: bcode_ipc::DaemonStatus {
namespace: bcode_ipc::daemon_namespace(),
protocol_version: u32::from(bcode_ipc::ProtocolVersion::current().0),
artifact_id: Some(bcode_ipc::ArtifactId::current()),
executable_digest: Some(
bcode_daemon_lifecycle::current_executable_identity()
.unwrap()
.1,
),
build_fingerprint: bcode_ipc::BUILD_FINGERPRINT.to_owned(),
storage_writer_epoch: Some(
bcode_session_models::CURRENT_SESSION_STORAGE_WRITER_EPOCH,
),
session_event_schema_version: Some(CURRENT_SESSION_EVENT_SCHEMA_VERSION),
state_location_id: Some(bcode_ipc::state_location_id()),
..bcode_ipc::DaemonStatus::default()
},
}
}
async fn respond_to_prepare(
listener: bcode_ipc::LocalIpcListener,
session_id: SessionId,
submission_id: bcode_session_models::SubmissionId,
confirmation: bcode_session_models::SubmissionResumeConfirmation,
) {
let mut prepared = false;
let mut finished = false;
while !finished {
let mut stream = listener.accept().await.unwrap();
loop {
let Ok(envelope) = bcode_ipc::recv_envelope(&mut stream).await else {
break;
};
let request = bcode_ipc::decode_request(&envelope.payload).unwrap();
let response = match request {
bcode_ipc::Request::Hello { .. } => queue_test_hello(),
bcode_ipc::Request::PrepareSubmissionResume {
session_id: requested,
submission_id: requested_id,
expected_accepted_sequence,
} => {
assert_eq!(requested, session_id);
assert_eq!(requested_id, submission_id);
assert_eq!(expected_accepted_sequence, 17);
assert!(!prepared);
prepared = true;
bcode_ipc::ResponsePayload::SubmissionResumePrepared {
confirmation: confirmation.clone(),
}
}
bcode_ipc::Request::SubmissionQueueStatus {
session_id: requested,
} => {
assert!(prepared);
assert_eq!(requested, session_id);
finished = true;
bcode_ipc::ResponsePayload::SubmissionQueueStatus {
status: bcode_session_models::SubmissionQueueStatus {
oldest: None,
blocker: None,
},
}
}
other => panic!("prepare must not mutate or scan history: {other:?}"),
};
let reply = bcode_ipc::response_envelope(
envelope.request_id,
&bcode_ipc::Response::Ok(response),
)
.unwrap();
bcode_ipc::send_envelope(&mut stream, &reply).await.unwrap();
if finished {
break;
}
}
}
}
#[tokio::test]
async fn targeted_prepare_dispatches_read_only_identity_then_bounded_status() {
use bcode_session_models::*;
let session_id = SessionId::new();
let submission_id = SubmissionId("exact-pending".to_owned());
let confirmation = SubmissionResumeConfirmation {
version: 1,
submission_id: submission_id.clone(),
expected_accepted_sequence: 17,
descriptor: SubmissionExecutionDescriptor {
version: 2,
provider: "provider".to_owned(),
model: "model".to_owned(),
reasoning: None,
model_profile: Some("selected-profile".to_owned()),
auth_profile: None,
config_identity: "digest".to_owned(),
recovery_recipe: None,
},
};
let expected = confirmation.clone();
let socket_dir = tempfile::tempdir().unwrap();
let endpoint = bcode_ipc::IpcEndpoint::unix_socket(socket_dir.path().join("queue.sock"));
let listener = bcode_ipc::LocalIpcListener::bind(&endpoint).unwrap();
let responder = tokio::spawn(respond_to_prepare(
listener,
session_id,
submission_id,
confirmation,
));
let effect = TuiEffect::QueueRecovery {
session_id,
generation: 0,
action: Box::new(SessionViewAction::PrepareSubmissionResume {
session_id,
submission_id: expected.submission_id.clone(),
expected_accepted_sequence: 17,
}),
};
let (sender, _receiver) = tokio::sync::mpsc::channel(1);
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
effect.run(BcodeClient::new(endpoint), sender),
)
.await
.unwrap();
responder.await.unwrap();
let TuiEffectResult::QueueRecovery { result, .. } = result else {
panic!("expected recovery result");
};
let Ok((SessionViewActionOutcome::SubmissionResumePrepared { confirmation }, status)) =
*result
else {
panic!("expected read-only prepared confirmation");
};
assert_eq!(confirmation, expected);
assert!(status.oldest.is_none());
}
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn scheduled_history_effect_fetches_bounded_page_over_ipc() {
use bcode_session_models::{ProjectionHistoryPage, ProjectionWindow};
let session_id = SessionId::new();
let socket_dir = tempfile::tempdir().expect("socket directory");
let endpoint = bcode_ipc::IpcEndpoint::unix_socket(socket_dir.path().join("history.sock"));
let listener = bcode_ipc::LocalIpcListener::bind(&endpoint).expect("IPC listener");
let responder = tokio::spawn(async move {
let mut stream = listener.accept().await.expect("history client");
let mut observed_request = None;
loop {
let envelope = bcode_ipc::recv_envelope(&mut stream)
.await
.expect("client request");
let request = bcode_ipc::decode_request(&envelope.payload).expect("decode request");
let response = match request {
bcode_ipc::Request::Hello { .. } => {
bcode_ipc::Response::Ok(bcode_ipc::ResponsePayload::Hello {
protocol_version: bcode_ipc::ProtocolVersion::current(),
client_id: ClientId::new(),
daemon: bcode_ipc::DaemonStatus {
namespace: bcode_ipc::daemon_namespace(),
protocol_version: u32::from(
bcode_ipc::ProtocolVersion::current().0,
),
artifact_id: Some(bcode_ipc::ArtifactId::current()),
executable_digest: Some(
bcode_daemon_lifecycle::current_executable_identity()
.expect("test executable identity")
.1,
),
build_fingerprint: bcode_ipc::BUILD_FINGERPRINT.to_owned(),
storage_writer_epoch: Some(
bcode_ipc::CURRENT_SESSION_STORAGE_WRITER_EPOCH,
),
session_event_schema_version: Some(
CURRENT_SESSION_EVENT_SCHEMA_VERSION,
),
state_location_id: Some(bcode_ipc::state_location_id()),
..bcode_ipc::DaemonStatus::default()
},
})
}
bcode_ipc::Request::SessionProjectionHistoryPage {
session_id: requested,
request,
} => {
assert_eq!(requested, session_id);
assert_eq!(request.anchor, ProjectionWindowAnchor::BeforeSequence(11));
assert_eq!(request.direction, ProjectionWindowDirection::Backward);
assert_eq!(request.projection, SessionProjectionKind::Transcript);
assert!(
request.limits.max_events_scanned <= TRANSCRIPT_PAGE_MAX_EVENTS_SCANNED
);
observed_request = Some(request);
bcode_ipc::Response::Ok(
bcode_ipc::ResponsePayload::SessionProjectionHistoryPage {
page: ProjectionHistoryPage {
session_id,
window: ProjectionWindow {
projection: SessionProjectionKind::Transcript,
transcript_items: Vec::new(),
source_range: Some(ProjectionSourceRange {
start_sequence: 7,
end_sequence: 10,
}),
has_older: true,
has_newer: true,
scanned_events: 4,
},
events: projection_page(session_id, 7, 10, true).events,
},
},
)
}
other => panic!("unexpected history request: {other:?}"),
};
let reply = bcode_ipc::response_envelope(envelope.request_id, &response)
.expect("encode response");
bcode_ipc::send_envelope(&mut stream, &reply)
.await
.expect("send response");
if observed_request.is_some() {
break;
}
}
observed_request.expect("projection request")
});
let effect = TuiEffect::LoadOlderHistory {
session_id,
cursor: SessionHistoryCursor { sequence: 10 },
generation: 42,
width_columns: 80,
};
let (sender, _receiver) = tokio::sync::mpsc::channel(1);
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
effect.run(BcodeClient::new(endpoint), sender),
)
.await
.expect("history fetch timeout");
let request = responder.await.expect("IPC responder");
assert_eq!(request.target.min_items, Some(TRANSCRIPT_PAGE_TARGET_ITEMS));
match result {
TuiEffectResult::OlderHistoryLoaded {
session_id: returned,
cursor,
generation,
result,
} => {
assert_eq!(returned, session_id);
assert_eq!(cursor.sequence, 10);
assert_eq!(generation, 42);
let (page, _) = result.expect("bounded history page");
assert_eq!(page.events.len(), 4);
assert_eq!(page.events.first().map(|event| event.sequence), Some(7));
assert!(page.has_more);
}
_ => panic!("unexpected effect result"),
}
}
#[test]
fn projection_pages_preserve_non_presenting_source_progress_in_both_directions() {
let session_id = SessionId::new();
let older = projection_page_as_history_page(
session_id,
SessionHistoryCursor { sequence: 10 },
true,
projection_page(session_id, 7, 10, true),
)
.expect("older non-presenting page");
assert_eq!(older.events.len(), 4);
assert!(older.has_more);
let newer = projection_page_as_history_page(
session_id,
SessionHistoryCursor { sequence: 12 },
false,
projection_page(session_id, 12, 15, false),
)
.expect("newer non-presenting page");
assert_eq!(newer.events.len(), 4);
assert!(!newer.has_more);
}
#[test]
fn projection_pages_reject_wrong_boundary_or_stale_session() {
let session_id = SessionId::new();
let page = projection_page(session_id, 7, 10, true);
assert!(
projection_page_as_history_page(
session_id,
SessionHistoryCursor { sequence: 11 },
true,
page.clone(),
)
.is_err()
);
assert!(
projection_page_as_history_page(
SessionId::new(),
SessionHistoryCursor { sequence: 10 },
true,
page,
)
.is_err()
);
}
}
#[cfg(test)]
mod progress_routing_tests {
use super::*;
fn picker_effect() -> TuiEffect {
TuiEffect::LoadSessionPicker {
generation: 1,
scope: bcode_session_models::SessionDiscoveryScope::AllWorkingDirectories,
query: bcode_session_models::SessionCatalogQuery::default(),
cursor: None,
}
}
#[test]
fn session_picker_subscription_replacement_and_cancellation_are_coalesced() {
let mut queue = TuiEffectQueue::default();
queue.replace(picker_effect());
queue.replace(picker_effect());
let (effects, _) = queue.drain_runtime();
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0].0, EffectSchedule::Replace));
queue.replace(picker_effect());
queue.cancel(picker_effect());
let (effects, _) = queue.drain_runtime();
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0].0, EffectSchedule::Cancel));
}
#[test]
fn application_failures_do_not_claim_the_daemon_is_offline() {
let server_error = ClientError::Server {
code: "permission_denied".to_owned(),
message: "request rejected".to_owned(),
};
assert!(matches!(
DaemonObservation::from_client_result::<()>(&Err(server_error)),
DaemonObservation::Failed(_)
));
let optional_domain = ClientError::Server {
code: "workflow_capability_unavailable".to_owned(),
message: "maintenance required".to_owned(),
};
assert!(optional_domain.is_optional_domain_unavailable());
assert_eq!(
DaemonObservation::from_tui_result::<()>(&Err(TuiError::SessionUnavailable {
session_id: SessionId::new(),
reason: "owned elsewhere".to_owned(),
})),
DaemonObservation::None
);
}
#[test]
fn transport_unavailability_remains_connectivity_evidence() {
let error = ClientError::Transport(bcode_ipc::IpcTransportError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"offline",
)));
assert!(matches!(
DaemonObservation::from_client_result::<()>(&Err(error)),
DaemonObservation::Unavailable(_)
));
}
#[test]
fn runtime_effect_drain_preserves_every_ordered_note_per_session() {
let session_id = SessionId::new();
let mut queue = TuiEffectQueue::default();
for note_id in ["0001", "0002", "0003"] {
queue.start_ordered(TuiEffect::AppendPresentationNote {
session_id,
source_id: "test".to_owned(),
note_id: note_id.to_owned(),
text: note_id.to_owned(),
format: bcode_command::CommandTextFormat::PlainText,
});
}
let (effects, notes) = queue.drain_runtime();
assert!(effects.is_empty());
let queued = notes
.get(&session_id)
.expect("ordered session queue")
.iter()
.map(TuiEffect::key)
.collect::<Vec<_>>();
assert_eq!(
queued,
vec![
EffectKey::AppendPresentationNote(session_id, "0001".to_owned()),
EffectKey::AppendPresentationNote(session_id, "0002".to_owned()),
EffectKey::AppendPresentationNote(session_id, "0003".to_owned()),
]
);
}
}