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
35 changes: 31 additions & 4 deletions src/apps/cli/src/peer_host/commands/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ pub(super) async fn require_local_snapshot_workspace(
Ok(())
}

async fn require_complete_rollback_workspace(
request: &Value,
workspace_path: &str,
) -> Result<(), String> {
let is_remote = optional_string(request, "remoteConnectionId").is_some()
|| optional_string(request, "remoteSshHost").is_some()
|| is_remote_path(workspace_path).await;
if is_remote {
return Err(format!(
"Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}"
));
}
Ok(())
}

pub(super) fn snapshot_compatibility_error(error: PortError) -> String {
if error.kind == PortErrorKind::InvalidRequest {
error.message
Expand Down Expand Up @@ -166,7 +181,7 @@ pub(crate) async fn rollback_to_turn(state: &PeerHostState, args: &Value) -> Res
let delete_turns = optional_bool(request, "deleteTurns").unwrap_or(false);

bitfun_agent_runtime::session_control::validate_session_id(&session_id)?;
require_local_snapshot_workspace(request, &workspace_path).await?;
require_complete_rollback_workspace(request, &workspace_path).await?;
let workspace = PathBuf::from(&workspace_path);
let scope = ensure_session_workspace_runtime_ownership(state, request)?;
let session_storage_path = resolved_session_storage_scope(state, scope).await?;
Expand Down Expand Up @@ -305,8 +320,9 @@ mod tests {

use super::{
history_rollback_partial_failure, local_snapshot_session_files,
local_snapshot_session_stats, require_local_snapshot_workspace, rollback_device_events,
rollback_local_workspace_files, snapshot_compatibility_error,
local_snapshot_session_stats, require_complete_rollback_workspace,
require_local_snapshot_workspace, rollback_device_events, rollback_local_workspace_files,
snapshot_compatibility_error,
};

#[derive(Default)]
Expand Down Expand Up @@ -374,12 +390,23 @@ mod tests {
);
}

let rollback_error = require_complete_rollback_workspace(
&json!({ "remoteConnectionId": "remote-1" }),
"/root/repos",
)
.await
.expect_err("complete remote rollback must report missing snapshot coverage");
assert_eq!(
rollback_error,
"Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: /root/repos"
);

let source = include_str!("snapshot.rs");
let rollback_source = &source[source
.find("pub(crate) async fn rollback_to_turn")
.expect("rollback handler must exist")..];
let remote_guard = rollback_source
.find("require_local_snapshot_workspace(request, &workspace_path).await?")
.find("require_complete_rollback_workspace(request, &workspace_path).await?")
.expect("rollback must have an explicit remote guard");
let maintenance = rollback_source
.find("begin_session_maintenance")
Expand Down
17 changes: 13 additions & 4 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1547,8 +1547,8 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
("rollback_miniapp", RemoteWorkspacePolicy::LegacyUnaudited),
("rollback_session", RemoteWorkspacePolicy::LegacyUnaudited),
("rollback_to_turn", RemoteWorkspacePolicy::LegacyUnaudited),
("rollback_session", RemoteWorkspacePolicy::RemoteUnsupported),
("rollback_to_turn", RemoteWorkspacePolicy::RemoteUnsupported),
("run_init_agents_md", RemoteWorkspacePolicy::LegacyUnaudited),
("run_system_command", RemoteWorkspacePolicy::LegacyUnaudited),
(
Expand Down Expand Up @@ -2025,6 +2025,17 @@ mod tests {
}
}

#[test]
fn complete_rollback_commands_explicitly_reject_remote_workspaces() {
for command in ["rollback_session", "rollback_to_turn"] {
assert_eq!(
remote_workspace_policy(command),
Some(RemoteWorkspacePolicy::RemoteUnsupported),
"{command} must not offer message-only rollback without remote file snapshots"
);
}
}

#[test]
fn external_source_control_web_command_is_registered() {
const COMMAND: &str = "get_external_source_control_snapshot";
Expand Down Expand Up @@ -2364,8 +2375,6 @@ mod tests {
"restore_session_view",
"restore_session_with_turns",
"rollback_miniapp",
"rollback_session",
"rollback_to_turn",
"run_init_agents_md",
"run_system_command",
"save_acp_json_config",
Expand Down
47 changes: 37 additions & 10 deletions src/apps/desktop/src/api/snapshot_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,18 @@ async fn ensure_local_snapshot_mutation_path(
Ok(())
}

async fn ensure_complete_rollback_supported(
workspace_path: &str,
remote_scope: &SnapshotRemoteScope,
) -> Result<(), String> {
if remote_scope.declares_remote() || is_remote_path(workspace_path).await {
return Err(format!(
"Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: {workspace_path}"
));
}
Ok(())
}

async fn snapshot_manager_for_view(
workspace_path: &str,
remote_scope: &SnapshotRemoteScope,
Expand Down Expand Up @@ -528,8 +540,7 @@ pub async fn rollback_session(
runtime: State<'_, DesktopRuntimeContext>,
request: RollbackSessionRequest,
) -> Result<Vec<String>, String> {
// Remote workspaces have no local snapshots — nothing to roll back
ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope).await?;
ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;

let manager =
Expand Down Expand Up @@ -563,8 +574,7 @@ pub async fn rollback_to_turn(
runtime: State<'_, DesktopRuntimeContext>,
request: RollbackTurnRequest,
) -> Result<Vec<String>, String> {
// Remote workspaces have no local snapshots — nothing to roll back
ensure_local_snapshot_mutation_path(&request.workspace_path, &request.remote_scope).await?;
ensure_complete_rollback_supported(&request.workspace_path, &request.remote_scope).await?;
ensure_local_runtime_ownership(runtime.inner(), &request.workspace_path).await?;
let workspace_path = resolve_workspace_dir(&request.workspace_path).await?;

Expand Down Expand Up @@ -1285,10 +1295,10 @@ mod tests {
};

use super::{
ensure_local_snapshot_mutation_path, get_snapshot_manager_for_workspace,
local_snapshot_command_error, local_snapshot_session_files, local_snapshot_session_stats,
rollback_local_workspace_files, snapshot_manager_for_view, RollbackTurnRequest,
SnapshotRemoteScope,
ensure_complete_rollback_supported, ensure_local_snapshot_mutation_path,
get_snapshot_manager_for_workspace, local_snapshot_command_error,
local_snapshot_session_files, local_snapshot_session_stats, rollback_local_workspace_files,
snapshot_manager_for_view, RollbackTurnRequest, SnapshotRemoteScope,
};

#[test]
Expand Down Expand Up @@ -1355,6 +1365,23 @@ mod tests {
assert!(get_snapshot_manager_for_workspace(workspace.path()).is_none());
}

#[tokio::test]
async fn remote_complete_rollback_reports_missing_file_snapshot_coverage() {
let scope = SnapshotRemoteScope {
remote_connection_id: Some("connection-1".to_string()),
remote_ssh_host: Some("example.com".to_string()),
};

let error = ensure_complete_rollback_supported("/root/repos", &scope)
.await
.expect_err("remote rollback must fail before changing files or history");

assert_eq!(
error,
"Complete rollback is not supported for remote workspaces because remote file snapshots are not recorded. No workspace files or session messages were changed: /root/repos"
);
}

#[test]
fn rollback_commands_reject_remote_workspaces_before_local_side_effects() {
let source = include_str!("snapshot_service.rs");
Expand All @@ -1375,8 +1402,8 @@ mod tests {

let assert_remote_guard_precedes = |body: &str, side_effect: &str| {
let guard = body
.find("ensure_local_snapshot_mutation_path")
.expect("remote mutation guard remains present");
.find("ensure_complete_rollback_supported")
.expect("complete rollback guard remains present");
let effect = body
.find(side_effect)
.unwrap_or_else(|| panic!("expected side effect remains present: {side_effect}"));
Expand Down
2 changes: 1 addition & 1 deletion src/apps/desktop/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ mod tests {
.find("pub async fn rollback_to_turn")
.expect("rollback command must exist")..];
let remote_guard = rollback_source
.find("ensure_local_snapshot_mutation_path")
.find("ensure_complete_rollback_supported")
.expect("remote rollback guard must remain host-owned");
let cancellation = rollback_source
.find("cancel_active_turn_for_session")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,53 @@ describe('UserMessageItem steering tag', () => {
expect(container.querySelector('.user-message-item__rollback-btn')).not.toBeNull();
});

it('disables file-consistent rollback and message editing for remote workspaces', () => {
activeSessionRef.current = {
sessionId: 'remote-session',
sessionKind: 'normal',
remoteConnectionId: 'ssh:user@example.com:22',
remoteSshHost: 'example.com',
config: {},
dialogTurns: [
{
id: 'turn-1',
status: 'completed',
},
],
};

act(() => {
root.render(
<FlowChatContext.Provider
value={{
sessionId: 'remote-session',
allowUserMessageRollback: true,
allowUserMessageEdit: true,
}}
>
<UserMessageItem
message={{
id: 'user-remote-1',
content: 'remote session question',
timestamp: 1000,
}}
turnId="turn-1"
/>
</FlowChatContext.Provider>,
);
});

const rollbackButton = container.querySelector<HTMLButtonElement>(
'.user-message-item__rollback-btn',
);
const editButton = container.querySelector<HTMLButtonElement>('.user-message-item__edit-btn');

expect(rollbackButton?.disabled).toBe(true);
expect(rollbackButton?.title).toContain('message.rollbackDisabledRemote');
expect(editButton?.disabled).toBe(true);
expect(editButton?.title).toContain('message.editDisabledRemote');
});

it('hides the edit button when the panel context disables user message editing', () => {
activeSessionRef.current = {
sessionId: 'btw-session',
Expand Down
30 changes: 21 additions & 9 deletions src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { SessionUsageReportCard } from '../usage/SessionUsageReportCard';
import type { SessionUsagePanelTab } from '../usage/sessionUsagePanelTypes';
import { coerceSessionUsageReport } from '../usage/usageReportUtils';
import { resolveSessionRelationship } from '../../utils/sessionMetadata';
import { isRemoteWorkspaceSession } from '../../utils/sessionWorkspace';
import {
composerPresentationToAccessibleText,
composerPresentationContexts,
Expand Down Expand Up @@ -139,6 +140,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
const isFailed = dialogTurn?.status === 'error';
const resolvedSessionId = sessionId ?? currentSession?.sessionId;
const historyActionsBlockedByPartialRestore = currentSession?.isPartial === true;
const isRemoteSession = isRemoteWorkspaceSession(currentSession ?? undefined, null);
const isSystemTriggered = Boolean(
message?.metadata?.triggerSource && message.metadata.triggerSource !== 'desktop_ui',
);
Expand All @@ -148,27 +150,36 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
!!resolvedSessionId &&
turnIndex >= 0 &&
!historyActionsBlockedByPartialRestore &&
!isRemoteSession &&
!isRollingBack &&
!isEditSubmitting;
const canEditBase =
allowUserMessageEdit &&
!!resolvedSessionId &&
turnIndex >= 0 &&
!historyActionsBlockedByPartialRestore &&
!isRemoteSession &&
!isThreadGoalSystemMessage &&
!isSystemTriggered &&
!steeringStatus;
const canEdit = canEditBase && !isEditSubmitting && !isRollingBack;
const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage;
const editDisabledReason = isSystemTriggered
? t('message.cannotEdit')
: steeringStatus
const editDisabledReason = isRemoteSession
? t('message.editDisabledRemote')
: isSystemTriggered
? t('message.cannotEdit')
: historyActionsBlockedByPartialRestore
? t('message.editDisabledHistoryNotReady')
: !resolvedSessionId || turnIndex < 0
? t('message.editDisabledHistoryNotReady')
: t('message.cannotEdit');
: steeringStatus
? t('message.cannotEdit')
: historyActionsBlockedByPartialRestore
? t('message.editDisabledHistoryNotReady')
: !resolvedSessionId || turnIndex < 0
? t('message.editDisabledHistoryNotReady')
: t('message.cannotEdit');
const rollbackTooltip = canRollback
? t('message.rollbackTo', { index: turnIndex + 1 })
: isRemoteSession
? t('message.rollbackDisabledRemote')
: t('message.cannotRollback');
const steeringTag = steeringStatus === 'pending'
? {
className: 'user-message-item__steering-tag--pending',
Expand Down Expand Up @@ -582,11 +593,12 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
</button>
</Tooltip>
) : canShowRollbackAction && !steeringStatus ? (
<Tooltip content={canRollback ? t('message.rollbackTo', { index: turnIndex + 1 }) : t('message.cannotRollback')}>
<Tooltip content={rollbackTooltip}>
<button
className="user-message-item__rollback-btn"
onClick={handleRollback}
disabled={!canRollback}
title={rollbackTooltip}
>
{isRollingBack ? (
<Loader2 size={14} className="user-message-item__rollback-spinner" />
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@
"clickToCollapse": "Click to collapse",
"fillToInput": "Fill to input",
"cannotRollback": "Cannot rollback",
"rollbackDisabledRemote": "Remote workspaces do not support complete rollback because remote file snapshots are not recorded. No files or messages will be changed.",
"rollbackTo": "Rollback to before turn {{index}} (delete this and following turns)",
"rollbackDialogTitle": "Rollback to before turn {{index}}?",
"rollbackDialogIntro": "This will:",
Expand All @@ -495,7 +496,7 @@
"editPlaceholder": "Edit your message...",
"cannotEdit": "Cannot edit this message",
"editDisabledImages": "Image messages cannot be edited yet",
"editDisabledRemote": "Remote sessions do not support message editing yet",
"editDisabledRemote": "Editing and rerunning requires complete rollback, which is unavailable because remote file snapshots are not recorded.",
"editDisabledLocalCommand": "Local command messages cannot be edited",
"editDisabledHistoryNotReady": "Session history is not ready yet",
"editDialogTitle": "Edit and rerun from turn {{index}}?",
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@
"clickToCollapse": "点击收起",
"fillToInput": "填充到输入框",
"cannotRollback": "无法回滚",
"rollbackDisabledRemote": "远程工作区未记录文件快照,暂不支持文件与消息一致回滚;不会更改任何文件或消息。",
"rollbackTo": "回滚到第 {{index}} 轮之前(删除该轮及之后)",
"rollbackDialogTitle": "确定回滚到第 {{index}} 轮之前?",
"rollbackDialogIntro": "将执行:",
Expand All @@ -495,7 +496,7 @@
"editPlaceholder": "编辑你的消息...",
"cannotEdit": "无法编辑该消息",
"editDisabledImages": "暂不支持编辑图片消息",
"editDisabledRemote": "远程会话暂不支持消息编辑",
"editDisabledRemote": "编辑并重跑需要完整回滚;远程工作区未记录文件快照,因此暂不支持。",
"editDisabledLocalCommand": "无法编辑本地命令消息",
"editDisabledHistoryNotReady": "会话历史尚未就绪",
"editDialogTitle": "从第 {{index}} 轮编辑并重跑?",
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@
"clickToCollapse": "點擊收起",
"fillToInput": "填充到輸入框",
"cannotRollback": "無法回滾",
"rollbackDisabledRemote": "遠端工作區未記錄檔案快照,暫不支援檔案與訊息一致回滾;不會變更任何檔案或訊息。",
"rollbackTo": "回滾到第 {{index}} 輪之前(刪除該輪及之後)",
"rollbackDialogTitle": "確定回滾到第 {{index}} 輪之前?",
"rollbackDialogIntro": "將執行:",
Expand All @@ -495,7 +496,7 @@
"editPlaceholder": "編輯你的消息...",
"cannotEdit": "無法編輯該消息",
"editDisabledImages": "暫不支援編輯圖片消息",
"editDisabledRemote": "遠端會話暫不支援消息編輯",
"editDisabledRemote": "編輯並重新執行需要完整回滾;遠端工作區未記錄檔案快照,因此暫不支援。",
"editDisabledLocalCommand": "無法編輯本機命令消息",
"editDisabledHistoryNotReady": "會話歷史尚未就緒",
"editDialogTitle": "從第 {{index}} 輪編輯並重跑?",
Expand Down