Skip to content

Commit 63a7d54

Browse files
authored
feat(agentic,web-ui): user steering with pending queue and agent runtime refactor (#610)
Backend changes: - Remove orphan ModelRound struct and model_round.rs module; the actual round lifecycle is fully tracked through execution engine state - Simplify DialogTurn / DialogTurnState — only TurnStats and new_turn_id helper survive; on-disk shape lives in SessionState as before - Refactor execution engine, round_executor, and stream_processor to align with the simplified runtime model - Add user-steering support in coordinator and scheduler: mid-turn messages from the user can be injected without aborting the current round - Improve agentic tool pipeline (pipeline/tool_pipeline.rs, types.rs), deep-review policy, and review specialist agents - Anthropic stream handler and tool-call accumulator hardening - MCP server manager reconnect and interaction improvements - Snapshot service dedup / reference-safety fixes - Various service cleanups: session manager, persistence, side_question, project context, search, workspace Frontend changes: - Add PendingQueuePanel: shows queued user messages above the chat input; supports inline edit, "send now" mid-turn steering, and delete - Add UserSteeringBubble: renders a user-steering flow item as a normal user bubble inside the active model round - Add PendingQueueModule: manages per-session pending message queue state - Add modelRoundItemGrouping: groups consecutive flow items by model round for virtualised rendering - Update FlowChatManager, EventHandlerModule, TextChunkModule, and MessageModule to handle steering events and pending queue lifecycle - Update modernFlowChatStore and FlowChatContext for new item types - Update AgentAPI with steering / queue endpoints - Update flow-chat types with FlowUserSteeringItem - Update AgentCompanionDesktopPet click-through and pixel-pet UI - Update ChatInput / ChatInputPixelPet for queue-aware send behaviour - Theme minor colour fixes across all presets - i18n: add pending-queue and steering keys for en-US, zh-CN, zh-TW; remove stale error keys - Remove deprecated SessionConfig option and related locale keys
1 parent 651530b commit 63a7d54

128 files changed

Lines changed: 4505 additions & 1728 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/apps/desktop/src/api/agentic_api.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,26 @@ pub struct CancelDialogTurnRequest {
156156
pub dialog_turn_id: String,
157157
}
158158

159+
#[derive(Debug, Deserialize)]
160+
#[serde(rename_all = "camelCase")]
161+
pub struct SteerDialogTurnRequest {
162+
pub session_id: String,
163+
pub dialog_turn_id: String,
164+
/// Rendered content delivered to the model. When omitted by the caller this
165+
/// equals the displayed user text.
166+
pub content: String,
167+
/// Original user text for UI rendering (defaults to `content`).
168+
#[serde(default)]
169+
pub display_content: Option<String>,
170+
}
171+
172+
#[derive(Debug, Serialize)]
173+
#[serde(rename_all = "camelCase")]
174+
pub struct SteerDialogTurnResponse {
175+
pub success: bool,
176+
pub steering_id: String,
177+
}
178+
159179
#[derive(Debug, Deserialize)]
160180
#[serde(rename_all = "camelCase")]
161181
pub struct CancelSessionRequest {
@@ -615,6 +635,40 @@ pub async fn cancel_dialog_turn(
615635
})
616636
}
617637

