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
2 changes: 1 addition & 1 deletion docs/architecture/agent-runtime-services-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1129,7 +1129,7 @@ Product 测试:
turn、处理基础会话控制、更新会话模型并处理工具确认/拒绝;本地工作区快照准备、文件清单、统计和文件回滚通过独立 owner port 复用 Core 实现,
富历史和其余持久化维护缺口仍通过单一 Core 兼容接口处理,不再构造独立调度器、持久化 manager 或事件队列;
wire schema、Relay ACK/重放和重连协议未在该切换中扩张。
- CLI 主会话客户端通过 Rust Runtime SDK 处理 session、transcript、fork、usage report、用量卡片完成态本地命令轮次、turn、cancel 与 settlement;其他 preview 缺口仍通过一个 Core 兼容接口处理;
- CLI 主会话客户端通过 Rust Runtime SDK 处理 session、transcript、fork、本地 Session undo/redo、usage report、用量卡片完成态本地命令轮次、turn、cancel 与 settlement;undo/redo 使用独立窄 port,由 Core 统一暂存 transcript、模型上下文与工作区边界,不扩展 `RuntimeServices` 为 service locator;其他 preview 缺口仍通过一个 Core 兼容接口处理;
该接口复用现有归属模块,不建立第二套状态或事件格式。
- CLI 托管的 ACP 服务端已以 `DeliveryProfile::Acp` 构造真实 Runtime Parts;会话创建/列举、轮次、取消、会话模型更新、工具确认/拒绝和
Agent 事件订阅复用同一 Agent Runtime API 语义,ACP stdio、连接与协议转换保持不变。Agentic Event Queue 仍是唯一事件归属模块;
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/cli-product-line-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,15 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付

