Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/development/ui-testids-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
| Chat 输入可编辑区域 | `chat-input-textarea` | 富文本可编辑区域。 |
| Chat 发送按钮 | `chat-input-send-btn` | 输入有效时的发送动作。 |
| Chat 取消按钮 | `chat-input-cancel-btn` | 存在时用于取消进行中的发送/生成。 |
| Chat 继续中断任务按钮 | `chat-input-continue-interrupted-btn` | 输入框为空时继续最近一个可恢复的中断 Turn。 |
| Chat 输入工作区条 | `chat-input-workspace-strip` | composer 上方的活动工作区条。 |
| Chat 输入目标切换器 | `chat-input-target-switcher` | 目标/模式切换器。 |
| Chat 输入图片条 | `chat-input-image-strip` | 已附加图片条。 |
Expand Down
1 change: 1 addition & 0 deletions docs/development/ui-testids.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ Avoid adding IDs to these surfaces unless there is a clear automated workflow.
| Chat input editable region | `chat-input-textarea` | Rich text editable region. |
| Chat send button | `chat-input-send-btn` | Send action when input is valid. |
| Chat cancel button | `chat-input-cancel-btn` | Cancels in-progress send/generation when present. |
| Chat continue interrupted button | `chat-input-continue-interrupted-btn` | Resumes the latest recoverably interrupted turn when the composer is empty. |
| Chat input workspace strip | `chat-input-workspace-strip` | Active workspace strip above composer. |
| Chat input target switcher | `chat-input-target-switcher` | Target/mode switcher. |
| Chat input image strip | `chat-input-image-strip` | Attached image strip. |
Expand Down
126 changes: 119 additions & 7 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ use crate::runtime::{
use crate::startup_trace::DesktopStartupTrace;
use bitfun_agent_runtime::deep_review::sanitize_focused_review_public_metadata;
use bitfun_agent_runtime::sdk::{
AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest,
AgentInputAttachment, AgentSessionCreateResult, AgentSessionModeUpdateRequest,
AgentSessionModelSelection, AgentSessionModelSelectionUpdateRequest,
AgentSessionModelUpdateRequest, AgentSubmissionSource, AgentTurnCancellationRequest,
DialogSteerOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey,
PermissionReply, PermissionRequest,
AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRecoveryOutcome,
AgentDialogTurnRecoveryRequest, AgentDialogTurnRequest, AgentInputAttachment,
AgentSessionCreateResult, AgentSessionModeUpdateRequest, AgentSessionModelSelection,
AgentSessionModelSelectionUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource,
AgentTurnCancellationRequest, AgentTurnInterruptionRequest, DialogSteerOutcome,
PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PermissionReply, PermissionRequest,
RuntimeError,
};
use bitfun_core::agentic::agents::AgentSource;
use bitfun_core::agentic::coordination::{
Expand Down Expand Up @@ -65,7 +66,7 @@ use bitfun_core_types::{
WorktreeError, WorktreeErrorCode,
};
use bitfun_product_domains::tool_permissions::PermissionRule;
use bitfun_runtime_ports::{PermissionMode, SessionTurnWindowRequest};
use bitfun_runtime_ports::{PermissionMode, PortErrorKind, SessionTurnWindowRequest};

const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024;
const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024;
Expand Down Expand Up @@ -764,6 +765,20 @@ pub struct CancelDialogTurnRequest {
pub dialog_turn_id: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoverInterruptedDialogTurnRequest {
pub session_id: String,
pub dialog_turn_id: String,
pub execution_generation: u32,
#[serde(default)]
pub workspace_path: Option<String>,
#[serde(default)]
pub remote_connection_id: Option<String>,
#[serde(default)]
pub remote_ssh_host: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SteerDialogTurnRequest {
Expand Down Expand Up @@ -2886,6 +2901,97 @@ pub async fn cancel_dialog_turn(
.map(|_| ())
}

/// Request a recoverable interruption for a native local dialog turn. The
/// command returns only after the old execution has settled and the
/// `dialog-turn-interrupted` event is safe to act on.
#[tauri::command]
pub async fn interrupt_dialog_turn(
runtime: State<'_, DesktopRuntimeContext>,
request: CancelDialogTurnRequest,
) -> Result<(), String> {
let interruption = runtime
.agent_runtime()
.interrupt_turn(AgentTurnInterruptionRequest {
session_id: request.session_id.clone(),
turn_id: request.dialog_turn_id.clone(),
source: Some(AgentSubmissionSource::DesktopUi),
wait_timeout_ms: Some(30_000),
})
.await;
match interruption {
Ok(_) => Ok(()),
// Stop remains universally available. Unsupported recovery surfaces
// (external routes, Goal, remote, non-standard sessions) degrade to
// the existing hard cancellation and emit DialogTurnCancelled.
Err(RuntimeError::Port(error))
if matches!(error.kind, PortErrorKind::InvalidRequest | PortErrorKind::Timeout) => runtime
.agent_runtime()
.cancel_turn(AgentTurnCancellationRequest {
session_id: request.session_id.clone(),
turn_id: Some(request.dialog_turn_id.clone()),
source: Some(AgentSubmissionSource::DesktopUi),
requester_session_id: None,
reason: Some(if error.kind == PortErrorKind::Timeout {
"recoverable interruption timed out".to_string()
} else {
"recoverable interruption unavailable".to_string()
}),
wait_timeout_ms: None,
cancel_descendants: true,
})
.await
.map(|_| ())
.map_err(|fallback_error| {
log::error!(
"Failed to cancel dialog turn after interruption was unavailable: session_id={}, dialog_turn_id={}, error={}",
request.session_id,
request.dialog_turn_id,
fallback_error
);
format!("Failed to cancel dialog turn: {}", fallback_error.into_message())
}),
Err(error) => {
log::error!(
"Failed to interrupt dialog turn: session_id={}, dialog_turn_id={}, error={}",
request.session_id,
request.dialog_turn_id,
error
);
Err(format!("Failed to interrupt dialog turn: {}", error.into_message()))
}
}
}

#[tauri::command]
pub async fn recover_interrupted_dialog_turn(
runtime: State<'_, DesktopRuntimeContext>,
request: RecoverInterruptedDialogTurnRequest,
) -> Result<AgentDialogTurnRecoveryOutcome, String> {
runtime
.agent_runtime()
.recover_interrupted_turn(AgentDialogTurnRecoveryRequest {
session_id: request.session_id.clone(),
turn_id: request.dialog_turn_id.clone(),
execution_generation: request.execution_generation,
workspace_path: request.workspace_path,
remote_connection_id: request.remote_connection_id,
remote_ssh_host: request.remote_ssh_host,
})
.await
.map_err(|error| {
log::error!(
"Failed to recover interrupted dialog turn: session_id={}, dialog_turn_id={}, error={}",
request.session_id,
request.dialog_turn_id,
error
);
format!(
"Failed to recover interrupted dialog turn: {}",
error.into_message()
)
})
}

#[tauri::command]
pub async fn steer_dialog_turn(
runtime: State<'_, DesktopRuntimeContext>,
Expand Down Expand Up @@ -4281,6 +4387,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
recovery: None,
recovery_epoch: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
Expand Down Expand Up @@ -4365,6 +4473,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
recovery: None,
recovery_epoch: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
Expand Down Expand Up @@ -4430,6 +4540,8 @@ mod tests {
token_usage: None,
finish_reason: None,
has_final_response: None,
recovery: None,
recovery_epoch: None,
error: None,
error_detail: None,
status: TurnStatus::Completed,
Expand Down
5 changes: 5 additions & 0 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::LegacyUnaudited,
),
("cancel_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited),
("interrupt_dialog_turn", RemoteWorkspacePolicy::LocalOnly),
(
"cancel_insights_generation",
RemoteWorkspacePolicy::LocalOnly,
Expand Down Expand Up @@ -1401,6 +1402,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"remove_project_permission_grant",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"recover_interrupted_dialog_turn",
RemoteWorkspacePolicy::LocalOnly,
),
(
"clear_project_permission_grants",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,8 @@ pub async fn run() {
api::agentic_api::ensure_assistant_bootstrap,
api::agentic_api::run_init_agents_md,
api::agentic_api::cancel_dialog_turn,
api::agentic_api::interrupt_dialog_turn,
api::agentic_api::recover_interrupted_dialog_turn,
api::agentic_api::steer_dialog_turn,
api::agentic_api::control_deep_review_queue,
api::agentic_api::cancel_session,
Expand Down
Loading