From eaf4ad891c5e8ef299dbe37f367b64031614e8c1 Mon Sep 17 00:00:00 2001 From: limityan Date: Wed, 29 Jul 2026 23:52:34 +0800 Subject: [PATCH] feat(cli): support Agent mode switching in Shared TUI --- .../agent-runtime-deployment-design.md | 14 +- docs/architecture/cli-product-line-design.md | 6 +- .../rules/source/forbidden-rules.mjs | 4 +- scripts/core-boundaries/self-test.mjs | 11 +- src/apps/cli/src/actions.rs | 63 +++++++-- src/apps/cli/src/agent/runtime_client.rs | 127 +++++++++++++++--- src/apps/cli/src/modes/chat.rs | 5 +- src/apps/cli/src/modes/chat/commands.rs | 25 ++++ src/apps/cli/src/modes/chat/input.rs | 16 +++ src/apps/cli/src/modes/chat/run.rs | 10 +- src/apps/cli/src/modes/chat/selection.rs | 85 +++++++++--- src/apps/cli/src/modes/chat/tests.rs | 83 +++++++++++- src/apps/cli/src/shared_runtime.rs | 23 ++++ src/apps/cli/src/ui/agent_selector.rs | 115 +++++++++++++--- src/apps/cli/src/ui/chat/popups.rs | 54 ++++++++ src/apps/cli/src/ui/startup.rs | 25 +++- src/apps/cli/tests/product_assembly_cli.rs | 6 + .../agent-runtime-ipc/src/operation.rs | 11 +- .../agent-runtime-ipc/src/protocol.rs | 2 +- .../adapters/agent-runtime-ipc/src/server.rs | 4 +- .../src/tests/protocol_contracts.rs | 40 +++++- .../src/tests/shared_controller.rs | 111 ++++++++++++++- 22 files changed, 749 insertions(+), 91 deletions(-) diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 2011848128..ba8990882e 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -42,7 +42,7 @@ flowchart TB | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | | Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,切换当前 Session 的 Agent mode,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session delete/fork 同样不在当前协议中 | 因此当前交付的是一条窄的、显式启用的 Shared TUI deployment,不是通用本机 Server。具体 `EventQueue` 仍由 Core 产品装配;IPC 只把当前 TUI 必需的强类型操作和事件映射到同一个 Runtime owner,没有事件重放或公开协议承诺。 @@ -196,6 +196,7 @@ sequenceDiagram S-->>C: initialized(health + interactive_tui) C->>S: create or restore Session S-->>C: Session control + Session facts + C->>S: update current Session Agent mode C->>S: submit/cancel Turn or answer Permission/UserInput S-->>C: Session-filtered authoritative events else invalid @@ -203,11 +204,11 @@ sequenceDiagram end ``` -当前协议只覆盖第一个 TUI 纵向切片: +当前私有协议(v3)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| -| Health、Session list/create、原子 restore(含 transcript 与 pending Permission) | Session delete/fork、跨 workspace attach、transcript 分页 | +| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session Agent mode update | Session delete/fork、跨 workspace attach、transcript 分页、模型和 Agent/Subagent 管理 | | Turn submit/cancel | replay、cursor、resume event stream | | pending/respond Permission、submit UserInput answers | observer、controller transfer、多 Session multiplex | | 连接断开清理、Session-filtered events | detach/observer/controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | @@ -223,9 +224,10 @@ sequenceDiagram - 未认证握手预算为 2 秒;认证后的单次操作、响应写入和断线取消预算为 120 秒,避免坏客户端长期占用连接或 Runtime handler; - JSON frame 使用 4-byte 长度前缀;request 在发送前执行 128 KiB 上限(覆盖 TUI 已有的 64 KiB 粘贴输入及类型化信封),response/event 在序列化时执行 8 MiB 上限。超限返回类型化错误,不能进行无界分配;超过该上限的历史 Session 暂由 Embedded TUI 打开,不在本阶段引入分页协议; - 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task; -- 未知字段、未知 operation、错误身份和不兼容版本 fail closed; -- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session。 +- 未知 frame/operation 信封字段、未知 operation、错误身份和不兼容版本 fail closed;复用的 Runtime DTO 按其既有反序列化契约处理字段; +- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session 或 Agent mode。 - Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 +- Agent mode update 复用既有 Runtime 端口和校验。它是有副作用操作;若响应超时,或 Client 在收到权威结果前丢失连接,按 `outcome_unknown` 处理并断开连接,Client 不自动重试。用户重新打开 Shared TUI、restore Session 并核对当前 mode 后,才能决定是否重试。模式目录仍是同版本第一方产品事实,不加入 IPC;Runtime 对最终更新保持权威并拒绝无效 mode。 - Agent 事件流 lag/closed 后 fail closed;Permission lag 先从 Runtime 权威 pending 集合重建,重建失败或流关闭时取消当前 Turn 并退出。路由到父 Session 的嵌套 Permission 与 AskUserQuestion 复用现有 TUI 交互,不新增第二套 UI 状态。 - Windows Shared Runtime 在初始化前把自身放入 kill-on-close Job;Unix 仅在应用内优雅退出路径中通过受管子进程组回收后代。Runtime 被 `SIGTERM`、`SIGKILL` 或崩溃直接终止后的 Unix 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 - 最后一个连接离开后等待 30 秒再退出;新连接会取消 idle 退出。退出只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 @@ -295,7 +297,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 |---|---| | 当前 consumer | 仅第一方交互式 TUI adapter;不自动包含 GUI、Headless CLI、Remote 或 SDK Host | | 稳定测试合同 | 本机 endpoint、initialize-first、128 KiB request / 8 MiB response-event 上限、连接上限、owner-checked cleanup、原子 Session controller 切换、单连接单活动 Turn、事件流失效后 fail closed、断连取消、30 秒空闲退出 | -| 当前业务范围 | Session/Turn/transcript/Permission/UserInput 的 TUI 必需子集;任何新增操作都需要真实 consumer 和 owner 等价测试 | +| 当前业务范围 | Session/Turn/transcript/Agent mode/Permission/UserInput 的 TUI 必需子集;任何新增操作都需要真实 consumer 和 owner 等价测试 | | 协议地位 | crate 保持 `publish = false`;这是 workspace 内私有协议,不是 Agent SDK 或远程兼容承诺 | 架构守卫只允许 CLI 消费该 crate;IPC 可以复用稳定的 Event、Product Domain 与 Runtime Port DTO,但禁止依赖 Runtime 实现、SDK Host、services、Tauri 或远程网络 transport。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index bdd4fbd368..3c19503dee 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -256,12 +256,12 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付 | 形态 | 默认部署 | 当前 Shared 范围 | |---|---|---| -| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、Turn submit/cancel、Permission 和 UserInput | +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session Agent mode、Turn submit/cancel、Permission 和 UserInput | | `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 | | ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 | -Shared TUI 首版不提供 Session delete/fork、模式/模型、MCP/扩展、账号同步、用量、observer、replay 或 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 -Shared 模式的命令面板、快捷键帮助和底部提示使用同一能力投影:不支持的管理动作不显示为可执行入口。Session 切换失败保留原控制权,单个连接已有活动 Turn 时拒绝重复提交;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 +Shared TUI 不提供 Session delete/fork、模型、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 +Shared 模式的命令面板、快捷键帮助和底部提示使用同一能力投影:`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode,不进入管理页面;其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权,单个连接已有活动 Turn 时拒绝重复提交和 mode update;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 #### 管理与诊断 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index ab8c854a6b..01cb39cf06 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -3,10 +3,10 @@ export const forbiddenContentRules = [ { path: 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', - reason: 'agent-runtime-ipc operation scope is frozen to the first Shared TUI slice', + reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|Self|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|UpdateSessionMode|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|Self|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add replay, observer, controller-transfer, deletion, fork, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 074f1c20b1..91d07e9f04 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4850,10 +4850,15 @@ export function runManifestParserSelfTest({ const runtimeIpcOperationPattern = runtimeIpcOperationRule?.patterns[0]?.regex; if ( !runtimeIpcOperationPattern || - !['ReplayEvents', 'ReadTranscript', 'DetachSession'].every((name) => - runtimeIpcOperationPattern.test(` ${name},`), - ) || + ![ + 'ReplayEvents', + 'ReadTranscript', + 'DetachSession', + 'UpdateSessionModel', + 'ManageAgents', + ].every((name) => runtimeIpcOperationPattern.test(` ${name},`)) || runtimeIpcOperationPattern.test(' Health,') || + runtimeIpcOperationPattern.test(' UpdateSessionMode {') || runtimeIpcOperationPattern.test(' SubmitTurn {') ) { throw new Error('agent-runtime-ipc operation guard must preserve the Shared TUI operation budget'); diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 95834a046e..bc01ed82b4 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -114,7 +114,7 @@ pub(crate) enum ActionHandler { pub(crate) const SHARED_TUI_EMBEDDED_HANDOFF: &str = "Exit all Shared TUI clients, wait up to 30 seconds for their Runtime to stop, then use default Embedded `bitfun chat`"; pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Session/turn interaction is available; model, agent, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Agent mode switching is available with `/agent`, Tab, and Shift+Tab. Model selection, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui_preview(self) -> bool { @@ -129,6 +129,9 @@ impl ActionHandler { | Self::Init | Self::History | Self::ToggleAutoApprove + | Self::OpenAgentSelector + | Self::SwitchAgent + | Self::SwitchAgentReverse | Self::Exit | Self::OpenPalette | Self::SubmitInput @@ -899,6 +902,14 @@ impl ActionSpec { } } + fn description(&self, state: ActionState) -> &'static str { + if state.shared_tui && self.handler == ActionHandler::OpenAgentSelector { + "Choose an Agent mode" + } else { + self.description + } + } + pub(crate) fn unavailable_message(&self, state: ActionState) -> String { if state.shared_tui && !self.handler.available_in_shared_tui_preview() { return format!( @@ -992,7 +1003,7 @@ pub(crate) fn slash_actions(state: ActionState) -> Vec { spec.aliases.iter().map(|alias| ActionProjection { id: spec.id, name: alias, - description: spec.description, + description: spec.description(state), palette_group: None, suggested: false, }) @@ -1008,7 +1019,7 @@ pub(crate) fn palette_actions(state: ActionState) -> Vec { spec.available(state).then_some(ActionProjection { id: spec.id, name: spec.name, - description: spec.description, + description: spec.description(state), palette_group: Some(palette.group), suggested: palette.suggested, }) @@ -1770,12 +1781,18 @@ mod tests { use super::*; #[test] - fn shared_tui_preview_keeps_management_outside_the_first_slice() { + fn shared_tui_supports_mode_switching_without_embedded_management() { assert!(ActionHandler::Sessions.available_in_shared_tui_preview()); assert!(ActionHandler::Interrupt.available_in_shared_tui_preview()); for action in [ - ActionHandler::SelectModel, ActionHandler::OpenAgentSelector, + ActionHandler::SwitchAgent, + ActionHandler::SwitchAgentReverse, + ] { + assert!(action.available_in_shared_tui_preview(), "{action:?}"); + } + for action in [ + ActionHandler::SelectModel, ActionHandler::McpServers, ActionHandler::Tools, ActionHandler::Extensions, @@ -1788,6 +1805,8 @@ mod tests { } assert!(SHARED_TUI_HELP_NOTE.contains("bitfun chat --shared")); assert!(SHARED_TUI_HELP_NOTE.contains("one Session")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/agent`, Tab, and Shift+Tab")); + assert!(SHARED_TUI_HELP_NOTE.contains("Agent/Subagent management")); assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); } @@ -1804,7 +1823,6 @@ mod tests { .collect::>(); for unavailable in [ - "switch_agent", "select_model", "skills", "mcp_servers", @@ -1818,9 +1836,11 @@ mod tests { for available in ["new_session", "sessions", "theme", "help", "exit"] { assert!(palette_ids.contains(&available), "{available}"); } + assert!(slash_ids.contains(&"switch_agent")); + assert!(palette_ids.contains(&"switch_agent")); let help = ResolvedKeymap::new(&ShortcutsConfig::default()).help_text(state); - assert!(!help.contains("Switch Agent")); + assert!(help.contains("Switch Agent")); } fn resolve_id( @@ -1876,10 +1896,18 @@ mod tests { resolve_id( &keymap, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - ActionState::chat(false, false), + ActionState::chat(false, false).for_shared_tui(), ), Some("cycle_agent") ); + assert_eq!( + resolve_id( + &keymap, + KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), + ActionState::chat(false, false).for_shared_tui(), + ), + Some("switch_agent_reverse") + ); } #[test] @@ -1928,6 +1956,25 @@ mod tests { ); } + #[test] + fn agent_action_description_matches_each_tui_shape() { + let embedded = ActionState::chat(false, false); + let shared = embedded.for_shared_tui(); + + for (projections, expected) in [ + (slash_actions(embedded), "Switch modes and manage agents"), + (palette_actions(embedded), "Switch modes and manage agents"), + (slash_actions(shared), "Choose an Agent mode"), + (palette_actions(shared), "Choose an Agent mode"), + ] { + let agent = projections + .iter() + .find(|action| action.id == "switch_agent") + .expect("Agent action projection"); + assert_eq!(agent.description, expected); + } + } + #[test] fn no_config_uses_current_real_dispatch_defaults() { let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index c9e350e385..7ad92a208f 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -6,6 +6,7 @@ use anyhow::Result; use std::collections::HashMap; +use std::fmt; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use tokio::sync::{broadcast, Mutex}; @@ -89,6 +90,58 @@ fn session_mode_migration_notice( }) } +#[derive(Debug)] +pub(crate) struct SessionModeUpdateError { + message: String, + outcome_unknown: bool, +} + +impl fmt::Display for SessionModeUpdateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for SessionModeUpdateError {} + +impl SessionModeUpdateError { + fn runtime(error: RuntimeError) -> Self { + Self { + message: error.into_message(), + outcome_unknown: false, + } + } + + fn shared(error: RuntimeIpcClientError) -> Self { + let outcome_unknown = matches!( + &error, + RuntimeIpcClientError::Remote(remote) + if remote.code == RuntimeIpcErrorCode::OutcomeUnknown + ) || matches!( + &error, + RuntimeIpcClientError::Timeout + | RuntimeIpcClientError::Disconnected + | RuntimeIpcClientError::UnexpectedResponse + | RuntimeIpcClientError::Io(_) + ); + Self { + message: error.to_string(), + outcome_unknown, + } + } + + fn unexpected(error: anyhow::Error) -> Self { + Self { + message: error.to_string(), + outcome_unknown: true, + } + } + + pub(crate) fn outcome_unknown(&self) -> bool { + self.outcome_unknown + } +} + #[derive(Clone, Debug)] struct CliWorkspacePaths { project: Option, @@ -543,14 +596,29 @@ impl CliAgentRuntimeClient { .map_err(|error| anyhow::anyhow!(error.into_message())) } - pub(crate) async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> Result<()> { - self.embedded_runtime("changing the session mode")? - .update_session_mode(AgentSessionModeUpdateRequest { - session_id: session_id.to_string(), - mode_id: mode_id.to_string(), - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) + pub(crate) async fn update_session_mode( + &self, + session_id: &str, + mode_id: &str, + ) -> std::result::Result<(), SessionModeUpdateError> { + let request = AgentSessionModeUpdateRequest { + session_id: session_id.to_string(), + mode_id: mode_id.to_string(), + }; + match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => runtime + .update_session_mode(request) + .await + .map_err(SessionModeUpdateError::runtime), + CliAgentRuntimeBackend::Shared(client) => { + let result = client + .request(RuntimeIpcOperation::UpdateSessionMode { request }) + .await + .map_err(SessionModeUpdateError::shared)?; + expect_unit(result, "update_session_mode") + .map_err(SessionModeUpdateError::unexpected) + } + } } pub(crate) async fn branch_session_at_latest_turn( @@ -1178,7 +1246,7 @@ mod tests { use super::{ project_routed_permission_event, session_mode_migration_notice, shared_disconnect_message, - shared_restore_error, validated_session_summary, CliWorkspacePaths, + shared_restore_error, validated_session_summary, CliWorkspacePaths, SessionModeUpdateError, }; use bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason; @@ -1193,6 +1261,37 @@ mod tests { assert!(message.contains("default Embedded `bitfun chat`")); } + #[test] + fn shared_mode_update_preserves_unknown_outcome_as_a_typed_fact() { + let error = + SessionModeUpdateError::shared(RuntimeIpcClientError::Remote(RuntimeIpcError { + code: RuntimeIpcErrorCode::OutcomeUnknown, + message: "inspect authoritative state before retrying".to_string(), + })); + + assert!(error.outcome_unknown()); + assert!(error.to_string().contains("OutcomeUnknown")); + + for transport_error in [ + RuntimeIpcClientError::Timeout, + RuntimeIpcClientError::Disconnected, + RuntimeIpcClientError::UnexpectedResponse, + ] { + assert!(SessionModeUpdateError::shared(transport_error).outcome_unknown()); + } + assert!( + SessionModeUpdateError::unexpected(anyhow::anyhow!("unexpected response shape")) + .outcome_unknown() + ); + assert!( + !SessionModeUpdateError::shared(RuntimeIpcClientError::Remote(RuntimeIpcError { + code: RuntimeIpcErrorCode::InvalidRequest, + message: "unknown mode".to_string(), + },)) + .outcome_unknown() + ); + } + #[test] fn oversized_shared_event_explains_cancellation_and_handoff() { let message = @@ -1263,18 +1362,16 @@ mod tests { #[test] fn mode_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); - let runtime_update = [ - "self.embedded_runtime(\"changing the session mode\")?", - "\n .update_session_mode", - ] - .concat(); let compatibility_update = [ "self.compatibility", "\n .update_session_agent_type", ] .concat(); - assert!(source.contains(&runtime_update)); + assert!(source.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(source.contains("runtime.update_session_mode(request)")); + assert!(source.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(source.contains("RuntimeIpcOperation::UpdateSessionMode { request }")); assert!(!source.contains(&compatibility_update)); } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index e37336a8b3..4293b80cb5 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -28,7 +28,7 @@ use crate::actions::{ removed_management_command_hint, slash_actions, ActionContext, ActionHandler, ActionSpec, ActionState, ResolvedKeymap, SHARED_TUI_EMBEDDED_HANDOFF, SHARED_TUI_HELP_NOTE, }; -use crate::agent::runtime_client::CliAgentRuntimeClient; +use crate::agent::runtime_client::{CliAgentRuntimeClient, SessionModeUpdateError}; use crate::chat_state::ChatState; use crate::config::CliConfig; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; @@ -173,10 +173,11 @@ struct PendingModeChange { started_at: Instant, slow_notice_shown: bool, exit_warning_shown: bool, - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, } const MODE_CHANGE_SLOW_NOTICE: Duration = Duration::from_secs(15); +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, turns, and the current Session Agent mode; local extension, MCP, account-sync, model, and Agent/Subagent management remain Embedded."; #[derive(Default)] struct NonKeyEventOutcome { diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index d31d47600f..b6ee25f2c0 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -2,6 +2,16 @@ fn mode_change_blocks_typed_submission(pending_for_current_session: bool, input: pending_for_current_session && !input.trim().starts_with('/') } +fn pending_mode_change_blocks_runtime_action( + shared_tui: bool, + pending_for_current_session: bool, + handler: ActionHandler, +) -> bool { + shared_tui + && pending_for_current_session + && matches!(handler, ActionHandler::Sessions | ActionHandler::Init) +} + fn native_command_choice_is_active( resolved: Option<&ExternalCommandProjection>, unresolved: &[ExternalCommandProjection], @@ -634,6 +644,21 @@ impl ChatMode { chat_view.set_status(Some(action.unavailable_message(state))); return Ok(None); } + let pending_for_current_session = self + .pending_mode_change + .as_ref() + .is_some_and(|pending| pending.session_id == chat_state.core_session_id); + if pending_mode_change_blocks_runtime_action( + self.agent.is_shared(), + pending_for_current_session, + action.handler, + ) { + chat_view.set_status(Some(format!( + "Waiting for the agent mode change to finish before using {}.", + action.name + ))); + return Ok(None); + } match action.handler { ActionHandler::Help => { let mut help = self.keymap.help_text(state); diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index 9153eedbdb..c614cc7589 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -1,3 +1,7 @@ +fn shared_session_change_is_blocked(is_shared: bool, mode_change_pending: bool) -> bool { + is_shared && mode_change_pending +} + impl ChatMode { fn handle_key_event( &mut self, @@ -395,6 +399,18 @@ impl ChatMode { should_quit, exit_reason, } = context; + if matches!( + &reason, + ChatExitReason::SwitchSession(_) | ChatExitReason::NewSession + ) && shared_session_change_is_blocked( + this.agent.is_shared(), + this.pending_mode_change.is_some(), + ) { + chat_view.set_status(Some( + "Wait for the agent mode change to finish before changing sessions.".to_string(), + )); + return; + } match reason { ChatExitReason::SwitchSession(new_session_id) => { if let Some(pending) = this.pending_mode_change.as_mut() { diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 5bd03d33fe..8ff93de241 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -111,7 +111,7 @@ impl ChatMode { let mut external_source_rx = None; if self.agent.is_shared() { chat_view.set_status(Some(format!( - "Shared TUI preview: this view controls sessions and turns; local extension, MCP, account-sync, model, and mode management remain Embedded. {SHARED_TUI_EMBEDDED_HANDOFF}" + "{SHARED_TUI_CHAT_STATUS} {SHARED_TUI_EMBEDDED_HANDOFF}" ))); } else { let external_workspace = self.agent.workspace_path_buf(); @@ -251,6 +251,10 @@ impl ChatMode { self.action_state(chat_state.is_processing, false), &self.keymap, ); + chat_view.set_agent_mode_switch_allowed(agent_mode_switch_allowed( + chat_state.is_processing, + self.pending_mode_change.is_some(), + )); // Keep spinner animation smooth without forcing full redraw every loop. // Pause spinner updates while resize is still being debounced. @@ -277,6 +281,10 @@ impl ChatMode { exit_reason = ChatExitReason::Quit; continue; } + ModeChangePollOutcome::ExitAfterUnknownOutcome(message) => { + fatal_event_stream_error = Some(message); + break; + } } if self.poll_external_tool_mutation(&mut chat_view) { needs_redraw = true; diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index fabee14d62..c2f654b3de 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -7,6 +7,7 @@ enum ModelSelectionApplyOutcome { enum ModeSelectionApplyOutcome { SessionUpdateFailed(String), + OutcomeUnknown(String), Applied, } @@ -14,6 +15,7 @@ enum ModeChangePollOutcome { NoChange, Redraw, ExitAfterSave, + ExitAfterUnknownOutcome(String), } fn previous_session_mode_change_status( @@ -27,6 +29,9 @@ fn previous_session_mode_change_status( ModeSelectionApplyOutcome::SessionUpdateFailed(error) => format!( "The previous session mode change to {mode_id} failed: {error}. Return to that session to retry." ), + ModeSelectionApplyOutcome::OutcomeUnknown(error) => format!( + "The previous session mode change to {mode_id} has an unknown outcome: {error}. Reopen Shared TUI, restore that session, and inspect its current mode before retrying." + ), } } @@ -52,6 +57,17 @@ fn apply_agent_mode_feedback( )); false } + ModeSelectionApplyOutcome::OutcomeUnknown(error) => { + tracing::error!( + "Agent mode update outcome is unknown for {}: {}", + selected_mode, + error + ); + chat_state.add_system_message(format!( + "Agent mode update outcome is unknown: {error}. The Shared connection is closing; reopen Shared TUI, restore this session, and inspect its current mode before retrying." + )); + false + } ModeSelectionApplyOutcome::Applied => { *current_mode = selected_mode.to_string(); chat_state.agent_type = selected_mode.to_string(); @@ -520,9 +536,15 @@ impl ChatMode { ) { let modes = self.get_mode_agents(rt_handle); if modes.is_empty() { - chat_view.set_status(Some( - "Main agent modes are unavailable; agent management remains available.".to_string(), - )); + let message = if self.agent.is_shared() { + "Main agent modes are unavailable." + } else { + "Main agent modes are unavailable; agent management remains available." + }; + chat_view.set_status(Some(message.to_string())); + if self.agent.is_shared() { + return; + } } let agent_items: Vec = modes @@ -533,12 +555,22 @@ impl ChatMode { }) .collect(); - chat_view.show_agent_selector( - agent_items, - Some(self.agent_type.clone()), - true, - agent_mode_switch_allowed(chat_state.is_processing, self.pending_mode_change.is_some()), - ); + let allow_mode_switch = + agent_mode_switch_allowed(chat_state.is_processing, self.pending_mode_change.is_some()); + if self.agent.is_shared() { + chat_view.show_agent_modes_only( + agent_items, + Some(self.agent_type.clone()), + allow_mode_switch, + ); + } else { + chat_view.show_agent_selector( + agent_items, + Some(self.agent_type.clone()), + true, + allow_mode_switch, + ); + } } fn handle_agent_selector_action( @@ -626,10 +658,12 @@ impl ChatMode { { pending.slow_notice_shown = true; if !pending.exit_warning_shown { - chat_view.set_status(Some( + let message = if self.agent.is_shared() { + "The agent mode change is still being saved. You can keep editing; changing sessions and sending wait for the result." + } else { "The agent mode change is still being saved. You can edit or switch sessions; sending in this session waits." - .to_string(), - )); + }; + chat_view.set_status(Some(message.to_string())); } return ModeChangePollOutcome::Redraw; } @@ -641,6 +675,9 @@ impl ChatMode { .expect("finished mode task should remain present"); let outcome = match tokio::task::block_in_place(|| rt_handle.block_on(pending.handle)) { Ok(Ok(())) => ModeSelectionApplyOutcome::Applied, + Ok(Err(error)) if error.outcome_unknown() => { + ModeSelectionApplyOutcome::OutcomeUnknown(error.to_string()) + } Ok(Err(error)) => ModeSelectionApplyOutcome::SessionUpdateFailed(error.to_string()), Err(error) => ModeSelectionApplyOutcome::SessionUpdateFailed(format!( "mode update task failed: {error}" @@ -661,10 +698,16 @@ impl ChatMode { ))); return ModeChangePollOutcome::Redraw; } + let unknown_outcome = matches!(&outcome, ModeSelectionApplyOutcome::OutcomeUnknown(_)); let applied = apply_agent_mode_feedback(&mut self.agent_type, chat_state, &pending.mode_id, outcome); if applied { chat_view.set_status(Some(format!("Agent mode set to {}", pending.mode_id))); + } else if unknown_outcome { + let message = "Agent mode update outcome is unknown. The Shared connection closed; reopen Shared TUI, restore the session, and inspect its current mode before retrying." + .to_string(); + chat_view.set_status(Some(message.clone())); + return ModeChangePollOutcome::ExitAfterUnknownOutcome(message); } else { chat_view.set_status(Some("Agent mode change failed. Please retry.".to_string())); } @@ -698,8 +741,7 @@ fn agent_mode_switch_allowed(is_processing: bool, mode_change_pending: bool) -> fn mode_switch_unavailable_message(is_processing: bool) -> String { if is_processing { - "Agent mode cannot be changed during the current turn. Subagent and external source management remain available." - .to_string() + "Agent mode cannot be changed during the current turn.".to_string() } else { "An agent mode change is already in progress. Please wait.".to_string() } @@ -707,7 +749,10 @@ fn mode_switch_unavailable_message(is_processing: bool) -> String { #[cfg(test)] mod usage_metadata_tests { - use super::{agent_mode_switch_allowed, usage_report_metadata, SessionUsageReport}; + use super::{ + agent_mode_switch_allowed, mode_switch_unavailable_message, usage_report_metadata, + SessionUsageReport, + }; #[test] fn mode_switch_is_rechecked_when_an_idle_popup_outlives_turn_start() { @@ -716,6 +761,16 @@ mod usage_metadata_tests { assert!(!agent_mode_switch_allowed(false, true)); } + #[test] + fn active_turn_message_does_not_advertise_hidden_management() { + let message = mode_switch_unavailable_message(true); + + assert_eq!( + message, + "Agent mode cannot be changed during the current turn." + ); + } + #[test] fn usage_metadata_preserves_the_existing_tui_transcript_schema() { let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index d6d73a178d..fcfab7a955 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -18,10 +18,12 @@ mod tests { native_command_reconfirmation_is_required, native_hook_help_text, parse_external_agent_review_action, parse_external_control_action, parse_external_tool_review_action, parse_hook_management_action, - previous_session_mode_change_status, render_external_hook_catalog, - render_native_hook_overview, CommandRoute, ExternalAgentReviewAction, + pending_mode_change_blocks_runtime_action, previous_session_mode_change_status, + render_external_hook_catalog, render_native_hook_overview, + shared_session_change_is_blocked, CommandRoute, ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, HookManagementAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, + SHARED_TUI_CHAT_STATUS, }; use crate::actions::{ action_conflict_behavior_version, ActionHandler, ActionState, ResolvedKeymap, @@ -1345,12 +1347,87 @@ mod tests { } #[test] - fn pending_mode_change_allows_host_commands_but_blocks_agent_submission() { + fn unknown_mode_update_outcome_requires_restore_before_retry() { + let mut current_mode = "agentic".to_string(); + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + + let applied = apply_agent_mode_feedback( + &mut current_mode, + &mut state, + "plan", + ModeSelectionApplyOutcome::OutcomeUnknown("request timed out".to_string()), + ); + + assert!(!applied); + assert_eq!(current_mode, "agentic"); + assert_eq!(state.agent_type, "agentic"); + let notice = state.messages.last().expect("unknown-outcome notice"); + let crate::chat_state::FlowItem::Text { content, .. } = ¬ice.flow_items[0] else { + panic!("unknown-outcome notice must be text"); + }; + assert!(content.contains("outcome is unknown")); + assert!(content.contains("reopen Shared TUI")); + assert!(content.contains("restore this session")); + assert!(!content.contains("was not changed")); + } + + #[test] + fn pending_mode_change_routes_commands_to_their_action_guards() { assert!(mode_change_blocks_typed_submission(true, "continue")); assert!(!mode_change_blocks_typed_submission(true, "/new")); assert!(!mode_change_blocks_typed_submission(true, "/sessions")); assert!(!mode_change_blocks_typed_submission(true, "/exit")); assert!(!mode_change_blocks_typed_submission(false, "continue")); + + assert!(pending_mode_change_blocks_runtime_action( + true, + true, + ActionHandler::Sessions, + )); + assert!(pending_mode_change_blocks_runtime_action( + true, + true, + ActionHandler::Init, + )); + assert!(!pending_mode_change_blocks_runtime_action( + true, + true, + ActionHandler::Exit, + )); + assert!(!pending_mode_change_blocks_runtime_action( + true, + true, + ActionHandler::OpenAgentSelector, + )); + assert!(!pending_mode_change_blocks_runtime_action( + false, + true, + ActionHandler::Sessions, + )); + assert!(!pending_mode_change_blocks_runtime_action( + true, + false, + ActionHandler::Sessions, + )); + } + + #[test] + fn shared_session_change_waits_for_the_mode_update_result() { + assert!(shared_session_change_is_blocked(true, true)); + assert!(!shared_session_change_is_blocked(true, false)); + assert!(!shared_session_change_is_blocked(false, true)); + } + + #[test] + fn shared_chat_status_separates_mode_switching_from_agent_management() { + assert!(SHARED_TUI_CHAT_STATUS.contains("current Session Agent mode")); + assert!(SHARED_TUI_CHAT_STATUS.contains("Agent/Subagent management remain Embedded")); + assert!(!SHARED_TUI_CHAT_STATUS.contains("mode management remain Embedded")); } #[test] diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 4c3ed98c11..067e7ea42b 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -258,6 +258,13 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { pending_permissions, }) } + RuntimeIpcOperation::UpdateSessionMode { request } => { + self.runtime + .update_session_mode(request) + .await + .map_err(runtime_ipc_error)?; + Ok(RuntimeIpcOperationResult::Unit) + } RuntimeIpcOperation::SubmitTurn { request } => { let outcome = self .runtime @@ -919,6 +926,9 @@ fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError { RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::SessionInUse => { RuntimeIpcErrorCode::SessionInUse } + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::InvalidRequest => { + RuntimeIpcErrorCode::InvalidRequest + } _ => RuntimeIpcErrorCode::Unavailable, }; RuntimeIpcError { @@ -959,6 +969,19 @@ mod tests { ); } + #[test] + fn invalid_runtime_requests_keep_their_ipc_error_category() { + let error = runtime_ipc_error(RuntimeError::Port(PortError::new( + PortErrorKind::InvalidRequest, + "Unknown agent mode: missing", + ))); + + assert_eq!( + error.code, + bitfun_agent_runtime_ipc::RuntimeIpcErrorCode::InvalidRequest + ); + } + #[tokio::test] async fn existing_runtime_connection_errors_are_not_hidden_as_absence() { let root = tempfile::tempdir().unwrap(); diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index 19549a5a16..ad7b45c0d4 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -37,6 +37,7 @@ pub(super) struct AgentSelectorState { visible: bool, /// Currently active agent ID (for highlighting) current_agent_id: Option, + include_management: bool, include_external_sources: bool, allow_mode_switch: bool, last_area: Option, @@ -50,6 +51,7 @@ impl AgentSelectorState { list_state: ListState::default(), visible: false, current_agent_id: None, + include_management: true, include_external_sources: false, allow_mode_switch: true, last_area: None, @@ -64,6 +66,32 @@ impl AgentSelectorState { current_agent_id: Option, include_external_sources: bool, allow_mode_switch: bool, + ) { + self.show_with_options( + agents, + current_agent_id, + true, + include_external_sources, + allow_mode_switch, + ); + } + + pub(super) fn show_modes_only( + &mut self, + agents: Vec, + current_agent_id: Option, + allow_mode_switch: bool, + ) { + self.show_with_options(agents, current_agent_id, false, false, allow_mode_switch); + } + + fn show_with_options( + &mut self, + agents: Vec, + current_agent_id: Option, + include_management: bool, + include_external_sources: bool, + allow_mode_switch: bool, ) { let initial_idx = current_agent_id .as_ref() @@ -72,6 +100,7 @@ impl AgentSelectorState { self.items = agents; self.current_agent_id = current_agent_id; + self.include_management = include_management; self.include_external_sources = include_external_sources; self.allow_mode_switch = allow_mode_switch; self.list_state.select(Some(initial_idx)); @@ -128,10 +157,12 @@ impl AgentSelectorState { if let Some(agent) = self.items.get(idx) { return Some(AgentSelectorAction::SwitchMode(agent.clone())); } - if idx == self.items.len() { + if self.include_management && idx == self.items.len() { return Some(AgentSelectorAction::ManageSubagents); } - if self.include_external_sources && idx == self.items.len() + 1 { + if self.include_external_sources + && idx == self.items.len() + usize::from(self.include_management) + { return Some(AgentSelectorAction::ReviewExternalSources); } None @@ -185,7 +216,7 @@ impl AgentSelectorState { if !self.allow_mode_switch { spans.push(Span::styled( if popup_area.width >= 28 { - "After current turn · " + "Please wait · " } else { "Wait · " }, @@ -203,23 +234,25 @@ impl AgentSelectorState { ListItem::new(line) }) .collect(); - let mut subagent_spans = vec![ - Span::raw(" "), - Span::styled( - "Subagents", - theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), - ), - ]; - if !compact { - subagent_spans.extend([ + if self.include_management { + let mut subagent_spans = vec![ Span::raw(" "), Span::styled( - "List, launch, or configure delegated agents", - theme.style(StyleKind::Muted), + "Subagents", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), ), - ]); + ]; + if !compact { + subagent_spans.extend([ + Span::raw(" "), + Span::styled( + "List, launch, or configure delegated agents", + theme.style(StyleKind::Muted), + ), + ]); + } + list_items.push(ListItem::new(Line::from(subagent_spans))); } - list_items.push(ListItem::new(Line::from(subagent_spans))); if self.include_external_sources { let mut external_spans = vec![ Span::raw(" "), @@ -332,7 +365,9 @@ impl AgentSelectorState { } fn len(&self) -> usize { - self.items.len() + 1 + usize::from(self.include_external_sources) + self.items.len() + + usize::from(self.include_management) + + usize::from(self.include_external_sources) } } @@ -394,6 +429,48 @@ mod tests { )); } + #[test] + fn mode_only_entry_exposes_no_management_rows() { + let mut state = AgentSelectorState::new(); + state.show_modes_only(modes(), Some("agentic".to_string()), true); + + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::SwitchMode(AgentItem { id, .. })) if id == "ask" + )); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::SwitchMode(AgentItem { id, .. })) if id == "agentic" + )); + } + + #[test] + fn mode_only_entry_renders_no_management_copy() { + let mut state = AgentSelectorState::new(); + state.show_modes_only(modes(), Some("agentic".to_string()), true); + let mut terminal = Terminal::new(TestBackend::new(52, 12)).expect("test terminal"); + + terminal + .draw(|frame| state.render(frame, frame.area(), &Theme::dark_ansi16())) + .expect("render mode-only agent selector"); + + let buffer = terminal.backend().buffer(); + let rendered = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(rendered.contains("General purpose")); + assert!(!rendered.contains("Subagents")); + assert!(!rendered.contains("External AI applications")); + } + #[test] fn processing_turn_keeps_management_available_and_defers_mode_guard_to_dispatch() { let mut state = AgentSelectorState::new(); @@ -435,8 +512,8 @@ mod tests { .join("\n"); assert!( - rendered.contains("After current turn"), - "narrow popup should explain why mode rows are disabled: {rendered:?}" + rendered.contains("Please wait"), + "narrow popup should show that mode rows are disabled: {rendered:?}" ); } diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index b778b0e8d0..a56a18b9d0 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -169,6 +169,17 @@ impl ChatView { self.popup_stack.push(PopupType::AgentSelector); } + pub(crate) fn show_agent_modes_only( + &mut self, + agents: Vec, + current_agent_id: Option, + allow_mode_switch: bool, + ) { + self.agent_selector + .show_modes_only(agents, current_agent_id, allow_mode_switch); + self.popup_stack.push(PopupType::AgentSelector); + } + pub(crate) fn hide_agent_selector(&mut self) { self.agent_selector.hide(); } @@ -193,6 +204,10 @@ impl ChatView { self.agent_selector.confirm_selection() } + pub(crate) fn set_agent_mode_switch_allowed(&mut self, allowed: bool) { + self.agent_selector.set_mode_switch_allowed(allowed); + } + // ============ Skill selector methods ============ pub(crate) fn show_skill_menu(&mut self) { @@ -559,8 +574,10 @@ impl ChatView { #[cfg(test)] mod tests { use super::ChatView; + use crate::chat_state::ChatState; use crate::ui::agent_selector::AgentItem; use crate::ui::theme::Theme; + use ratatui::{backend::TestBackend, Terminal}; #[test] fn opening_subagent_management_hides_the_parent_agent_selector() { @@ -580,4 +597,41 @@ mod tests { assert!(!view.agent_selector_visible()); assert!(view.subagent_selector_visible()); } + + #[test] + fn pending_mode_update_keeps_an_open_selector_disabled() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + view.show_agent_modes_only( + vec![AgentItem { + id: "agentic".to_string(), + description: "General purpose".to_string(), + }], + Some("agentic".to_string()), + true, + ); + + view.set_agent_mode_switch_allowed(false); + + let state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).expect("test terminal"); + terminal + .draw(|frame| view.render(frame, &state)) + .expect("render disabled mode selector"); + let buffer = terminal.backend().buffer(); + let rendered = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!(rendered.contains("Please wait"), "{rendered:?}"); + } } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index bc9e64686b..91aeb14000 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1748,15 +1748,21 @@ impl StartupPage { } fn show_agent_selector(&mut self) { - self.push_current_popup_to_stack(); - let modes = self.get_mode_agents(); if modes.is_empty() { - self.status = Some( - "Main agent modes are unavailable; agent management remains available.".to_string(), - ); + let message = if self.agent.is_shared() { + "Main agent modes are unavailable." + } else { + "Main agent modes are unavailable; agent management remains available." + }; + self.status = Some(message.to_string()); + if self.agent.is_shared() { + return; + } } + self.push_current_popup_to_stack(); + let agent_items: Vec = modes .into_iter() .map(|m| AgentItem { @@ -1765,8 +1771,13 @@ impl StartupPage { }) .collect(); - self.agent_selector - .show(agent_items, Some(self.agent_type.clone()), false, true); + if self.agent.is_shared() { + self.agent_selector + .show_modes_only(agent_items, Some(self.agent_type.clone()), true); + } else { + self.agent_selector + .show(agent_items, Some(self.agent_type.clone()), false, true); + } } fn handle_agent_selector_action(&mut self, action: AgentSelectorAction) { diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index c63d7cb6a6..92dbe171da 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -345,6 +345,12 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { && !CHAT_MODE.contains("RuntimeIpcClient"), "Shared IPC must remain behind CliAgentRuntimeClient instead of leaking into TUI controllers" ); + assert!( + RUNTIME_CLIENT.contains("RuntimeIpcOperation::UpdateSessionMode { request }") + && SHARED_RUNTIME.contains("RuntimeIpcOperation::UpdateSessionMode { request }") + && SHARED_RUNTIME.contains(".update_session_mode(request)"), + "Shared Agent mode updates must reuse the Runtime port through the private IPC adapter" + ); let shared_command_path = CHAT_COMMANDS .split_once("fn handle_command(") .expect("handle_command") diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index eeb151b8a7..c468dedb97 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,8 +1,8 @@ use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionListRequest, AgentSessionSummary, AgentTurnCancellationRequest, - AgentTurnCancellationResult, SessionTranscript, + AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionSummary, + AgentTurnCancellationRequest, AgentTurnCancellationResult, SessionTranscript, }; use serde::{Deserialize, Serialize}; @@ -39,6 +39,9 @@ pub enum RuntimeIpcOperation { RestoreSession { request: RuntimeSessionRestoreRequest, }, + UpdateSessionMode { + request: AgentSessionModeUpdateRequest, + }, SubmitTurn { request: AgentDialogTurnRequest, }, @@ -62,6 +65,7 @@ impl RuntimeIpcOperation { pub fn session_id(&self) -> Option<&str> { match self { Self::RestoreSession { request } => Some(&request.session_id), + Self::UpdateSessionMode { request } => Some(&request.session_id), Self::SubmitTurn { request } => Some(&request.session_id), Self::CancelTurn { request } => Some(&request.session_id), Self::PendingPermissions { session_id } @@ -74,7 +78,8 @@ impl RuntimeIpcOperation { pub fn requires_controller(&self) -> bool { matches!( self, - Self::SubmitTurn { .. } + Self::UpdateSessionMode { .. } + | Self::SubmitTurn { .. } | Self::CancelTurn { .. } | Self::PendingPermissions { .. } | Self::RespondPermission { .. } diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 05f6dcdafc..055862e90d 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 2; +pub const PROTOCOL_VERSION: u32 = 3; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index 4d04761b79..8e9ed0f2cb 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -413,6 +413,7 @@ async fn run_initialized_connection( RuntimeIpcOperation::SubmitTurn { .. } | RuntimeIpcOperation::RestoreSession { .. } | RuntimeIpcOperation::CreateSession { .. } + | RuntimeIpcOperation::UpdateSessionMode { .. } ) { send_error( @@ -420,7 +421,7 @@ async fn run_initialized_connection( config.request_timeout, Some(request_id), RuntimeIpcErrorCode::SessionInUse, - "finish or cancel the active turn before changing the controlled session", + "finish or cancel the active turn before changing the controlled session or its agent mode", ) .await?; continue; @@ -668,6 +669,7 @@ fn operation_has_side_effects(operation: &RuntimeIpcOperation) -> bool { operation, RuntimeIpcOperation::CreateSession { .. } | RuntimeIpcOperation::RestoreSession { .. } + | RuntimeIpcOperation::UpdateSessionMode { .. } | RuntimeIpcOperation::SubmitTurn { .. } | RuntimeIpcOperation::CancelTurn { .. } | RuntimeIpcOperation::RespondPermission { .. } diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 241c312f38..176627deba 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -4,7 +4,10 @@ use crate::{ }; use bitfun_product_domains::tool_permissions::PermissionReply; -use bitfun_runtime_ports::{AgentDialogTurnRequest, AgentSubmissionSource, DialogSubmissionPolicy}; +use bitfun_runtime_ports::{ + AgentDialogTurnRequest, AgentSessionModeUpdateRequest, AgentSubmissionSource, + DialogSubmissionPolicy, +}; use serde_json::{json, Map}; #[test] @@ -62,6 +65,41 @@ fn protocol_round_trips_reviewed_permission_and_user_input_operations() { } } +#[test] +fn protocol_round_trips_the_reviewed_session_mode_operation() { + let operation = RuntimeIpcOperation::UpdateSessionMode { + request: AgentSessionModeUpdateRequest { + session_id: "session-1".to_string(), + mode_id: "ask".to_string(), + }, + }; + + let encoded = serde_json::to_value(&operation).expect("serialize mode update"); + assert_eq!(encoded["operation"], "update_session_mode"); + assert_eq!(encoded["request"]["sessionId"], "session-1"); + assert_eq!(encoded["request"]["modeId"], "ask"); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize mode update"); + + assert_eq!(decoded, operation); + assert_eq!(decoded.session_id(), Some("session-1")); + assert!(decoded.requires_controller()); +} + +#[test] +fn session_mode_operation_rejects_unknown_envelope_fields() { + let unknown_field = json!({ + "operation": "update_session_mode", + "request": { + "sessionId": "session-1", + "modeId": "ask" + }, + "metadata": {} + }); + + assert!(serde_json::from_value::(unknown_field).is_err()); +} + #[test] fn submit_turn_accepts_the_existing_64_kib_tui_paste_contract() { let frame = RuntimeIpcFrame::Request { diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index 74f8d8890c..58788e797a 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -8,7 +8,8 @@ use async_trait::async_trait; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; use bitfun_runtime_ports::{ AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy, SessionTranscript, + AgentSessionModeUpdateRequest, AgentSessionSummary, AgentSubmissionSource, + DialogSubmissionPolicy, SessionTranscript, }; use serde_json::Map; use std::path::Path; @@ -81,6 +82,7 @@ impl TestServer { struct FakeHandler { calls: Mutex>, delay: Option, + mode_delay: Option, submit_delay: Option, settle_cancel: bool, events: broadcast::Sender, @@ -101,6 +103,7 @@ impl Default for FakeHandler { Self { calls: Mutex::new(Vec::new()), delay: None, + mode_delay: None, submit_delay: None, settle_cancel: true, events, @@ -171,6 +174,11 @@ impl RuntimeIpcRequestHandler for FakeHandler { if let Some(delay) = self.delay { tokio::time::sleep(delay).await; } + if matches!(operation, RuntimeIpcOperation::UpdateSessionMode { .. }) { + if let Some(delay) = self.mode_delay { + tokio::time::sleep(delay).await; + } + } match operation { RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), RuntimeIpcOperation::SubmitTurn { request } => { @@ -483,6 +491,15 @@ fn submit_operation(workspace: &Path, session_id: &str, turn_id: &str) -> Runtim } } +fn update_mode_operation(session_id: &str, mode_id: &str) -> RuntimeIpcOperation { + RuntimeIpcOperation::UpdateSessionMode { + request: AgentSessionModeUpdateRequest { + session_id: session_id.to_string(), + mode_id: mode_id.to_string(), + }, + } +} + fn server_config() -> RuntimeIpcServerConfig { RuntimeIpcServerConfig { server_version: "shared-controller-test".to_string(), @@ -683,6 +700,98 @@ async fn one_connection_rejects_a_second_turn_until_the_first_finishes() { server.finish().await; } +#[tokio::test] +async fn mode_update_requires_the_controlled_idle_session() { + let handler = Arc::new(FakeHandler::default()); + let server = TestServer::start(server_config(), handler.clone()).await; + let mut client = server.connect("mode-controller").await; + + expect_error( + &mut client, + 2, + update_mode_operation("session-a", "ask"), + RuntimeIpcErrorCode::ControllerRequired, + ) + .await; + expect_response( + &mut client, + 3, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut client, + 4, + update_mode_operation("session-b", "ask"), + RuntimeIpcErrorCode::SessionMismatch, + ) + .await; + expect_response(&mut client, 5, update_mode_operation("session-a", "ask")).await; + expect_response( + &mut client, + 6, + submit_operation(server.workspace.path(), "session-a", "turn-a"), + ) + .await; + expect_error( + &mut client, + 7, + update_mode_operation("session-a", "agentic"), + RuntimeIpcErrorCode::SessionInUse, + ) + .await; + + let calls = handler.calls.lock().expect("calls"); + assert_eq!( + calls + .iter() + .filter(|operation| matches!(operation, RuntimeIpcOperation::UpdateSessionMode { .. })) + .count(), + 1, + "only the controlled idle-session update reaches the Runtime handler" + ); + drop(calls); + drop(client); + server.finish().await; +} + +#[tokio::test] +async fn timed_out_mode_update_reports_unknown_outcome_and_closes_the_connection() { + let handler = Arc::new(FakeHandler { + mode_delay: Some(Duration::from_millis(100)), + ..FakeHandler::default() + }); + let mut config = server_config(); + config.request_timeout = Duration::from_millis(20); + let server = TestServer::start(config, handler).await; + let mut first = server.connect("mode-timeout").await; + expect_response( + &mut first, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut first, + 3, + update_mode_operation("session-a", "ask"), + RuntimeIpcErrorCode::OutcomeUnknown, + ) + .await; + + assert!(read_frame(&mut first).await.is_err()); + let mut second = server.connect("mode-timeout-successor").await; + expect_response( + &mut second, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + drop(first); + drop(second); + server.finish().await; +} + #[tokio::test] async fn timed_out_submit_closes_and_cancels_its_provisional_turn() { let handler = Arc::new(FakeHandler {