| 形态 | 默认部署 | 当前 Shared 范围 |
|---|---|---|
| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore/delete/fork、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、当前 Session 手动 context compaction、Turn submit/cancel、Permission 和 UserInput |
| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore/delete/fork、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、当前 Session 手动 context compaction、Session undo/redo、Turn submit/cancel、Permission 和 UserInput |
| `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 |
| ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 |

Shared TUI 不提供 Session archive、模型目录/默认值、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或通用 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。
Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:OpenCode 对齐的 `/fork` 以 `Full session` 或历史用户提示词选择分支边界;选择提示词时 fork 只复制该 Turn 之前的历史,并把提示词放回 composer 而不自动发送。`/rename <name>` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model;`/reload [skills|instructions]` 刷新下一条消息使用的声明式上下文;OpenCode 对齐的 `/compact` 及其 `/summarize` alias 以一个可取消的 maintenance Turn 压缩当前 Session 上下文,不增加自创命令或快捷键。该 Turn 与普通对话共用 Session 原子准入,取得所有权后再读取待压缩上下文;权威 transcript 保留完整 tool payload,但重建模型上下文时排除该 maintenance Turn。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示这些入口;在 slash menu 中选择 rename 只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换或 fork 失败保留原控制权;Shared fork 只有在新 Session 与 transcript 的响应可编码后才原子转移 controller。单个连接已有活动 Turn 时拒绝重复提交、fork、manual compaction 以及 Session rename/mode/model update,但允许 reload 只影响下一条消息;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。

OpenCode 对齐的 `/undo` 与 `/redo` 只提供这两个命令名,不增加 alias、快捷键或通用 checkpoint API。Core 以持久化的 staged boundary 同步裁剪可见 transcript、模型上下文和该边界后的受跟踪工作区改动;`/undo` 把被撤销的用户提示词放回 composer,连续调用可继续向前,`/redo` 逐级恢复直到清除 boundary,usage 与 fork 也只读取该 boundary 前的可见事实。活动 Turn 必须先由 scheduler 完成取消和 drain,失败时不写入 boundary;Shared 仍要求 current controller,并以 Runtime 返回的权威 transcript 更新 TUI。暂存期间提交新提示词或执行既有本地 snapshot accept/reject/rollback mutation,会先进入可恢复的 committing 阶段,永久删除隐藏后缀再建立新分支;同一阶段的 snapshot record 被拒绝,避免追加到隐藏历史。崩溃恢复会按持久化阶段重放 workspace/context 对齐或完成提交;跨文件系统与 Session 存储不宣称原子事务,部分失败返回 `outcome_unknown` 并要求恢复 Session 后检查。该能力当前只支持本地工作区,Remote 在任何回退写入前返回 `NotAvailable`。

部署差异由 CLI Runtime client 封装。Embedded 以 Rust 类型直接调用 `AgentRuntime`,不初始化 IPC 或执行 JSON 编解码;Shared 将同一业务请求映射为一个有界本机 frame,Client/Server 各自只编码一次,再交给同一 Runtime owner。多 TUI 复用一个 Runtime 进程,连接和队列保持有界,不按 TUI 数量复制 Session owner。详细的 4+1 视图、帧上限和并发边界见
[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。

Expand Down
2 changes: 1 addition & 1 deletion scripts/core-boundaries/rules/source/forbidden-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const forbiddenContentRules = [
reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice',
patterns: [
{
regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/,
regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/,
message:
'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice',
},
Expand Down
5 changes: 4 additions & 1 deletion scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4863,8 +4863,11 @@ export function runManifestParserSelfTest({
runtimeIpcOperationPattern.test(' RenameSession {') ||
runtimeIpcOperationPattern.test(' UpdateSessionMode {') ||
runtimeIpcOperationPattern.test(' UpdateSessionModel {') ||
runtimeIpcOperationPattern.test(' UndoSession {') ||
runtimeIpcOperationPattern.test(' RedoSession {') ||
runtimeIpcOperationPattern.test(' SubmitTurn {') ||
runtimeIpcOperationPattern.test(' SessionForked {')
runtimeIpcOperationPattern.test(' SessionForked {') ||
runtimeIpcOperationPattern.test(' SessionReverted {')
) {
throw new Error('agent-runtime-ipc operation guard must preserve the Shared TUI operation budget');
}
Expand Down
30 changes: 26 additions & 4 deletions src/apps/cli/src/account_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use tokio::sync::RwLock;

use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility;
use bitfun_core::service::config::get_global_config_service;
use bitfun_core::service::remote_connect::account::{
ensure_relay_session_history_exportable, relay_session_export_metadata,
};
use bitfun_core::service::remote_connect::settings_sync;
use bitfun_core::service::remote_connect::{sync_state, AccountClient};

Expand Down Expand Up @@ -95,8 +98,7 @@ pub(crate) async fn push_settings_after_local_change() {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Default)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum SyncStatus {
#[default]
Idle,
Expand All @@ -105,7 +107,6 @@ pub(crate) enum SyncStatus {
Failed,
}


#[derive(Debug, Clone)]
pub(crate) struct SyncProgress {
pub status: SyncStatus,
Expand Down Expand Up @@ -317,12 +318,17 @@ pub(crate) async fn run_auto_sync(
if !account_context_is_current(generation) {
return Err(anyhow!("account sync cancelled"));
}
if let Err(error) = ensure_relay_session_history_exportable(meta) {
tracing::debug!("Skipping CLI account session export: {error}");
continue;
}
let turns = compatibility
.load_persisted_session_turns(&storage_path, &meta.session_id, None)
.await
.map_err(|e| anyhow!("load turns: {e}"))?;
let metadata = relay_session_export_metadata(meta, turns.len());
let metadata_json =
serde_json::to_value(meta).map_err(|e| anyhow!("serialize metadata: {e}"))?;
serde_json::to_value(metadata).map_err(|e| anyhow!("serialize metadata: {e}"))?;
let turns_json: Vec<serde_json::Value> = turns
.iter()
.map(|t| serde_json::to_value(t).unwrap_or(serde_json::Value::Null))
Expand Down Expand Up @@ -482,4 +488,20 @@ mod tests {
.to_string()
.contains("HTTP 507"));
}

#[test]
fn cli_session_backup_uses_the_shared_import_guard_and_visible_count() {
let source = include_str!("account_sync.rs").replace("\r\n", "\n");
let export_loop = source
.split_once("for meta in local_sessions.iter()")
.expect("CLI account Session export loop")
.1
.split_once("let upload_total = pending_uploads.len()")
.expect("CLI account Session export loop boundary")
.0;

assert!(export_loop.contains("ensure_relay_session_history_exportable(meta)"));
assert!(export_loop.contains("relay_session_export_metadata(meta, turns.len())"));
assert!(export_loop.contains("pending_uploads.push"));
}
}
63 changes: 63 additions & 0 deletions src/apps/cli/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub(crate) enum ActionHandler {
NewSession,
Sessions,
ForkSession,
UndoSession,
RedoSession,
RenameSession,
Skills,
Reload,
Expand Down Expand Up @@ -128,6 +130,8 @@ impl ActionHandler {
| Self::NewSession
| Self::Sessions
| Self::ForkSession
| Self::UndoSession
| Self::RedoSession
| Self::RenameSession
| Self::AcpHelp
| Self::Init
Expand Down Expand Up @@ -391,6 +395,36 @@ static ACTION_SPECS: &[ActionSpec] = &[
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "undo_session",
name: "Undo session",
aliases: &["/undo"],
description: "Undo the latest user prompt and restore it to the composer",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::UndoSession,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: None,
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "redo_session",
name: "Redo session",
aliases: &["/redo"],
description: "Redo the most recently undone session history",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::RedoSession,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: None,
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "skills",
name: "Skills",
Expand Down Expand Up @@ -1910,6 +1944,35 @@ mod tests {
assert!(action_for_alias("/branch", ActionContext::Chat).is_none());
}

#[test]
fn undo_and_redo_use_only_the_opencode_commands_in_both_deployments() {
let undo = action_by_id("undo_session", ActionContext::Chat)
.expect("OpenCode-compatible session undo action");
let redo = action_by_id("redo_session", ActionContext::Chat)
.expect("OpenCode-compatible session redo action");

assert_eq!(undo.aliases, &["/undo"]);
assert_eq!(undo.handler, ActionHandler::UndoSession);
assert_eq!(undo.availability, ActionAvailability::Always);
assert!(undo.default_bindings.is_empty());
assert!(undo.fallback_bindings.is_empty());
assert!(undo.available(ActionState::chat(false, false).for_shared_tui()));
assert!(undo.available(ActionState::chat(true, false).for_shared_tui()));

assert_eq!(redo.aliases, &["/redo"]);
assert_eq!(redo.handler, ActionHandler::RedoSession);
assert_eq!(redo.availability, ActionAvailability::Always);
assert!(redo.default_bindings.is_empty());
assert!(redo.fallback_bindings.is_empty());
assert!(redo.available(ActionState::chat(false, false).for_shared_tui()));
assert!(redo.available(ActionState::chat(true, false).for_shared_tui()));

assert!(action_for_alias("/rewind", ActionContext::Chat).is_none());
assert!(action_for_alias("/revert", ActionContext::Chat).is_none());
assert!(action_by_id("undo_session", ActionContext::Startup).is_none());
assert!(action_by_id("redo_session", ActionContext::Startup).is_none());
}

#[test]
fn compact_uses_opencode_commands_without_an_invented_shortcut() {
let action = action_by_id("compact_session", ActionContext::Chat)
Expand Down
77 changes: 73 additions & 4 deletions src/apps/cli/src/agent/runtime_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ use bitfun_agent_runtime::sdk::{
AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest,
AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult,
AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest,
AgentSessionRenameRequest, AgentSessionRestoreRequest, AgentSessionUsageRequest,
AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest,
PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind,
RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport,
AgentSessionRenameRequest, AgentSessionRestoreRequest, AgentSessionRevertRequest,
AgentSessionRevertResult, AgentSessionUsageRequest, AgentTurnCancellationRequest,
AgentTurnSettlementRequest, AgentUserAnswersRequest, PermissionReply, PermissionRequest,
PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript,
SessionTranscriptRequest, SessionUsageReport,
};
use bitfun_agent_runtime_ipc::{
RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode,
Expand Down Expand Up @@ -815,6 +816,55 @@ impl CliAgentRuntimeClient {
Ok((session, binding, transcript))
}

pub(crate) async fn revert_current_session(
&self,
undo: bool,
) -> Result<AgentSessionRevertResult> {
let session_id = self.require_session_id().await?;
let request = AgentSessionRevertRequest {
workspace_path: self.project_workspace_path_string(),
session_id: session_id.clone(),
remote_connection_id: None,
remote_ssh_host: None,
};
let locally_active_turn_id = self.current_turn_id.lock().await.clone();
let mut reverted = match &self.backend {
CliAgentRuntimeBackend::Embedded(runtime) => if undo {
runtime.undo_session(request).await
} else {
runtime.redo_session(request).await
}
.map_err(|error| anyhow::anyhow!(error.into_message()))?,
CliAgentRuntimeBackend::Shared(client) => {
let operation = if undo {
RuntimeIpcOperation::UndoSession { request }
} else {
RuntimeIpcOperation::RedoSession { request }
};
match client.request(operation).await? {
RuntimeIpcOperationResult::SessionReverted { revert }
if revert.session_id == session_id =>
{
revert
}
_ => return Err(unexpected_shared_result("revert_session")),
}
}
};
if reverted.session_id != session_id {
return Err(anyhow::anyhow!(
"Runtime reverted an unexpected session identity"
));
}
if let Some(turn_id) = locally_active_turn_id {
if !reverted.retired_turn_ids.contains(&turn_id) {
reverted.retired_turn_ids.push(turn_id);
}
}
*self.current_turn_id.lock().await = None;
Ok(reverted)
}

pub(crate) async fn generate_session_usage_report(
&self,
request: AgentSessionUsageRequest,
Expand Down Expand Up @@ -1656,6 +1706,25 @@ mod tests {
assert!(fork.contains("RuntimeIpcOperationResult::SessionForked"));
}

#[test]
fn session_revert_uses_the_same_authoritative_result_in_both_deployments() {
let source = include_str!("runtime_client.rs").replace("\r\n", "\n");
let revert = source
.split_once("pub(crate) async fn revert_current_session(")
.expect("session revert method")
.1
.split_once("pub(crate) async fn generate_session_usage_report(")
.expect("session revert method boundary")
.0;

assert!(revert.contains("CliAgentRuntimeBackend::Embedded(runtime)"));
assert!(revert.contains("runtime.undo_session(request)"));
assert!(revert.contains("runtime.redo_session(request)"));
assert!(revert.contains("RuntimeIpcOperation::UndoSession"));
assert!(revert.contains("RuntimeIpcOperation::RedoSession"));
assert!(revert.contains("RuntimeIpcOperationResult::SessionReverted"));
}

#[test]
fn session_delete_uses_direct_runtime_or_private_shared_ipc() {
let source = include_str!("runtime_client.rs").replace("\r\n", "\n");
Expand Down
Loading
Loading