638+
#[tauri::command]
639+
pub async fn steer_dialog_turn(
640+
scheduler: State<'_, Arc<DialogScheduler>>,
641+
request: SteerDialogTurnRequest,
642+
) -> Result<SteerDialogTurnResponse, String> {
643+
let SteerDialogTurnRequest {
644+
session_id,
645+
dialog_turn_id,
646+
content,
647+
display_content,
648+
} = request;
649+
650+
let trimmed = content.trim();
651+
if trimmed.is_empty() {
652+
return Err("Steering content cannot be empty".to_string());
653+
}
654+
655+
let outcome = scheduler
656+
.submit_steering(session_id, dialog_turn_id, content, display_content)
657+
.await
658+
.map_err(|e| format!("Failed to steer dialog turn: {}", e))?;
659+
660+
let steering_id = match outcome {
661+
bitfun_core::agentic::coordination::DialogSteerOutcome::Buffered {
662+
steering_id, ..
663+
} => steering_id,
664+
};
665+
666+
Ok(SteerDialogTurnResponse {
667+
success: true,
668+
steering_id,
669+
})
670+
}
671+
618672
#[tauri::command]
619673
pub async fn cancel_session(
620674
coordinator: State<'_, Arc<ConversationCoordinator>>,

src/apps/desktop/src/api/btw_api.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ pub async fn btw_cancel(
4848
return Err("requestId is required".to_string());
4949
}
5050

51-
state.side_question_runtime.cancel(&request.request_id).await;
51+
state
52+
.side_question_runtime
53+
.cancel(&request.request_id)
54+
.await;
5255
if let Some(active_turn) = state
5356
.side_question_runtime
5457
.get_btw_turn(&request.request_id)
@@ -58,7 +61,10 @@ pub async fn btw_cancel(
5861
.cancel_dialog_turn(&active_turn.session_id, &active_turn.turn_id)
5962
.await
6063
.map_err(|e| e.to_string())?;
61-
state.side_question_runtime.remove(&request.request_id).await;
64+
state
65+
.side_question_runtime
66+
.remove(&request.request_id)
67+
.await;
6268
}
6369
Ok(())
6470
}
@@ -110,7 +116,10 @@ pub async fn btw_ask_stream(
110116
let coordinator = coordinator.inner().clone();
111117
tokio::spawn(async move {
112118
loop {
113-
let Some(session) = coordinator.get_session_manager().get_session(&child_session_id) else {
119+
let Some(session) = coordinator
120+
.get_session_manager()
121+
.get_session(&child_session_id)
122+
else {
114123
runtime.remove(&request_id).await;
115124
break;
116125
};

src/apps/desktop/src/api/commands.rs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2028,8 +2028,8 @@ fn load_pet_manifest_from_bytes(bytes: &[u8]) -> Result<(serde_json::Value, Path
20282028
fn load_pet_package_source(source_path: &Path) -> Result<PetPackageSource, String> {
20292029
if source_path.is_dir() {
20302030
let pet_json_path = source_path.join("pet.json");
2031-
let pet_json = std::fs::read(&pet_json_path)
2032-
.map_err(|e| format!("Failed to read pet.json: {}", e))?;
2031+
let pet_json =
2032+
std::fs::read(&pet_json_path).map_err(|e| format!("Failed to read pet.json: {}", e))?;
20332033
let (_, spritesheet_name) = load_pet_manifest_from_bytes(&pet_json)?;
20342034
let spritesheet_path = source_path.join(&spritesheet_name);
20352035
let spritesheet = std::fs::read(&spritesheet_path)
@@ -2043,8 +2043,8 @@ fn load_pet_package_source(source_path: &Path) -> Result<PetPackageSource, Strin
20432043

20442044
let file = std::fs::File::open(source_path)
20452045
.map_err(|e| format!("Failed to open pet zip package: {}", e))?;
2046-
let mut archive = zip::ZipArchive::new(file)
2047-
.map_err(|e| format!("Failed to read pet zip package: {}", e))?;
2046+
let mut archive =
2047+
zip::ZipArchive::new(file).map_err(|e| format!("Failed to read pet zip package: {}", e))?;
20482048

20492049
let mut manifest_index = None;
20502050
for index in 0..archive.len() {
@@ -2098,15 +2098,22 @@ fn companion_user_packages_dir(state: &AppState) -> PathBuf {
20982098
.join("agent-companions")
20992099
}
21002100

2101-
fn pet_package_dto_from_dir(dir: &Path, source: &str) -> Result<AgentCompanionPetPackageDto, String> {
2101+
fn pet_package_dto_from_dir(
2102+
dir: &Path,
2103+
source: &str,
2104+
) -> Result<AgentCompanionPetPackageDto, String> {
21022105
let pet_json_path = dir.join("pet.json");
21032106
let pet_json = std::fs::read(&pet_json_path)
21042107
.map_err(|e| format!("Failed to read {}: {}", pet_json_path.display(), e))?;
21052108
let (manifest, spritesheet_rel_path) = load_pet_manifest_from_bytes(&pet_json)?;
21062109
let raw_id = manifest
21072110
.get("id")
21082111
.and_then(|value| value.as_str())
2109-
.unwrap_or_else(|| dir.file_name().and_then(|name| name.to_str()).unwrap_or("pet"));
2112+
.unwrap_or_else(|| {
2113+
dir.file_name()
2114+
.and_then(|name| name.to_str())
2115+
.unwrap_or("pet")
2116+
});
21102117
let display_name = manifest
21112118
.get("displayName")
21122119
.and_then(|value| value.as_str())
@@ -2121,7 +2128,10 @@ fn pet_package_dto_from_dir(dir: &Path, source: &str) -> Result<AgentCompanionPe
21212128
.filter(|value| !value.is_empty());
21222129
let spritesheet_path = dir.join(&spritesheet_rel_path);
21232130
if !spritesheet_path.is_file() {
2124-
return Err(format!("Spritesheet not found: {}", spritesheet_path.display()));
2131+
return Err(format!(
2132+
"Spritesheet not found: {}",
2133+
spritesheet_path.display()
2134+
));
21252135
}
21262136
let spritesheet_file_name = spritesheet_rel_path
21272137
.file_name()
@@ -2154,7 +2164,11 @@ fn scan_pet_package_dirs(root: &Path, source: &str) -> Vec<AgentCompanionPetPack
21542164
Err(err) => warn!("Skipping invalid Agent companion pet package: {}", err),
21552165
}
21562166
}
2157-
pets.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
2167+
pets.sort_by(|a, b| {
2168+
a.display_name
2169+
.to_lowercase()
2170+
.cmp(&b.display_name.to_lowercase())
2171+
});
21582172
pets
21592173
}
21602174

@@ -2262,7 +2276,9 @@ pub async fn delete_agent_companion_pet_package(
22622276
.map_err(|e| format!("Pet package path not found: {}", e))?;
22632277

22642278
if !resolved.starts_with(&root) {
2265-
return Err("Refusing to delete path outside imported Agent companion packages".to_string());
2279+
return Err(
2280+
"Refusing to delete path outside imported Agent companion packages".to_string(),
2281+
);
22662282
}
22672283
if !resolved.is_dir() {
22682284
return Err("Pet package is not a directory".to_string());

src/apps/desktop/src/api/config_api.rs

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,7 @@ pub async fn set_mode_config(
250250
)
251251
.await
252252
{
253-
Ok(_) => {
254-
Ok(format!("Mode '{}' configuration set successfully", mode_id))
255-
}
253+
Ok(_) => Ok(format!("Mode '{}' configuration set successfully", mode_id)),
256254
Err(e) => {
257255
error!(
258256
"Failed to set mode config: mode_id={}, error={}",
@@ -273,12 +271,10 @@ pub async fn reset_mode_config(
273271
)
274272
.await
275273
{
276-
Ok(_) => {
277-
Ok(format!(
278-
"Mode '{}' configuration reset successfully",
279-
mode_id
280-
))
281-
}
274+
Ok(_) => Ok(format!(
275+
"Mode '{}' configuration reset successfully",
276+
mode_id
277+
)),
282278
Err(e) => {
283279
error!(
284280
"Failed to reset mode config: mode_id={}, error={}",
@@ -348,12 +344,10 @@ pub async fn set_subagent_config(
348344
let config_value = to_json_value(&config, "subagent config")?;
349345

350346
match config_service.set_config(&path, config_value).await {
351-
Ok(_) => {
352-
Ok(format!(
353-
"SubAgent '{}' configuration set successfully",
354-
subagent_id
355-
))
356-
}
347+
Ok(_) => Ok(format!(
348+
"SubAgent '{}' configuration set successfully",
349+
subagent_id
350+
)),
357351
Err(e) => {
358352
error!(
359353
"Failed to set subagent config: subagent_id={}, enabled={}, error={}",

src/apps/desktop/src/api/search_api.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ async fn workspace_search_unavailable_message(root_path: &str) -> Option<String>
4949
}
5050

5151
pub(crate) async fn should_use_workspace_search(root_path: &str) -> bool {
52-
workspace_search_unavailable_message(root_path).await.is_none()
52+
workspace_search_unavailable_message(root_path)
53+
.await
54+
.is_none()
5355
}
5456

5557
pub(crate) async fn search_file_contents_via_workspace_search(

src/apps/desktop/src/api/snapshot_service.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
use bitfun_core::infrastructure::try_get_path_manager_arc;
44
use bitfun_core::service::remote_ssh::workspace_state::is_remote_path;
55
use bitfun_core::service::snapshot::{
6-
OperationType, SnapshotConfig, SnapshotManager, ensure_snapshot_manager_for_workspace,
7-
get_snapshot_manager_for_workspace, initialize_snapshot_manager_for_workspace,
6+
ensure_snapshot_manager_for_workspace, get_snapshot_manager_for_workspace,
7+
initialize_snapshot_manager_for_workspace, OperationType, SnapshotConfig, SnapshotManager,
88
};
99
use log::{info, warn};
1010
use serde::{Deserialize, Serialize};

src/apps/desktop/src/api/tool_api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ use std::collections::HashMap;
66
use std::path::PathBuf;
77

88
use bitfun_core::agentic::{
9-
WorkspaceBinding,
109
tools::framework::ToolUseContext,
1110
tools::{get_all_tools, get_readonly_tools},
1211
workspace::{local_workspace_services, remote_workspace_services},
12+
WorkspaceBinding,
1313
};
1414
use bitfun_core::service::remote_ssh::workspace_state::{
1515
get_remote_workspace_manager, lookup_remote_connection, resolve_workspace_session_identity,

src/apps/desktop/src/api/workspace_activation.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::api::app_state::AppState;
2-
use bitfun_core::service::search::workspace_search_runtime_available;
32
use bitfun_core::service::remote_ssh::workspace_state::is_remote_path;
3+
use bitfun_core::service::search::workspace_search_runtime_available;
44
use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind};
55
use log::{debug, info, warn};
66
use std::path::{Path, PathBuf};

src/apps/desktop/src/lib.rs

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ pub async fn run() {
141141
}
142142
startup_timings.record_elapsed("init_function_agents", step_started);
143143

144-
let workspace_search_enabled = bitfun_core::service::search::workspace_search_feature_enabled().await;
144+
let workspace_search_enabled =
145+
bitfun_core::service::search::workspace_search_feature_enabled().await;
145146
let startup_flashgrep_path = configure_workspace_search_daemon_env();
146147

147148
let step_started = Instant::now();
@@ -395,6 +396,7 @@ pub async fn run() {
395396
api::agentic_api::compact_session,
396397
api::agentic_api::ensure_assistant_bootstrap,
397398
api::agentic_api::cancel_dialog_turn,
399+
api::agentic_api::steer_dialog_turn,
398400
api::agentic_api::cancel_session,
399401
api::agentic_api::set_subagent_timeout,
400402
api::agentic_api::delete_session,
@@ -870,7 +872,6 @@ async fn init_agentic_system() -> anyhow::Result<(
870872
Arc<bitfun_core::service::token_usage::TokenUsageService>,
871873
)> {
872874
use bitfun_core::agentic::*;
873-
use bitfun_core::service::config::get_global_config_service;
874875

875876
let ai_client_factory = AIClientFactory::get_global().await?;
876877

@@ -908,27 +909,13 @@ async fn init_agentic_system() -> anyhow::Result<(
908909
event_queue.clone(),
909910
tool_pipeline.clone(),
910911
));
911-
912-
// Get execution config from global settings
913-
let exec_config = match get_global_config_service().await {
914-
Ok(config_service) => {
915-
match config_service.get_config::<bitfun_core::service::config::types::GlobalConfig>(None).await {
916-
Ok(global_config) => execution::ExecutionEngineConfig {
917-
max_rounds: global_config.ai.max_rounds,
918-
..Default::default()
919-
},
920-
Err(_) => Default::default(),
921-
}
922-
},
923-
Err(_) => Default::default(),
924-
};
925-
912+
926913
let execution_engine = Arc::new(execution::ExecutionEngine::new(
927914
round_executor,
928915
event_queue.clone(),
929916
session_manager.clone(),
930917
context_compressor,
931-
exec_config,
918+
execution::ExecutionEngineConfig::default(),
932919
));
933920

934921
let coordinator = Arc::new(coordination::ConversationCoordinator::new(
@@ -959,6 +946,7 @@ async fn init_agentic_system() -> anyhow::Result<(
959946
coordination::DialogScheduler::new(coordinator.clone(), session_manager.clone());
960947
coordinator.set_scheduler_notifier(scheduler.outcome_sender());
961948
coordinator.set_round_preempt_source(scheduler.preempt_monitor());
949+
coordinator.set_round_steering_source(scheduler.steering_monitor());
962950
coordination::set_global_scheduler(scheduler.clone());
963951

964952
let cron_service =

src/apps/desktop/src/theme.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -358,9 +358,8 @@ fn agent_companion_default_position(
358358
.as_ref()
359359
.map(|size| size.height)
360360
.unwrap_or(AGENT_COMPANION_WINDOW_MIN_SIZE);
361-
let x = area_position.x + area_size.width
362-
- window_width
363-
- f64::from(AGENT_COMPANION_WINDOW_MARGIN);
361+
let x =
362+
area_position.x + area_size.width - window_width - f64::from(AGENT_COMPANION_WINDOW_MARGIN);
364363
let y = area_position.y + area_size.height
365364
- window_height
366365
- f64::from(AGENT_COMPANION_WINDOW_MARGIN);

0 commit comments

Comments
 (0)