diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index ba8990882e..75b586e7b1 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,切换当前 Session 的 Agent mode,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,读取 transcript,切换当前 Session 的 Agent mode/model,提交/取消 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,7 +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: update current Session Agent mode or model C->>S: submit/cancel Turn or answer Permission/UserInput S-->>C: Session-filtered authoritative events else invalid @@ -204,11 +204,11 @@ sequenceDiagram end ``` -当前私有协议(v3)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v4)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| -| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session Agent mode update | Session delete/fork、跨 workspace attach、transcript 分页、模型和 Agent/Subagent 管理 | +| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session Agent mode/model 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 | @@ -225,9 +225,10 @@ sequenceDiagram - JSON frame 使用 4-byte 长度前缀;request 在发送前执行 128 KiB 上限(覆盖 TUI 已有的 64 KiB 粘贴输入及类型化信封),response/event 在序列化时执行 8 MiB 上限。超限返回类型化错误,不能进行无界分配;超过该上限的历史 Session 暂由 Embedded TUI 打开,不在本阶段引入分页协议; - 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task; - 未知 frame/operation 信封字段、未知 operation、错误身份和不兼容版本 fail closed;复用的 Runtime DTO 按其既有反序列化契约处理字段; -- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session 或 Agent mode。 +- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session、Agent mode 或 model。 - 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 mode/model update 复用既有 Runtime 端口和校验。二者都是有副作用操作;若响应超时,或 Client 在收到权威结果前丢失连接,按 `outcome_unknown` 处理并断开连接,Client 不自动重试。用户重新打开 Shared TUI、restore Session 并核对当前值后,才能决定是否重试。模式与模型目录仍是同版本第一方产品事实,不加入 IPC;Runtime 对最终更新保持权威并拒绝无效值。 +- Shared TUI 的模型选择器复用 Client 已有的只读产品配置来显示同版本模型目录;它只把选中的 model ID 通过 `update current Session model` 交给 Runtime。Client 不持有 Session 写入权,也不通过 IPC 管理模型目录或默认值。 - 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。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index 54f03fb519..bd42e07f81 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -205,7 +205,7 @@ flowchart TB 一次性 Headless CLI 继续 Embedded;公开 SDK 默认连接私有 SDK Host。Shared Agent Runtime process 和 SDK Host 都是 Rust 产品进程, 与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。 -当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、Permission/UserInput、 +当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、当前 Session 的 Agent mode/model、Permission/UserInput、 ownership 和生命周期治理;GUI、Headless CLI、ACP、SDK Host、Server/Remote 仍没有 Shared consumer。该图中的多入口逻辑复用是 当前事实,除 Shared TUI 外的跨进程 Shared deployment 仍是目标架构。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 3c19503dee..7ebb67172f 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、当前 Session Agent mode、Turn submit/cancel、Permission 和 UserInput | +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session Agent mode/model、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、模型、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。 +Shared TUI 不提供 Session delete/fork、模型目录/默认值、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 +Shared 模式的命令面板、快捷键帮助和底部提示使用同一能力投影:`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode,`/models` 只切换当前 Session 的 model,二者都不进入管理页面或修改未来 Session 的默认值;其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权,单个连接已有活动 Turn 时拒绝重复提交和 Session mode/model update;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 #### 管理与诊断 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 01cb39cf06..10f81fed29 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -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|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/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|UpdateSessionMode|UpdateSessionModel|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|Self|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|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 91d07e9f04..f7bf82645c 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4854,11 +4854,12 @@ export function runManifestParserSelfTest({ 'ReplayEvents', 'ReadTranscript', 'DetachSession', - 'UpdateSessionModel', + 'ManageModels', 'ManageAgents', ].every((name) => runtimeIpcOperationPattern.test(` ${name},`)) || runtimeIpcOperationPattern.test(' Health,') || runtimeIpcOperationPattern.test(' UpdateSessionMode {') || + runtimeIpcOperationPattern.test(' UpdateSessionModel {') || 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 bc01ed82b4..8a2e18f8db 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -114,44 +114,45 @@ 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. 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`."; + "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. Use `/agent`, Tab, or Shift+Tab to change the current Session Agent mode, and `/models` to change its model. Model configuration, 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 { - matches!( - self, - Self::Help - | Self::ClearConversation - | Self::SelectTheme - | Self::NewSession - | Self::Sessions - | Self::AcpHelp - | Self::Init - | Self::History - | Self::ToggleAutoApprove - | Self::OpenAgentSelector - | Self::SwitchAgent - | Self::SwitchAgentReverse - | Self::Exit - | Self::OpenPalette - | Self::SubmitInput - | Self::Interrupt - | Self::ClosePopups - | Self::NavigateBack - | Self::InsertNewline - | Self::Paste - | Self::ToggleFocusedTool - | Self::PreviousTool - | Self::NextTool - | Self::HistoryPrevious - | Self::HistoryNext - | Self::JumpTop - | Self::JumpBottom - | Self::ClearInput - | Self::ToggleBrowse - | Self::ScrollUp - | Self::ScrollDown - ) + pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool { + (matches!(self, Self::SelectModel) && matches!(context, ActionContext::Chat)) + || matches!( + self, + Self::Help + | Self::ClearConversation + | Self::SelectTheme + | Self::NewSession + | Self::Sessions + | Self::AcpHelp + | Self::Init + | Self::History + | Self::ToggleAutoApprove + | Self::OpenAgentSelector + | Self::SwitchAgent + | Self::SwitchAgentReverse + | Self::Exit + | Self::OpenPalette + | Self::SubmitInput + | Self::Interrupt + | Self::ClosePopups + | Self::NavigateBack + | Self::InsertNewline + | Self::Paste + | Self::ToggleFocusedTool + | Self::PreviousTool + | Self::NextTool + | Self::HistoryPrevious + | Self::HistoryNext + | Self::JumpTop + | Self::JumpBottom + | Self::ClearInput + | Self::ToggleBrowse + | Self::ScrollUp + | Self::ScrollDown + ) } } @@ -299,9 +300,9 @@ static ACTION_SPECS: &[ActionSpec] = &[ id: "select_model", name: "Select model", aliases: &["/models"], - description: "Select AI model for all modes", + description: "Select model", contexts: BOTH, - availability: ActionAvailability::Always, + availability: ActionAvailability::Idle, handler: ActionHandler::SelectModel, default_bindings: &[], fallback_bindings: &[], @@ -891,7 +892,7 @@ impl ActionSpec { if !self.supports_context(state.context) { return false; } - if state.shared_tui && !self.handler.available_in_shared_tui_preview() { + if state.shared_tui && !self.handler.available_in_shared_tui(state.context) { return false; } match self.availability { @@ -903,15 +904,20 @@ impl ActionSpec { } fn description(&self, state: ActionState) -> &'static str { - if state.shared_tui && self.handler == ActionHandler::OpenAgentSelector { - "Choose an Agent mode" - } else { - self.description + match (self.handler, state.context) { + (ActionHandler::OpenAgentSelector, _) if state.shared_tui => "Choose an Agent mode", + (ActionHandler::SelectModel, ActionContext::Chat) => { + "Select model for the current session" + } + (ActionHandler::SelectModel, ActionContext::Startup) => { + "Select default model for future sessions" + } + _ => self.description, } } pub(crate) fn unavailable_message(&self, state: ActionState) -> String { - if state.shared_tui && !self.handler.available_in_shared_tui_preview() { + if state.shared_tui && !self.handler.available_in_shared_tui(state.context) { return format!( "{} is unavailable in Shared TUI preview. {}", self.name, SHARED_TUI_EMBEDDED_HANDOFF @@ -1195,7 +1201,7 @@ impl ResolvedBinding { if !self.spec.supports_context(state.context) { return false; } - if state.shared_tui && !self.spec.handler.available_in_shared_tui_preview() { + if state.shared_tui && !self.spec.handler.available_in_shared_tui(state.context) { return false; } if state.popup_open @@ -1781,18 +1787,21 @@ mod tests { use super::*; #[test] - 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()); + fn shared_tui_supports_current_session_model_selection_without_model_management() { + assert!(ActionHandler::Sessions.available_in_shared_tui(ActionContext::Chat)); + assert!(ActionHandler::Interrupt.available_in_shared_tui(ActionContext::Chat)); for action in [ ActionHandler::OpenAgentSelector, ActionHandler::SwitchAgent, ActionHandler::SwitchAgentReverse, + ActionHandler::SelectModel, ] { - assert!(action.available_in_shared_tui_preview(), "{action:?}"); + assert!( + action.available_in_shared_tui(ActionContext::Chat), + "{action:?}" + ); } for action in [ - ActionHandler::SelectModel, ActionHandler::McpServers, ActionHandler::Tools, ActionHandler::Extensions, @@ -1801,11 +1810,15 @@ mod tests { ActionHandler::Login, ActionHandler::Usage, ] { - assert!(!action.available_in_shared_tui_preview(), "{action:?}"); + assert!( + !action.available_in_shared_tui(ActionContext::Chat), + "{action:?}" + ); } + assert!(!ActionHandler::SelectModel.available_in_shared_tui(ActionContext::Startup)); 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("`/models`")); assert!(SHARED_TUI_HELP_NOTE.contains("Agent/Subagent management")); assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); } @@ -1822,14 +1835,7 @@ mod tests { .map(|action| action.id) .collect::>(); - for unavailable in [ - "select_model", - "skills", - "mcp_servers", - "extensions", - "hooks", - "usage", - ] { + for unavailable in ["skills", "mcp_servers", "extensions", "hooks", "usage"] { assert!(!slash_ids.contains(&unavailable), "{unavailable}"); assert!(!palette_ids.contains(&unavailable), "{unavailable}"); } @@ -1838,9 +1844,39 @@ mod tests { } assert!(slash_ids.contains(&"switch_agent")); assert!(palette_ids.contains(&"switch_agent")); + assert!(slash_ids.contains(&"select_model")); + assert!(palette_ids.contains(&"select_model")); let help = ResolvedKeymap::new(&ShortcutsConfig::default()).help_text(state); assert!(help.contains("Switch Agent")); + let model_action = slash_actions(state) + .into_iter() + .find(|action| action.id == "select_model") + .expect("shared chat model action"); + assert_eq!( + model_action.description, + "Select model for the current session" + ); + + let startup_state = ActionState::startup(false).for_shared_tui(); + assert!(!slash_actions(startup_state) + .iter() + .any(|action| action.id == "select_model")); + } + + #[test] + fn model_action_describes_session_scope_in_chat_and_default_scope_at_startup() { + let model = action_by_id("select_model", ActionContext::Chat).expect("model action"); + + assert_eq!( + model.description(ActionState::chat(false, false)), + "Select model for the current session" + ); + assert_eq!( + model.description(ActionState::startup(false)), + "Select default model for future sessions" + ); + assert!(!model.available(ActionState::chat(true, false))); } fn resolve_id( diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 7ad92a208f..f62f960b74 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -66,45 +66,74 @@ fn validated_session_summary( } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct SessionModeMigrationNotice { - pub(crate) previous_mode_id: String, - pub(crate) restored_mode_id: String, +pub(crate) enum SessionMigrationNotice { + Mode { + previous_id: String, + restored_id: String, + }, + Model { + previous_id: String, + restored_id: String, + }, } -impl SessionModeMigrationNotice { +impl SessionMigrationNotice { pub(crate) fn user_message(&self) -> String { + let (setting, previous_id, restored_id) = match self { + Self::Mode { + previous_id, + restored_id, + } => ("mode", previous_id, restored_id), + Self::Model { + previous_id, + restored_id, + } => ("model", previous_id, restored_id), + }; format!( - "Session mode \"{}\" is unavailable. This session was restored with \"{}\". Review the mode before continuing.", - self.previous_mode_id, self.restored_mode_id + "Session {setting} \"{previous_id}\" is unavailable. This session was restored with \"{restored_id}\". Review the {setting} before continuing." ) } } -fn session_mode_migration_notice( +fn session_migration_notices( previous: &AgentSessionSummary, restored: &AgentSessionSummary, -) -> Option { - (previous.agent_type != restored.agent_type).then(|| SessionModeMigrationNotice { - previous_mode_id: previous.agent_type.clone(), - restored_mode_id: restored.agent_type.clone(), - }) +) -> Vec { + let mut notices = Vec::with_capacity(2); + if previous.agent_type != restored.agent_type { + notices.push(SessionMigrationNotice::Mode { + previous_id: previous.agent_type.clone(), + restored_id: restored.agent_type.clone(), + }); + } + if let (Some(previous_id), Some(restored_id)) = + (previous.model_id.as_ref(), restored.model_id.as_ref()) + { + if previous_id != restored_id { + notices.push(SessionMigrationNotice::Model { + previous_id: previous_id.clone(), + restored_id: restored_id.clone(), + }); + } + } + notices } #[derive(Debug)] -pub(crate) struct SessionModeUpdateError { +pub(crate) struct SessionUpdateError { message: String, outcome_unknown: bool, } -impl fmt::Display for SessionModeUpdateError { +impl fmt::Display for SessionUpdateError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(&self.message) } } -impl std::error::Error for SessionModeUpdateError {} +impl std::error::Error for SessionUpdateError {} -impl SessionModeUpdateError { +impl SessionUpdateError { fn runtime(error: RuntimeError) -> Self { Self { message: error.into_message(), @@ -446,7 +475,7 @@ impl CliAgentRuntimeClient { ) -> Result<( AgentSessionSummary, AgentSessionWorkspaceBinding, - Option, + Vec, SessionTranscript, )> { tracing::info!("Restoring session: {}", session_id); @@ -529,8 +558,8 @@ impl CliAgentRuntimeClient { ); } - let migration_notice = session_mode_migration_notice(&previous_summary, &restored); - Ok((restored, binding, migration_notice, transcript)) + let migration_notices = session_migration_notices(&previous_summary, &restored); + Ok((restored, binding, migration_notices, transcript)) } async fn resolve_session_workspace_binding( @@ -586,21 +615,31 @@ impl CliAgentRuntimeClient { &self, session_id: &str, model_id: &str, - ) -> Result<()> { - self.embedded_runtime("changing the session model")? - .update_session_model(AgentSessionModelUpdateRequest { - session_id: session_id.to_string(), - model_id: model_id.to_string(), - }) - .await - .map_err(|error| anyhow::anyhow!(error.into_message())) + ) -> std::result::Result<(), SessionUpdateError> { + let request = AgentSessionModelUpdateRequest { + session_id: session_id.to_string(), + model_id: model_id.to_string(), + }; + match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => runtime + .update_session_model(request) + .await + .map_err(SessionUpdateError::runtime), + CliAgentRuntimeBackend::Shared(client) => { + let result = client + .request(RuntimeIpcOperation::UpdateSessionModel { request }) + .await + .map_err(SessionUpdateError::shared)?; + expect_unit(result, "update_session_model").map_err(SessionUpdateError::unexpected) + } + } } pub(crate) async fn update_session_mode( &self, session_id: &str, mode_id: &str, - ) -> std::result::Result<(), SessionModeUpdateError> { + ) -> std::result::Result<(), SessionUpdateError> { let request = AgentSessionModeUpdateRequest { session_id: session_id.to_string(), mode_id: mode_id.to_string(), @@ -609,14 +648,13 @@ impl CliAgentRuntimeClient { CliAgentRuntimeBackend::Embedded(runtime) => runtime .update_session_mode(request) .await - .map_err(SessionModeUpdateError::runtime), + .map_err(SessionUpdateError::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) + .map_err(SessionUpdateError::shared)?; + expect_unit(result, "update_session_mode").map_err(SessionUpdateError::unexpected) } } } @@ -1245,8 +1283,9 @@ mod tests { use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; use super::{ - project_routed_permission_event, session_mode_migration_notice, shared_disconnect_message, - shared_restore_error, validated_session_summary, CliWorkspacePaths, SessionModeUpdateError, + project_routed_permission_event, session_migration_notices, shared_disconnect_message, + shared_restore_error, validated_session_summary, CliWorkspacePaths, SessionMigrationNotice, + SessionUpdateError, }; use bitfun_agent_runtime_ipc::RuntimeIpcStreamInvalidationReason; @@ -1262,12 +1301,11 @@ mod tests { } #[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(), - })); + fn shared_session_update_preserves_unknown_outcome_as_a_typed_fact() { + let error = SessionUpdateError::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")); @@ -1277,14 +1315,14 @@ mod tests { RuntimeIpcClientError::Disconnected, RuntimeIpcClientError::UnexpectedResponse, ] { - assert!(SessionModeUpdateError::shared(transport_error).outcome_unknown()); + assert!(SessionUpdateError::shared(transport_error).outcome_unknown()); } assert!( - SessionModeUpdateError::unexpected(anyhow::anyhow!("unexpected response shape")) + SessionUpdateError::unexpected(anyhow::anyhow!("unexpected response shape")) .outcome_unknown() ); assert!( - !SessionModeUpdateError::shared(RuntimeIpcClientError::Remote(RuntimeIpcError { + !SessionUpdateError::shared(RuntimeIpcClientError::Remote(RuntimeIpcError { code: RuntimeIpcErrorCode::InvalidRequest, message: "unknown mode".to_string(), },)) @@ -1347,15 +1385,13 @@ mod tests { #[test] fn model_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 model\")?", - "\n .update_session_model", - ] - .concat(); let compatibility_update = ["self.compatibility", "\n .update_session_model"].concat(); - assert!(source.contains(&runtime_update)); + assert!(source.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(source.contains("runtime.update_session_model(request)")); + assert!(source.contains("CliAgentRuntimeBackend::Shared(client)")); + assert!(source.contains("RuntimeIpcOperation::UpdateSessionModel { request }")); assert!(!source.contains(&compatibility_update)); } @@ -1439,18 +1475,45 @@ mod tests { }; let restored = session_summary("mode-migration"); - let notice = session_mode_migration_notice(&previous, &restored) - .expect("changed mode should be reported to the TUI"); + let notices = session_migration_notices(&previous, &restored); - assert_eq!(notice.previous_mode_id, "removed-mode"); - assert_eq!(notice.restored_mode_id, "agentic"); + assert_eq!( + notices, + vec![SessionMigrationNotice::Mode { + previous_id: "removed-mode".to_string(), + restored_id: "agentic".to_string(), + }] + ); + } + + #[test] + fn restore_reports_a_cli_local_notice_when_core_migrates_the_model() { + let previous = AgentSessionSummary { + model_id: Some("removed-model".to_string()), + ..session_summary("model-migration") + }; + let restored = AgentSessionSummary { + model_id: Some("auto".to_string()), + ..session_summary("model-migration") + }; + + let notices = session_migration_notices(&previous, &restored); + + assert_eq!( + notices, + vec![SessionMigrationNotice::Model { + previous_id: "removed-model".to_string(), + restored_id: "auto".to_string(), + }] + ); + assert!(notices[0].user_message().contains("unavailable")); } #[test] - fn restore_does_not_report_a_notice_when_the_mode_is_unchanged() { + fn restore_does_not_report_notices_when_session_settings_are_unchanged() { let summary = session_summary("unchanged-mode"); - assert!(session_mode_migration_notice(&summary, &summary).is_none()); + assert!(session_migration_notices(&summary, &summary).is_empty()); } #[test] diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index fbcde75038..8aba9c1257 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -318,7 +318,9 @@ pub(crate) struct ChatState { /// Empty-session preference. The actual worktree is created only after the /// user submits the first prompt. worktree_isolation_requested: Option, - /// Current model display name (shown in shortcuts bar) + /// Current Session model identity reported by the Runtime owner. + pub current_model_id: Option, + /// Current model display name (shown in shortcuts bar). pub current_model_name: String, /// Effective Auto mode for permission results that evaluate to Ask. pub auto_approve_ask: bool, @@ -379,6 +381,7 @@ impl ChatState { is_git_repository: false, worktree_control_available: true, worktree_isolation_requested: None, + current_model_id: None, current_model_name: String::new(), auto_approve_ask: false, messages: Vec::new(), diff --git a/src/apps/cli/src/model_selection.rs b/src/apps/cli/src/model_selection.rs index 3267be14ab..b4bd59ac1c 100644 --- a/src/apps/cli/src/model_selection.rs +++ b/src/apps/cli/src/model_selection.rs @@ -1,15 +1,32 @@ use bitfun_core::service::config::AIConfig; -/// Resolve the shared future-mode selector to the concrete enabled model shown -/// by CLI model pickers and status surfaces. -pub(crate) fn resolve_mode_model_id(ai_config: &AIConfig) -> Option { - let selector = ai_config.agent_model_defaults.mode.trim(); - match selector { +fn resolve_model_selector(ai_config: &AIConfig, selector: &str) -> Option { + match selector.trim() { "" | "auto" | "default" => ai_config.resolve_model_selection("primary"), selector => ai_config.resolve_model_selection(selector), } } +/// Resolve the shared future-mode selector to the concrete enabled model shown +/// by CLI model pickers and status surfaces. +pub(crate) fn resolve_mode_model_id(ai_config: &AIConfig) -> Option { + resolve_model_selector(ai_config, &ai_config.agent_model_defaults.mode) +} + +/// Resolve the Runtime-owned Session selector to the concrete catalog model +/// used by CLI display surfaces. A missing selector is limited to the fresh +/// Session fallback; it does not become Session authority in the Client. +pub(crate) fn resolve_session_model_display_id( + ai_config: &AIConfig, + session_selector: Option<&str>, +) -> Option { + let selector = session_selector + .map(str::trim) + .filter(|selector| !selector.is_empty()) + .unwrap_or(ai_config.agent_model_defaults.mode.as_str()); + resolve_model_selector(ai_config, selector) +} + #[cfg(test)] mod tests { use super::*; @@ -65,4 +82,36 @@ mod tests { Some("explicit-model") ); } + + #[test] + fn resolves_runtime_session_selectors_to_the_effective_catalog_model() { + let config = config_with_selector("fast"); + + assert_eq!( + resolve_session_model_display_id(&config, Some("auto")).as_deref(), + Some("primary-model") + ); + assert_eq!( + resolve_session_model_display_id(&config, Some("primary")).as_deref(), + Some("primary-model") + ); + assert_eq!( + resolve_session_model_display_id(&config, Some("fast")).as_deref(), + Some("fast-model") + ); + assert_eq!( + resolve_session_model_display_id(&config, Some("explicit-model")).as_deref(), + Some("explicit-model") + ); + } + + #[test] + fn missing_runtime_session_selector_uses_the_future_session_default_for_display_only() { + let config = config_with_selector("fast"); + + assert_eq!( + resolve_session_model_display_id(&config, None).as_deref(), + Some("fast-model") + ); + } } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 4293b80cb5..25d562b5fc 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, SessionModeUpdateError}; +use crate::agent::runtime_client::{CliAgentRuntimeClient, SessionUpdateError}; use crate::chat_state::ChatState; use crate::config::CliConfig; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; @@ -167,17 +167,43 @@ enum PendingMcpTask { }, } -struct PendingModeChange { +enum PendingSessionUpdateKind { + Mode { + mode_id: String, + }, + Model { + model_id: String, + display_name: String, + }, +} + +impl PendingSessionUpdateKind { + fn name(&self) -> &'static str { + match self { + Self::Mode { .. } => "agent mode", + Self::Model { .. } => "model", + } + } + + fn selected_id(&self) -> &str { + match self { + Self::Mode { mode_id } => mode_id, + Self::Model { model_id, .. } => model_id, + } + } +} + +struct PendingSessionUpdate { session_id: String, - mode_id: String, + kind: PendingSessionUpdateKind, 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."; +const SESSION_UPDATE_SLOW_NOTICE: Duration = Duration::from_secs(15); +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, turns, the current Session Agent mode, and the current Session model; model management remains Embedded, along with local extension, MCP, account-sync, and Agent/Subagent management."; #[derive(Default)] struct NonKeyEventOutcome { @@ -215,9 +241,9 @@ pub(crate) struct ChatMode { pending_mcp_op: Option, /// Running MCP tasks (non-blocking, polled in main loop) pending_mcp_tasks: Vec, - /// One durable mode update in flight. The event loop remains responsive + /// One durable current-Session update in flight. The event loop remains responsive /// while the runtime owner writes session metadata. - pending_mode_change: Option, + pending_session_update: Option, external_source_snapshot: Option, external_source_conflict_choices: BTreeMap, external_source_conflict_lineage_current_keys: BTreeMap, @@ -271,7 +297,7 @@ impl ChatMode { initial_prompt: None, pending_mcp_op: None, pending_mcp_tasks: Vec::new(), - pending_mode_change: None, + pending_session_update: None, external_source_snapshot: None, external_source_conflict_choices: BTreeMap::new(), external_source_conflict_lineage_current_keys: BTreeMap::new(), diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index b6ee25f2c0..baffdc116e 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -1,8 +1,8 @@ -fn mode_change_blocks_typed_submission(pending_for_current_session: bool, input: &str) -> bool { +fn session_update_blocks_typed_submission(pending_for_current_session: bool, input: &str) -> bool { pending_for_current_session && !input.trim().starts_with('/') } -fn pending_mode_change_blocks_runtime_action( +fn pending_session_update_blocks_runtime_action( shared_tui: bool, pending_for_current_session: bool, handler: ActionHandler, @@ -645,16 +645,16 @@ impl ChatMode { return Ok(None); } let pending_for_current_session = self - .pending_mode_change + .pending_session_update .as_ref() .is_some_and(|pending| pending.session_id == chat_state.core_session_id); - if pending_mode_change_blocks_runtime_action( + if pending_session_update_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 {}.", + "Waiting for the current session update to finish before using {}.", action.name ))); return Ok(None); @@ -849,12 +849,12 @@ impl ChatMode { let trimmed = chat_view.input_text().trim(); let pending_for_current_session = self - .pending_mode_change + .pending_session_update .as_ref() .is_some_and(|pending| pending.session_id == chat_state.core_session_id); - if mode_change_blocks_typed_submission(pending_for_current_session, trimmed) { + if session_update_blocks_typed_submission(pending_for_current_session, trimmed) { chat_view.set_status(Some( - "Waiting for the agent mode change to finish before sending.".to_string(), + "Waiting for the current session update to finish before sending.".to_string(), )); return Ok(None); } diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index c614cc7589..e9ac2d7f69 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -1,5 +1,5 @@ -fn shared_session_change_is_blocked(is_shared: bool, mode_change_pending: bool) -> bool { - is_shared && mode_change_pending +fn shared_session_change_is_blocked(is_shared: bool, session_update_pending: bool) -> bool { + is_shared && session_update_pending } impl ChatMode { @@ -127,7 +127,7 @@ impl ChatMode { self.apply_model_selection(&selected, chat_view, chat_state, rt_handle); } } - KeyCode::Char('e') => { + KeyCode::Char('e') if chat_view.model_selector_allows_edit() => { if let Some(selected) = chat_view.model_selector_confirm() { chat_view.hide_model_selector(); self.edit_model(&selected, chat_view, rt_handle); @@ -404,16 +404,17 @@ impl ChatMode { ChatExitReason::SwitchSession(_) | ChatExitReason::NewSession ) && shared_session_change_is_blocked( this.agent.is_shared(), - this.pending_mode_change.is_some(), + this.pending_session_update.is_some(), ) { chat_view.set_status(Some( - "Wait for the agent mode change to finish before changing sessions.".to_string(), + "Wait for the current session update 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() { + if let Some(pending) = this.pending_session_update.as_mut() { pending.exit_warning_shown = false; } match this.switch_to_session( @@ -431,7 +432,7 @@ impl ChatMode { } } ChatExitReason::NewSession => { - if let Some(pending) = this.pending_mode_change.as_mut() { + if let Some(pending) = this.pending_session_update.as_mut() { pending.exit_warning_shown = false; } match this.create_new_session(session_id, chat_state, chat_view, rt_handle) { @@ -444,11 +445,11 @@ impl ChatMode { } } ChatExitReason::Quit => { - if let Some(pending) = this.pending_mode_change.as_mut() { + if let Some(pending) = this.pending_session_update.as_mut() { if !pending.exit_warning_shown { pending.exit_warning_shown = true; chat_view.set_status(Some( - "Exit requested. Waiting for the agent mode change to finish; exit again to leave now. This mode change may not be saved, and the next restore will use the last successfully persisted mode." + "Exit requested. Waiting for the current session update to finish; exit again to leave now. The update may not be saved, and the next restore will use the last successfully persisted session state." .to_string(), )); return; diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 8ff93de241..62626467bf 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -43,7 +43,7 @@ impl ChatMode { }) }); - let (mut session_id, mut chat_state, mode_migration_notice) = + let (mut session_id, mut chat_state, migration_notices) = if let Some(ref restore_id) = self.restore_session_id { // Restore existing session tracing::info!("Restoring session: {}", restore_id); @@ -53,7 +53,7 @@ impl ChatMode { tokio::task::block_in_place(|| { rt_handle.block_on(async { // Restore session in core (loads metadata, messages, managers) - let (summary, workspace_binding, migration_notice, transcript) = + let (summary, workspace_binding, migration_notices, transcript) = agent.restore_session_in_current_workspace(&rid).await?; let effective_workspace = Some(workspace_binding.workspace_path.clone()); @@ -64,6 +64,7 @@ impl ChatMode { effective_workspace, &transcript, ); + state.current_model_id = summary.model_id; state.apply_workspace_binding(workspace_binding); tracing::info!( @@ -72,7 +73,7 @@ impl ChatMode { transcript.messages.len() ); - Ok::<_, anyhow::Error>((rid, state, migration_notice)) + Ok::<_, anyhow::Error>((rid, state, migration_notices)) }) })? } else { @@ -95,7 +96,7 @@ impl ChatMode { Some(workspace_binding.workspace_path.clone()), ); state.apply_workspace_binding(workspace_binding); - (session_id, state, None) + (session_id, state, Vec::new()) }; chat_state.set_worktree_control_available(!self.agent.is_shared()); self.auto_approve_ask_override = None; @@ -199,16 +200,16 @@ impl ChatMode { } } - if let Some(notice) = &mode_migration_notice { + for notice in &migration_notices { chat_state.add_system_message(notice.user_message()); } // Send initial prompt if provided (from startup page input) if let Some(prompt) = self.initial_prompt.take() { - if mode_migration_notice.is_some() { + if !migration_notices.is_empty() { chat_view.text_input.set_text(&prompt); chat_view.set_status(Some( - "The restored session uses a fallback mode. Review it, then send the preserved input explicitly." + "The restored session uses fallback settings. Review them, then send the preserved input explicitly." .to_string(), )); } else if prompt.starts_with('/') { @@ -251,9 +252,9 @@ impl ChatMode { self.action_state(chat_state.is_processing, false), &self.keymap, ); - chat_view.set_agent_mode_switch_allowed(agent_mode_switch_allowed( + chat_view.set_agent_mode_switch_allowed(session_update_allowed( chat_state.is_processing, - self.pending_mode_change.is_some(), + self.pending_session_update.is_some(), )); // Keep spinner animation smooth without forcing full redraw every loop. @@ -273,15 +274,15 @@ impl ChatMode { if self.poll_mcp_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { needs_redraw = true; } - match self.poll_mode_change_completion(&mut chat_view, &mut chat_state, &rt_handle) { - ModeChangePollOutcome::NoChange => {} - ModeChangePollOutcome::Redraw => needs_redraw = true, - ModeChangePollOutcome::ExitAfterSave => { + match self.poll_session_update_completion(&mut chat_view, &mut chat_state, &rt_handle) { + SessionUpdatePollOutcome::NoChange => {} + SessionUpdatePollOutcome::Redraw => needs_redraw = true, + SessionUpdatePollOutcome::ExitAfterSave => { should_quit = true; exit_reason = ChatExitReason::Quit; continue; } - ModeChangePollOutcome::ExitAfterUnknownOutcome(message) => { + SessionUpdatePollOutcome::ExitAfterUnknownOutcome(message) => { fatal_event_stream_error = Some(message); break; } @@ -704,6 +705,26 @@ impl ChatMode { } } + AgenticEvent::SessionModelAutoMigrated { + session_id, + previous_model_id, + new_model_id, + reason, + .. + } => { + if apply_session_model_migration( + &mut chat_state, + session_id, + previous_model_id, + new_model_id, + reason, + ) { + self.load_current_model_name(&mut chat_state, &rt_handle); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + } + AgenticEvent::SystemError { error, .. } => { chat_state.add_system_message(format!("[System error: {}]", error)); chat_view.invalidate_lines_cache(); diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index c2f654b3de..a4bf42f088 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -1,41 +1,35 @@ -enum ModelSelectionApplyOutcome { - SessionUpdateFailed(String), - Applied { - default_persist_error: Option, - }, -} - -enum ModeSelectionApplyOutcome { +enum SessionUpdateApplyOutcome { SessionUpdateFailed(String), OutcomeUnknown(String), Applied, } -enum ModeChangePollOutcome { +enum SessionUpdatePollOutcome { NoChange, Redraw, ExitAfterSave, ExitAfterUnknownOutcome(String), } -fn previous_session_mode_change_status( - mode_id: &str, - outcome: &ModeSelectionApplyOutcome, +fn previous_session_update_status( + setting_name: &str, + selected_id: &str, + outcome: &SessionUpdateApplyOutcome, ) -> String { match outcome { - ModeSelectionApplyOutcome::Applied => format!( - "The previous session mode was changed to {mode_id}; the current session was not modified." + SessionUpdateApplyOutcome::Applied => format!( + "The previous session {setting_name} was changed to {selected_id}; the current session was not modified." ), - ModeSelectionApplyOutcome::SessionUpdateFailed(error) => format!( - "The previous session mode change to {mode_id} failed: {error}. Return to that session to retry." + SessionUpdateApplyOutcome::SessionUpdateFailed(error) => format!( + "The previous session {setting_name} change to {selected_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." + SessionUpdateApplyOutcome::OutcomeUnknown(error) => format!( + "The previous session {setting_name} change to {selected_id} has an unknown outcome: {error}. Reopen Shared TUI, restore that session, and inspect its current {setting_name} before retrying." ), } } -fn mode_change_completion_should_exit(exit_requested: bool, applied: bool) -> bool { +fn session_update_completion_should_exit(exit_requested: bool, applied: bool) -> bool { exit_requested && applied } @@ -43,10 +37,10 @@ fn apply_agent_mode_feedback( current_mode: &mut String, chat_state: &mut ChatState, selected_mode: &str, - outcome: ModeSelectionApplyOutcome, + outcome: SessionUpdateApplyOutcome, ) -> bool { match outcome { - ModeSelectionApplyOutcome::SessionUpdateFailed(error) => { + SessionUpdateApplyOutcome::SessionUpdateFailed(error) => { tracing::error!( "Failed to switch agent mode to {}: {}", selected_mode, @@ -57,7 +51,7 @@ fn apply_agent_mode_feedback( )); false } - ModeSelectionApplyOutcome::OutcomeUnknown(error) => { + SessionUpdateApplyOutcome::OutcomeUnknown(error) => { tracing::error!( "Agent mode update outcome is unknown for {}: {}", selected_mode, @@ -68,7 +62,7 @@ fn apply_agent_mode_feedback( )); false } - ModeSelectionApplyOutcome::Applied => { + SessionUpdateApplyOutcome::Applied => { *current_mode = selected_mode.to_string(); chat_state.agent_type = selected_mode.to_string(); tracing::info!("Agent mode switched to: {}", selected_mode); @@ -95,10 +89,10 @@ fn apply_model_selection_feedback( chat_state: &mut ChatState, selected_display_name: &str, selected_id: &str, - outcome: ModelSelectionApplyOutcome, -) { + outcome: SessionUpdateApplyOutcome, +) -> bool { match outcome { - ModelSelectionApplyOutcome::SessionUpdateFailed(error) => { + SessionUpdateApplyOutcome::SessionUpdateFailed(error) => { tracing::error!( "Failed to switch model to {} ({}): {}", selected_display_name, @@ -108,30 +102,65 @@ fn apply_model_selection_feedback( chat_state.add_system_message(format!( "Current session model was not changed: {error}. Please retry." )); + false + } + SessionUpdateApplyOutcome::OutcomeUnknown(error) => { + tracing::error!( + "Model update outcome is unknown for {} ({}): {}", + selected_display_name, + selected_id, + error + ); + chat_state.add_system_message(format!( + "Model update outcome is unknown: {error}. The Shared connection is closing; reopen Shared TUI, restore this session, and inspect its current model before retrying." + )); + false } - ModelSelectionApplyOutcome::Applied { - default_persist_error, - } => { + SessionUpdateApplyOutcome::Applied => { + chat_state.current_model_id = Some(selected_id.to_string()); chat_state.current_model_name = selected_display_name.to_string(); tracing::info!( "Model switched to: {} ({})", selected_display_name, selected_id ); - if let Some(error) = default_persist_error { - tracing::warn!( - "Current session model changed, but the future default could not be saved: {}", - error - ); - chat_state.add_system_message( - "Model switched for the current session, but the default for future sessions could not be saved. Check configuration storage and retry if needed." - .to_string(), - ); - } + true } } } +fn apply_session_model_migration( + chat_state: &mut ChatState, + event_session_id: &str, + previous_model_id: &str, + new_model_id: &str, + reason: &str, +) -> bool { + if event_session_id != chat_state.core_session_id { + tracing::debug!( + "Ignoring model migration for another session: current_session_id={}, event_session_id={}", + chat_state.core_session_id, + event_session_id + ); + return false; + } + if chat_state.current_model_id.as_deref() != Some(previous_model_id) { + tracing::debug!( + "Ignoring stale model migration: session_id={}, current_model_id={:?}, previous_model_id={}", + event_session_id, + chat_state.current_model_id, + previous_model_id + ); + return false; + } + chat_state.current_model_id = Some(new_model_id.to_string()); + chat_state.current_model_name = new_model_id.to_string(); + chat_state.add_system_message(format!( + "The current session model changed from {previous_model_id} to {new_model_id} because {reason}." + )); + true +} + impl ChatMode { fn logout(&self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle) { let logged_in = @@ -337,9 +366,12 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - if !agent_mode_switch_allowed(chat_state.is_processing, self.pending_mode_change.is_some()) - { - chat_view.set_status(Some(mode_switch_unavailable_message( + if !session_update_allowed( + chat_state.is_processing, + self.pending_session_update.is_some(), + ) { + chat_view.set_status(Some(session_update_unavailable_message( + "Agent mode", chat_state.is_processing, ))); return; @@ -365,12 +397,13 @@ impl ChatMode { self.apply_agent_selection(&selected, chat_view, chat_state, rt_handle); } - /// Load current model name from global config for display + /// Resolve the Runtime-owned Session model through the local product catalog. fn load_current_model_name( &self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { + let session_model_id = chat_state.current_model_id.clone(); let result: Option = tokio::task::block_in_place(|| { rt_handle.block_on(async { let config_service = GlobalConfigManager::get_service().await.ok()?; @@ -379,7 +412,10 @@ impl ChatMode { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - let model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai)?; + let model_id = crate::model_selection::resolve_session_model_display_id( + &global_config.ai, + session_model_id.as_deref(), + )?; fn provider_display_name( model: &bitfun_core::service::config::AIModelConfig, @@ -412,14 +448,17 @@ impl ChatMode { let model_name = models .iter() .find(|model| model.id == model_id) - .map(model_display_name); + .map(model_display_name) + .unwrap_or_else(|| model_id.clone()); - model_name + Some(model_name) }) }); if let Some(name) = result { chat_state.current_model_name = name; + } else if let Some(model_id) = chat_state.current_model_id.as_ref() { + chat_state.current_model_name = model_id.clone(); } } @@ -444,9 +483,11 @@ impl ChatMode { config_service.get_ai_models().await.ok()?; let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - let current_model_id = - crate::model_selection::resolve_mode_model_id(&global_config.ai); + crate::model_selection::resolve_session_model_display_id( + &global_config.ai, + chat_state.current_model_id.as_deref(), + ); // Convert to ModelItem list (only enabled models) let model_items: Vec = models @@ -466,7 +507,7 @@ impl ChatMode { match result { Some((models, current_id)) if !models.is_empty() => { - chat_view.show_model_selector(models, current_id); + chat_view.show_model_selector(models, current_id, !self.agent.is_shared(), true); } _ => { chat_state.add_system_message( @@ -476,55 +517,55 @@ impl ChatMode { } } - /// Apply the current-session model and best-effort future-session default. + /// Apply only the current Session model through the Runtime owner. fn apply_model_selection( - &self, + &mut self, selected: &ModelItem, - _chat_view: &mut ChatView, + chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { let selected_id = selected.id.clone(); let selected_display_name = format!("{} / {}", selected.model_name, selected.name); + if chat_state.current_model_id.as_deref() == Some(selected_id.as_str()) { + chat_view.set_status(Some(format!( + "Current session already uses {selected_display_name}" + ))); + return; + } + if !session_update_allowed( + chat_state.is_processing, + self.pending_session_update.is_some(), + ) { + chat_view.set_status(Some(session_update_unavailable_message( + "Model", + chat_state.is_processing, + ))); + return; + } let session_id = chat_state.core_session_id.clone(); - - let outcome = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - if let Err(e) = self - .agent - .update_session_model(&session_id, &selected_id) - .await - { - return ModelSelectionApplyOutcome::SessionUpdateFailed(e.to_string()); - } - - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - return ModelSelectionApplyOutcome::Applied { - default_persist_error: Some(e.to_string()), - }; - } - }; - - if let Err(e) = config_service - .set_config("ai.agent_model_defaults.mode", &selected_id) - .await - { - return ModelSelectionApplyOutcome::Applied { - default_persist_error: Some(e.to_string()), - }; - } - - crate::account_sync::notify_local_settings_changed(); - - ModelSelectionApplyOutcome::Applied { - default_persist_error: None, - } - }) + let task_session_id = session_id.clone(); + let task_model_id = selected_id.clone(); + let agent = self.agent.clone(); + chat_view.set_status(Some(format!( + "Changing current session model to {selected_display_name}..." + ))); + let handle = rt_handle.spawn(async move { + agent + .update_session_model(&task_session_id, &task_model_id) + .await + }); + self.pending_session_update = Some(PendingSessionUpdate { + session_id, + kind: PendingSessionUpdateKind::Model { + model_id: selected_id, + display_name: selected_display_name, + }, + started_at: Instant::now(), + slow_notice_shown: false, + exit_warning_shown: false, + handle, }); - - apply_model_selection_feedback(chat_state, &selected_display_name, &selected_id, outcome); } /// Show agent selector popup with all available agent modes @@ -555,8 +596,10 @@ impl ChatMode { }) .collect(); - let allow_mode_switch = - agent_mode_switch_allowed(chat_state.is_processing, self.pending_mode_change.is_some()); + let allow_mode_switch = session_update_allowed( + chat_state.is_processing, + self.pending_session_update.is_some(), + ); if self.agent.is_shared() { chat_view.show_agent_modes_only( agent_items, @@ -582,11 +625,12 @@ impl ChatMode { ) { match action { AgentSelectorAction::SwitchMode(selected) => { - if !agent_mode_switch_allowed( + if !session_update_allowed( chat_state.is_processing, - self.pending_mode_change.is_some(), + self.pending_session_update.is_some(), ) { - chat_view.set_status(Some(mode_switch_unavailable_message( + chat_view.set_status(Some(session_update_unavailable_message( + "Agent mode", chat_state.is_processing, ))); return; @@ -616,9 +660,9 @@ impl ChatMode { return; } - if self.pending_mode_change.is_some() { + if self.pending_session_update.is_some() { chat_view.set_status(Some( - "An agent mode change is already in progress. Please wait.".to_string(), + "A current session update is already in progress. Please wait.".to_string(), )); return; } @@ -634,9 +678,9 @@ impl ChatMode { .update_session_mode(&task_session_id, &task_mode_id) .await }); - self.pending_mode_change = Some(PendingModeChange { + self.pending_session_update = Some(PendingSessionUpdate { session_id, - mode_id, + kind: PendingSessionUpdateKind::Mode { mode_id }, started_at: Instant::now(), slow_notice_shown: false, exit_warning_shown: false, @@ -644,75 +688,100 @@ impl ChatMode { }); } - fn poll_mode_change_completion( + fn poll_session_update_completion( &mut self, chat_view: &mut ChatView, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, - ) -> ModeChangePollOutcome { - let Some(pending) = self.pending_mode_change.as_mut() else { - return ModeChangePollOutcome::NoChange; + ) -> SessionUpdatePollOutcome { + let Some(pending) = self.pending_session_update.as_mut() else { + return SessionUpdatePollOutcome::NoChange; }; if !pending.handle.is_finished() { - if !pending.slow_notice_shown && pending.started_at.elapsed() >= MODE_CHANGE_SLOW_NOTICE + if !pending.slow_notice_shown + && pending.started_at.elapsed() >= SESSION_UPDATE_SLOW_NOTICE { pending.slow_notice_shown = true; if !pending.exit_warning_shown { 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." + "The current session update 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." + "The current session update is still being saved. You can edit or switch sessions; sending in this session waits." }; chat_view.set_status(Some(message.to_string())); } - return ModeChangePollOutcome::Redraw; + return SessionUpdatePollOutcome::Redraw; } - return ModeChangePollOutcome::NoChange; + return SessionUpdatePollOutcome::NoChange; } let pending = self - .pending_mode_change + .pending_session_update .take() - .expect("finished mode task should remain present"); + .expect("finished session update should remain present"); let outcome = match tokio::task::block_in_place(|| rt_handle.block_on(pending.handle)) { - Ok(Ok(())) => ModeSelectionApplyOutcome::Applied, + Ok(Ok(())) => SessionUpdateApplyOutcome::Applied, Ok(Err(error)) if error.outcome_unknown() => { - ModeSelectionApplyOutcome::OutcomeUnknown(error.to_string()) + SessionUpdateApplyOutcome::OutcomeUnknown(error.to_string()) } - Ok(Err(error)) => ModeSelectionApplyOutcome::SessionUpdateFailed(error.to_string()), - Err(error) => ModeSelectionApplyOutcome::SessionUpdateFailed(format!( - "mode update task failed: {error}" + Ok(Err(error)) => SessionUpdateApplyOutcome::SessionUpdateFailed(error.to_string()), + Err(error) => SessionUpdateApplyOutcome::SessionUpdateFailed(format!( + "session update task failed: {error}" )), }; if chat_state.core_session_id != pending.session_id { - if let ModeSelectionApplyOutcome::SessionUpdateFailed(error) = &outcome { + if let SessionUpdateApplyOutcome::SessionUpdateFailed(error) = &outcome { tracing::error!( - "Failed to switch previous session {} to agent mode {}: {}", + "Failed to change previous session {} {} to {}: {}", pending.session_id, - pending.mode_id, + pending.kind.name(), + pending.kind.selected_id(), error ); } - chat_view.set_status(Some(previous_session_mode_change_status( - &pending.mode_id, + chat_view.set_status(Some(previous_session_update_status( + pending.kind.name(), + pending.kind.selected_id(), &outcome, ))); - return ModeChangePollOutcome::Redraw; + return SessionUpdatePollOutcome::Redraw; } - let unknown_outcome = matches!(&outcome, ModeSelectionApplyOutcome::OutcomeUnknown(_)); - let applied = - apply_agent_mode_feedback(&mut self.agent_type, chat_state, &pending.mode_id, outcome); + let unknown_outcome = matches!(&outcome, SessionUpdateApplyOutcome::OutcomeUnknown(_)); + let applied = match &pending.kind { + PendingSessionUpdateKind::Mode { mode_id } => { + apply_agent_mode_feedback(&mut self.agent_type, chat_state, mode_id, outcome) + } + PendingSessionUpdateKind::Model { + model_id, + display_name, + } => apply_model_selection_feedback(chat_state, display_name, model_id, outcome), + }; if applied { - chat_view.set_status(Some(format!("Agent mode set to {}", pending.mode_id))); + chat_view.set_status(Some(format!( + "Current session {} set to {}", + pending.kind.name(), + pending.kind.selected_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(); + let message = format!( + "Current session {} update outcome is unknown. The Shared connection closed; reopen Shared TUI, restore the session, and inspect its current {} before retrying.", + pending.kind.name(), + pending.kind.name() + ); chat_view.set_status(Some(message.clone())); - return ModeChangePollOutcome::ExitAfterUnknownOutcome(message); + return SessionUpdatePollOutcome::ExitAfterUnknownOutcome(message); } else { - chat_view.set_status(Some("Agent mode change failed. Please retry.".to_string())); + chat_view.set_status(Some(format!( + "Current session {} change failed. Please retry.", + pending.kind.name() + ))); } - if applied && pending.mode_id == "HarmonyOSDev" { + if applied + && matches!( + &pending.kind, + PendingSessionUpdateKind::Mode { mode_id } if mode_id == "HarmonyOSDev" + ) + { let deveco_home = std::env::var("DEVECO_HOME").ok(); let missing = deveco_home .as_deref() @@ -725,45 +794,45 @@ impl ChatMode { ); } } - if mode_change_completion_should_exit(pending.exit_warning_shown, applied) { - ModeChangePollOutcome::ExitAfterSave + if session_update_completion_should_exit(pending.exit_warning_shown, applied) { + SessionUpdatePollOutcome::ExitAfterSave } else { - ModeChangePollOutcome::Redraw + SessionUpdatePollOutcome::Redraw } } // ============ MCP management ============ } -fn agent_mode_switch_allowed(is_processing: bool, mode_change_pending: bool) -> bool { - !is_processing && !mode_change_pending +fn session_update_allowed(is_processing: bool, update_pending: bool) -> bool { + !is_processing && !update_pending } -fn mode_switch_unavailable_message(is_processing: bool) -> String { +fn session_update_unavailable_message(setting_name: &str, is_processing: bool) -> String { if is_processing { - "Agent mode cannot be changed during the current turn.".to_string() + format!("{setting_name} cannot be changed during the current turn.") } else { - "An agent mode change is already in progress. Please wait.".to_string() + "A current session update is already in progress. Please wait.".to_string() } } #[cfg(test)] mod usage_metadata_tests { use super::{ - agent_mode_switch_allowed, mode_switch_unavailable_message, usage_report_metadata, + session_update_allowed, session_update_unavailable_message, usage_report_metadata, SessionUsageReport, }; #[test] - fn mode_switch_is_rechecked_when_an_idle_popup_outlives_turn_start() { - assert!(agent_mode_switch_allowed(false, false)); - assert!(!agent_mode_switch_allowed(true, false)); - assert!(!agent_mode_switch_allowed(false, true)); + fn session_update_is_rechecked_when_an_idle_popup_outlives_turn_start() { + assert!(session_update_allowed(false, false)); + assert!(!session_update_allowed(true, false)); + assert!(!session_update_allowed(false, true)); } #[test] fn active_turn_message_does_not_advertise_hidden_management() { - let message = mode_switch_unavailable_message(true); + let message = session_update_unavailable_message("Agent mode", true); assert_eq!( message, diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index fa8f32b4f0..1665998dc0 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -11,10 +11,10 @@ impl ChatMode { let agent = self.agent.clone(); let sid = new_session_id.to_string(); - let (new_state, restored_agent_type, migration_notice) = + let (new_state, restored_agent_type, migration_notices) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - let (session_summary, workspace_binding, migration_notice, transcript) = + let (session_summary, workspace_binding, migration_notices, transcript) = agent.restore_session_in_current_workspace(&sid).await?; let restored_agent_type = session_summary.agent_type.clone(); let effective_workspace = Some(workspace_binding.workspace_path.clone()); @@ -26,9 +26,10 @@ impl ChatMode { effective_workspace, &transcript, ); + state.current_model_id = session_summary.model_id; state.apply_workspace_binding(workspace_binding); - Ok::<_, anyhow::Error>((state, restored_agent_type, migration_notice)) + Ok::<_, anyhow::Error>((state, restored_agent_type, migration_notices)) }) })?; @@ -47,7 +48,7 @@ impl ChatMode { // Reload model name self.load_current_model_name(chat_state, rt_handle); - if let Some(notice) = migration_notice { + for notice in migration_notices { chat_state.add_system_message(notice.user_message()); } @@ -116,12 +117,12 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { if self - .pending_mode_change + .pending_session_update .as_ref() .is_some_and(|pending| pending.session_id == chat_state.core_session_id) { chat_view.set_status(Some( - "Waiting for the agent mode change to finish before sending.".to_string(), + "Waiting for the current session update to finish before sending.".to_string(), )); return; } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index fcfab7a955..bd38e10f2f 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -4,26 +4,25 @@ mod tests { use super::{ action_opens_extension_management, agent_event_stream_failure, apply_agent_mode_feedback, - apply_model_selection_feedback, builtin_command_reconfirmation, - cli_native_prompt_command_descriptors, command_route, extension_command_help_request, - external_agent_attention, external_agent_diagnostic_lines, + apply_model_selection_feedback, apply_session_model_migration, + builtin_command_reconfirmation, cli_native_prompt_command_descriptors, command_route, + extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, external_agent_review_text, external_command_projections, external_control_review_text, external_hook_help_text, external_integration_policy_lines, external_operation_error_status, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, mark_active_turn_failed, - merge_external_agent_mutation_snapshot, mode_change_blocks_typed_submission, - mode_change_completion_should_exit, native_command_choice_is_active, + merge_external_agent_mutation_snapshot, native_command_choice_is_active, 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, - pending_mode_change_blocks_runtime_action, previous_session_mode_change_status, - render_external_hook_catalog, render_native_hook_overview, + pending_session_update_blocks_runtime_action, previous_session_update_status, + render_external_hook_catalog, render_native_hook_overview, session_update_allowed, + session_update_blocks_typed_submission, session_update_completion_should_exit, shared_session_change_is_blocked, CommandRoute, ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, - HookManagementAction, ModeSelectionApplyOutcome, ModelSelectionApplyOutcome, - SHARED_TUI_CHAT_STATUS, + HookManagementAction, SessionUpdateApplyOutcome, SHARED_TUI_CHAT_STATUS, }; use crate::actions::{ action_conflict_behavior_version, ActionHandler, ActionState, ResolvedKeymap, @@ -1229,31 +1228,103 @@ mod tests { } #[test] - fn model_selection_keeps_the_applied_session_model_when_default_persistence_fails() { + fn model_selection_commits_only_the_current_session_state_after_runtime_success() { let mut state = ChatState::new( "session".to_string(), "Session".to_string(), "agentic".to_string(), Some("D:/workspace/current".to_string()), ); + state.current_model_id = Some("old-model-id".to_string()); state.current_model_name = "Old model".to_string(); apply_model_selection_feedback( &mut state, "New model / Provider", "new-model-id", - ModelSelectionApplyOutcome::Applied { - default_persist_error: Some("config storage unavailable".to_string()), - }, + SessionUpdateApplyOutcome::Applied, ); + assert_eq!(state.current_model_id.as_deref(), Some("new-model-id")); assert_eq!(state.current_model_name, "New model / Provider"); - let notice = state.messages.last().expect("partial-success notice"); + assert!(state.messages.is_empty()); + } + + #[test] + fn runtime_model_migration_replaces_the_visible_session_model_and_explains_why() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.current_model_id = Some("removed-model".to_string()); + state.current_model_name = "Removed model".to_string(); + + assert!(apply_session_model_migration( + &mut state, + "session", + "removed-model", + "replacement-model", + "model_deleted", + )); + + assert_eq!(state.current_model_id.as_deref(), Some("replacement-model")); + assert_eq!(state.current_model_name, "replacement-model"); + let notice = state.messages.last().expect("migration notice"); let crate::chat_state::FlowItem::Text { content, .. } = ¬ice.flow_items[0] else { - panic!("partial-success notice must be text"); + panic!("migration notice must be text"); }; - assert!(content.contains("current session")); - assert!(content.contains("future sessions")); + assert!(content.contains("removed-model")); + assert!(content.contains("replacement-model")); + assert!(content.contains("model_deleted")); + } + + #[test] + fn runtime_model_migration_ignores_another_session() { + let mut state = ChatState::new( + "current-session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.current_model_id = Some("removed-model".to_string()); + state.current_model_name = "Removed model".to_string(); + + assert!(!apply_session_model_migration( + &mut state, + "other-session", + "removed-model", + "replacement-model", + "model_deleted", + )); + assert_eq!(state.current_model_id.as_deref(), Some("removed-model")); + assert!(state.messages.is_empty()); + } + + #[test] + fn runtime_model_migration_ignores_a_stale_previous_selector() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.current_model_id = Some("newer-explicit-model".to_string()); + state.current_model_name = "Newer explicit model".to_string(); + + assert!(!apply_session_model_migration( + &mut state, + "session", + "removed-model", + "auto", + "model_deleted", + )); + assert_eq!( + state.current_model_id.as_deref(), + Some("newer-explicit-model") + ); + assert!(state.messages.is_empty()); } #[test] @@ -1264,15 +1335,17 @@ mod tests { "agentic".to_string(), Some("D:/workspace/current".to_string()), ); + state.current_model_id = Some("old-model-id".to_string()); state.current_model_name = "Old model".to_string(); apply_model_selection_feedback( &mut state, "New model / Provider", "new-model-id", - ModelSelectionApplyOutcome::SessionUpdateFailed("session unavailable".to_string()), + SessionUpdateApplyOutcome::SessionUpdateFailed("session unavailable".to_string()), ); + assert_eq!(state.current_model_id.as_deref(), Some("old-model-id")); assert_eq!(state.current_model_name, "Old model"); let notice = state.messages.last().expect("failure notice"); let crate::chat_state::FlowItem::Text { content, .. } = ¬ice.flow_items[0] else { @@ -1282,6 +1355,34 @@ mod tests { assert!(content.contains("retry")); } + #[test] + fn unknown_model_update_outcome_requires_restore_before_retry() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.current_model_id = Some("old-model-id".to_string()); + state.current_model_name = "Old model".to_string(); + + apply_model_selection_feedback( + &mut state, + "New model / Provider", + "new-model-id", + SessionUpdateApplyOutcome::OutcomeUnknown("request timed out".to_string()), + ); + + assert_eq!(state.current_model_id.as_deref(), Some("old-model-id")); + assert_eq!(state.current_model_name, "Old model"); + 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("restore this session")); + } + #[test] fn mode_selection_commits_visible_state_only_after_runtime_success() { let mut current_mode = "agentic".to_string(); @@ -1296,7 +1397,7 @@ mod tests { &mut current_mode, &mut state, "plan", - ModeSelectionApplyOutcome::Applied, + SessionUpdateApplyOutcome::Applied, ); assert!(applied); @@ -1318,7 +1419,7 @@ mod tests { &mut current_mode, &mut state, "plan", - ModeSelectionApplyOutcome::SessionUpdateFailed( + SessionUpdateApplyOutcome::SessionUpdateFailed( "session storage unavailable".to_string(), ), ); @@ -1335,10 +1436,11 @@ mod tests { } #[test] - fn previous_session_mode_failure_is_not_reported_as_a_success() { - let status = previous_session_mode_change_status( + fn previous_session_update_failure_is_not_reported_as_a_success() { + let status = previous_session_update_status( + "mode", "Plan", - &ModeSelectionApplyOutcome::SessionUpdateFailed("storage unavailable".to_string()), + &SessionUpdateApplyOutcome::SessionUpdateFailed("storage unavailable".to_string()), ); assert!(status.contains("failed")); @@ -1360,7 +1462,7 @@ mod tests { &mut current_mode, &mut state, "plan", - ModeSelectionApplyOutcome::OutcomeUnknown("request timed out".to_string()), + SessionUpdateApplyOutcome::OutcomeUnknown("request timed out".to_string()), ); assert!(!applied); @@ -1377,39 +1479,39 @@ mod tests { } #[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( + fn pending_session_update_routes_commands_to_their_action_guards() { + assert!(session_update_blocks_typed_submission(true, "continue")); + assert!(!session_update_blocks_typed_submission(true, "/new")); + assert!(!session_update_blocks_typed_submission(true, "/sessions")); + assert!(!session_update_blocks_typed_submission(true, "/exit")); + assert!(!session_update_blocks_typed_submission(false, "continue")); + + assert!(pending_session_update_blocks_runtime_action( true, true, ActionHandler::Sessions, )); - assert!(pending_mode_change_blocks_runtime_action( + assert!(pending_session_update_blocks_runtime_action( true, true, ActionHandler::Init, )); - assert!(!pending_mode_change_blocks_runtime_action( + assert!(!pending_session_update_blocks_runtime_action( true, true, ActionHandler::Exit, )); - assert!(!pending_mode_change_blocks_runtime_action( + assert!(!pending_session_update_blocks_runtime_action( true, true, ActionHandler::OpenAgentSelector, )); - assert!(!pending_mode_change_blocks_runtime_action( + assert!(!pending_session_update_blocks_runtime_action( false, true, ActionHandler::Sessions, )); - assert!(!pending_mode_change_blocks_runtime_action( + assert!(!pending_session_update_blocks_runtime_action( true, false, ActionHandler::Sessions, @@ -1417,24 +1519,32 @@ mod tests { } #[test] - fn shared_session_change_waits_for_the_mode_update_result() { + fn shared_session_change_waits_for_the_current_session_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() { + fn shared_chat_status_separates_session_selection_from_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")); + assert!(SHARED_TUI_CHAT_STATUS.contains("current Session model")); + assert!(SHARED_TUI_CHAT_STATUS.contains("Agent/Subagent management")); + assert!(SHARED_TUI_CHAT_STATUS.contains("model management remains Embedded")); + } + + #[test] + fn failed_session_update_cancels_automatic_exit() { + assert!(session_update_completion_should_exit(true, true)); + assert!(!session_update_completion_should_exit(true, false)); + assert!(!session_update_completion_should_exit(false, true)); } #[test] - fn failed_mode_save_cancels_automatic_exit() { - assert!(mode_change_completion_should_exit(true, true)); - assert!(!mode_change_completion_should_exit(true, false)); - assert!(!mode_change_completion_should_exit(false, true)); + fn session_updates_require_an_idle_session_and_one_pending_operation() { + assert!(session_update_allowed(false, false)); + assert!(!session_update_allowed(true, false)); + assert!(!session_update_allowed(false, true)); } #[test] diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 067e7ea42b..6ea45b5648 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -265,6 +265,13 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .map_err(runtime_ipc_error)?; Ok(RuntimeIpcOperationResult::Unit) } + RuntimeIpcOperation::UpdateSessionModel { request } => { + self.runtime + .update_session_model(request) + .await + .map_err(runtime_ipc_error)?; + Ok(RuntimeIpcOperationResult::Unit) + } RuntimeIpcOperation::SubmitTurn { request } => { let outcome = self .runtime diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index a56a18b9d0..b8d6226d6c 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -83,8 +83,15 @@ impl ChatView { &mut self, models: Vec, current_model_id: Option, + allow_edit: bool, + current_session_selection: bool, ) { - self.model_selector.show(models, current_model_id); + self.model_selector.show( + models, + current_model_id, + allow_edit, + current_session_selection, + ); self.popup_stack.push(PopupType::ModelSelector); } @@ -112,6 +119,10 @@ impl ChatView { self.model_selector.confirm_selection() } + pub(crate) fn model_selector_allows_edit(&self) -> bool { + self.model_selector.allows_edit() + } + // ============ Theme selector methods ============ pub(crate) fn show_theme_selector( diff --git a/src/apps/cli/src/ui/model_selector.rs b/src/apps/cli/src/ui/model_selector.rs index 5a7bc42c23..01ae2a2e9e 100644 --- a/src/apps/cli/src/ui/model_selector.rs +++ b/src/apps/cli/src/ui/model_selector.rs @@ -29,6 +29,10 @@ pub(super) struct ModelSelectorState { visible: bool, /// Currently active model ID (for highlighting) current_model_id: Option, + /// Embedded startup may open model management; current-Session selection may not. + allow_edit: bool, + /// Selection changes the active Session rather than the future default. + current_session_selection: bool, last_area: Option, } @@ -39,12 +43,20 @@ impl ModelSelectorState { list_state: ListState::default(), visible: false, current_model_id: None, + allow_edit: false, + current_session_selection: true, last_area: None, } } /// Show the model selector with given model list - pub(super) fn show(&mut self, models: Vec, current_model_id: Option) { + pub(super) fn show( + &mut self, + models: Vec, + current_model_id: Option, + allow_edit: bool, + current_session_selection: bool, + ) { if models.is_empty() { return; } @@ -57,6 +69,8 @@ impl ModelSelectorState { self.items = models; self.current_model_id = current_model_id; + self.allow_edit = allow_edit; + self.current_session_selection = current_session_selection; self.list_state.select(Some(initial_idx)); self.visible = true; } @@ -79,6 +93,18 @@ impl ModelSelectorState { self.visible } + pub(super) fn allows_edit(&self) -> bool { + self.allow_edit + } + + pub(super) fn scope_hint(&self) -> &'static str { + if self.current_session_selection { + "Applies to the current session only" + } else { + "Default for future sessions" + } + } + pub(super) fn move_up(&mut self) { if !self.visible || self.items.is_empty() { return; @@ -166,11 +192,16 @@ impl ModelSelectorState { }) .collect(); + let title = if self.allow_edit { + " Select Model (↑↓ Navigate, Enter Select, e Edit, Esc Cancel) " + } else { + " Select Model (↑↓ Navigate, Enter Select, Esc Cancel) " + }; let block = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) .style(Style::default().bg(theme.background)) - .title(" Select Model (↑↓ Navigate, Enter Select, e Edit, Esc Cancel) "); + .title(title); let list = List::new(list_items) .block(block) @@ -195,7 +226,7 @@ impl ModelSelectorState { }; if hint_area.height > 0 { let hint = Paragraph::new(Line::from(vec![Span::styled( - " Selecting a model will apply to all modes ", + format!(" {} ", self.scope_hint()), theme.style(StyleKind::Info), )])) .alignment(Alignment::Center); @@ -277,3 +308,44 @@ impl ModelSelectorState { Some(index) } } + +#[cfg(test)] +mod tests { + use super::{ModelItem, ModelSelectorState}; + + fn model() -> ModelItem { + ModelItem { + id: "model-1".to_string(), + name: "Provider".to_string(), + provider: "provider".to_string(), + model_name: "Model 1".to_string(), + } + } + + #[test] + fn current_session_picker_does_not_advertise_model_management() { + let mut state = ModelSelectorState::new(); + state.show(vec![model()], Some("model-1".to_string()), false, true); + + assert!(!state.allows_edit()); + assert_eq!(state.scope_hint(), "Applies to the current session only"); + } + + #[test] + fn startup_picker_keeps_embedded_model_management_and_default_scope() { + let mut state = ModelSelectorState::new(); + state.show(vec![model()], Some("model-1".to_string()), true, false); + + assert!(state.allows_edit()); + assert_eq!(state.scope_hint(), "Default for future sessions"); + } + + #[test] + fn embedded_chat_picker_can_edit_without_changing_the_selection_scope() { + let mut state = ModelSelectorState::new(); + state.show(vec![model()], Some("model-1".to_string()), true, true); + + assert!(state.allows_edit()); + assert_eq!(state.scope_hint(), "Applies to the current session only"); + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 91aeb14000..cdaf35491a 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1456,7 +1456,7 @@ impl StartupPage { match result { Some((models, current_id)) if !models.is_empty() => { - self.model_selector.show(models, current_id); + self.model_selector.show(models, current_id, true, false); } _ => { self.status = Some("No available models found.".to_string()); diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index 92dbe171da..66b87581ec 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -351,6 +351,12 @@ fn interactive_tui_agent_operations_stay_behind_cli_runtime_client() { && SHARED_RUNTIME.contains(".update_session_mode(request)"), "Shared Agent mode updates must reuse the Runtime port through the private IPC adapter" ); + assert!( + RUNTIME_CLIENT.contains("RuntimeIpcOperation::UpdateSessionModel { request }") + && SHARED_RUNTIME.contains("RuntimeIpcOperation::UpdateSessionModel { request }") + && SHARED_RUNTIME.contains(".update_session_model(request)"), + "Shared model 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/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index c0da541955..d8f638bab6 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,7 +15,7 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、Session list/create/restore(restore 结果包含 transcript)、Turn submit/cancel、pending/respond Permission 和 UserInput answers。断连 cleanup 属于内部生命周期,不是 detach operation。禁止顺带加入 delete、fork、replay、observer、controller transfer、Tool/MCP/Hook 管理或产品配置。 +- 封闭 operation 范围为 Health、Session list/create/restore(restore 结果包含 transcript)、当前 Session 的 Agent mode/model update、Turn submit/cancel、pending/respond Permission 和 UserInput answers。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 delete、fork、replay、observer、controller transfer、Tool/MCP/Hook 管理或其他产品配置。 - 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 - 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 - 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index 4f685b8bf0..e543952241 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -22,9 +22,10 @@ session controller leases, event delivery, connection bounds, and cleanup. It is - Export only the exact workspace-private API needed by the CLI adapter. Do not publish this crate or expose its wire as an SDK contract. -- The closed operation budget is Health, Session list/create/restore (including transcript), Turn submit/cancel, pending/respond Permission, - and UserInput answers. Disconnect cleanup is internal lifecycle, not a detach operation. Do not add delete, fork, replay, observer, - controller transfer, Tool/MCP/Hook management, or product configuration incidentally. +- The closed operation budget is Health, Session list/create/restore (including transcript), current-Session Agent mode/model update, + Turn submit/cancel, pending/respond Permission, and UserInput answers. Disconnect cleanup is internal lifecycle, not a detach operation. + Model catalogs and defaults remain product configuration outside this wire. Do not add delete, fork, replay, observer, + controller transfer, Tool/MCP/Hook management, or other product configuration incidentally. - Stable Event, Product Domain, and Runtime Port DTOs may be reused. Do not depend on `bitfun-core`, Agent Runtime implementations, SDK Host, services, Tauri, terminal, tool runtime, or remote transports. diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index c468dedb97..77d8d145ad 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,8 +1,9 @@ use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionSummary, - AgentTurnCancellationRequest, AgentTurnCancellationResult, SessionTranscript, + AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, + AgentSessionSummary, AgentTurnCancellationRequest, AgentTurnCancellationResult, + SessionTranscript, }; use serde::{Deserialize, Serialize}; @@ -42,6 +43,9 @@ pub enum RuntimeIpcOperation { UpdateSessionMode { request: AgentSessionModeUpdateRequest, }, + UpdateSessionModel { + request: AgentSessionModelUpdateRequest, + }, SubmitTurn { request: AgentDialogTurnRequest, }, @@ -66,6 +70,7 @@ impl RuntimeIpcOperation { match self { Self::RestoreSession { request } => Some(&request.session_id), Self::UpdateSessionMode { request } => Some(&request.session_id), + Self::UpdateSessionModel { request } => Some(&request.session_id), Self::SubmitTurn { request } => Some(&request.session_id), Self::CancelTurn { request } => Some(&request.session_id), Self::PendingPermissions { session_id } @@ -79,6 +84,7 @@ impl RuntimeIpcOperation { matches!( self, Self::UpdateSessionMode { .. } + | Self::UpdateSessionModel { .. } | Self::SubmitTurn { .. } | Self::CancelTurn { .. } | Self::PendingPermissions { .. } diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 055862e90d..cf8cd7d082 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 = 3; +pub const PROTOCOL_VERSION: u32 = 4; #[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 8e9ed0f2cb..9da2002036 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -414,6 +414,7 @@ async fn run_initialized_connection( | RuntimeIpcOperation::RestoreSession { .. } | RuntimeIpcOperation::CreateSession { .. } | RuntimeIpcOperation::UpdateSessionMode { .. } + | RuntimeIpcOperation::UpdateSessionModel { .. } ) { send_error( @@ -670,6 +671,7 @@ fn operation_has_side_effects(operation: &RuntimeIpcOperation) -> bool { RuntimeIpcOperation::CreateSession { .. } | RuntimeIpcOperation::RestoreSession { .. } | RuntimeIpcOperation::UpdateSessionMode { .. } + | RuntimeIpcOperation::UpdateSessionModel { .. } | 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 176627deba..99e569f1de 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 @@ -5,8 +5,8 @@ use crate::{ use bitfun_product_domains::tool_permissions::PermissionReply; use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionModeUpdateRequest, AgentSubmissionSource, - DialogSubmissionPolicy, + AgentDialogTurnRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, + AgentSubmissionSource, DialogSubmissionPolicy, }; use serde_json::{json, Map}; @@ -86,6 +86,27 @@ fn protocol_round_trips_the_reviewed_session_mode_operation() { assert!(decoded.requires_controller()); } +#[test] +fn protocol_round_trips_the_reviewed_session_model_operation() { + let operation = RuntimeIpcOperation::UpdateSessionModel { + request: AgentSessionModelUpdateRequest { + session_id: "session-1".to_string(), + model_id: "provider/model".to_string(), + }, + }; + + let encoded = serde_json::to_value(&operation).expect("serialize model update"); + assert_eq!(encoded["operation"], "update_session_model"); + assert_eq!(encoded["request"]["sessionId"], "session-1"); + assert_eq!(encoded["request"]["modelId"], "provider/model"); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize model 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!({ 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 58788e797a..f8d45a3e36 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,8 +8,8 @@ use async_trait::async_trait; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; use bitfun_runtime_ports::{ AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionModeUpdateRequest, AgentSessionSummary, AgentSubmissionSource, - DialogSubmissionPolicy, SessionTranscript, + AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionSummary, + AgentSubmissionSource, DialogSubmissionPolicy, SessionTranscript, }; use serde_json::Map; use std::path::Path; @@ -83,6 +83,7 @@ struct FakeHandler { calls: Mutex>, delay: Option, mode_delay: Option, + model_delay: Option, submit_delay: Option, settle_cancel: bool, events: broadcast::Sender, @@ -104,6 +105,7 @@ impl Default for FakeHandler { calls: Mutex::new(Vec::new()), delay: None, mode_delay: None, + model_delay: None, submit_delay: None, settle_cancel: true, events, @@ -179,6 +181,11 @@ impl RuntimeIpcRequestHandler for FakeHandler { tokio::time::sleep(delay).await; } } + if matches!(operation, RuntimeIpcOperation::UpdateSessionModel { .. }) { + if let Some(delay) = self.model_delay { + tokio::time::sleep(delay).await; + } + } match operation { RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), RuntimeIpcOperation::SubmitTurn { request } => { @@ -500,6 +507,15 @@ fn update_mode_operation(session_id: &str, mode_id: &str) -> RuntimeIpcOperation } } +fn update_model_operation(session_id: &str, model_id: &str) -> RuntimeIpcOperation { + RuntimeIpcOperation::UpdateSessionModel { + request: AgentSessionModelUpdateRequest { + session_id: session_id.to_string(), + model_id: model_id.to_string(), + }, + } +} + fn server_config() -> RuntimeIpcServerConfig { RuntimeIpcServerConfig { server_version: "shared-controller-test".to_string(), @@ -792,6 +808,103 @@ async fn timed_out_mode_update_reports_unknown_outcome_and_closes_the_connection server.finish().await; } +#[tokio::test] +async fn model_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("model-controller").await; + + expect_error( + &mut client, + 2, + update_model_operation("session-a", "provider/model-a"), + RuntimeIpcErrorCode::ControllerRequired, + ) + .await; + expect_response( + &mut client, + 3, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut client, + 4, + update_model_operation("session-b", "provider/model-a"), + RuntimeIpcErrorCode::SessionMismatch, + ) + .await; + expect_response( + &mut client, + 5, + update_model_operation("session-a", "provider/model-a"), + ) + .await; + expect_response( + &mut client, + 6, + submit_operation(server.workspace.path(), "session-a", "turn-a"), + ) + .await; + expect_error( + &mut client, + 7, + update_model_operation("session-a", "provider/model-b"), + RuntimeIpcErrorCode::SessionInUse, + ) + .await; + + let calls = handler.calls.lock().expect("calls"); + assert_eq!( + calls + .iter() + .filter(|operation| matches!(operation, RuntimeIpcOperation::UpdateSessionModel { .. })) + .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_model_update_reports_unknown_outcome_and_closes_the_connection() { + let handler = Arc::new(FakeHandler { + model_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("model-timeout").await; + expect_response( + &mut first, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut first, + 3, + update_model_operation("session-a", "provider/model-a"), + RuntimeIpcErrorCode::OutcomeUnknown, + ) + .await; + + assert!(read_frame(&mut first).await.is_err()); + let mut second = server.connect("model-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 { diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 2b7d086270..d27d17031c 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -8896,7 +8896,7 @@ fn runtime_session_summary(session: SessionSummary) -> bitfun_runtime_ports::Age session_id: session.session_id, session_name: session.session_name, agent_type: session.agent_type, - model_id: None, + model_id: session.model_id, last_user_dialog_agent_type: session.last_user_dialog_agent_type, last_submitted_agent_type: session.last_submitted_agent_type, turn_count: session.turn_count, @@ -9628,11 +9628,11 @@ mod tests { logical_subagent_type_or_runtime, merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, resolve_subagent_model_selection, - runtime_port_error_preserving_message, runtime_tool_restrictions_for_session_lifetime, - session_storage_workspace_locator, turn_review_manifest_for_agent, - BackgroundSubagentWaitMode, ConversationCoordinator, SessionMemoryMode, - SessionReferenceLocator, SessionRelationshipKind, SubagentExecutionRequest, - TEST_AGENT_MODEL_DEFAULTS, + runtime_port_error_preserving_message, runtime_session_summary, + runtime_tool_restrictions_for_session_lifetime, session_storage_workspace_locator, + turn_review_manifest_for_agent, BackgroundSubagentWaitMode, ConversationCoordinator, + SessionMemoryMode, SessionReferenceLocator, SessionRelationshipKind, + SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::coordination::coordination_store::{ BackgroundTaskRegistration, RegisteredBackgroundTask, @@ -9658,6 +9658,26 @@ mod tests { use crate::agentic::tools::{ToolPipeline, ToolStateManager}; use crate::agentic::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; + + #[test] + fn runtime_session_list_preserves_the_runtime_owned_model_selector() { + let summary = runtime_session_summary(bitfun_agent_runtime::session::SessionSummary { + session_id: "session".to_string(), + session_name: "Session".to_string(), + agent_type: "agentic".to_string(), + model_id: Some("fast".to_string()), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + kind: SessionKind::Standard, + turn_count: 0, + created_at: std::time::UNIX_EPOCH, + last_activity_at: std::time::UNIX_EPOCH, + state: bitfun_agent_runtime::session_state::SessionState::Idle, + }); + + assert_eq!(summary.model_id.as_deref(), Some("fast")); + } use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 2d79c6cb73..8b5e27cf41 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -2197,6 +2197,7 @@ impl PersistenceManager { session_id: metadata.session_id, session_name: metadata.session_name, agent_type: metadata.agent_type, + model_id: (!metadata.model_name.trim().is_empty()).then_some(metadata.model_name), last_user_dialog_agent_type: metadata.last_user_dialog_agent_type, last_submitted_agent_type: metadata.last_submitted_agent_type, created_by: metadata.created_by, @@ -3279,6 +3280,35 @@ mod tests { assert!(error.to_string().contains("session_id"), "{error}"); } + #[tokio::test] + async fn session_list_preserves_the_persisted_model_selector() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session = Session::new_with_id( + format!("model-summary-{}", Uuid::new_v4()), + "Model summary".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + model_id: Some("fast".to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should persist"); + let sessions = manager + .list_sessions(workspace.path()) + .await + .expect("session list should load"); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].model_id.as_deref(), Some("fast")); + } + #[tokio::test] async fn concurrent_first_session_persistence_keeps_the_winner() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 1a0fe306b3..60fd1dc9b8 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -3500,14 +3500,48 @@ impl SessionManager { // a late model update. let _mutation_guard = self.acquire_session_mutation(session_id).await?; - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.config.model_id = Some(model_id.to_string()); - if let Some(ai_config) = ai_config.as_ref() { - resolved_context_window = - Self::sync_session_context_window_from_ai_config(&mut session, ai_config); + let original_session = self + .sessions + .get(session_id) + .map(|session| session.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + let mut updated_session = original_session.clone(); + updated_session.config.model_id = Some(model_id.to_string()); + if let Some(ai_config) = ai_config.as_ref() { + resolved_context_window = + Self::sync_session_context_window_from_ai_config(&mut updated_session, ai_config); + } + let now = SystemTime::now(); + updated_session.updated_at = now; + updated_session.last_activity_at = now; + + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_storage_path(session_id).await; + if let Some(workspace_path) = effective_path { + if let Err(error) = self + .persistence_manager + .save_session(&workspace_path, &updated_session) + .await + { + if let Err(rollback_error) = self + .persistence_manager + .save_session(&workspace_path, &original_session) + .await + { + return Err(BitFunError::session(format!( + "Session model persistence failed and rollback did not complete: session_id={session_id}, error={error}, rollback_error={rollback_error}" + ))); + } + return Err(error); + } } - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); + } + + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.config.model_id = updated_session.config.model_id; + session.config.max_context_tokens = updated_session.config.max_context_tokens; + session.updated_at = now; + session.last_activity_at = now; } else { return Err(BitFunError::NotFound(format!( "Session not found: {}", @@ -3515,17 +3549,6 @@ impl SessionManager { ))); } - if self.should_persist_session_id(session_id) { - let effective_path = self.effective_session_storage_path(session_id).await; - let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); - // Ref guard released -- DashMap shard lock is free. - if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { - self.persistence_manager - .save_session(&workspace_path, &session) - .await?; - } - } - debug!( "Session model id updated: session_id={}, model_id={}, max_context_tokens={:?}", session_id, model_id, resolved_context_window @@ -5241,6 +5264,7 @@ impl SessionManager { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + model_id: session.config.model_id.clone(), last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), last_submitted_agent_type: session.last_submitted_agent_type.clone(), created_by: session.created_by.clone(), @@ -7896,6 +7920,49 @@ mod tests { assert_eq!(restored.config.model_id.as_deref(), Some("auto")); } + #[tokio::test] + async fn failed_session_model_update_preserves_runtime_and_persisted_selector() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Failed model update".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + model_id: Some("primary".to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + persistence_manager.fail_next_session_state_write_for_test(&session.session_id); + + manager + .update_session_model_id(&session.session_id, "fast") + .await + .expect_err("failed persistence must reject the model update"); + assert_eq!( + manager + .get_session(&session.session_id) + .expect("session remains loaded") + .config + .model_id + .as_deref(), + Some("primary") + ); + + manager.evict_loaded_session_for_test(&session.session_id); + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("failed update should restore the previous model"); + assert_eq!(restored.config.model_id.as_deref(), Some("primary")); + } + #[tokio::test] async fn session_storage_identity_rejects_same_id_in_another_workspace() { let workspace = TestWorkspace::new(); diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index 7520589f19..ce087b670d 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -233,6 +233,9 @@ pub struct SessionSummary { pub session_name: String, /// Current/default mode selection for the session. pub agent_type: String, + /// Runtime-owned model selector currently bound to the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, /// Mode of the last surviving user dialog turn in the session history. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_user_dialog_agent_type: Option,