diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 75b586e7b1..0cc7db9155 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/model,提交/取消 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,没有事件重放或公开协议承诺。 @@ -53,13 +53,13 @@ flowchart TB |---|---|---| | Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、事件和持久化行为的既有模块 | 进程名、Server 或 SDK | | Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | -| Shared deployment | 同一 Runtime 未来由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | +| Shared deployment | 同一 Runtime 由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | | Agent SDK Host | 将公开 SDK 合同映射到 Runtime API 的私有进程/adapter | CLI、Shared deployment 或 Plugin Host | | Plugin Host | 运行 Node/Bun 和第三方插件代码的受监督子进程 | Agent Runtime 或 Rust IPC client | `Host` 只表示“一个进程承载某些模块”的内部关系,不新增普通用户必须理解或管理的产品入口。 -## 3. 逻辑复用与物理部署 +## 3. Logical View · Level 1 ```mermaid flowchart TB @@ -98,7 +98,7 @@ flowchart LR - 有界 receiver 的 `Lagged` 或 `Closed` 是显式失败;当前没有 cursor/replay 合同,禁止伪装成透明恢复。 - 这条链路仍全部位于当前 Embedded 进程,不增加 SDK Host、IPC 或后台进程依赖。 -## 4. 当前基础架构 +## 4. Process View · Level 1 ### 4.1 Runtime ownership @@ -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 or model + C->>S: rename or update current Session C->>S: submit/cancel Turn or answer Permission/UserInput S-->>C: Session-filtered authoritative events else invalid @@ -204,11 +204,11 @@ sequenceDiagram end ``` -当前私有协议(v4)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v5)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| -| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session Agent mode/model update | Session delete/fork、跨 workspace attach、transcript 分页、模型目录/默认值和 Agent/Subagent 管理 | +| Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、当前 Session rename、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,9 @@ 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 或 model。 +- 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session,也不能修改其名称、Agent mode 或 model。 - Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 -- Agent mode/model update 复用既有 Runtime 端口和校验。二者都是有副作用操作;若响应超时,或 Client 在收到权威结果前丢失连接,按 `outcome_unknown` 处理并断开连接,Client 不自动重试。用户重新打开 Shared TUI、restore Session 并核对当前值后,才能决定是否重试。模式与模型目录仍是同版本第一方产品事实,不加入 IPC;Runtime 对最终更新保持权威并拒绝无效值。 +- Session rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终更新保持权威并拒绝无效值。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试;用户恢复 Session 并核对当前值后再决定是否重试。模式与模型目录仍是同版本第一方产品事实,不加入 IPC。 - 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 后代回收不在当前保证内。两者都只负责生命周期,不是安全沙箱。 @@ -235,7 +235,33 @@ sequenceDiagram 这是一条本机同用户边界,不是沙箱、远程协议或公开兼容承诺。 -## 5. 产品入口保持同级 +### 4.4 Serialization、并发与性能 + +```mermaid +flowchart LR + T1["TUI 1"] --> IPC["有界本机 IPC"] + T2["TUI 2"] --> IPC + TN["TUI N"] --> IPC + IPC --> Runtime["一个 Shared Runtime"] + Runtime --> Tasks["Tokio tasks"] + Runtime --> Owner["一个 Session owner"] +``` + +多个 Shared TUI 复用一个 Runtime 进程。每个连接使用独立异步任务,但连接、命令队列和事件队列都有上限;达到连接上限时暂停接收新连接,慢客户端不能建立无界任务或队列。默认不增加 Runtime 进程池,因为复制 Session 状态、模型连接和缓存会扩大一致性成本。只有经测量证明某类无状态 CPU 工作可独立分片时,才评审额外 worker 进程。 + +| 路径 | 数据边界 | 性能约束 | +|---|---|---| +| Embedded | 第一方 adapter 以 Rust 类型直接调用 `AgentRuntime` | 不初始化本机 IPC,不执行 JSON framing、序列化或反序列化 | +| Shared request | Client 将 operation 编码一次并写入一个长度前缀 frame | 请求保持 128 KiB 上限;业务层只接收类型化 operation | +| Shared response/event | Server 将结果或事件编码一次后写出 | 响应/事件保持 8 MiB 上限;超限使事件流明确失效,不能无界分配 | +| Shared receive | 每个方向只有一个严格 transport decode 边界 | 未知信封字段和不兼容版本 fail closed;严格校验可以检查规范化 JSON,但不能把动态 JSON 传入 Runtime owner | +| 多 TUI | 一个 Runtime、最多 64 个连接;每个 Client 的 command channel 容量为 64、event channel 容量为 256 | request gate 使每个 Client 同时只有一个请求进入 channel;事件落后时失效而非无限缓存 | + +协议只承载当前交互所需的小型控制请求和既有事件。大 transcript 继续受 frame 上限约束;本阶段不为假设场景增加通用分页、二进制 side channel、压缩或批处理协议。 + +## 5. Development and Physical Views · Level 1 + +### 5.1 Development View ```mermaid flowchart TB @@ -257,6 +283,18 @@ flowchart TB Ownership -. "injected once" .-> Coordinator ``` +```mermaid +flowchart LR + CLI["apps/cli"] --> Client["CLI Runtime client"] + Client -->|"Embedded"| Runtime["execution/agent-runtime"] + Client -->|"Shared only"| IPC["adapters/agent-runtime-ipc"] + IPC --> Handler["CLI Shared handler"] + Handler --> Runtime + Runtime --> Ports["runtime ports / owners"] +``` + +CLI adapter 负责命令解析、TUI 状态和错误文案;私有 IPC 只负责本机传输、连接控制和类型映射;Agent Runtime 与 owner 负责 Session 校验、持久化和权威结果。业务代码通过同一个 CLI Runtime client 调用能力,不根据部署形态复制业务分支。 + - CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 - 交互式 TUI 的启动页和会话页复用一个 CLI 私有 Runtime client;Session、Turn、Permission 和事件订阅都使用 Rust Runtime SDK(当前 preview)。该 client 只是第一方 adapter,不是公开 SDK、SDK Host client 或第二套 Runtime。 - Headless CLI 和 Peer Host 使用同一 Runtime 订阅入口,但分别保留确定性退出与 Peer fanout 语义;共享订阅入口不等于共享 renderer 或产品生命周期。 @@ -265,6 +303,43 @@ flowchart TB - Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 - Tauri 仍负责窗口和桌面能力;未来它可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 +### 5.2 Physical View + +```mermaid +flowchart TB + subgraph Embedded["默认 Embedded"] + TUI["TUI / Headless / CI"] --> Direct["in-process Agent Runtime"] + end + subgraph Shared["显式 --shared"] + Clients["one or more TUI processes"] -->|"Named Pipe / UDS"| SharedRuntime["Shared Runtime process"] + end + Direct --> Data["workspace + Session storage"] + SharedRuntime --> Data +``` + +默认交互式 TUI、Headless CLI 和 CI 保持 Embedded。只有显式 `--shared` 的交互式 TUI 进入 Shared;同一 workspace 的两种部署互斥。多开 TUI 增加 Client 进程和有界连接,不按 Client 数量复制 Runtime、Session owner 或 Plugin Host。 + +### 5.3 Scenario (+1) · Rename current Session + +```mermaid +sequenceDiagram + participant U as User + participant T as TUI adapter + participant C as CLI Runtime client + participant R as Agent Runtime + + U->>T: /rename Auth refactor + T->>T: trim + require idle Session + T->>C: rename_session(id, name) + C->>R: direct call or one Shared frame + R->>R: validate ownership + persist + R-->>C: applied / failed / outcome_unknown + C-->>T: typed result + T-->>U: update name only after applied +``` + +Embedded 和 Shared 最终调用同一 `AgentRuntime::rename_session`。Runtime 只有在确认旧名称已保留时才返回明确失败;持久化恢复无法确认时,两种部署都返回 `outcome_unknown`。Shared 还会在请求已发送但权威响应丢失时返回该结果并关闭连接。用户恢复 Session、检查当前名称后再决定是否重试。 + ## 6. 隔离和生命周期原则 实例身份与 ownership key 分工不同: @@ -298,7 +373,7 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 |---|---| | 当前 consumer | 仅第一方交互式 TUI adapter;不自动包含 GUI、Headless CLI、Remote 或 SDK Host | | 稳定测试合同 | 本机 endpoint、initialize-first、128 KiB request / 8 MiB response-event 上限、连接上限、owner-checked cleanup、原子 Session controller 切换、单连接单活动 Turn、事件流失效后 fail closed、断连取消、30 秒空闲退出 | -| 当前业务范围 | Session/Turn/transcript/Agent mode/Permission/UserInput 的 TUI 必需子集;任何新增操作都需要真实 consumer 和 owner 等价测试 | +| 当前业务范围 | Session/Turn/transcript、当前 Session name/Agent mode/model、Permission/UserInput 的 TUI 必需子集;任何新增操作都需要真实 consumer 和 owner 等价测试 | | 协议地位 | crate 保持 `publish = false`;这是 workspace 内私有协议,不是 Agent SDK 或远程兼容承诺 | 架构守卫只允许 CLI 消费该 crate;IPC 可以复用稳定的 Event、Product Domain 与 Runtime Port DTO,但禁止依赖 Runtime 实现、SDK Host、services、Tauri 或远程网络 transport。 @@ -307,11 +382,11 @@ Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理 | 产品 | 已验证做法 | BitFun 采用 | 不照搬 | |---|---|---|---| -| OpenCode | Core/Server 支持 TUI/Web/Desktop/SDK 多客户端 | 一个 Runtime owner 可服务多个第一方 Client | 不把全量 HTTP/OpenAPI route 提前固化为 Shared 或公开 SDK | -| Codex | App Server 面向 rich client;SDK 面向自动化 | rich-client 私有协议与公开 SDK 分层 | 不让 CLI 默认依赖 Server,也不把 App Server schema 原样复制 | -| Claude Code | 默认单进程;Remote/SDK 路径显式启用 | 默认单实例无额外常驻成本,Shared 必须显式且有真实收益 | 不提前引入云中继、移动端或全机器 daemon 心智 | +| [OpenCode Server/SDK](https://opencode.ai/docs/server/) | Server-first;类型化 SDK 直接消费 Server API | 一个 Runtime owner 可以服务多个第一方 Client | 不让默认 TUI 承担 HTTP/OpenAPI 编解码,也不把全量 route 固化为私有 Shared wire | +| [Codex App Server](https://developers.openai.com/codex/app-server/) | App Server 为 rich client 和 remote TUI 提供 JSON-RPC;自动化继续使用 SDK;WebSocket transport 仍是实验性接口 | rich-client 私有协议与公开 SDK 分层,并为 Shared 入口保留有界本机 transport | 不让默认 CLI 依赖 App Server,也不复制其完整 schema 或实验性远程 transport | +| [Claude Agent SDK](https://code.claude.com/docs/en/agent-sdk/typescript) | Agent loop 由长期运行的 CLI 子进程承载,并提供 `startup()` 预热以减少首次请求成本 | 长期交互可以复用已启动进程,空闲后回收 | 不让第一方 Embedded TUI 为接口统一付出子进程和编解码成本,也不把多 TUI 映射为多个 Runtime | -当前先落 identity、ownership、local transport 和 handshake,与这些产品共同采用的“先稳定宿主边界,再开放能力”一致;没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 +三种产品说明了不同部署的有效边界:server-first 适合稳定多客户端协议,长期子进程适合语言 SDK,进程内调用适合默认本机交互。BitFun 采用混合部署,不把任何一种形态强制成所有入口的公共底座;当前也没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 ## 9. 不变量 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index bd42e07f81..6d046d3ee8 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、当前 Session 的 Agent mode/model、Permission/UserInput、 +当前代码已经交付显式启用的 Shared TUI 最小切片,包含本机 IPC、身份、握手、Session/Turn、当前 Session 的 name/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 e2e9044233..2ad4496d35 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -256,12 +256,15 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付 | 形态 | 默认部署 | 当前 Shared 范围 | |---|---|---| -| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session Agent mode/model、Turn submit/cancel、Permission 和 UserInput | +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session rename/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,`/models` 只切换当前 Session 的 model,二者都不进入管理页面或修改未来 Session 的默认值;其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权,单个连接已有活动 Turn 时拒绝重复提交和 Session mode/model update;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 +Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示 `/rename `;在 slash menu 中选择它只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权;单个连接已有活动 Turn 时拒绝重复提交以及 Session rename/mode/model update;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 + +部署差异由 CLI Runtime client 封装。Embedded 以 Rust 类型直接调用 `AgentRuntime`,不初始化 IPC 或执行 JSON 编解码;Shared 将同一业务请求映射为一个有界本机 frame,Client/Server 各自只编码一次,再交给同一 Runtime owner。多 TUI 复用一个 Runtime 进程,连接和队列保持有界,不按 TUI 数量复制 Session owner。详细的 4+1 视图、帧上限和并发边界见 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 #### 管理与诊断 @@ -298,6 +301,8 @@ TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降 | 层/模块 | 负责 | 不负责 | |---|---|---| | `src/apps/cli` | Clap 入口、TUI 状态/渲染、终端事件、入口本地设置、命令展示与结构化输出 | 会话状态机、工具执行、权限裁决、插件内部 ABI、品牌能力真值 | +| CLI Runtime client | 屏蔽 Embedded/Shared 部署差异,将 CLI 的类型化调用映射到进程内 Runtime 或私有本机 IPC | 实现 Session 业务规则、暴露公开 SDK 或在两种部署中复制行为 | +| `adapters/agent-runtime-ipc` | Shared TUI 的私有本机 transport、严格握手、frame 上限、连接控制和封闭 operation 映射 | 服务 Embedded、公开协议、Remote transport 或 Runtime 业务 owner | | `assembly/product-capabilities` | Delivery Profile、Product Capability 计划、静态 eligibility、服务需求和组装计划 | 品牌资源读取、动态可用性、用户配置、UI 状态、具体服务创建 | | 产品构建期校验 | 校验产品定义、品牌资源、TUI 布局选择和内置扩展版本,输出产品组装结果 | 创建运行时服务、实现终端行为或保存用户配置 | | Product Assembly | 读取产品组装结果中本次 CLI 需要的字段,选择能力/服务/扩展,构建 Runtime Parts | 读取原始品牌资源、实现 Agent/Tool/插件适配器/终端行为或运行构建脚本 | @@ -334,6 +339,8 @@ CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Ru 账户同步、富历史及其他未覆盖操作继续使用经过审查的 Core compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 这是一条垂直链路迁移,不是删除整个兼容接口或新建 CLI 专用服务层。 +交互式命令 `/rename ` 复用已有 Session rename owner。Runtime 只写名称相关 metadata,再发布内存名称;写入失败时先恢复旧 metadata,无法确认恢复结果则返回 `outcome_unknown`。Shared 请求写入后的超时或断连也返回 `outcome_unknown`。两种情况都要求恢复 Session 后检查,不自动重试可能已经生效的写操作;发送前编码失败或请求过大则明确未执行并保留连接。Session 选择器不保留第二套内联重命名状态。 + Runtime Configuration Service 当前由 `bitfun-core/service/config` 负责。在经评审的 port/provider 迁移完成前,CLI 和生态适配器不得另建写入器;adapter 只做 discover/parse/normalize,配置服务才能 预览/应用、记录来源,并通过远程工作区 provider 写目标层。产品定义、品牌资源、界面布局选择 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 78ea688bc2..707917bc7b 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -71,7 +71,8 @@ BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和 4+1 视图分别描述系统职责、代码组织、运行协作、部署边界和关键场景,避免把逻辑模块、crate、进程和调用链混在同一张图中。分类沿用 [Kruchten 4+1](https://www3.software.ibm.com/ibmdl/pub/software/rational/web/whitepapers/2003/Pbk4p1.pdf),图的层级、动态协作和部署节点表达参考 [C4](https://c4model.com/diagrams) 以及 arc42 的 [Building Block](https://docs.arc42.org/section-5/)、[Runtime](https://docs.arc42.org/section-6/) 和 [Deployment](https://docs.arc42.org/section-7/) 视图;这些方法只提供视角和表达规则,不替代 BitFun 的真实 owner 与代码边界。 -Level 0 展示系统级主要边界和依赖方向;Level 1 再按 Level 0 的模块或范围展开。每张图必须能独立说明范围和图例,关系使用明确方向或协议,逻辑模块、crate、运行任务和部署实例不要求一一对应。 +Level 0 展示系统级主要边界和依赖方向;Level 1 再按 Level 0 的模块或范围展开。每张图必须能独立说明范围和图例,关系使用明确方向或协议,逻辑模块、crate、运行任务和部署实例不要求一一对应。Agent Runtime 的 Embedded/Shared 逻辑、开发、进程、物理和场景视图集中在 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md),本文件不重复其连接和性能细节。 ### 2.1 Logical View · Level 0 @@ -529,6 +530,8 @@ flowchart LR Assembly["产品组装"] -. "选择" .-> Runtime ``` +入口 adapter 消费同一 Runtime API,部署选择不能进入业务 owner:Embedded 使用进程内强类型调用;Shared 或 SDK Host 才在各自私有 adapter 中执行 transport 封装。GUI、TUI、Headless CLI、ACP 和 SDK 不共享 wire、renderer 或生命周期,也不得为了统一接口而让默认 Embedded 路径承担序列化成本。 + ### 4.2 插件调用 ```mermaid diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 10f81fed29..1ecf1a16f9 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|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/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|RenameSession|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 f7bf82645c..793658e5ce 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4858,6 +4858,7 @@ export function runManifestParserSelfTest({ 'ManageAgents', ].every((name) => runtimeIpcOperationPattern.test(` ${name},`)) || runtimeIpcOperationPattern.test(' Health,') || + runtimeIpcOperationPattern.test(' RenameSession {') || runtimeIpcOperationPattern.test(' UpdateSessionMode {') || runtimeIpcOperationPattern.test(' UpdateSessionModel {') || runtimeIpcOperationPattern.test(' SubmitTurn {') diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 8a2e18f8db..c6da3979d3 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -75,6 +75,7 @@ pub(crate) enum ActionHandler { AddModel, NewSession, Sessions, + RenameSession, Skills, ReloadSkills, McpServers, @@ -114,7 +115,7 @@ pub(crate) enum ActionHandler { pub(crate) const SHARED_TUI_EMBEDDED_HANDOFF: &str = "Exit all Shared TUI clients, wait up to 30 seconds for their Runtime to stop, then use default Embedded `bitfun chat`"; pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. 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`."; + "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 `/rename ` to rename the current Session, `/agent`, Tab, or Shift+Tab to change its 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(self, context: ActionContext) -> bool { @@ -126,6 +127,7 @@ impl ActionHandler { | Self::SelectTheme | Self::NewSession | Self::Sessions + | Self::RenameSession | Self::AcpHelp | Self::Init | Self::History @@ -371,6 +373,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: true, }, + ActionSpec { + id: "rename_session", + name: "Rename session", + aliases: &["/rename"], + description: "Rename the current session: /rename ", + contexts: CHAT, + availability: ActionAvailability::Idle, + handler: ActionHandler::RenameSession, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: None, + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "skills", name: "Skills", @@ -1795,6 +1812,7 @@ mod tests { ActionHandler::SwitchAgent, ActionHandler::SwitchAgentReverse, ActionHandler::SelectModel, + ActionHandler::RenameSession, ] { assert!( action.available_in_shared_tui(ActionContext::Chat), @@ -1819,10 +1837,26 @@ mod tests { assert!(SHARED_TUI_HELP_NOTE.contains("bitfun chat --shared")); assert!(SHARED_TUI_HELP_NOTE.contains("one Session")); assert!(SHARED_TUI_HELP_NOTE.contains("`/models`")); + assert!(SHARED_TUI_HELP_NOTE.contains("`/rename `")); assert!(SHARED_TUI_HELP_NOTE.contains("Agent/Subagent management")); assert!(SHARED_TUI_HELP_NOTE.contains("remain Embedded")); } + #[test] + fn rename_is_an_idle_current_session_chat_action() { + let action = action_by_id("rename_session", ActionContext::Chat) + .expect("current session rename action"); + + assert_eq!(action.aliases, &["/rename"]); + assert_eq!(action.handler, ActionHandler::RenameSession); + assert_eq!(action.availability, ActionAvailability::Idle); + assert!(action.description.contains("/rename ")); + assert!(action.available(ActionState::chat(false, false))); + assert!(action.available(ActionState::chat(false, false).for_shared_tui())); + assert!(!action.available(ActionState::chat(true, false))); + assert!(action_by_id("rename_session", ActionContext::Startup).is_none()); + } + #[test] fn shared_tui_projections_hide_embedded_management_actions() { let state = ActionState::chat(false, false).for_shared_tui(); @@ -1846,6 +1880,7 @@ mod tests { assert!(palette_ids.contains(&"switch_agent")); assert!(slash_ids.contains(&"select_model")); assert!(palette_ids.contains(&"select_model")); + assert!(slash_ids.contains(&"rename_session")); let help = ResolvedKeymap::new(&ShortcutsConfig::default()).help_text(state); assert!(help.contains("Switch Agent")); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index f62f960b74..55504f656a 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -15,15 +15,17 @@ use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentEventReceiver, AgentLocalCommandTurnRecordRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, AgentSessionUsageRequest, - AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest, - PermissionReply, PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind, - RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport, + AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRestoreRequest, + AgentSessionUsageRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest, + AgentUserAnswersRequest, PermissionReply, PermissionRequest, PermissionRequestEventReceiver, + PortError, PortErrorKind, RuntimeError, SessionTranscript, SessionTranscriptRequest, + SessionUsageReport, }; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, - RuntimeIpcStreamInvalidationReason, RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, + RuntimeIpcStreamInvalidationReason, RuntimeSessionRenameRequest, RuntimeSessionRestoreRequest, + RuntimeUserAnswersRequest, }; use bitfun_events::{AgenticEvent, AgenticEventEnvelope}; use bitfun_runtime_ports::{ @@ -135,9 +137,14 @@ impl std::error::Error for SessionUpdateError {} impl SessionUpdateError { fn runtime(error: RuntimeError) -> Self { + let outcome_unknown = matches!( + &error, + RuntimeError::Port(port_error) + if port_error.kind == PortErrorKind::OutcomeUnknown + ); Self { message: error.into_message(), - outcome_unknown: false, + outcome_unknown, } } @@ -635,6 +642,40 @@ impl CliAgentRuntimeClient { } } + pub(crate) async fn rename_session( + &self, + session_id: &str, + session_name: &str, + ) -> std::result::Result<(), SessionUpdateError> { + match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => { + let request = AgentSessionRenameRequest { + workspace_path: self.project_workspace_path_string(), + session_id: session_id.to_string(), + session_name: session_name.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }; + runtime + .rename_session(request) + .await + .map_err(SessionUpdateError::runtime) + } + CliAgentRuntimeBackend::Shared(client) => { + let result = client + .request(RuntimeIpcOperation::RenameSession { + request: RuntimeSessionRenameRequest { + session_id: session_id.to_string(), + session_name: session_name.to_string(), + }, + }) + .await + .map_err(SessionUpdateError::shared)?; + expect_unit(result, "rename_session").map_err(SessionUpdateError::unexpected) + } + } + } + pub(crate) async fn update_session_mode( &self, session_id: &str, @@ -1278,7 +1319,8 @@ mod tests { use bitfun_agent_runtime::sdk::{ PermissionDelegationContext, PermissionRequest, PermissionRequestEvent, - PermissionRequestSource, PermissionRequestSourceKind, + PermissionRequestSource, PermissionRequestSourceKind, PortError, PortErrorKind, + RuntimeError, }; use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode}; @@ -1328,6 +1370,25 @@ mod tests { },)) .outcome_unknown() ); + assert!( + !SessionUpdateError::shared(RuntimeIpcClientError::RequestEncoding( + bitfun_agent_runtime_ipc::RuntimeIpcIoError::FrameTooLarge { + size: 129, + max_bytes: 128, + }, + )) + .outcome_unknown() + ); + } + + #[test] + fn embedded_runtime_unknown_outcome_is_preserved() { + let error = SessionUpdateError::runtime(RuntimeError::Port(PortError::new( + PortErrorKind::OutcomeUnknown, + "inspect authoritative state", + ))); + + assert!(error.outcome_unknown()); } #[test] @@ -1395,6 +1456,25 @@ mod tests { assert!(!source.contains(&compatibility_update)); } + #[test] + fn session_rename_uses_direct_runtime_or_private_shared_ipc() { + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let rename = source + .split_once("pub(crate) async fn rename_session(") + .expect("rename method") + .1 + .split_once("pub(crate) async fn update_session_mode(") + .expect("rename method boundary") + .0; + + assert!(source.contains("pub(crate) async fn rename_session(")); + assert!(rename.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(rename.contains(".rename_session(request)")); + assert!(rename.contains("RuntimeIpcOperation::RenameSession")); + assert!(!rename.contains("serde_json::to_value")); + assert!(!rename.contains("serde_json::from_value")); + } + #[test] fn mode_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); diff --git a/src/apps/cli/src/diagnostics.rs b/src/apps/cli/src/diagnostics.rs index a29d770c07..7fea00ee03 100644 --- a/src/apps/cli/src/diagnostics.rs +++ b/src/apps/cli/src/diagnostics.rs @@ -8,6 +8,7 @@ use bitfun_agent_runtime_ipc::{RuntimeIpcClientError, RuntimeIpcErrorCode}; pub(crate) const EXIT_LINE_PREFIX: &str = "BITFUN_EXIT: "; pub(crate) const DETAIL_MAX_LEN: usize = 500; pub(crate) const SESSION_IN_USE_ERROR_CODE: &str = "session_in_use"; +pub(crate) const OUTCOME_UNKNOWN_ERROR_CODE: &str = "outcome_unknown"; pub(crate) const SESSION_IN_USE_USER_MESSAGE: &str = "This session is open in another BitFun instance. Close it there and retry."; diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 25d562b5fc..3945d7422f 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -175,6 +175,9 @@ enum PendingSessionUpdateKind { model_id: String, display_name: String, }, + Rename { + session_name: String, + }, } impl PendingSessionUpdateKind { @@ -182,6 +185,7 @@ impl PendingSessionUpdateKind { match self { Self::Mode { .. } => "agent mode", Self::Model { .. } => "model", + Self::Rename { .. } => "name", } } @@ -189,6 +193,7 @@ impl PendingSessionUpdateKind { match self { Self::Mode { mode_id } => mode_id, Self::Model { model_id, .. } => model_id, + Self::Rename { session_name } => session_name, } } } @@ -203,7 +208,7 @@ struct PendingSessionUpdate { } 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."; +const SHARED_TUI_CHAT_STATUS: &str = "Shared TUI preview: this view controls sessions, turns, the current Session name, current Session Agent mode, and current Session model; model management remains Embedded, along with local extension, MCP, account-sync, and Agent/Subagent management."; #[derive(Default)] struct NonKeyEventOutcome { @@ -244,6 +249,8 @@ pub(crate) struct ChatMode { /// One durable current-Session update in flight. The event loop remains responsive /// while the runtime owner writes session metadata. pending_session_update: Option, + /// One explicit native slash-menu choice waiting for its parameterized submission. + selected_native_command_once: Option, external_source_snapshot: Option, external_source_conflict_choices: BTreeMap, external_source_conflict_lineage_current_keys: BTreeMap, @@ -298,6 +305,7 @@ impl ChatMode { pending_mcp_op: None, pending_mcp_tasks: Vec::new(), pending_session_update: None, + selected_native_command_once: 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/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index a899c9b321..a845881bf0 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -110,7 +110,7 @@ impl ChatMode { } fn handle_skill_selector_action( - &self, + &mut self, action: SkillSelectorAction, chat_view: &mut ChatView, chat_state: &mut ChatState, @@ -135,8 +135,9 @@ impl ChatMode { } /// Apply skill selection: fill input box with execution command - fn apply_skill_selection(&self, selected: &SkillItem, chat_view: &mut ChatView) { + fn apply_skill_selection(&mut self, selected: &SkillItem, chat_view: &mut ChatView) { chat_view.set_input(&selected.invocation_text()); + self.selected_native_command_once = None; } fn set_skill_enabled( @@ -352,11 +353,12 @@ impl ChatMode { } /// Apply subagent selection: fill input box with launch command - fn apply_subagent_selection(&self, selected: &SubagentItem, chat_view: &mut ChatView) { + fn apply_subagent_selection(&mut self, selected: &SubagentItem, chat_view: &mut ChatView) { chat_view.set_input(&format!( "Launch subagent {} to finish task: ", selected.name )); + self.selected_native_command_once = None; } fn set_subagent_enabled( diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index baffdc116e..7ca9fe4f6e 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -9,7 +9,15 @@ fn pending_session_update_blocks_runtime_action( ) -> bool { shared_tui && pending_for_current_session - && matches!(handler, ActionHandler::Sessions | ActionHandler::Init) + && matches!( + handler, + ActionHandler::Sessions | ActionHandler::RenameSession | ActionHandler::Init + ) +} + +fn requested_session_name(arguments: &str) -> Option { + let session_name = arguments.trim(); + (!session_name.is_empty()).then(|| session_name.to_string()) } fn native_command_choice_is_active( @@ -36,7 +44,72 @@ fn native_command_reconfirmation_is_required( && !current_native_choice_is_active } +fn builtin_arguments_route(route: CommandRoute, handler: ActionHandler) -> bool { + route == CommandRoute::Builtin && handler == ActionHandler::RenameSession +} + +fn selected_command_prefill(handler: ActionHandler) -> Option<&'static str> { + match handler { + ActionHandler::RenameSession => Some("/rename "), + _ => None, + } +} + +fn begin_slash_menu_selection( + selected_command: &mut Option, + selected_command_name: Option<&str>, +) { + if selected_command_name.is_some() { + *selected_command = None; + } +} + +fn consume_selected_native_command_once( + selected_command: &mut Option, + command_name: &str, +) -> bool { + selected_command + .take() + .is_some_and(|selected| selected.eq_ignore_ascii_case(command_name)) +} + +fn retain_selected_native_command_for_input(selected_command: &mut Option, input: &str) { + let still_selected = selected_command.as_deref().is_some_and(|selected| { + input + .trim_start() + .split_whitespace() + .next() + .map(|token| token.trim_start_matches('/')) + .is_some_and(|command| command.eq_ignore_ascii_case(selected)) + }); + if !still_selected { + *selected_command = None; + } +} + +fn clear_selected_native_command_prefill( + selected_command: &mut Option, + chat_view: &mut ChatView, +) { + if selected_command.take().is_some() { + chat_view.clear_input(); + } +} + +fn session_command_help_note() -> String { + let rename = action_for_alias("/rename", ActionContext::Chat) + .expect("current session rename action must remain registered"); + format!("Session Commands\n {}", rename.description) +} + impl ChatMode { + fn sync_selected_native_command(&mut self, chat_view: &ChatView) { + retain_selected_native_command_for_input( + &mut self.selected_native_command_once, + chat_view.input_text(), + ); + } + /// Handle command palette action fn handle_palette_action( &mut self, @@ -62,6 +135,10 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) -> Result> { + begin_slash_menu_selection( + &mut self.selected_native_command_once, + selected_command_name, + ); if action_id == "toggle_auto_approve" || action_id.starts_with("toggle_auto_approve:") { let action = action_by_id("toggle_auto_approve", ActionContext::Chat) .expect("Auto mode action must remain registered"); @@ -155,6 +232,14 @@ impl ChatMode { ); } } + if let Some(selected_command_name) = selected_command_name { + if let Some(prefill) = selected_command_prefill(action.handler) { + self.selected_native_command_once = + Some(selected_command_name.to_ascii_lowercase()); + chat_view.set_input(prefill); + return Ok(None); + } + } self.dispatch_action( action, self.action_state(chat_state.is_processing, false), @@ -183,6 +268,10 @@ impl ChatMode { .get(token.len()..) .map(str::trim_start) .unwrap_or(""); + let selected_native_once = consume_selected_native_command_once( + &mut self.selected_native_command_once, + command_name, + ); if command_name == "auto" { let action_id = match arguments.trim() { "on" | "enable" => "toggle_auto_approve:on", @@ -213,13 +302,15 @@ impl ChatMode { let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); if self.agent.is_shared() { if let Some(action) = builtin_action { - return self.dispatch_action( - action, - self.action_state(chat_state.is_processing, false), - chat_view, - chat_state, - rt_handle, - ); + let state = self.action_state(chat_state.is_processing, false); + if builtin_arguments_route(CommandRoute::Builtin, action.handler) { + if !action.available(state) { + chat_view.set_status(Some(action.unavailable_message(state))); + return Ok(None); + } + return self.start_session_rename(arguments, chat_view, chat_state, rt_handle); + } + return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); } chat_state.add_system_message(format!( "External prompt command /{command_name} is unavailable in Shared TUI preview. {SHARED_TUI_EMBEDDED_HANDOFF}." @@ -258,14 +349,30 @@ impl ChatMode { .is_some_and(|reconfirmation| !reconfirmation.confirmed), native_choice_is_active, ); - let route = command_route( - builtin_action.is_some(), - external.as_ref(), - self.external_source_snapshot - .as_ref() - .is_some_and(|snapshot| snapshot.discovery_pending), - builtin_reconfirmation_required, - ); + let route = if selected_native_once + && builtin_action.is_some_and(|action| action.handler == ActionHandler::RenameSession) + { + CommandRoute::Builtin + } else { + command_route( + builtin_action.is_some(), + external.as_ref(), + self.external_source_snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.discovery_pending), + builtin_reconfirmation_required, + ) + }; + if let Some(action) = builtin_action { + if builtin_arguments_route(route, action.handler) { + let state = self.action_state(chat_state.is_processing, false); + if !action.available(state) { + chat_view.set_status(Some(action.unavailable_message(state))); + return Ok(None); + } + return self.start_session_rename(arguments, chat_view, chat_state, rt_handle); + } + } if route == CommandRoute::Builtin { if let Some(help) = extension_command_help_request(command_name, arguments) { chat_state.add_system_message(help); @@ -662,6 +769,8 @@ impl ChatMode { match action.handler { ActionHandler::Help => { let mut help = self.keymap.help_text(state); + help.push_str("\n\n"); + help.push_str(&session_command_help_note()); if self.agent.is_shared() { help.push_str("\n\n"); help.push_str(SHARED_TUI_HELP_NOTE); @@ -706,6 +815,9 @@ impl ChatMode { ActionHandler::Sessions => { self.show_session_selector(chat_view, chat_state, rt_handle); } + ActionHandler::RenameSession => { + return self.start_session_rename("", chat_view, chat_state, rt_handle); + } ActionHandler::Skills => { self.show_skill_selector(chat_view, chat_state, rt_handle); } @@ -778,7 +890,10 @@ impl ChatMode { } ActionHandler::ClosePopups => self.close_all_popups(chat_view), ActionHandler::NavigateBack => self.navigate_back(chat_view), - ActionHandler::InsertNewline => chat_view.handle_newline(), + ActionHandler::InsertNewline => { + chat_view.handle_newline(); + self.sync_selected_native_command(chat_view); + } ActionHandler::Paste => self.paste_clipboard(chat_view), ActionHandler::ToggleFocusedTool => { chat_view.toggle_focused_tool_expand(chat_state); @@ -794,6 +909,7 @@ impl ChatMode { chat_view.command_menu_up(); } else { chat_view.history_prev(); + self.selected_native_command_once = None; } } ActionHandler::HistoryNext => { @@ -801,6 +917,7 @@ impl ChatMode { chat_view.command_menu_down(); } else { chat_view.history_next(); + self.selected_native_command_once = None; } } ActionHandler::JumpTop => { @@ -812,7 +929,10 @@ impl ChatMode { chat_view.scroll_to_bottom(); chat_view.set_status(Some("Jumped to conversation bottom".to_string())); } - ActionHandler::ClearInput => chat_view.clear_input(), + ActionHandler::ClearInput => { + chat_view.clear_input(); + self.selected_native_command_once = None; + } ActionHandler::ToggleBrowse => { chat_view.toggle_browse_mode(); let status = if chat_view.browse_mode { @@ -831,6 +951,51 @@ impl ChatMode { Ok(None) } + fn start_session_rename( + &mut self, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + let Some(session_name) = requested_session_name(arguments) else { + chat_view.set_status(Some("Usage: /rename ".to_string())); + return Ok(None); + }; + if self.pending_session_update.is_some() { + chat_view.set_status(Some( + "A current session update is already in progress. Please wait.".to_string(), + )); + return Ok(None); + } + if session_name == chat_state.session_name { + chat_view.set_status(Some( + "The current session already uses that name.".to_string(), + )); + return Ok(None); + } + + let session_id = chat_state.core_session_id.clone(); + let task_session_id = session_id.clone(); + let task_session_name = session_name.clone(); + let agent = self.agent.clone(); + chat_view.set_status(Some("Renaming current session...".to_string())); + let handle = rt_handle.spawn(async move { + agent + .rename_session(&task_session_id, &task_session_name) + .await + }); + self.pending_session_update = Some(PendingSessionUpdate { + session_id, + kind: PendingSessionUpdateKind::Rename { session_name }, + started_at: Instant::now(), + slow_notice_shown: false, + exit_warning_shown: false, + handle, + }); + Ok(None) + } + fn submit_input( &mut self, chat_view: &mut ChatView, @@ -848,6 +1013,9 @@ impl ChatMode { } let trimmed = chat_view.input_text().trim(); + if !trimmed.starts_with('/') { + self.selected_native_command_once = None; + } let pending_for_current_session = self .pending_session_update .as_ref() @@ -908,9 +1076,10 @@ impl ChatMode { } } - fn paste_clipboard(&self, chat_view: &mut ChatView) { + fn paste_clipboard(&mut self, chat_view: &mut ChatView) { if let Ok(text) = Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) { chat_view.insert_paste(&text); + self.sync_selected_native_command(chat_view); } } } diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index e9ac2d7f69..e653f18633 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -352,6 +352,7 @@ impl ChatMode { match (key.code, key.modifiers) { (KeyCode::Backspace, _) => { chat_view.handle_backspace(); + self.sync_selected_native_command(chat_view); } (KeyCode::Left, _) => { @@ -380,6 +381,7 @@ impl ChatMode { if !c.is_control() && c != '\u{0}' => { chat_view.handle_char(c); + self.sync_selected_native_command(chat_view); } _ => {} @@ -623,6 +625,7 @@ impl ChatMode { && !context.this.any_popup_visible(context.chat_view) { context.chat_view.insert_paste(&text); + context.this.sync_selected_native_command(context.chat_view); } outcome.request_redraw = true; } diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index a4bf42f088..53730d78a7 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -24,7 +24,7 @@ fn previous_session_update_status( "The previous session {setting_name} change to {selected_id} failed: {error}. Return to that session to retry." ), 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." + "The previous session {setting_name} change to {selected_id} has an unknown outcome: {error}. This TUI is closing; reopen it, restore that session, and inspect its current {setting_name} before retrying." ), } } @@ -58,7 +58,7 @@ fn apply_agent_mode_feedback( error ); chat_state.add_system_message(format!( - "Agent mode update outcome is unknown: {error}. The Shared connection is closing; reopen Shared TUI, restore this session, and inspect its current mode before retrying." + "Agent mode update outcome is unknown: {error}. This TUI is closing; reopen it, restore this session, and inspect its current mode before retrying." )); false } @@ -112,7 +112,7 @@ fn apply_model_selection_feedback( 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." + "Model update outcome is unknown: {error}. This TUI is closing; reopen it, restore this session, and inspect its current model before retrying." )); false } @@ -129,6 +129,34 @@ fn apply_model_selection_feedback( } } +fn apply_session_rename_feedback( + chat_state: &mut ChatState, + session_name: &str, + outcome: SessionUpdateApplyOutcome, +) -> bool { + match outcome { + SessionUpdateApplyOutcome::SessionUpdateFailed(error) => { + tracing::error!("Failed to rename the current session: {}", error); + chat_state.add_system_message(format!( + "Current session name was not changed: {error}. Please retry." + )); + false + } + SessionUpdateApplyOutcome::OutcomeUnknown(error) => { + tracing::error!("Session rename outcome is unknown: {}", error); + chat_state.add_system_message(format!( + "Session rename outcome is unknown: {error}. This TUI is closing; reopen it, restore this session, and inspect its current name before retrying." + )); + false + } + SessionUpdateApplyOutcome::Applied => { + chat_state.session_name = session_name.to_string(); + tracing::info!("Current session renamed"); + true + } + } +} + fn apply_session_model_migration( chat_state: &mut ChatState, event_session_id: &str, @@ -483,11 +511,10 @@ 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_session_model_display_id( - &global_config.ai, - chat_state.current_model_id.as_deref(), - ); + let current_model_id = 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 @@ -728,6 +755,7 @@ impl ChatMode { "session update task failed: {error}" )), }; + let unknown_outcome = matches!(&outcome, SessionUpdateApplyOutcome::OutcomeUnknown(_)); if chat_state.core_session_id != pending.session_id { if let SessionUpdateApplyOutcome::SessionUpdateFailed(error) = &outcome { tracing::error!( @@ -738,14 +766,17 @@ impl ChatMode { error ); } - chat_view.set_status(Some(previous_session_update_status( + let status = previous_session_update_status( pending.kind.name(), pending.kind.selected_id(), &outcome, - ))); + ); + chat_view.set_status(Some(status.clone())); + if unknown_outcome { + return SessionUpdatePollOutcome::ExitAfterUnknownOutcome(status); + } return SessionUpdatePollOutcome::Redraw; } - 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) @@ -754,6 +785,9 @@ impl ChatMode { model_id, display_name, } => apply_model_selection_feedback(chat_state, display_name, model_id, outcome), + PendingSessionUpdateKind::Rename { session_name } => { + apply_session_rename_feedback(chat_state, session_name, outcome) + } }; if applied { chat_view.set_status(Some(format!( @@ -763,7 +797,7 @@ impl ChatMode { ))); } else if unknown_outcome { 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.", + "Current session {} update outcome is unknown. This TUI is closing; reopen it, restore the session, and inspect its current {} before retrying.", pending.kind.name(), pending.kind.name() ); diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs index 1665998dc0..e2d120f7bb 100644 --- a/src/apps/cli/src/modes/chat/sessions.rs +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -41,6 +41,7 @@ impl ChatMode { self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(chat_state, rt_handle); self.auto_approve_ask_override = None; + clear_selected_native_command_prefill(&mut self.selected_native_command_once, chat_view); chat_state.auto_approve_ask = self.auto_approve_ask_default; self.agent .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); @@ -92,6 +93,7 @@ impl ChatMode { self.workspace = chat_state.workspace.clone(); self.refresh_workspace_git_status(chat_state, rt_handle); self.auto_approve_ask_override = None; + clear_selected_native_command_prefill(&mut self.selected_native_command_once, chat_view); chat_state.auto_approve_ask = self.auto_approve_ask_default; self.agent .set_approval_policy(crate::runtime::approval::CliApprovalPolicy::Ask); diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index bd38e10f2f..ebf33907d4 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -5,7 +5,9 @@ mod tests { use super::{ action_opens_extension_management, agent_event_stream_failure, apply_agent_mode_feedback, apply_model_selection_feedback, apply_session_model_migration, - builtin_command_reconfirmation, cli_native_prompt_command_descriptors, command_route, + apply_session_rename_feedback, begin_slash_menu_selection, builtin_arguments_route, + builtin_command_reconfirmation, clear_selected_native_command_prefill, + cli_native_prompt_command_descriptors, command_route, consume_selected_native_command_once, 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, @@ -18,18 +20,22 @@ mod tests { parse_external_agent_review_action, parse_external_control_action, parse_external_tool_review_action, parse_hook_management_action, 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, SessionUpdateApplyOutcome, SHARED_TUI_CHAT_STATUS, + render_external_hook_catalog, render_native_hook_overview, requested_session_name, + retain_selected_native_command_for_input, selected_command_prefill, + session_command_help_note, session_update_allowed, session_update_blocks_typed_submission, + session_update_completion_should_exit, shared_session_change_is_blocked, CommandRoute, + ExternalAgentReviewAction, ExternalControlUiAction, ExternalSourceConflictPreferences, + ExternalToolReviewAction, HookManagementAction, SessionUpdateApplyOutcome, + SHARED_TUI_CHAT_STATUS, }; use crate::actions::{ action_conflict_behavior_version, ActionHandler, ActionState, ResolvedKeymap, }; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; + use crate::ui::chat::ChatView; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; + use crate::ui::theme::Theme; use bitfun_core::external_hooks::ExternalHookCatalogSnapshotV1; use bitfun_core::external_sources::{ native_prompt_command_conflict_key, ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, @@ -1108,6 +1114,25 @@ mod tests { ); } + #[test] + fn rename_arguments_run_only_after_the_builtin_collision_route_wins() { + let action = + crate::actions::action_for_alias("/rename", crate::actions::ActionContext::Chat) + .expect("rename action"); + + assert!(builtin_arguments_route( + CommandRoute::Builtin, + action.handler, + )); + for route in [ + CommandRoute::External, + CommandRoute::AskForCollisionChoice, + CommandRoute::WaitForDiscovery, + ] { + assert!(!builtin_arguments_route(route, action.handler)); + } + } + #[test] fn native_choice_is_reused_when_multiple_external_candidates_remain_unresolved() { let selected_native = "bitfun.cli:help"; @@ -1448,6 +1473,19 @@ mod tests { assert!(status.contains("retry")); } + #[test] + fn previous_session_unknown_outcome_requires_a_reload() { + let status = previous_session_update_status( + "name", + "Renamed", + &SessionUpdateApplyOutcome::OutcomeUnknown("rollback was not confirmed".to_string()), + ); + + assert!(status.contains("This TUI is closing")); + assert!(status.contains("restore that session")); + assert!(!status.contains("Shared TUI")); + } + #[test] fn unknown_mode_update_outcome_requires_restore_before_retry() { let mut current_mode = "agentic".to_string(); @@ -1473,11 +1511,52 @@ mod tests { panic!("unknown-outcome notice must be text"); }; assert!(content.contains("outcome is unknown")); - assert!(content.contains("reopen Shared TUI")); + assert!(content.contains("This TUI is closing")); assert!(content.contains("restore this session")); assert!(!content.contains("was not changed")); } + #[test] + fn rename_arguments_are_trimmed_and_empty_names_show_usage() { + assert_eq!( + requested_session_name(" Auth refactor ").as_deref(), + Some("Auth refactor") + ); + assert!(requested_session_name("").is_none()); + assert!(requested_session_name(" ").is_none()); + } + + #[test] + fn session_name_changes_only_after_runtime_confirmation() { + let mut state = ChatState::new( + "session".to_string(), + "Original".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + + assert!(!apply_session_rename_feedback( + &mut state, + "Rejected", + SessionUpdateApplyOutcome::SessionUpdateFailed("storage failed".to_string()), + )); + assert_eq!(state.session_name, "Original"); + + assert!(!apply_session_rename_feedback( + &mut state, + "Unknown", + SessionUpdateApplyOutcome::OutcomeUnknown("request timed out".to_string()), + )); + assert_eq!(state.session_name, "Original"); + + assert!(apply_session_rename_feedback( + &mut state, + "Auth refactor", + SessionUpdateApplyOutcome::Applied, + )); + assert_eq!(state.session_name, "Auth refactor"); + } + #[test] fn pending_session_update_routes_commands_to_their_action_guards() { assert!(session_update_blocks_typed_submission(true, "continue")); @@ -1496,6 +1575,11 @@ mod tests { true, ActionHandler::Init, )); + assert!(pending_session_update_blocks_runtime_action( + true, + true, + ActionHandler::RenameSession, + )); assert!(!pending_session_update_blocks_runtime_action( true, true, @@ -1518,6 +1602,90 @@ mod tests { )); } + #[test] + fn parameterized_slash_selection_prefills_the_native_command() { + assert_eq!( + selected_command_prefill(ActionHandler::RenameSession), + Some("/rename ") + ); + assert_eq!(selected_command_prefill(ActionHandler::Sessions), None); + } + + #[test] + fn explicit_native_selection_is_consumed_by_one_matching_submission() { + let mut selected = Some("rename".to_string()); + assert!(consume_selected_native_command_once( + &mut selected, + "rename" + )); + assert!(selected.is_none()); + assert!(!consume_selected_native_command_once( + &mut selected, + "rename" + )); + + let mut different = Some("rename".to_string()); + assert!(!consume_selected_native_command_once( + &mut different, + "help" + )); + assert!(different.is_none()); + } + + #[test] + fn selected_native_command_choice_is_cleared_when_prefill_is_edited_away() { + let mut selected = Some("rename".to_string()); + + retain_selected_native_command_for_input(&mut selected, "/rename Auth refactor"); + assert_eq!(selected.as_deref(), Some("rename")); + + retain_selected_native_command_for_input(&mut selected, "/renam Auth refactor"); + assert!(selected.is_none()); + } + + #[test] + fn selected_native_command_prefill_is_cleared_without_discarding_normal_drafts() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + view.set_input("/rename Release notes"); + let mut selected = Some("rename".to_string()); + + clear_selected_native_command_prefill(&mut selected, &mut view); + + assert!(selected.is_none()); + assert!(view.input_text().is_empty()); + + view.set_input("Keep this normal draft"); + clear_selected_native_command_prefill(&mut selected, &mut view); + assert_eq!(view.input_text(), "Keep this normal draft"); + } + + #[test] + fn every_new_slash_menu_selection_clears_the_pending_native_choice() { + let mut selected = Some("rename".to_string()); + begin_slash_menu_selection(&mut selected, Some("external-command")); + assert_eq!(selected, None); + + selected = Some("rename".to_string()); + begin_slash_menu_selection(&mut selected, Some("auto")); + assert_eq!(selected, None); + + selected = Some("rename".to_string()); + begin_slash_menu_selection(&mut selected, None); + assert_eq!(selected.as_deref(), Some("rename")); + } + + #[test] + fn session_command_help_comes_from_the_action_registry() { + let help = session_command_help_note(); + let rename = + crate::actions::action_for_alias("/rename", crate::actions::ActionContext::Chat) + .expect("rename action"); + + assert!(help.contains("Session Commands")); + assert!(help.contains(rename.description)); + assert!(help.contains("/rename ")); + } + #[test] fn shared_session_change_waits_for_the_current_session_update_result() { assert!(shared_session_change_is_blocked(true, true)); @@ -1529,6 +1697,7 @@ mod tests { 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("current Session model")); + assert!(SHARED_TUI_CHAT_STATUS.contains("current Session name")); assert!(SHARED_TUI_CHAT_STATUS.contains("Agent/Subagent management")); assert!(SHARED_TUI_CHAT_STATUS.contains("model management remains Embedded")); } diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index a5bcbf4753..d30fcf74c1 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -17,7 +17,7 @@ use bitfun_runtime_ports::{ SessionStoragePathRequest, }; -use crate::diagnostics::SESSION_IN_USE_ERROR_CODE; +use crate::diagnostics::{OUTCOME_UNKNOWN_ERROR_CODE, SESSION_IN_USE_ERROR_CODE}; use crate::peer_host::args::{get_string, optional_bool, optional_string, request_value}; use crate::peer_host::state::PeerHostState; @@ -130,6 +130,9 @@ fn peer_runtime_session_error(operation: &str, error: RuntimeError) -> String { RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::SessionInUse => { format!("{SESSION_IN_USE_ERROR_CODE}: {}", port_error.message) } + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::OutcomeUnknown => { + format!("{OUTCOME_UNKNOWN_ERROR_CODE}: {}", port_error.message) + } error => format!("{operation}: {}", error.into_message()), } } @@ -380,7 +383,7 @@ pub(crate) async fn rename_session(state: &PeerHostState, args: &Value) -> Resul remote_ssh_host: storage_request.remote_ssh_host, }) .await - .map_err(|error| format!("Failed to rename session: {}", error.into_message()))?; + .map_err(|error| peer_runtime_session_error("Failed to rename session", error))?; Ok(Value::Null) } @@ -649,6 +652,19 @@ mod tests { ); } + #[test] + fn peer_rename_unknown_outcomes_keep_the_stable_transport_code() { + let error = peer_runtime_session_error( + "Failed to rename session", + RuntimeError::Port(PortError::new( + PortErrorKind::OutcomeUnknown, + "inspect authoritative state", + )), + ); + + assert_eq!(error, "outcome_unknown: inspect authoritative state"); + } + #[test] fn peer_attach_and_raw_mutations_reuse_core_runtime_ownership() { let session_source = include_str!("session.rs"); diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 6ea45b5648..5507e521f0 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -1,15 +1,15 @@ use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use bitfun_agent_runtime::sdk::{ - AgentRuntime, AgentSessionRestoreRequest, AgentUserAnswersRequest, DialogSubmitOutcome, - PermissionRequest, PermissionRequestEvent, PortErrorKind, RuntimeError, + AgentRuntime, AgentSessionRenameRequest, AgentSessionRestoreRequest, AgentUserAnswersRequest, + DialogSubmitOutcome, PermissionRequest, PermissionRequestEvent, PortErrorKind, RuntimeError, SessionTranscriptRequest, }; use bitfun_agent_runtime_ipc::{ DiscoveryStore, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcServer, RuntimeIpcServerConfig, - RuntimeIpcStreamInvalidationReason, PROTOCOL_VERSION, + RuntimeIpcStreamInvalidationReason, RuntimeSessionRenameRequest, PROTOCOL_VERSION, }; use bitfun_core::runtime_ownership::CoreRuntimeOwnership; use bitfun_events::{AgenticEvent, ToolEventData}; @@ -272,6 +272,13 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .map_err(runtime_ipc_error)?; Ok(RuntimeIpcOperationResult::Unit) } + RuntimeIpcOperation::RenameSession { request } => { + self.runtime + .rename_session(owned_session_rename_request(&self.workspace, request)) + .await + .map_err(runtime_ipc_error)?; + Ok(RuntimeIpcOperationResult::Unit) + } RuntimeIpcOperation::SubmitTurn { request } => { let outcome = self .runtime @@ -380,6 +387,19 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { } } +fn owned_session_rename_request( + workspace: &Path, + request: RuntimeSessionRenameRequest, +) -> AgentSessionRenameRequest { + AgentSessionRenameRequest { + workspace_path: workspace.to_string_lossy().to_string(), + session_id: request.session_id, + session_name: request.session_name, + remote_connection_id: None, + remote_ssh_host: None, + } +} + fn subscribe_session_events( events: &SessionEventSenders, available: &watch::Sender, @@ -936,6 +956,9 @@ fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError { RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::InvalidRequest => { RuntimeIpcErrorCode::InvalidRequest } + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::OutcomeUnknown => { + RuntimeIpcErrorCode::OutcomeUnknown + } _ => RuntimeIpcErrorCode::Unavailable, }; RuntimeIpcError { @@ -948,14 +971,23 @@ fn runtime_ipc_error(error: RuntimeError) -> RuntimeIpcError { mod tests { use super::{ await_permission_route, connect_existing, index_user_question, invalidate_event_stream, - permission_event_session, permission_targets_session, project_subagent_link_route, - project_user_question_route, publish_event, route_agent_event, runtime_ipc_error, - subscribe_session_events, SessionEventSenders, EVENT_BUFFER, + owned_session_rename_request, permission_event_session, permission_targets_session, + project_subagent_link_route, project_user_question_route, publish_event, route_agent_event, + runtime_ipc_error, subscribe_session_events, SessionEventSenders, SharedRuntimeHandler, + EVENT_BUFFER, }; use bitfun_agent_runtime::sdk::{ - PermissionDelegationContext, PermissionReplySource, PermissionRequest, - PermissionRequestEvent, PermissionRequestSource, PermissionRequestSourceKind, PortError, - PortErrorKind, RuntimeError, + AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionManagementPort, + AgentSessionRenameRequest, AgentSessionSummary, AgentSessionWorkspaceBinding, + AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, + AgentSubmissionResult, PermissionDelegationContext, PermissionReplySource, + PermissionRequest, PermissionRequestEvent, PermissionRequestSource, + PermissionRequestSourceKind, PortError, PortErrorKind, PortResult, RuntimeError, + }; + use bitfun_agent_runtime_ipc::{ + RuntimeIpcErrorCode, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeIpcRequestHandler, RuntimeSessionRenameRequest, }; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use std::collections::HashMap; @@ -963,6 +995,68 @@ mod tests { use std::time::Duration; use tokio::sync::{watch, Notify}; + #[derive(Default)] + struct RecordingSessionPort { + rename_requests: Mutex>, + } + + #[async_trait::async_trait] + impl AgentSubmissionPort for RecordingSessionPort { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + Ok(AgentSessionCreateResult::new( + "session-1", + request.session_name, + request.agent_type, + )) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + Ok(AgentSubmissionResult { + turn_id: request.turn_id.unwrap_or_else(|| "turn-1".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type( + &self, + _session_id: &str, + ) -> PortResult> { + Ok(Some("agentic".to_string())) + } + } + + #[async_trait::async_trait] + impl AgentSessionManagementPort for RecordingSessionPort { + async fn list_sessions( + &self, + _request: AgentSessionListRequest, + ) -> PortResult> { + Ok(Vec::new()) + } + + async fn delete_session(&self, _request: AgentSessionDeleteRequest) -> PortResult<()> { + Ok(()) + } + + async fn rename_session(&self, request: AgentSessionRenameRequest) -> PortResult<()> { + self.rename_requests.lock().unwrap().push(request); + Ok(()) + } + + async fn resolve_session_workspace_binding( + &self, + _request: AgentSessionWorkspaceRequest, + ) -> PortResult> { + Ok(None) + } + } + #[test] fn session_writer_conflict_reuses_the_existing_ipc_error() { let error = runtime_ipc_error(RuntimeError::Port(PortError::new( @@ -989,6 +1083,83 @@ mod tests { ); } + #[test] + fn unknown_runtime_outcomes_keep_their_ipc_error_category() { + let error = runtime_ipc_error(RuntimeError::Port(PortError::new( + PortErrorKind::OutcomeUnknown, + "inspect authoritative state", + ))); + + assert_eq!(error.code, RuntimeIpcErrorCode::OutcomeUnknown); + } + + #[test] + fn shared_rename_uses_the_server_workspace_and_no_remote_identity() { + let request = owned_session_rename_request( + std::path::Path::new("D:/workspace/project"), + RuntimeSessionRenameRequest { + session_id: "session-1".to_string(), + session_name: "Auth refactor".to_string(), + }, + ); + + assert_eq!(request.workspace_path, "D:/workspace/project"); + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.session_name, "Auth refactor"); + assert!(request.remote_connection_id.is_none()); + assert!(request.remote_ssh_host.is_none()); + } + + #[tokio::test] + async fn embedded_and_shared_rename_reach_the_same_runtime_owner() { + let workspace = tempfile::tempdir().expect("workspace"); + let canonical_workspace = dunce::canonicalize(workspace.path()).expect("workspace path"); + let workspace_path = canonical_workspace.to_string_lossy().to_string(); + let port = Arc::new(RecordingSessionPort::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(port.clone()) + .with_session_management_port(port.clone()) + .build() + .expect("runtime"); + let expected = AgentSessionRenameRequest { + workspace_path: workspace_path.clone(), + session_id: "session-1".to_string(), + session_name: "Auth refactor".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }; + + runtime + .rename_session(expected.clone()) + .await + .expect("embedded rename"); + + let (event_stream_available, _) = watch::channel(true); + let handler = SharedRuntimeHandler { + runtime, + workspace: canonical_workspace, + events: Arc::new(Mutex::new(HashMap::new())), + question_sessions: Arc::new(Mutex::new(HashMap::new())), + subagent_routes: Arc::new(Mutex::new(HashMap::new())), + event_stream_available, + }; + let result = handler + .execute(RuntimeIpcOperation::RenameSession { + request: RuntimeSessionRenameRequest { + session_id: "session-1".to_string(), + session_name: "Auth refactor".to_string(), + }, + }) + .await + .expect("shared rename"); + + assert_eq!(result, RuntimeIpcOperationResult::Unit); + assert_eq!( + port.rename_requests.lock().unwrap().as_slice(), + &[expected.clone(), expected] + ); + } + #[tokio::test] async fn existing_runtime_connection_errors_are_not_hidden_as_absence() { let root = tempfile::tempdir().unwrap(); diff --git a/src/apps/cli/src/ui/session_selector.rs b/src/apps/cli/src/ui/session_selector.rs index 435eab6d7a..34973f8bcc 100644 --- a/src/apps/cli/src/ui/session_selector.rs +++ b/src/apps/cli/src/ui/session_selector.rs @@ -45,10 +45,6 @@ pub(super) struct SessionSelectorState { current_session_id: Option, can_delete: bool, last_area: Option, - /// Inline rename state - rename_editing: bool, - rename_buffer: String, - rename_cursor: usize, } impl SessionSelectorState { @@ -60,9 +56,6 @@ impl SessionSelectorState { current_session_id: None, can_delete: false, last_area: None, - rename_editing: false, - rename_buffer: String::new(), - rename_cursor: 0, } } @@ -87,14 +80,12 @@ impl SessionSelectorState { self.can_delete = can_delete; self.list_state.select(Some(initial_idx)); self.visible = true; - self.rename_editing = false; } pub(super) fn hide(&mut self) { self.visible = false; // Note: we don't clear items here to support back navigation self.last_area = None; - self.rename_editing = false; } /// Reshow the session selector (for back navigation) @@ -133,11 +124,6 @@ impl SessionSelectorState { return SessionAction::None; } - // ── Rename editing mode ── - if self.rename_editing { - return self.handle_rename_key(key); - } - // ── Normal navigation mode ── match (key.code, key.modifiers) { (KeyCode::Up, _) => { @@ -175,54 +161,6 @@ impl SessionSelectorState { } } - /// Handle keys while in rename editing mode. - /// This path is unreachable while rename is disabled, but keeps stale state harmless. - fn handle_rename_key(&mut self, key: KeyEvent) -> SessionAction { - match key.code { - KeyCode::Enter => { - self.rename_editing = false; - SessionAction::None - } - KeyCode::Esc => { - self.rename_editing = false; - SessionAction::None - } - KeyCode::Char(c) => { - let byte_pos = self.char_to_byte(&self.rename_buffer, self.rename_cursor); - self.rename_buffer.insert(byte_pos, c); - self.rename_cursor += 1; - SessionAction::None - } - KeyCode::Backspace => { - if self.rename_cursor > 0 { - self.rename_cursor -= 1; - let byte_pos = self.char_to_byte(&self.rename_buffer, self.rename_cursor); - let next = self.char_to_byte(&self.rename_buffer, self.rename_cursor + 1); - self.rename_buffer.replace_range(byte_pos..next, ""); - } - SessionAction::None - } - KeyCode::Left => { - self.rename_cursor = self.rename_cursor.saturating_sub(1); - SessionAction::None - } - KeyCode::Right => { - let max = self.rename_buffer.chars().count(); - self.rename_cursor = (self.rename_cursor + 1).min(max); - SessionAction::None - } - KeyCode::Home => { - self.rename_cursor = 0; - SessionAction::None - } - KeyCode::End => { - self.rename_cursor = self.rename_buffer.chars().count(); - SessionAction::None - } - _ => SessionAction::None, - } - } - fn move_up(&mut self) { if self.items.is_empty() { return; @@ -267,13 +205,10 @@ impl SessionSelectorState { }; self.last_area = Some(popup_area); - let selected_idx = self.list_state.selected(); - let list_items: Vec = self .items .iter() - .enumerate() - .map(|(i, session)| { + .map(|session| { let is_current = self .current_session_id .as_ref() @@ -286,19 +221,6 @@ impl SessionSelectorState { theme.style(StyleKind::Muted) }; - // If this row is being renamed, show the edit buffer - if self.rename_editing && selected_idx == Some(i) { - let edit_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD); - let line = Line::from(vec![ - Span::styled(marker, marker_style), - Span::styled(&self.rename_buffer, edit_style), - Span::styled("_", Style::default().fg(Color::Yellow)), - ]); - return ListItem::new(line); - } - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); let time_style = theme.style(StyleKind::Muted); let workspace_style = Style::default().fg(Color::DarkGray); @@ -356,9 +278,7 @@ impl SessionSelectorState { width: popup_area.width, height: 1, }; - let hint_text = if self.rename_editing { - " Enter: Save Esc: Cancel " - } else if self.can_delete { + let hint_text = if self.can_delete { " Up/Down: Navigate Enter: Switch Ctrl+D: Delete Esc: Close " } else { " Up/Down: Navigate Enter: Switch Esc: Close " @@ -373,7 +293,7 @@ impl SessionSelectorState { /// Handle mouse events pub(super) fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> SessionAction { - if !self.visible || self.rename_editing { + if !self.visible { return SessionAction::None; } @@ -443,11 +363,4 @@ impl SessionSelectorState { Some(index) } - - fn char_to_byte(&self, s: &str, char_pos: usize) -> usize { - s.char_indices() - .nth(char_pos) - .map(|(i, _)| i) - .unwrap_or(s.len()) - } } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 9caf8072f8..11364948a1 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1067,6 +1067,7 @@ impl StartupPage { ActionHandler::ClosePopups => self.close_all_popups(), ActionHandler::NavigateBack => self.navigate_back(), ActionHandler::ClearConversation + | ActionHandler::RenameSession | ActionHandler::ReloadSkills | ActionHandler::Tools | ActionHandler::Extensions diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index dfafdf44b7..e680b2e014 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1710,13 +1710,20 @@ pub async fn update_session_title( .session_application() .rename_session(scope, session_id.to_string(), request.title) .await - .map_err(|error| match error { - DesktopSessionApplicationError::Validation(message) => message, - DesktopSessionApplicationError::RestoreBeforeRename(message) => { - format!("Failed to restore session before renaming: {message}") - } - error => format!("Failed to update session title: {error}"), - }) + .map_err(desktop_update_session_title_error) +} + +fn desktop_update_session_title_error(error: DesktopSessionApplicationError) -> String { + match error { + DesktopSessionApplicationError::Validation(message) => message, + DesktopSessionApplicationError::RestoreBeforeRename(message) => { + format!("Failed to restore session before renaming: {message}") + } + DesktopSessionApplicationError::OutcomeUnknown(message) => { + format!("outcome_unknown: {message}") + } + error => format!("Failed to update session title: {error}"), + } } /// Load the session into the coordinator process when it exists on disk but is not in memory. @@ -3221,6 +3228,16 @@ mod tests { use bitfun_product_domains::tool_permissions::{PermissionEffect, PermissionRule}; use serde_json::json; + #[test] + fn unknown_title_outcomes_reach_the_frontend_with_a_stable_code() { + assert_eq!( + desktop_update_session_title_error(DesktopSessionApplicationError::OutcomeUnknown( + "inspect authoritative state".to_string(), + ),), + "outcome_unknown: inspect authoritative state" + ); + } + #[test] fn project_permission_rule_revisions_distinguish_missing_and_present_files() { assert_eq!( diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 903a974520..657f6a1d28 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -12,6 +12,7 @@ use async_trait::async_trait; use bitfun_agent_runtime::sdk::{ AgentRuntime, AgentSessionArchiveStateRequest, AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionRenameRequest, AgentSessionUsageRequest, + PortErrorKind, RuntimeError, }; use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use bitfun_core::agentic::core::Session; @@ -59,6 +60,8 @@ pub(crate) enum DesktopSessionApplicationError { Runtime(String), #[error("{0}")] RestoreBeforeRename(String), + #[error("outcome_unknown: {0}")] + OutcomeUnknown(String), #[error("session_in_use: {0}")] SessionInUse(String), } @@ -70,10 +73,22 @@ fn desktop_core_session_error(error: BitFunError) -> DesktopSessionApplicationEr BitFunError::SessionInUse { session_id } => DesktopSessionApplicationError::SessionInUse( format!("Session is already open for writing: {session_id}"), ), + BitFunError::OutcomeUnknown(message) => { + DesktopSessionApplicationError::OutcomeUnknown(message) + } error => DesktopSessionApplicationError::Core(error.to_string()), } } +fn desktop_runtime_session_error(error: RuntimeError) -> DesktopSessionApplicationError { + match error { + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::OutcomeUnknown => { + DesktopSessionApplicationError::OutcomeUnknown(port_error.message) + } + error => DesktopSessionApplicationError::Runtime(error.into_message()), + } +} + #[derive(Debug)] pub(crate) struct DesktopSessionViewRestore { pub session: Session, @@ -505,7 +520,7 @@ impl DesktopSessionApplication { remote_ssh_host: scope.resolved_remote_ssh_host, }) .await - .map_err(|error| DesktopSessionApplicationError::Runtime(error.into_message()))?; + .map_err(desktop_runtime_session_error)?; self.host_effects .notify_session_changed(&session_id, &scope.workspace_path); return Ok(normalized_title); @@ -524,7 +539,7 @@ impl DesktopSessionApplication { .compatibility .update_loaded_session_title(&session_id, &title) .await - .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))?; + .map_err(desktop_core_session_error)?; self.host_effects.notify_session_changed(&session_id, ""); Ok(updated_title) } @@ -736,6 +751,35 @@ mod tests { ); } + #[test] + fn unknown_rename_outcomes_keep_a_stable_desktop_transport_code() { + let error = desktop_runtime_session_error(RuntimeError::Port(PortError::new( + PortErrorKind::OutcomeUnknown, + "inspect authoritative state", + ))); + + assert!(matches!( + error, + DesktopSessionApplicationError::OutcomeUnknown(_) + )); + assert_eq!( + error.to_string(), + "outcome_unknown: inspect authoritative state" + ); + } + + #[test] + fn compatibility_rename_unknown_outcomes_keep_the_same_transport_code() { + let error = desktop_core_session_error(BitFunError::OutcomeUnknown( + "inspect authoritative state".to_string(), + )); + + assert_eq!( + error.to_string(), + "outcome_unknown: inspect authoritative state" + ); + } + struct RecordingDeletePort { events: Arc>>, workspace_path: Arc>>, diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index d8f638bab6..7f5a54515a 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,10 +15,11 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 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 管理或其他产品配置。 +- 封闭 operation 范围为 Health、Session list/create/restore(restore 结果包含 transcript)、当前 Session rename 和 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 目录。 +- Embedded 调用方必须继续以强类型直接调用 Agent Runtime,不能初始化本 transport。Shared 的 request、response 和 event frame 在写出前只编码一次;不能为了吞吐量削弱严格解码、未知字段拒绝、frame 上限、有界队列和背压。 ## 验证 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index e543952241..3a5c3e17f9 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -22,7 +22,7 @@ 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), current-Session Agent mode/model update, +- The closed operation budget is Health, Session list/create/restore (including transcript), current-Session rename and 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. @@ -33,6 +33,11 @@ session controller leases, event delivery, connection bounds, and cleanup. It is WebSocket, browser access, or remote fallback. - Treat this as same-user local isolation, not a sandbox. Product composition must supply a user-private runtime directory. +- Embedded callers must continue to invoke the typed Agent Runtime directly and + must not initialize this transport. Shared outgoing request, response, and + event frames are encoded once before write; strict decoding, unknown-field + rejection, frame limits, bounded queues, and backpressure must not be weakened + for throughput. ## Verification diff --git a/src/crates/adapters/agent-runtime-ipc/src/client.rs b/src/crates/adapters/agent-runtime-ipc/src/client.rs index ee3462c53a..b119734241 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/client.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/client.rs @@ -1,12 +1,13 @@ use crate::{ - read_frame_strict_with_limit, serialize_frame_with_limit, write_frame, DiscoveryRecord, - HealthResult, InitializeRequest, LocalIpcEndpoint, RuntimeIpcCapabilities, RuntimeIpcError, - RuntimeIpcFrame, RuntimeIpcFrameReader, RuntimeIpcIoError, RuntimeIpcOperation, - RuntimeIpcOperationResult, RuntimeIpcTransportError, MAX_REQUEST_FRAME_BYTES, - MAX_RESPONSE_FRAME_BYTES, PROTOCOL_VERSION, + read_frame_strict_with_limit, serialize_frame_with_limit, write_frame, + write_serialized_frame_with_limit, DiscoveryRecord, HealthResult, InitializeRequest, + LocalIpcEndpoint, RuntimeIpcCapabilities, RuntimeIpcError, RuntimeIpcFrame, + RuntimeIpcFrameReader, RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeIpcTransportError, MAX_REQUEST_FRAME_BYTES, MAX_RESPONSE_FRAME_BYTES, PROTOCOL_VERSION, }; use std::fmt; use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::sync::{broadcast, mpsc, oneshot, watch, Mutex, OwnedMutexGuard}; @@ -26,13 +27,15 @@ pub struct RuntimeIpcClient { instance_identity: String, request_timeout: Duration, request_gate: Arc>, + next_request_id: Arc, events: broadcast::Sender, capabilities: RuntimeIpcCapabilities, disconnect: watch::Sender, } struct ClientCommand { - operation: RuntimeIpcOperation, + request_id: u64, + frame_bytes: Vec, response: oneshot::Sender, deadline: tokio::time::Instant, _request_gate: OwnedMutexGuard<()>, @@ -41,7 +44,6 @@ struct ClientCommand { enum PendingResponse { Result(RuntimeIpcOperationResult), Remote(RuntimeIpcError), - RequestIdExhausted, Timeout, Io(RuntimeIpcIoError), Disconnected, @@ -140,6 +142,7 @@ impl RuntimeIpcClient { instance_identity: discovery.instance_identity.as_str().to_string(), request_timeout, request_gate: Arc::new(Mutex::new(())), + next_request_id: Arc::new(AtomicU64::new(2)), events, capabilities, disconnect, @@ -159,19 +162,27 @@ impl RuntimeIpcClient { operation: RuntimeIpcOperation, ) -> Result { let request_gate = self.request_gate.clone().lock_owned().await; - serialize_frame_with_limit( + let request_id = self + .next_request_id + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .map_err(|_| RuntimeIpcClientError::RequestIdExhausted)?; + let frame_bytes = serialize_frame_with_limit( &RuntimeIpcFrame::Request { - request_id: u64::MAX, - operation: operation.clone(), + request_id, + operation, }, MAX_REQUEST_FRAME_BYTES, - )?; + ) + .map_err(RuntimeIpcClientError::RequestEncoding)?; let deadline = tokio::time::Instant::now() + self.request_timeout; let (sender, receiver) = oneshot::channel(); match tokio::time::timeout_at( deadline, self.commands.send(ClientCommand { - operation, + request_id, + frame_bytes, response: sender, deadline, _request_gate: request_gate, @@ -198,7 +209,6 @@ impl RuntimeIpcClient { match response { PendingResponse::Result(result) => Ok(result), PendingResponse::Remote(error) => Err(RuntimeIpcClientError::Remote(error)), - PendingResponse::RequestIdExhausted => Err(RuntimeIpcClientError::RequestIdExhausted), PendingResponse::Timeout => Err(RuntimeIpcClientError::Timeout), PendingResponse::Io(error) => Err(RuntimeIpcClientError::Io(error)), PendingResponse::Disconnected => Err(RuntimeIpcClientError::Disconnected), @@ -225,7 +235,6 @@ async fn run_connection( events: broadcast::Sender, mut disconnect: watch::Receiver, ) { - let mut next_request_id = 2u64; let mut pending = std::collections::HashMap::new(); let mut frames = RuntimeIpcFrameReader::new(MAX_RESPONSE_FRAME_BYTES); loop { @@ -240,21 +249,19 @@ async fn run_connection( let Some(command) = command else { break; }; - let Some(incremented) = next_request_id.checked_add(1) else { - let _ = command.response.send(PendingResponse::RequestIdExhausted); - break; - }; - let request_id = next_request_id; - next_request_id = incremented; + let request_id = command.request_id; if tokio::time::Instant::now() >= command.deadline { let _ = command.response.send(PendingResponse::Timeout); continue; } - let frame = RuntimeIpcFrame::Request { - request_id, - operation: command.operation, - }; - match tokio::time::timeout_at(command.deadline, write_frame(&mut stream, &frame)).await { + match tokio::time::timeout_at( + command.deadline, + write_serialized_frame_with_limit( + &mut stream, + &command.frame_bytes, + MAX_REQUEST_FRAME_BYTES, + ), + ).await { Err(_) => { let _ = command.response.send(PendingResponse::Timeout); break; @@ -334,6 +341,8 @@ pub enum RuntimeIpcClientError { UnexpectedResponse, #[error("runtime IPC request identifiers are exhausted")] RequestIdExhausted, + #[error("runtime IPC request could not be encoded")] + RequestEncoding(#[source] RuntimeIpcIoError), #[error("runtime IPC timeouts must be greater than zero")] InvalidTimeout, #[error("runtime IPC request was rejected: {0:?}")] diff --git a/src/crates/adapters/agent-runtime-ipc/src/framing.rs b/src/crates/adapters/agent-runtime-ipc/src/framing.rs index a762fed630..18bc3568e7 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/framing.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/framing.rs @@ -24,12 +24,29 @@ where W: AsyncWrite + Unpin, { let bytes = serialize_frame_with_limit(frame, max_bytes)?; + write_serialized_frame_with_limit(writer, &bytes, max_bytes).await +} + +pub(crate) async fn write_serialized_frame_with_limit( + writer: &mut W, + bytes: &[u8], + max_bytes: usize, +) -> Result<(), RuntimeIpcIoError> +where + W: AsyncWrite + Unpin, +{ + if bytes.len() > max_bytes { + return Err(RuntimeIpcIoError::FrameTooLarge { + size: bytes.len(), + max_bytes, + }); + } writer .write_u32(bytes.len() as u32) .await .map_err(RuntimeIpcIoError::Io)?; writer - .write_all(&bytes) + .write_all(bytes) .await .map_err(RuntimeIpcIoError::Io)?; writer.flush().await.map_err(RuntimeIpcIoError::Io) diff --git a/src/crates/adapters/agent-runtime-ipc/src/lib.rs b/src/crates/adapters/agent-runtime-ipc/src/lib.rs index 5fd345146d..8830ad908f 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/lib.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/lib.rs @@ -18,15 +18,15 @@ pub use discovery::{ pub use framing::RuntimeIpcIoError; pub(crate) use framing::{ read_frame, read_frame_strict_with_limit, serialize_frame_with_limit, write_frame, - write_frame_with_limit, RuntimeIpcFrameReader, MAX_REQUEST_FRAME_BYTES, - MAX_RESPONSE_FRAME_BYTES, + write_frame_with_limit, write_serialized_frame_with_limit, RuntimeIpcFrameReader, + MAX_REQUEST_FRAME_BYTES, MAX_RESPONSE_FRAME_BYTES, }; pub use handler::RuntimeIpcRequestHandler; pub use ipc::RuntimeIpcTransportError; pub(crate) use ipc::{LocalIpcEndpoint, LocalIpcListener, LocalIpcStream}; pub use operation::{ - RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeSessionRestoreRequest, - RuntimeUserAnswersRequest, + RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeSessionRenameRequest, + RuntimeSessionRestoreRequest, RuntimeUserAnswersRequest, }; pub use protocol::{ HealthResult, InitializeRequest, InitializeResult, RuntimeIpcCapabilities, RuntimeIpcError, diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 77d8d145ad..a780791425 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -14,6 +14,13 @@ pub struct RuntimeSessionRestoreRequest { pub session_id: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeSessionRenameRequest { + pub session_id: String, + pub session_name: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RuntimeUserAnswersRequest { @@ -46,6 +53,9 @@ pub enum RuntimeIpcOperation { UpdateSessionModel { request: AgentSessionModelUpdateRequest, }, + RenameSession { + request: RuntimeSessionRenameRequest, + }, SubmitTurn { request: AgentDialogTurnRequest, }, @@ -71,6 +81,7 @@ impl RuntimeIpcOperation { Self::RestoreSession { request } => Some(&request.session_id), Self::UpdateSessionMode { request } => Some(&request.session_id), Self::UpdateSessionModel { request } => Some(&request.session_id), + Self::RenameSession { request } => Some(&request.session_id), Self::SubmitTurn { request } => Some(&request.session_id), Self::CancelTurn { request } => Some(&request.session_id), Self::PendingPermissions { session_id } @@ -85,6 +96,7 @@ impl RuntimeIpcOperation { self, Self::UpdateSessionMode { .. } | Self::UpdateSessionModel { .. } + | Self::RenameSession { .. } | 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 cf8cd7d082..8a95b586c2 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 = 4; +pub const PROTOCOL_VERSION: u32 = 5; #[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 9da2002036..78ac0d9a18 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -1,11 +1,12 @@ use crate::{ - read_frame, serialize_frame_with_limit, write_frame_with_limit, DiscoveryRecord, - DiscoveryStore, InitializeResult, LeaseTransition, LocalIpcEndpoint, LocalIpcListener, - LocalIpcStream, RuntimeInstanceIdentity, RuntimeInstanceLock, RuntimeIpcCapabilities, - RuntimeIpcDiscoveryError, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, - RuntimeIpcFrame, RuntimeIpcFrameReader, RuntimeIpcIoError, RuntimeIpcOperation, - RuntimeIpcOperationResult, RuntimeIpcRequestHandler, RuntimeIpcTransportError, - RuntimeSessionLeases, MAX_REQUEST_FRAME_BYTES, MAX_RESPONSE_FRAME_BYTES, PROTOCOL_VERSION, + read_frame, serialize_frame_with_limit, write_frame_with_limit, + write_serialized_frame_with_limit, DiscoveryRecord, DiscoveryStore, InitializeResult, + LeaseTransition, LocalIpcEndpoint, LocalIpcListener, LocalIpcStream, RuntimeInstanceIdentity, + RuntimeInstanceLock, RuntimeIpcCapabilities, RuntimeIpcDiscoveryError, RuntimeIpcError, + RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcFrame, RuntimeIpcFrameReader, + RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, + RuntimeIpcTransportError, RuntimeSessionLeases, MAX_REQUEST_FRAME_BYTES, + MAX_RESPONSE_FRAME_BYTES, PROTOCOL_VERSION, }; use bitfun_events::AgenticEvent; use bitfun_runtime_ports::{AgentSubmissionSource, AgentTurnCancellationRequest}; @@ -342,23 +343,26 @@ async fn run_initialized_connection( *active_turn_id = None; } let frame = RuntimeIpcFrame::Event { event }; - if matches!( - serialize_frame_with_limit(&frame, MAX_RESPONSE_FRAME_BYTES), - Err(RuntimeIpcIoError::FrameTooLarge { .. }) - ) { - timeout_write( - config.request_timeout, - stream, - &RuntimeIpcFrame::Event { - event: RuntimeIpcEvent::StreamInvalidated { - reason: crate::RuntimeIpcStreamInvalidationReason::FrameTooLarge, + let frame_bytes = match serialize_frame_with_limit(&frame, MAX_RESPONSE_FRAME_BYTES) + { + Ok(bytes) => bytes, + Err(RuntimeIpcIoError::FrameTooLarge { .. }) => { + timeout_write( + config.request_timeout, + stream, + &RuntimeIpcFrame::Event { + event: RuntimeIpcEvent::StreamInvalidated { + reason: + crate::RuntimeIpcStreamInvalidationReason::FrameTooLarge, + }, }, - }, - ) - .await?; - return Err(RuntimeIpcServerError::EventStreamUnavailable); - } - timeout_write(config.request_timeout, stream, &frame).await?; + ) + .await?; + return Err(RuntimeIpcServerError::EventStreamUnavailable); + } + Err(error) => return Err(RuntimeIpcServerError::Io(error)), + }; + timeout_write_serialized(config.request_timeout, stream, &frame_bytes).await?; } ConnectionInput::EventLagged => { return Err(RuntimeIpcServerError::EventStreamUnavailable) @@ -415,6 +419,7 @@ async fn run_initialized_connection( | RuntimeIpcOperation::CreateSession { .. } | RuntimeIpcOperation::UpdateSessionMode { .. } | RuntimeIpcOperation::UpdateSessionModel { .. } + | RuntimeIpcOperation::RenameSession { .. } ) { send_error( @@ -422,7 +427,7 @@ async fn run_initialized_connection( config.request_timeout, Some(request_id), RuntimeIpcErrorCode::SessionInUse, - "finish or cancel the active turn before changing the controlled session or its agent mode", + "finish or cancel the active turn before changing the controlled session, name, agent mode, or model", ) .await?; continue; @@ -471,11 +476,9 @@ async fn run_initialized_connection( } _ => None, }; - let result = tokio::time::timeout( - config.request_timeout, - handler.execute(operation.clone()), - ) - .await; + let side_effecting = operation_has_side_effects(&operation); + let result = + tokio::time::timeout(config.request_timeout, handler.execute(operation)).await; let result = match result { Ok(Ok(result)) => result, Ok(Err(error)) => { @@ -500,7 +503,7 @@ async fn run_initialized_connection( .await?; return Err(RuntimeIpcServerError::Disconnected); } - Err(_) if operation_has_side_effects(&operation) => { + Err(_) if side_effecting => { config .leases .rollback(connection_id, lease_transition.clone()); @@ -530,22 +533,23 @@ async fn run_initialized_connection( } }; let response = RuntimeIpcFrame::Response { request_id, result }; - match serialize_frame_with_limit(&response, MAX_RESPONSE_FRAME_BYTES) { - Err(RuntimeIpcIoError::FrameTooLarge { .. }) => { - config.leases.rollback(connection_id, lease_transition); - send_error( - stream, - config.request_timeout, - Some(request_id), - RuntimeIpcErrorCode::FrameTooLarge, - "runtime IPC response exceeds the supported frame size", - ) - .await?; - continue; - } - Err(error) => return Err(RuntimeIpcServerError::Io(error)), - Ok(_) => {} - } + let response_bytes = + match serialize_frame_with_limit(&response, MAX_RESPONSE_FRAME_BYTES) { + Err(RuntimeIpcIoError::FrameTooLarge { .. }) => { + config.leases.rollback(connection_id, lease_transition); + send_error( + stream, + config.request_timeout, + Some(request_id), + RuntimeIpcErrorCode::FrameTooLarge, + "runtime IPC response exceeds the supported frame size", + ) + .await?; + continue; + } + Err(error) => return Err(RuntimeIpcServerError::Io(error)), + Ok(bytes) => bytes, + }; let RuntimeIpcFrame::Response { result, .. } = &response else { unreachable!("response frame was just constructed") @@ -594,7 +598,9 @@ async fn run_initialized_connection( return Err(RuntimeIpcServerError::Disconnected); } } - if let Err(error) = timeout_write(config.request_timeout, stream, &response).await { + if let Err(error) = + timeout_write_serialized(config.request_timeout, stream, &response_bytes).await + { config.leases.rollback(connection_id, lease_transition); return Err(error); } @@ -672,6 +678,7 @@ fn operation_has_side_effects(operation: &RuntimeIpcOperation) -> bool { | RuntimeIpcOperation::RestoreSession { .. } | RuntimeIpcOperation::UpdateSessionMode { .. } | RuntimeIpcOperation::UpdateSessionModel { .. } + | RuntimeIpcOperation::RenameSession { .. } | RuntimeIpcOperation::SubmitTurn { .. } | RuntimeIpcOperation::CancelTurn { .. } | RuntimeIpcOperation::RespondPermission { .. } @@ -842,6 +849,20 @@ async fn timeout_write( .map_err(RuntimeIpcServerError::Io) } +async fn timeout_write_serialized( + timeout: Duration, + stream: &mut LocalIpcStream, + bytes: &[u8], +) -> Result<(), RuntimeIpcServerError> { + tokio::time::timeout( + timeout, + write_serialized_frame_with_limit(stream, bytes, MAX_RESPONSE_FRAME_BYTES), + ) + .await + .map_err(|_| RuntimeIpcServerError::IoTimeout)? + .map_err(RuntimeIpcServerError::Io) +} + async fn send_error( stream: &mut LocalIpcStream, timeout: Duration, diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs index 524125b300..32a7e18273 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs @@ -1,8 +1,9 @@ use crate::{ read_frame, write_frame, DiscoveryStore, InitializeRequest, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcErrorCode, RuntimeIpcFrame, - RuntimeIpcOperation, RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcTransportError, - MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, + RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcServer, RuntimeIpcServerConfig, + RuntimeIpcTransportError, RuntimeSessionRenameRequest, MAX_REQUEST_FRAME_BYTES, + PROTOCOL_VERSION, }; use std::time::Duration; use tempfile::tempdir; @@ -68,6 +69,53 @@ async fn authenticated_client_can_read_health_and_idle_server_cleans_discovery() assert_eq!(store.read().expect("read cleaned discovery"), None); } +#[tokio::test] +async fn oversized_request_is_rejected_before_send_without_closing_the_connection() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) + .await + .expect("bind server"); + let discovery = server.discovery_record().clone(); + let server_task = tokio::spawn(server.serve()); + let client = RuntimeIpcClient::connect( + runtime_root.path(), + &discovery, + "oversized-request-test", + "0.1.0", + Duration::from_secs(2), + Duration::from_secs(2), + ) + .await + .expect("initialize client"); + + let error = client + .request(RuntimeIpcOperation::RenameSession { + request: RuntimeSessionRenameRequest { + session_id: "session-1".to_string(), + session_name: "x".repeat(MAX_REQUEST_FRAME_BYTES), + }, + }) + .await + .expect_err("oversized request must fail before transport write"); + assert!(matches!( + error, + RuntimeIpcClientError::RequestEncoding(RuntimeIpcIoError::FrameTooLarge { .. }) + )); + client + .health() + .await + .expect("pre-send rejection must leave the connection usable"); + + drop(client); + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits after idle timeout") + .expect("server task joins") + .expect("server exits cleanly"); +} + #[tokio::test] async fn cancelling_the_server_task_cleans_its_discovery_record() { let runtime_root = tempdir().expect("runtime root"); 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 99e569f1de..bbcc60ac88 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 @@ -1,6 +1,7 @@ use crate::{ serialize_frame_with_limit, InitializeRequest, RuntimeIpcFrame, RuntimeIpcOperation, - RuntimeUserAnswersRequest, MAX_REQUEST_FRAME_BYTES, PROTOCOL_VERSION, + RuntimeSessionRenameRequest, RuntimeUserAnswersRequest, MAX_REQUEST_FRAME_BYTES, + PROTOCOL_VERSION, }; use bitfun_product_domains::tool_permissions::PermissionReply; @@ -107,6 +108,36 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { assert!(decoded.requires_controller()); } +#[test] +fn protocol_round_trips_the_current_session_rename_operation() { + assert_eq!(PROTOCOL_VERSION, 5); + + let operation = RuntimeIpcOperation::RenameSession { + request: RuntimeSessionRenameRequest { + session_id: "session-1".to_string(), + session_name: "Auth refactor".to_string(), + }, + }; + + let encoded = serde_json::to_value(&operation).expect("serialize session rename"); + assert_eq!( + encoded, + json!({ + "operation": "rename_session", + "request": { + "sessionId": "session-1", + "sessionName": "Auth refactor" + } + }) + ); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize session rename"); + + 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 f8d45a3e36..2bd6f44ac6 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 @@ -2,7 +2,8 @@ use crate::{ read_frame, write_frame, InitializeRequest, LocalIpcStream, RuntimeInstanceIdentity, RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcError, RuntimeIpcErrorCode, RuntimeIpcEvent, RuntimeIpcFrame, RuntimeIpcOperation, RuntimeIpcOperationResult, RuntimeIpcRequestHandler, - RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeSessionRestoreRequest, PROTOCOL_VERSION, + RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeSessionRenameRequest, + RuntimeSessionRestoreRequest, PROTOCOL_VERSION, }; use async_trait::async_trait; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; @@ -84,6 +85,7 @@ struct FakeHandler { delay: Option, mode_delay: Option, model_delay: Option, + rename_delay: Option, submit_delay: Option, settle_cancel: bool, events: broadcast::Sender, @@ -106,6 +108,7 @@ impl Default for FakeHandler { delay: None, mode_delay: None, model_delay: None, + rename_delay: None, submit_delay: None, settle_cancel: true, events, @@ -186,6 +189,11 @@ impl RuntimeIpcRequestHandler for FakeHandler { tokio::time::sleep(delay).await; } } + if matches!(operation, RuntimeIpcOperation::RenameSession { .. }) { + if let Some(delay) = self.rename_delay { + tokio::time::sleep(delay).await; + } + } match operation { RuntimeIpcOperation::RestoreSession { request } => Ok(restored(&request.session_id)), RuntimeIpcOperation::SubmitTurn { request } => { @@ -516,6 +524,15 @@ fn update_model_operation(session_id: &str, model_id: &str) -> RuntimeIpcOperati } } +fn rename_operation(session_id: &str, session_name: &str) -> RuntimeIpcOperation { + RuntimeIpcOperation::RenameSession { + request: RuntimeSessionRenameRequest { + session_id: session_id.to_string(), + session_name: session_name.to_string(), + }, + } +} + fn server_config() -> RuntimeIpcServerConfig { RuntimeIpcServerConfig { server_version: "shared-controller-test".to_string(), @@ -905,6 +922,103 @@ async fn timed_out_model_update_reports_unknown_outcome_and_closes_the_connectio server.finish().await; } +#[tokio::test] +async fn rename_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("rename-controller").await; + + expect_error( + &mut client, + 2, + rename_operation("session-a", "Auth refactor"), + RuntimeIpcErrorCode::ControllerRequired, + ) + .await; + expect_response( + &mut client, + 3, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut client, + 4, + rename_operation("session-b", "Other work"), + RuntimeIpcErrorCode::SessionMismatch, + ) + .await; + expect_response( + &mut client, + 5, + rename_operation("session-a", "Auth refactor"), + ) + .await; + expect_response( + &mut client, + 6, + submit_operation(server.workspace.path(), "session-a", "turn-a"), + ) + .await; + expect_error( + &mut client, + 7, + rename_operation("session-a", "Blocked during turn"), + RuntimeIpcErrorCode::SessionInUse, + ) + .await; + + let calls = handler.calls.lock().expect("calls"); + assert_eq!( + calls + .iter() + .filter(|operation| matches!(operation, RuntimeIpcOperation::RenameSession { .. })) + .count(), + 1, + "only the controlled idle-session rename reaches the Runtime handler" + ); + drop(calls); + drop(client); + server.finish().await; +} + +#[tokio::test] +async fn timed_out_rename_reports_unknown_outcome_and_closes_the_connection() { + let handler = Arc::new(FakeHandler { + rename_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("rename-timeout").await; + expect_response( + &mut first, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut first, + 3, + rename_operation("session-a", "Outcome unknown"), + RuntimeIpcErrorCode::OutcomeUnknown, + ) + .await; + + assert!(read_frame(&mut first).await.is_err()); + let mut second = server.connect("rename-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 d27d17031c..e201ebe3da 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -8934,6 +8934,9 @@ fn runtime_port_error_from_bitfun(error: BitFunError) -> bitfun_runtime_ports::P bitfun_runtime_ports::PortErrorKind::SessionInUse, format!("Session is already open for writing: {session_id}"), ), + BitFunError::OutcomeUnknown(message) => { + (bitfun_runtime_ports::PortErrorKind::OutcomeUnknown, message) + } BitFunError::NotImplemented(message) => { (bitfun_runtime_ports::PortErrorKind::NotAvailable, message) } diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 3adc9c5f79..7d0d4f4f12 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -138,6 +138,9 @@ impl SchedulerSubmitError { PortErrorKind::SessionInUse, format!("Session is already open for writing: {session_id}"), ), + Self::Core(BitFunError::OutcomeUnknown(message)) => { + PortError::new(PortErrorKind::OutcomeUnknown, message) + } Self::Core(BitFunError::NotImplemented(message)) => { PortError::new(PortErrorKind::NotAvailable, message) } diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 8b5e27cf41..55de2e24bb 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -350,6 +350,8 @@ pub struct PersistenceManager { fail_next_session_state_write: std::sync::Mutex>, #[cfg(test)] fail_next_session_metadata_write: std::sync::Mutex>, + #[cfg(test)] + fail_next_session_metadata_rollback: std::sync::Mutex>, } impl PersistenceManager { @@ -361,6 +363,8 @@ impl PersistenceManager { fail_next_session_state_write: std::sync::Mutex::new(None), #[cfg(test)] fail_next_session_metadata_write: std::sync::Mutex::new(None), + #[cfg(test)] + fail_next_session_metadata_rollback: std::sync::Mutex::new(None), }) } @@ -393,6 +397,14 @@ impl PersistenceManager { .expect("session metadata fault lock") = Some(session_id.to_string()); } + #[cfg(test)] + pub(crate) fn fail_next_session_metadata_rollback_for_test(&self, session_id: &str) { + *self + .fail_next_session_metadata_rollback + .lock() + .expect("session metadata rollback fault lock") = Some(session_id.to_string()); + } + /// Resolve the on-disk sessions directory for `workspace_path`. /// /// Callers may pass either a logical workspace root or an already-resolved @@ -1150,6 +1162,95 @@ impl PersistenceManager { } } + pub async fn update_session_title_metadata( + &self, + workspace_path: &Path, + session_id: &str, + session_name: &str, + last_active_at: u64, + ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; + let _session_write = self.lock_session_write_operation(workspace_path, session_id)?; + self.ensure_runtime_for_write(workspace_path).await?; + let persistence_lock = self + .get_session_persistence_lock(workspace_path, session_id) + .await; + let _persistence_guard = persistence_lock.lock().await; + let original = self + .load_session_metadata(workspace_path, session_id) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!("Session metadata not found: {session_id}")) + })?; + let mut updated = original.clone(); + updated.session_name = session_name.to_string(); + updated.last_active_at = last_active_at; + + let Err(write_error) = self + .save_session_metadata_locked(workspace_path, &updated) + .await + else { + return Ok(()); + }; + if self + .load_session_metadata(workspace_path, session_id) + .await + .is_ok_and(|metadata| { + metadata.is_some_and(|metadata| { + metadata.session_name == original.session_name + && metadata.last_active_at == original.last_active_at + }) + }) + { + return Err(write_error); + } + + #[cfg(test)] + let skip_rollback = { + let mut fault = self + .fail_next_session_metadata_rollback + .lock() + .expect("session metadata rollback fault lock"); + if fault.as_deref() == Some(session_id) { + *fault = None; + true + } else { + false + } + }; + #[cfg(not(test))] + let skip_rollback = false; + + let rollback_error = if skip_rollback { + Some(BitFunError::io( + "Injected session metadata rollback failure", + )) + } else { + self.save_session_metadata_locked(workspace_path, &original) + .await + .err() + }; + if self + .load_session_metadata(workspace_path, session_id) + .await + .is_ok_and(|metadata| { + metadata.is_some_and(|metadata| { + metadata.session_name == original.session_name + && metadata.last_active_at == original.last_active_at + }) + }) + { + return Err(write_error); + } + + Err(BitFunError::OutcomeUnknown(format!( + "Session title persistence failed and rollback did not restore the previous metadata: session_id={session_id}, error={write_error}, rollback_error={}", + rollback_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "none".to_string()) + ))) + } + pub async fn update_session_metadata_if_present( &self, workspace_path: &Path, 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 60fd1dc9b8..7497d589dd 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -3228,18 +3228,15 @@ impl SessionManager { normalized_title: String, ) -> BitFunResult<()> { let workspace_path = self.effective_session_storage_path(session_id).await; - - { - let Some(mut session) = self.sessions.get_mut(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - session.session_name = normalized_title.clone(); - session.updated_at = SystemTime::now(); - session.last_activity_at = SystemTime::now(); - } + let mut updated_session = self + .sessions + .get(session_id) + .map(|session| session.clone()) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + let now = SystemTime::now(); + updated_session.session_name = normalized_title.clone(); + updated_session.updated_at = now; + updated_session.last_activity_at = now; if self.should_persist_session_id(session_id) { let Some(workspace_path) = workspace_path.as_ref() else { @@ -3248,22 +3245,29 @@ impl SessionManager { session_id ))); }; - // Clone the session data out of the DashMap guard before awaiting I/O. - let session_snapshot = { - let Some(session) = self.sessions.get(session_id) else { - return Err(BitFunError::NotFound(format!( - "Session not found: {}", - session_id - ))); - }; - session.clone() - }; - // Ref guard released -- DashMap shard lock is free. + let last_active_at = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; self.persistence_manager - .save_session(workspace_path, &session_snapshot) + .update_session_title_metadata( + workspace_path, + session_id, + &updated_session.session_name, + last_active_at, + ) .await?; } + let Some(mut session) = self.sessions.get_mut(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {session_id}" + ))); + }; + session.session_name = updated_session.session_name; + session.updated_at = now; + session.last_activity_at = now; + info!( "Session title updated: session_id={}, title={}", session_id, normalized_title @@ -6966,6 +6970,7 @@ mod tests { SessionRelationship, SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, }; + use crate::util::errors::BitFunError; use bitfun_core_types::SessionExecutionTarget; use bitfun_runtime_ports::SessionStoragePathRequest; use dashmap::{try_result::TryResult, DashMap}; @@ -8237,6 +8242,161 @@ mod tests { assert!(!sessions_dir.join(&session_id).exists()); } + #[tokio::test] + async fn failed_title_persistence_does_not_publish_the_new_name_in_memory() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Original".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + manager + .sessions + .get_mut(&session.session_id) + .expect("loaded session") + .config + .workspace_path = None; + + manager + .update_session_title(&session.session_id, "Not persisted") + .await + .expect_err("missing persistence path must reject the title update"); + + let loaded = manager + .get_session(&session.session_id) + .expect("session must remain loaded"); + assert_eq!(loaded.session_name, "Original"); + } + + #[tokio::test] + async fn session_title_update_does_not_rewrite_the_runtime_state_file() { + 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( + "Original".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + persistence_manager.fail_next_session_state_write_for_test(&session.session_id); + + manager + .update_session_title(&session.session_id, "Renamed") + .await + .expect("title update must not rewrite runtime state"); + manager.evict_loaded_session_for_test(&session.session_id); + + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("metadata-only title update should remain restorable"); + assert_eq!(restored.session_name, "Renamed"); + } + + #[tokio::test] + async fn failed_title_index_update_rolls_back_persisted_metadata() { + 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( + "Original".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + let index_path = sessions_dir.join("index.json"); + std::fs::remove_file(&index_path).expect("replace index file"); + std::fs::create_dir(&index_path).expect("create invalid index directory"); + + manager + .update_session_title(&session.session_id, "Renamed") + .await + .expect_err("index failure must reject the title update"); + + assert_eq!( + manager + .get_session(&session.session_id) + .expect("session remains loaded") + .session_name, + "Original" + ); + let metadata = persistence_manager + .load_session_metadata(&sessions_dir, &session.session_id) + .await + .expect("metadata should remain readable") + .expect("metadata should exist"); + assert_eq!(metadata.session_name, "Original"); + } + + #[tokio::test] + async fn failed_title_rollback_reports_an_unknown_outcome() { + 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( + "Original".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + let index_path = sessions_dir.join("index.json"); + std::fs::remove_file(&index_path).expect("replace index file"); + std::fs::create_dir(&index_path).expect("create invalid index directory"); + persistence_manager.fail_next_session_metadata_rollback_for_test(&session.session_id); + + let error = manager + .update_session_title(&session.session_id, "Renamed") + .await + .expect_err("failed rollback must not report a definite failure"); + + assert!(matches!(error, BitFunError::OutcomeUnknown(_))); + let metadata = persistence_manager + .load_session_metadata(&sessions_dir, &session.session_id) + .await + .expect("metadata should remain readable") + .expect("metadata should exist"); + assert_eq!(metadata.session_name, "Renamed"); + } + #[tokio::test] async fn loaded_session_identity_check_preserves_processing_state() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs index 4634f13503..f654b95cf5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs @@ -57,6 +57,9 @@ fn thread_goal_port_error(port_error: PortError) -> BitFunError { PortErrorKind::NotAvailable => { user_facing_thread_goal_error(BitFunError::NotImplemented(port_error.message)) } + PortErrorKind::OutcomeUnknown => { + user_facing_thread_goal_error(BitFunError::OutcomeUnknown(port_error.message)) + } PortErrorKind::PermissionDenied | PortErrorKind::SessionInUse | PortErrorKind::CleanupRequired diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index a2787d7561..b03ab1cc11 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -1044,6 +1044,7 @@ fn runtime_port_error(error: BitFunError) -> PortError { BitFunError::Cancelled(_) => PortErrorKind::Cancelled, BitFunError::SessionInUse { .. } => PortErrorKind::SessionInUse, BitFunError::SessionCreateCleanupRequired { .. } => PortErrorKind::CleanupRequired, + BitFunError::OutcomeUnknown(_) => PortErrorKind::OutcomeUnknown, _ => PortErrorKind::Backend, }; PortError::new(kind, error.to_string()) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 095cf4dd4d..2f14a42916 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -534,6 +534,9 @@ impl AgentSessionManagementPort for ScheduledSessionManagementPort { crate::util::errors::BitFunError::SessionInUse { .. } => { bitfun_runtime_ports::PortErrorKind::SessionInUse } + crate::util::errors::BitFunError::OutcomeUnknown(_) => { + bitfun_runtime_ports::PortErrorKind::OutcomeUnknown + } _ => bitfun_runtime_ports::PortErrorKind::Backend, }; bitfun_runtime_ports::PortError::new(kind, error.to_string()) @@ -656,6 +659,9 @@ fn map_session_close_error( crate::util::errors::BitFunError::SessionInUse { .. } => { bitfun_runtime_ports::PortErrorKind::SessionInUse } + crate::util::errors::BitFunError::OutcomeUnknown(_) => { + bitfun_runtime_ports::PortErrorKind::OutcomeUnknown + } _ => bitfun_runtime_ports::PortErrorKind::Backend, }; bitfun_runtime_ports::PortError::new(kind, error.to_string()) diff --git a/src/crates/assembly/core/src/util/errors.rs b/src/crates/assembly/core/src/util/errors.rs index bb14b5ee96..28061e383c 100644 --- a/src/crates/assembly/core/src/util/errors.rs +++ b/src/crates/assembly/core/src/util/errors.rs @@ -39,6 +39,9 @@ pub enum BitFunError { #[error("Session is already open for writing: {session_id}")] SessionInUse { session_id: String }, + #[error("Operation outcome is unknown: {0}")] + OutcomeUnknown(String), + #[error( "Session creation persistence failed and rollback did not complete: session_id={session_id}, error={error}, cleanup_error={cleanup_error}" )] diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 68c4ed2242..627df3d5bf 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -76,6 +76,7 @@ pub enum PortErrorKind { Timeout, SessionInUse, CleanupRequired, + OutcomeUnknown, Backend, } diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index 829a9c585e..d4519f79b7 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -2161,6 +2161,7 @@ fn runtime_error_facts(error: &RuntimeError) -> (ErrorCode, bool, Option (ErrorCode::ActionRequired, false, None), PortErrorKind::Backend => { (ErrorCode::Internal, true, Some(RecoveryAction::RestartHost)) } @@ -2193,6 +2194,7 @@ fn runtime_error_kind(error: &RuntimeError) -> &'static str { PortErrorKind::Timeout => "timeout", PortErrorKind::SessionInUse => "session_in_use", PortErrorKind::CleanupRequired => "cleanup_required", + PortErrorKind::OutcomeUnknown => "outcome_unknown", PortErrorKind::Backend => "backend", }, RuntimeError::MissingDialogTurnPort @@ -2212,7 +2214,7 @@ fn runtime_error_kind(error: &RuntimeError) -> &'static str { } #[cfg(test)] -mod session_conflict_tests { +mod runtime_error_tests { use super::{runtime_error_facts, runtime_error_kind}; use crate::protocol::{ErrorCode, RecoveryAction}; use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; @@ -2230,4 +2232,18 @@ mod session_conflict_tests { ); assert_eq!(runtime_error_kind(&error), "session_in_use"); } + + #[test] + fn unknown_outcome_requires_an_authoritative_read_before_retry() { + let error = RuntimeError::Port(PortError::new( + PortErrorKind::OutcomeUnknown, + "inspect authoritative state", + )); + + assert_eq!( + runtime_error_facts(&error), + (ErrorCode::ActionRequired, false, None) + ); + assert_eq!(runtime_error_kind(&error), "outcome_unknown"); + } } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index e6e67a8660..77c663063c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -53,6 +53,7 @@ import type { TranscriptExportScope } from '@/flow_chat/utils/dialogTranscriptEx import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; import { notificationService } from '@/shared/notification-system'; import { copyTextToClipboard } from '@/shared/utils/textSelection'; +import { isOutcomeUnknownError } from '@/infrastructure/api/errors/TauriCommandError'; import { scheduleAfterStartupPaint, scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; import { isNonLocalDispatchTarget, @@ -983,12 +984,23 @@ const SessionsSection: React.FC = ({ try { await flowChatManager.renameChatSessionTitle(editingSessionId, trimmed); } catch (err) { - log.error('Failed to update session title', err); + log.error('Failed to update session title', { sessionId: editingSessionId, error: err }); + if (isOutcomeUnknownError(err)) { + notificationService.warning(t('nav.sessions.renameOutcomeUnknown'), { duration: 6000 }); + try { + await flowChatManager.reloadSessionTitle(editingSessionId); + } catch (refreshError) { + log.error('Failed to reload the session title after an unknown rename outcome', { + sessionId: editingSessionId, + error: refreshError, + }); + } + } } } setEditingSessionId(null); setEditingTitle(''); - }, [editingSessionId, editingTitle]); + }, [editingSessionId, editingTitle, t]); const handleCancelEdit = useCallback(() => { setEditingSessionId(null); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index af4515382a..6f687e6aaa 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -76,6 +76,7 @@ vi.mock('./flow-chat-manager', () => ({ deleteChatSession: vi.fn(), archiveChatSession: vi.fn(), renameChatSessionTitle: vi.fn(), + reloadSessionTitle: vi.fn(), forkChatSession: vi.fn(), cleanupSaveState: vi.fn(), cleanupSessionBuffers: vi.fn(), diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 2fef896f61..0ae865dded 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -39,6 +39,7 @@ import { deleteChatSession as deleteChatSessionModule, archiveChatSession as archiveChatSessionModule, renameChatSessionTitle as renameChatSessionTitleModule, + reloadSessionTitle as reloadSessionTitleModule, forkChatSession as forkChatSessionModule, cleanupSaveState, cleanupSessionBuffers, @@ -564,6 +565,10 @@ export class FlowChatManager { return renameChatSessionTitleModule(this.context, sessionId, title); } + async reloadSessionTitle(sessionId: string): Promise { + await reloadSessionTitleModule(this.context, sessionId); + } + async forkChatSession(sourceSessionId: string, sourceTurnId: string): Promise { return forkChatSessionModule(this.context, sourceSessionId, sourceTurnId); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index b8c0e77f02..92c87f891a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -6,6 +6,7 @@ import { ensureBackendSession, hydrateSessionHistoryForDetail, preloadHistoricalSessionForOpen, + reloadSessionTitle, retryCreateBackendSession, resolveAgentTypeForSessionCreation, SESSION_ACTIVITY_TOUCH_DELAY_MS, @@ -34,6 +35,7 @@ const configManagerMocks = vi.hoisted(() => ({ const sessionApiMocks = vi.hoisted(() => ({ archiveSession: vi.fn(), + loadSessionMetadata: vi.fn(), })); const persistenceMocks = vi.hoisted(() => ({ @@ -470,6 +472,44 @@ describe('createChatSession', () => { }); }); +describe('reloadSessionTitle', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('replaces only the existing title from authoritative remote metadata', async () => { + const dialogTurns = [{ id: 'turn-1' }] as Session['dialogTurns']; + const session = createSession({ + title: 'Stale title', + dialogTurns, + workspacePath: '/remote/worktree', + projectWorkspacePath: '/remote/project', + remoteConnectionId: 'connection-1', + remoteSshHost: 'ssh.example.test', + }); + const { context, flowChatStore } = createContext(session); + sessionApiMocks.loadSessionMetadata.mockResolvedValue({ + sessionId: session.sessionId, + sessionName: 'Authoritative title', + turnCount: 1, + customMetadata: null, + }); + + await reloadSessionTitle(context, session.sessionId); + + expect(sessionApiMocks.loadSessionMetadata).toHaveBeenCalledWith( + session.sessionId, + '/remote/project', + 'connection-1', + 'ssh.example.test', + ); + const updated = flowChatStore.getState().sessions.get(session.sessionId); + expect(updated?.title).toBe('Authoritative title'); + expect(updated?.dialogTurns).toBe(dialogTurns); + expect(updated?.workspacePath).toBe('/remote/worktree'); + }); +}); + describe('SessionModule historical session coordination', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 6f1ca3d804..48ed85c33c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -28,6 +28,7 @@ import { cleanupSessionBuffers } from './TextChunkModule'; import { createTextSessionTitleDescriptor, createDefaultSessionTitleDescriptor, + deriveSessionTitleStateFromMetadata, getNextDefaultSessionTitleCount, resolveSessionTitle, } from '../../utils/sessionTitle'; @@ -1123,6 +1124,36 @@ export async function renameChatSessionTitle( return updatedTitle; } +export async function reloadSessionTitle( + context: FlowChatContext, + sessionId: string, +): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) return; + + const metadata = await sessionAPI.loadSessionMetadata( + sessionId, + requireSessionProjectWorkspacePath(session, sessionId), + session.remoteConnectionId, + session.remoteSshHost, + ); + if (!metadata) return; + + const titleState = deriveSessionTitleStateFromMetadata(metadata); + context.flowChatStore.setState(previous => { + const current = previous.sessions.get(sessionId); + if (!current) return previous; + + const sessions = new Map(previous.sessions); + sessions.set(sessionId, { + ...current, + ...titleState, + titleStatus: 'generated', + }); + return { ...previous, sessions }; + }); +} + export async function forkChatSession( context: FlowChatContext, sourceSessionId: string, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index 3e299424da..4b99a7092f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -38,6 +38,7 @@ export { deleteChatSession, archiveChatSession, renameChatSessionTitle, + reloadSessionTitle, forkChatSession, } from './SessionModule'; diff --git a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.test.ts b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.test.ts index f221528a26..4b31bc4285 100644 --- a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.test.ts +++ b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { isSessionInUseError, TauriCommandError } from './TauriCommandError'; +import { + isOutcomeUnknownError, + isSessionInUseError, + TauriCommandError, +} from './TauriCommandError'; describe('isSessionInUseError', () => { it('recognizes local Tauri command errors without parsing human prose', () => { @@ -33,3 +37,26 @@ describe('isSessionInUseError', () => { ).toBe(false); }); }); + +describe('isOutcomeUnknownError', () => { + it('recognizes the stable rename error through Tauri and Peer wrappers', () => { + expect( + isOutcomeUnknownError( + new TauriCommandError('Command failed', { + command: 'update_session_title', + originalError: 'outcome_unknown: inspect authoritative state', + }), + ), + ).toBe(true); + expect( + isOutcomeUnknownError({ + message: 'Host command failed', + details: { originalError: 'outcome_unknown: inspect authoritative state' }, + }), + ).toBe(true); + }); + + it('does not infer unknown outcomes from human prose', () => { + expect(isOutcomeUnknownError(new Error('The rename might have worked'))).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts index b46ae2f73b..527dfd772b 100644 --- a/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts +++ b/src/web-ui/src/infrastructure/api/errors/TauriCommandError.ts @@ -101,16 +101,16 @@ export function isTauriCommandError(error: any): error is TauriCommandError { } const SESSION_IN_USE_PREFIX = 'session_in_use:'; +const OUTCOME_UNKNOWN_PREFIX = 'outcome_unknown:'; -/** Recognizes the stable Desktop/Peer error code without parsing localized prose. */ -export function isSessionInUseError(error: unknown): boolean { +function hasStableErrorPrefix(error: unknown, prefix: string): boolean { const pending: unknown[] = [error]; const seen = new Set(); for (let inspected = 0; pending.length > 0 && inspected < 12; inspected += 1) { const current = pending.shift(); if (typeof current === 'string') { - if (current.trimStart().startsWith(SESSION_IN_USE_PREFIX)) return true; + if (current.trimStart().startsWith(prefix)) return true; continue; } if (!current || typeof current !== 'object' || seen.has(current)) continue; @@ -132,3 +132,13 @@ export function isSessionInUseError(error: unknown): boolean { return false; } + +/** Recognizes the stable Desktop/Peer error code without parsing localized prose. */ +export function isSessionInUseError(error: unknown): boolean { + return hasStableErrorPrefix(error, SESSION_IN_USE_PREFIX); +} + +/** Identifies a mutation that must be read back before the user retries it. */ +export function isOutcomeUnknownError(error: unknown): boolean { + return hasStableErrorPrefix(error, OUTCOME_UNKNOWN_PREFIX); +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 74e3eb69c4..02ab25d908 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -182,6 +182,7 @@ "modeCowork": "Cowork", "noSessions": "No sessions", "rename": "Rename", + "renameOutcomeUnknown": "The rename result is uncertain. Refresh or reopen the session list, then check the current title before retrying.", "copySessionId": "Copy ID", "copySessionIdSuccess": "Session ID copied", "copySessionIdFailed": "Failed to copy session ID", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 51c2acb153..6c9856cee3 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -182,6 +182,7 @@ "modeCowork": "Cowork", "noSessions": "暂无会话", "rename": "重命名", + "renameOutcomeUnknown": "重命名结果尚不确定。请刷新或重新打开会话列表,确认当前名称后再重试。", "copySessionId": "复制 ID", "copySessionIdSuccess": "已复制会话 ID", "copySessionIdFailed": "复制会话 ID 失败", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 2a6423f38c..599b310075 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -182,6 +182,7 @@ "modeCowork": "Cowork", "noSessions": "暫無會話", "rename": "重新命名", + "renameOutcomeUnknown": "重新命名結果尚不確定。請重新整理或重新開啟工作階段清單,確認目前名稱後再重試。", "copySessionId": "複製 ID", "copySessionIdSuccess": "已複製會話 ID", "copySessionIdFailed": "複製會話 ID 失敗",