From 17d9be1023686e2750a1037f7418277e928bebe7 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 14 Jul 2026 20:52:41 +0800 Subject: [PATCH] refactor(cli): consume the assembled product runtime Route local CLI execution, management, session, and usage paths through one invocation-scoped runtime context while preserving Core ownership. Harden approval, scheduling, event, persistence, and usage invariants required by the cutover, with focused compatibility documentation and tests. --- .../agent-runtime-services-design.md | 35 +- docs/architecture/cli-product-line-design.md | 82 +- docs/architecture/product-architecture.md | 2 +- docs/plans/core-decomposition-plan.md | 28 +- src/apps/cli/Cargo.toml | 4 +- src/apps/cli/README.md | 49 + src/apps/cli/src/account_sync.rs | 26 +- src/apps/cli/src/agent/agentic_system.rs | 12 - src/apps/cli/src/agent/core_adapter.rs | 454 +++-- src/apps/cli/src/agent/mod.rs | 2 +- src/apps/cli/src/diagnostics.rs | 10 + src/apps/cli/src/main.rs | 389 ++++- src/apps/cli/src/management.rs | 86 +- src/apps/cli/src/modes/chat.rs | 368 ++-- src/apps/cli/src/modes/exec.rs | 1524 +++++++++++++---- src/apps/cli/src/product_assembly.rs | 53 +- src/apps/cli/src/root_handlers.rs | 255 ++- src/apps/cli/src/runtime/approval.rs | 154 ++ src/apps/cli/src/runtime/events.rs | 116 ++ src/apps/cli/src/runtime/mod.rs | 181 ++ src/apps/cli/src/runtime/services.rs | 257 +++ src/apps/cli/src/ui/mod.rs | 101 +- src/apps/cli/src/ui/permission.rs | 30 +- src/apps/cli/src/ui/startup.rs | 47 +- src/apps/cli/tests/exec_cli_contracts.rs | 156 ++ src/apps/cli/tests/product_assembly_cli.rs | 102 +- .../src/agentic/coordination/coordinator.rs | 163 +- .../src/agentic/coordination/scheduler.rs | 654 +++++-- .../src/agentic/execution/round_executor.rs | 35 +- .../assembly/core/src/agentic/keyed_lock.rs | 94 + src/crates/assembly/core/src/agentic/mod.rs | 1 + .../core/src/agentic/persistence/manager.rs | 91 +- .../src/agentic/persistence/session_branch.rs | 2 + .../src/agentic/session/session_manager.rs | 1496 ++++++++++++++-- .../src/agentic/session/session_store_port.rs | 148 +- .../implementations/ask_user_question_tool.rs | 52 +- .../tools/implementations/cron_tool.rs | 19 +- .../implementations/session_control_tool.rs | 11 +- .../implementations/session_history_tool.rs | 19 +- .../implementations/session_message_tool.rs | 53 +- .../tools/implementations/task/execution.rs | 61 +- .../assembly/core/src/product_runtime.rs | 300 ++++ .../core/src/service/session_usage/service.rs | 421 ++++- .../core/src/service/token_usage/service.rs | 13 +- .../core/src/service_agent_runtime.rs | 140 +- src/crates/contracts/core-types/src/lib.rs | 2 +- .../contracts/core-types/src/session.rs | 56 + src/crates/contracts/runtime-ports/src/lib.rs | 27 + .../agent-runtime/src/event_queue.rs | 149 +- .../execution/agent-runtime/src/runtime.rs | 1 + .../execution/agent-runtime/src/scheduler.rs | 95 +- .../agent-runtime/src/session_control.rs | 17 +- .../agent-runtime/src/tool_confirmation.rs | 40 +- .../agent-runtime/src/user_questions.rs | 10 + .../tests/scheduler_contracts.rs | 4 + .../tests/tool_confirmation_contracts.rs | 17 +- .../tests/user_question_tool_contracts.rs | 26 +- .../src/session/metadata_store.rs | 99 ++ .../services-core/src/token_usage/service.rs | 145 +- .../miniAppCustomizationSession.test.ts | 17 +- .../miniAppCustomizationSession.ts | 6 +- 61 files changed, 7619 insertions(+), 1388 deletions(-) create mode 100644 src/apps/cli/src/runtime/approval.rs create mode 100644 src/apps/cli/src/runtime/events.rs create mode 100644 src/apps/cli/src/runtime/mod.rs create mode 100644 src/apps/cli/src/runtime/services.rs create mode 100644 src/apps/cli/tests/exec_cli_contracts.rs create mode 100644 src/crates/assembly/core/src/agentic/keyed_lock.rs diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index c244688fa9..97ebf6e119 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -9,7 +9,8 @@ CLI Agent 体验边界见 [`cli-product-line-design.md`](cli-product-line-design 本文中的接口片段只说明依赖方向和职责,不自动构成当前 API 或实施承诺。当前接口名称、字段和消费方以代码为准; 新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 Agent Runtime SDK 仍是 v1 preview,CLI、ACP、 -Desktop 也尚未从 `bitfun-core/product-full` 切换到独立产品组装。 +Desktop 仍保留 `bitfun-core/product-full` 兼容 owner。CLI 已消费独立的产品组装结果和 SDK 端口,但这不等于 +协调器、调度器、持久化或工具执行 owner 已迁移;ACP 与 Desktop 尚未完成对应入口切换。 阅读路径:第 1 节确认 SDK、内核、产品特性、扩展接口和 crate 边界;第 2-3 节说明稳定接口、 运行时服务、内核、工具和工作流;第 4 节说明产品组装与扩展注册;第 5 节作为质量保护和 @@ -691,11 +692,15 @@ pub struct HarnessExecutionContext { `src/crates/assembly/core` 仍承担 `bitfun-core` 兼容组装。现有 `ProductAssembler` 是具体结构体, 通过 `assemble(ProductAssemblyInput)` 产生 `ProductRuntimeParts`,本文件不再为它定义第二套目标接口。 -当前边界仍未完成:CLI 的 `doctor` 命令已选择 `DeliveryProfile::Cli`,并读取 `ProductAssemblyPlan` 的 -静态 profile;它明确说明未评估运行时 readiness,不把静态服务需求投影为实际 availability, -也不构造 `ProductRuntimeParts` 或注册占位 provider。Chat、Exec、ACP 与其他管理路径仍依赖 `bitfun-core/product-full`。 -Desktop 和 ACP 尚未接入组装结果,Server 仅提供健康检查、信息与 ping 路由。除上述 CLI 诊断切片外, -profile、枚举分支和单元测试仍不能证明对应产品形态已接入。 +当前 CLI 入口已使用类型化 `RuntimeServices` 构造 `ProductRuntimeParts`,并通过一个调用级上下文把 +Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session 与 Usage。 +SDK 已承接会话创建/列举/删除、轮次提交和取消;SDK v1 尚未覆盖的恢复视图、消息、分支、用量和 +工具确认由 `assembly/core` 的单一兼容门面转发。`doctor` 与 `health` 校验真实组装结果及必需注册完整性; +Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。 + +该切换仍是 `product-full` 兼容组装,不是 owner 迁移。协调器、调度器、持久化、工具管线和 Agentic Event Queue +仍由 Core 唯一持有;CLI 不复制这些状态。ACP 与 Desktop 尚未接入组装结果,Server 仅提供健康检查、信息与 +ping 路由。未接入入口的 profile、枚举分支和单元测试仍不能证明对应产品形态可用。 职责: @@ -708,8 +713,8 @@ profile、枚举分支和单元测试仍不能证明对应产品形态已接入 | 阶段 | 约束 | |---|---| -| 当前 | 维持现有 `ProductAssembler` API 和 `product-full` 兼容门面,不扩张字段或再造描述符 | -| 迁移 | CLI 可先经现有兼容门面消费只读静态计划;迁移执行 owner 或继续接入 ACP、Desktop 前,必须消除 `assembly/core -> apps/relay-server` 反向依赖 | +| 当前 | CLI 消费真实 Runtime Parts 与 SDK,Core 兼容门面只承接 SDK v1 缺口;不扩张字段或再造描述符 | +| 迁移 | 迁移执行 owner 或继续接入 ACP、Desktop 前,必须分别证明行为等价;`assembly/core -> apps/relay-server` 反向依赖仍需消除 | | 完成 | 每个声称支持的 profile 都由生产入口消费组装结果,并有最小入口验证;无消费方的 profile 不对外宣称可用 | 产品定义、品牌资源和界面布局的长期边界以 @@ -720,6 +725,9 @@ profile、枚举分支和单元测试仍不能证明对应产品形态已接入 当前组装路径: - 具体运行时服务通过 `RuntimeServicesBuilder` / provider registry 构造。 +- CLI 只选择 `DeliveryProfile::Cli` 一次;必需服务缺失时组装失败,不回退到静态计划或另一 profile。 +- CLI 的 `json` 输出为单结果文档,`stream-json` 直接复用现有 `AgenticEventEnvelope`;协议层不新增 + `schema_version`、`sequence` 或平行事件 taxonomy。 - 能力计划选择工具提供方组计划和 Harness 描述符;当前不存在供任意模块注册所有对象的通用组装注册表。 - 插件运行时通过 `runtime-ports` 的 `PluginRuntimeBinding` 注入;`assembly/core` 负责构造当前 Host 与适配器组合。 - 智能体、命令、skill 和 UI 继续由各自归属模块管理。仓库尚无稳定的 `ProductCommandRegistry` 或 @@ -990,14 +998,17 @@ Product 测试: - `bitfun-runtime-services` 提供类型化服务注入;工具 contracts、provider groups 与 execution 已分层。 - `bitfun-harness` 已提供类型化工作流描述与注册能力。 - `bitfun-core` 可继续作为 `product-full` 兼容门面,避免迁移期间一次性重写入口。 -- CLI 已有首个生产入口计划投影:`doctor` 选择 `DeliveryProfile::Cli`,显示静态 profile, - 并明确说明未评估运行时 readiness;它尚未构造运行时组装结果。 +- CLI 已以 `DeliveryProfile::Cli` 构造真实 Runtime Parts 和 SDK runtime;本地 Agent 入口、会话和用量 + 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 仍保留既有 Core 兼容路径, + 其协议与生命周期切换由独立变更处理。 +- CLI 通过 SDK 处理已覆盖的 session/turn/cancel 操作,并通过一个 Core 兼容门面处理 SDK v1 缺口; + 该门面复用现有 owner,不建立第二套状态或事件 schema。 仍需完成: - 消除 `assembly/core -> apps/relay-server` 的反向依赖,并用通用边界检查固定依赖方向。 -- 为 CLI 接入 owner-owned Runtime Services 与调用级权限、权威事件路径,再让执行路径消费 - `ProductRuntimeParts`;之后让 ACP、Desktop 依次接入,并为每条路径证明行为等价。 +- 继续缩小 CLI 的 Core 兼容门面;只有稳定端口、真实生产调用方和行为等价测试齐备时才迁移 owner。 +- 让 ACP、Desktop 依次接入产品组装,并为每条路径证明行为等价;ACP 生命周期和 Desktop 平台资源仍留在入口。 - 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。 - 仅在真实端到端切片中接入插件主机;外部插件先转换为类型化工具、Hook、事件、权限请求或诊断, 不把生态对象带入 Agent Runtime。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 8cf2618288..510e052a06 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -79,28 +79,47 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 当前主线已经具备以下基础: -- 交互式 TUI、Markdown/代码/Diff/工具卡片、权限交互基础、主题、模型/Agent/MCP/Skill/Subagent/Session 选择; - 当前入口默认策略仍需按 CLI-P0 迁移。 -- `exec` 的 stdin、`text/json/stream-json`、会话恢复/分叉和 Patch 输出。 +- 交互式 TUI、Markdown/代码/Diff/工具卡片、主题、模型/Agent/MCP/Skill/Subagent/Session 选择;权限请求默认询问, + 提供 `Allow once / Allow always / Reject`,其中 `Allow always` 只对当前运行上下文中的同名工具有效。 +- `exec` 支持 stdin、会话恢复/分叉、Patch 输出和 `text/json/stream-json`。非交互执行默认拒绝权限请求, + 显式 `--auto` 才在本次调用内自动批准;兼容参数 `--confirm` 隐藏并映射到安全默认值。`Ctrl+C` 会请求取消 + 当前 turn;失败完成事件、事件流失步和 Patch 写入失败均返回错误结果。 - Agent、模型、MCP、会话、用量、诊断、ACP 外部 Agent 和插件来源管理命令。 - BitFun 原生插件目录的发现、内容校验、来源确认,以及 OpenCode custom tool 静态名称预览。 -- CLI 的 `doctor` 生产命令选择 `DeliveryProfile::Cli` 并投影 `ProductAssemblyPlan`;它展示静态 profile, - 明确说明未评估运行时 readiness,不构造 Runtime Parts 或注册占位 provider。 +- CLI 本地 Agent 入口以类型化 `RuntimeServices` 调用 `ProductAssembler`,选择 `DeliveryProfile::Cli`, + 并把 `ProductRuntimeParts`、Agent Runtime SDK、事件源和调用级审批策略保存在一个 `CliRuntimeContext` 中。 +- TUI、`exec`、会话和用量复用同一上下文。SDK 已承接会话创建/列举/删除、轮次提交和取消; + SDK v1 尚未覆盖的固定 ID、恢复视图、消息、分支、用量和工具确认由一个 Core 兼容门面转发给原 owner。 +- Agentic Event Queue 仍是唯一事件 owner;TUI 与 `exec` 使用独立广播订阅,不互相消费事件。 +- 有界旧队列只承担兼容存储;达到容量时不得抑制广播。CLI 保持一个后台 drain,订阅方一旦报告 lag/closed, + 必须取消活动 turn 并显式失败,不能在状态不完整时继续报告成功。 +- 会话 ID 在进入存储路径前统一校验;运行时索引同时绑定 ID 与规范化存储路径,并以待提交 claim 计数保护 + 并发恢复。同一进程不能把另一个工作区中已加载的同 ID 会话当作当前会话,单个失败恢复也不能释放其他 + 同路径恢复仍在使用的绑定;已加载会话只校验身份,不通过完整 restore 重置活动状态。删除路径不能通过 + 相对路径、绝对路径或分隔符越出 sessions 根目录。 +- TUI 终端句柄由恢复守卫持有;初始化中途失败、正常返回、错误返回或 panic 展开都会尽力退出 alternate screen、 + 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 故障注入仍需独立验收。 +- 初始化按入口分级:交互模式启动 Peer Host 与 MCP,`exec` 只启动 MCP;本地 session 管理和 usage 查询不启动 + Peer Host/MCP。该分级不改变 Agentic/Terminal owner,也不等同于管理命令已有独立轻量 Runtime。 +- Peer Host 的 HostInvoke、Relay、控制器身份、确认和重连协议仍走既有兼容路径,不属于本次本地 Runtime 切换。 +- `doctor` 与 `health` 构造并校验真实 Runtime Parts,区分 assembly-ready、Core compatibility owner 和不可用扩展。 + 它们证明必需能力已注册,不把 Core 的 Network/Git/MCP compatibility marker 描述为外部服务实时可用。 - 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译。 -首个只读计划诊断切片不等于 CLI 已完成独立产品化。当前 CLI crate 仍直接依赖 `bitfun-core` 的 -`product-full`,生产代码尚未消费 `ProductAssembler` 或 Runtime Parts;插件命令也仍以来源管理和静态预览为主。 +上述切换不等于运行时 owner 已迁移,也不表示 CLI-P0 全部完成。CLI crate 仍以 `bitfun-core/product-full` +承载协调器、调度器、持久化、工具管线和部分 SDK v1 缺口;ACP stdio 仍走原入口,插件命令仍以来源管理和 +静态预览为主。兼容门面只转发,不重新计算或写入同一事实。 目标态仍存在以下结构缺口: | 缺口 | 影响 | 本设计的处理 | |---|---|---| -| CLI 只有 `doctor` 消费 `DeliveryProfile::Cli` 的静态计划;所有执行路径仍直接依赖 `bitfun-core/product-full` 和部分具体管理器 | CLI 能力仍难以独立裁剪,执行入口继续承担全局状态职责 | 接入 owner-owned Runtime Services、调用级权限和权威事件路径后,按行为等价测试逐条迁移到 Runtime Parts;迁移期间保留兼容门面。 | +| CLI 已消费 Runtime Parts,但部分执行与持久化操作仍由 `bitfun-core/product-full` 兼容 owner 提供 | SDK 尚不能独立覆盖完整产品会话,过早删除兼容路径会改变行为 | 仅在稳定端口、真实嵌入方和行为等价测试齐备后迁移 owner;兼容门面保持单一且不扩展成第二套 Runtime。 | | TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上增量收敛为事件、状态归约、副作用和渲染四个边界,不重写全部 TUI。 | | CLI 配置只覆盖入口本地选项,缺少统一层级、来源解释和兼容导入 | 用户无法安全迁移其他 CLI 资产,也难以解释最终配置来源 | 建立 BitFun Canonical Config、来源视图和一次性导入报告。 | | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 直接发现 OpenCode 来源,后台准备真实执行版本;状态明确区分预览、准备、可用和降级。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| CLI 已有独立 Linux 测试,三平台编译由通用 workspace check 覆盖,但结构化协议和常规打包 smoke 尚未进入同一快速门禁 | 协议或产物回归仍可能晚于常规 PR 发现 | 在对应协议和产品构建切片中补 focused contract 与 smoke;避免为同一依赖图重复建立三平台编译矩阵。 | +| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;三平台编译由通用 workspace check 覆盖 | 真实模型审批/取消、Patch I/O 失败、PTY 与常规打包仍可能晚于 PR 发现 | 继续补进程级和 PTY 契约及 package smoke;避免为同一依赖图重复建立三平台编译矩阵。 | ## 3. 分阶段产品需求 @@ -108,16 +127,16 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现有用户主路径。 -CLI-P0 不是一个统一重构 PR。首个切片只让生产入口选择 `DeliveryProfile::Cli`,读取静态 profile, -不把服务需求或扩展计划解释为运行时 availability,并为一条用户可见诊断路径补入口验证和独立 CLI 测试。旧门面仅在后续执行切片 -行为等价成立后退出。 +CLI-P0 不是一个统一重构 PR。静态 profile、真实 Runtime Services、Runtime Parts、调用级审批、共享事件源和 +本地 Agent 纵向入口已接入;旧门面仅在后续 owner 迁移的行为等价成立后退出。配置解释、产品定制消费、TUI +进一步拆分、ACP 切换和 package smoke 仍需独立交付,不能由本次运行时切换代替。 其余工作独立立项,不能与 profile 迁移互相充当完成条件: | 切片 | 范围 | 退出条件 | |---|---|---| -| 调用级审批 | TUI、`exec`、ACP 使用本次调用内的类型化 Approval Policy,不写回全局配置 | 交互/非交互默认值、显式批准和组织策略均有 focused test | -| 输出协议 | 盘点 `text/json/stream-json` 消费方,再设计版本、序号、terminal event、stdout/stderr 和退出分类 | 真实消费者兼容测试与迁移窗口明确 | +| 调用级审批 | TUI 与 `exec` 已使用调用级策略且不写全局配置;ACP 需独立迁移 | Runtime-context `Allow always`、审批规划、`exec` 安全默认值和显式 `--auto` 有 focused test;真实模型/PTY 审批流与 ACP 另行验收 | +| 输出协议 | 保持通用 `text/json/stream-json` 心智,复用现有 Agentic envelope,不新建 CLI schema | 已覆盖结果/envelope 序列化、参数与前置 JSON 失败、失败完成、同会话跨 turn 隔离和 stream-json/Patch stdout 冲突;真实信号、模型权限失败与 Patch I/O 故障注入仍需进程级契约 | | 配置解释 | Canonical Config 层级、来源解释和兼容导入 dry-run | 不自动写入;冲突、未知字段和凭据引用可解释 | | 产品定制 | 消费最小产品定义、组装结果和已注册 TUI layout/theme ID | 第二个真实 CLI 产品复用后再提升公共字段 | | TUI 边界 | 增量提取终端恢复守卫、命令分发和副作用边界 | 不改版视觉设计,恢复/取消回归可单独验证 | @@ -151,23 +170,25 @@ CLI-P1 应保证: - stdin、显式 prompt、固定/恢复/继续/分叉会话互斥关系可验证。 - `stream-json` 每行一个完整事件;`json` 只输出一个完整结果文档;日志和诊断默认进入 stderr。 -- 事件包含 schema 版本、session/turn 身份、每 turn 单调 sequence、辅助时间、完成原因、用量和产物引用。 - 失败使用稳定退出码分类:输入/配置、认证、权限、运行时、取消、超时、工具/工作流、输出写入。 -- 支持可选结果 JSON Schema 约束;Schema 失败不得伪装成成功结果。 - 大型工具结果和二进制附件只在事件中传递存储引用,不把 data URL 或大块内容写入事件流。 -- 结构化模式下 Patch 只能进入版本化事件、结果文档、存储引用或显式文件,不能混入协议 stdout。 +- 结构化模式下 Patch 只能进入最终结果、已有事件、存储引用或显式文件,不能混入 `stream-json` stdout。 -候选 v1 协议只先约束语义形态;字段名、数值退出码和默认切换必须在真实消费方盘点与兼容测试后冻结: +当前协议直接采用同类产品的通用输出心智,不建立 BitFun 专属的平行事件分类: -| 项目 | v1 约束 | +| 模式 | 当前约束 | |---|---| -| `stream-json` | 每行一个 `{schema_version,type,session_id,turn_id,sequence,payload}` envelope;每 turn 恰有一个 terminal event。 | -| `json` | 只输出一个 `{schema_version,outcome,session,turn,usage,artifacts,error}` 结果文档。 | -| 退出码 | 至少区分成功、输入/配置、认证、权限、运行时、取消、超时、工具/工作流和输出写入;数值映射不得在无消费方证据时预先冻结。 | -| 多错误 | terminal outcome 只设置一次,以首个因果终止错误为准;结果无法写出时由输出写入错误覆盖。 | - -实施迁移时可新增显式 `--output-schema v1`;未指定时保留旧行为一个已公告的兼容窗口并在 stderr 提示弃用。 -兼容窗口、默认切换和旧协议退场由已盘点的真实消费方决定,不能无限期双轨。 +| `text` | 最终助手文本写 stdout;进度、思考、工具状态、日志和诊断写 stderr。显式 `--output-patch -` 是用户选择的额外 stdout 内容。 | +| `json` | stdout 只写一个结果对象,包含 `type=result`、`subtype`、`is_error`、`result`,以及已建立时的 `session_id`/`turn_id`、本 turn 累计 `usage` 和可用的 `patch`。 | +| `stream-json` | 每行直接序列化一个现有 `AgenticEventEnvelope`;不增加 `schema_version`、`sequence` 或第二套 CLI 事件 taxonomy。 | +| 事件范围 | 只输出本次 session/turn 的事件,以及与其明确关联的 subagent link/tool 事件;同 session 的其他并发 turn 不得混入。 | +| Patch | `json` 可把 `--output-patch -` 放入最终对象;`stream-json` 要求显式文件路径。Patch 是写出显式 Patch 文件前捕获的仓库 `HEAD` 相对工作区快照,包含 staged、unstaged、untracked 及命令启动前已有改动,不包含输出 artifact 本身,也不表达改动归因。 | +| 权限 | 非交互默认拒绝并返回权限失败;`--auto` 只改变当前提交策略,不修改持久化配置。 | +| 人工输入 | 非交互 `exec` 不暴露 `AskUserQuestion`;调用方必须在初始输入中提供完整上下文。该事实沿 Task、SessionMessage 及其自动回复链传播,避免子 Agent 或后续 turn 等待不存在的 stdin 处理器。 | +| 终止 | terminal event 决定结果;`success=false` 不能映射为成功。`Ctrl+C` 请求取消,并在有界等待内继续转发当前 turn 的 terminal envelope 后返回取消结果。当前公开契约不新增 Agent turn 总时限参数;调用方可使用进程级期限,只有出现真实消费方时才单独设计 deadline。 | + +CLI 不提供 `--output-schema v1`。Codex/Claude 同类参数表达的是调用方提供的 JSON Schema,用于约束最终模型 +响应,不是协议版本选择;如未来支持,应复用该语义并独立设计,不能借此重定义事件 envelope。 #### 管理与诊断 @@ -498,7 +519,7 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes |---|---| | Capability/Profile | 产品组装结果/TUI 布局引用、依赖闭包、冲突、未知能力、缺失资源、产品能力上限和后端/入口一致性 | | TUI | Reducer/命令单测、渲染 snapshot、PTY resize/paste/interrupt/restore、Approval Policy、纯文本/屏幕阅读器、性能预算 | -| Exec | v1 `json`/`stream-json` schema(后者为 JSONL)、单调序号/唯一 terminal event、stdout/stderr、退出码、旧协议迁移、取消、超时、resume/fork、大结果存储引用 | +| Exec | 单结果 `json`、现有 `AgenticEventEnvelope` JSONL、stdout/stderr、权限默认值、取消、超时、resume/fork、Patch 与大结果存储引用 | | Config | 层级合并、来源解释、策略约束、资产处置、三类外部 fixture、MCP disabled、Skill 可执行资源、冲突、回滚和脱敏 | | Plugin | OpenCode 直接来源、依赖与真实导出、工具/全部稳定 Hook/Client、server 与 tui 双 target、加载顺序、超时、崩溃、过载、恢复、策略差异和 unsupported fixture | | Agent Runtime | session/turn/cancel、compact/checkpoint/rewind 的补偿/partial/re-entry、后台投递、Subagent、Hook 顺序和持久化恢复 | @@ -507,14 +528,15 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes | 平台 | Windows、macOS、Linux 的 build/smoke;Windows 单独覆盖 ConPTY、Ctrl+C、路径和进程树清理 | 通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 -`cargo test --locked -p bitfun-cli`。结构化协议测试和打包 smoke test 在对应切片落地后进入门禁。 +`cargo test --locked -p bitfun-cli`。已落地的 focused 协议契约进入该测试;完整进程/PTY 矩阵与打包 smoke +仍按对应切片补入门禁,不能由序列化单测代替。 ### 10.2 阶段退出条件 CLI-P0 完成: -- CLI 使用显式组装计划和统一能力可用性,不新增入口侧产品逻辑。 -- 结构化输出、Approval Policy、配置来源和产品组装结果/TUI 布局消费有可复核契约测试。 +- CLI 使用真实 Runtime Parts 和统一能力可用性,不新增入口侧产品逻辑。 +- 结构化输出、Approval Policy、配置来源和产品组装结果/TUI 布局消费均有可复核契约测试。 - 两个已解析产品输入能在不修改源码的情况下生成当前平台最小 CLI smoke artifact。 - CLI 独立 CI 成为必需检查。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 2facbd2942..56a729b68f 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -236,7 +236,7 @@ flowchart LR | 产品形态 | 当前 P0 插件能力 | 入口行为 | |---|---|---| | Desktop / product-full | 生产入口仍直接依赖 `bitfun-core/product-full`;当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 共享代码可编译不等于 Desktop 已消费插件能力 | -| CLI | 所有执行路径仍依赖 `bitfun-core/product-full`;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | `doctor` 选择 `DeliveryProfile::Cli` 并展示静态 profile,但不评估运行时 readiness;尚未构造 Runtime Parts,不代表执行路径已隔离,也不执行 OpenCode 插件代码 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;只为 BitFun 原生包提供来源审核、启用预览、精确内容确认和停用 | 本地 Agent 路径选择 `DeliveryProfile::Cli`,校验必需 Runtime Service 注册并消费 Runtime Parts/SDK;SDK 缺口由单一 Core 兼容门面转发。部分注册仍是 compatibility marker,不代表实时探活;插件 binding 明确禁用,不执行 OpenCode 插件代码 | | ACP | 生产入口仍直接依赖 `bitfun-core/product-full` | `DeliveryProfile::Acp` 尚未进入入口组装;不得把测试中的 profile 解释为生产隔离 | | Server / Remote | 当前生产路由没有插件状态消费闭环;Remote 插件执行未实现 | 不在本地替远端项目发现、准备或执行插件;未接入时返回明确不支持 | | Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 8502b51572..93d2569602 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -22,13 +22,13 @@ | 事实 | 当前状态 | 结论 | |---|---|---| | 产品能力组装 | `DeliveryProfile`、`ProductAssembler`、能力计划、服务可用性和测试已存在 | 这些是可测试的 assembly facts,不代表产品入口已接入 | -| CLI / Desktop / ACP | Cargo 仍直接启用 `bitfun-core/product-full`;生产代码没有提交对应 `DeliveryProfile` | 三个入口仍处于兼容组装路径 | +| CLI / Desktop / ACP | 三者仍启用 `bitfun-core/product-full`;CLI 已提交 `DeliveryProfile::Cli` 并消费 Runtime Parts/SDK,Desktop 与 ACP 尚未切换 | CLI 已建立产品组装边界但仍保留 Core owner;三个入口均未完成 owner 迁移 | | Server | 当前生产路由只形成 health/info/ping 基线 | 没有插件状态或独立产品组装闭环 | | Server / Remote / Web / Mobile Web / SDK profile | 当前为空计划、未接入入口或仅有 preview 测试 | 不得据枚举值宣称产品能力已交付 | | Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v1 preview 门面和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用 envelope 或静态候选扩张稳定 ABI | | Relay | `assembly/core` 直接依赖 `apps/relay-server` 以复用嵌入式 relay | 依赖方向反转,且当前边界检查未阻止该问题 | -| CLI CI | 通用 Rust job 排除 `bitfun-cli`;发布工作流只负责打包 | CLI 缺少常规 PR 的独立 check/test 门禁 | +| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;发布工作流负责打包 | 参数/序列化/前置失败和组装已有 focused contract;真实模型/PTY、Patch I/O 失败与常规 package smoke 仍需补齐 | ## 3. 目标依赖与归属 @@ -57,12 +57,26 @@ Relay 是该规则的首个修复对象;不能把 `apps/relay-server` 改名 CLI 是首个入口迁移对象,因为它已有独立产品诉求、显式设计和最小 CI 命令。 -1. 入口提交 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性、Harness 和插件 binding。 -2. 先迁移一条有用户结果的能力链;推荐从只读能力/诊断或一次最小 Agent 会话开始,不一次替换全部 manager。 -3. 新旧路径并行期间只有一个权威写入方;兼容门面只转发,不重新计算状态。 -4. 补 CLI PR check/test 和入口级 smoke;等价后删除该切片对具体 `bitfun-core` manager 的直接读取。 +当前纵向切片已经完成:入口只提交一次 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性、 +Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。会话创建/列举/ +删除、轮次提交和取消走 Agent Runtime SDK;SDK v1 缺口集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 +owner,各入口只建立独立广播订阅,有界兼容队列满载不再阻断广播。TUI 与 Exec 审批均为调用级策略,不写全局 +配置;CLI 本地路径不获取具体 PersistenceManager。交互、执行和管理入口分别控制 Peer Host/MCP 生命周期,管理查询不启动 +这两类外部服务。结构化输出复用现有 Agentic envelope;会话 ID 与 +存储路径绑定并在删除前校验;TUI 终端恢复由 RAII guard 覆盖错误和 panic 展开路径。 -退出条件:CLI 生产入口实际消费组装结果与统一可用性;目标切片没有第二套状态;常规 PR 有独立门禁。 +Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立;本切片不改变其 HostInvoke、身份、确认或 +重连语义。 + +下一步按独立纵向切片推进: + +1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小固定 ID、恢复视图、消息、分支、用量和工具确认兼容面。 +2. 迁移 ACP 的会话/权限/事件投影,但保留 ACP stdio 生命周期在接口入口。 +3. 继续拆分 TUI 副作用边界并补 package smoke,不以大规模重写替代现有回归保护。 + +当前 assembly 切换条件已经满足:CLI 生产入口消费真实组装结果,目标链路没有第二套状态,独立测试与三平台 +编译门禁存在。CLI-P0 整体退出条件尚未满足;真实模型/PTY 协议矩阵、兼容门面退出、ACP/Desktop 切换和 +package smoke 需分别验收。 ### 4.3 依次切换 ACP 与 Desktop diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index bdfcdea796..1519c18f5a 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -14,6 +14,9 @@ path = "src/main.rs" bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } +bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } +bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } # CLI framework clap = { workspace = true } @@ -71,7 +74,6 @@ tracing-subscriber = { workspace = true } tempfile = "3" sha2 = { workspace = true } hex = { workspace = true } -bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } [features] default = [] diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index da7ba719b9..97080e7c54 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -2,6 +2,55 @@ Terminal UI for BitFun (chat, tools, `/login` account + Peer Host). +The local Agent paths build the CLI product profile once per invocation. Interactive chat, `exec`, +session commands, and usage reports use that invocation-scoped runtime context and event source. +Local management queries do not start Peer Host or MCP; `exec` starts MCP but not Peer Host. +Core remains the compatibility owner for execution and persistence operations not yet covered by the +Agent Runtime SDK; plugin execution is not enabled by this assembly path. + +## Common commands + +```bash +bitfun-cli # interactive TUI +bitfun-cli exec "summarize this project" # non-interactive, rejects permission requests +bitfun-cli exec "run tests" --auto # approve tool requests for this invocation +bitfun-cli sessions list +bitfun-cli usage +bitfun-cli doctor +bitfun-cli health +``` + +The TUI asks before protected tool calls and offers `Allow once`, `Allow always`, and `Reject`. +`Allow always` applies only to matching tools in the current runtime context; it does not update the +global configuration. Non-interactive `exec` rejects permission requests by default. Use `--auto` +only when the current invocation may approve tool requests. Non-interactive `exec` does not expose +`AskUserQuestion`; provide all required input in the initial prompt. The hidden legacy `--confirm` +flag maps to the safe default and should not be used in new automation. + +### Structured output + +| Format | stdout contract | +|---|---| +| `text` | Assistant text. Progress, tool status, logs, and diagnostics use stderr. | +| `json` | One final result object with status and result, plus session/turn identity once established, turn-accumulated usage, and available Patch facts. | +| `stream-json` | JSONL containing existing `AgenticEventEnvelope` values; no separate CLI event schema. | + +Select a format with `--output-format text|json|stream-json`. When `--output-patch -` is used with +`json`, the Patch is included in the final object. For `stream-json`, write the Patch to an explicit +file path so protocol stdout remains valid JSONL. A Patch is the repository's `HEAD`-relative +workspace snapshot captured before an explicit Patch artifact is written. It includes staged, +unstaged, untracked, and pre-existing changes, excludes the output artifact itself, and does not +attribute changes to this invocation. + +`Ctrl+C` requests cancellation of the active turn and briefly drains its terminal envelope before +returning. Cancellation, an unsuccessful completion event, +and a requested Patch that cannot be generated or written are error outcomes. An explicit Patch +file is created even when the diff is empty. + +`doctor` and `health` validate product assembly and required capability registrations. They are not +live probes for Network, Git, or MCP integrations that are currently represented by compatibility +registrations. + ## One-click install (Linux / macOS, amd64 + arm64) From the repository root: diff --git a/src/apps/cli/src/account_sync.rs b/src/apps/cli/src/account_sync.rs index 16261d6663..9c07b2eecc 100644 --- a/src/apps/cli/src/account_sync.rs +++ b/src/apps/cli/src/account_sync.rs @@ -10,8 +10,7 @@ use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -use bitfun_core::agentic::persistence::PersistenceManager; -use bitfun_core::infrastructure::try_get_path_manager_arc; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::get_global_config_service; use bitfun_core::service::remote_connect::{sync_state, AccountClient}; @@ -120,13 +119,17 @@ async fn emit_progress( /// Start auto-sync in the background. Returns immediately; progress is in /// [`current_sync_progress`]. -pub(crate) fn start_auto_sync_background(is_first_login: bool, workspace_path: PathBuf) { +pub(crate) fn start_auto_sync_background( + compatibility: CoreAgentRuntimeCompatibility, + is_first_login: bool, + workspace_path: PathBuf, +) { if AUTO_SYNC_IN_FLIGHT.swap(true, Ordering::SeqCst) { tracing::warn!("Account auto-sync already in flight; skipping duplicate start"); return; } tokio::spawn(async move { - let result = run_auto_sync(is_first_login, &workspace_path).await; + let result = run_auto_sync(&compatibility, is_first_login, &workspace_path).await; AUTO_SYNC_IN_FLIGHT.store(false, Ordering::SeqCst); match result { Ok(r) => { @@ -153,6 +156,7 @@ pub(crate) fn start_auto_sync_background(is_first_login: bool, workspace_path: P } pub(crate) async fn run_auto_sync( + compatibility: &CoreAgentRuntimeCompatibility, is_first_login: bool, workspace_path: &Path, ) -> Result { @@ -222,16 +226,10 @@ pub(crate) async fn run_auto_sync( }; emit_progress("listing_sessions", 18, None, None, None).await; - let path_manager = try_get_path_manager_arc().map_err(|e| anyhow!(e.to_string()))?; - let manager = - PersistenceManager::new(path_manager).map_err(|e| anyhow!("persistence manager: {e}"))?; - - // PersistenceManager APIs take the workspace root and resolve the sessions - // directory internally (same path family as CLI session listing). let storage_path = workspace_path.to_path_buf(); - let local_sessions = manager - .list_session_metadata(&storage_path) + let local_sessions = compatibility + .list_persisted_sessions(&storage_path) .await .map_err(|e| anyhow!("list sessions: {e}"))?; @@ -247,8 +245,8 @@ pub(crate) async fn run_auto_sync( let mut sync_state_local = sync_state::load(&acct_session.user_id); let mut pending_uploads: Vec<(String, String, String)> = Vec::new(); for meta in local_sessions.iter() { - let turns = manager - .load_session_turns(&storage_path, &meta.session_id) + let turns = compatibility + .load_persisted_session_turns(&storage_path, &meta.session_id, None) .await .map_err(|e| anyhow!("load turns: {e}"))?; let metadata_json = diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs index 86d23a2518..694c11b391 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -1,8 +1,6 @@ use anyhow::{Context, Result}; -use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::product_runtime::CoreRuntimeServicesProvider; -use bitfun_core::service::config::initialize_global_config; pub(crate) use bitfun_core::agentic::system::AgenticSystem; @@ -18,13 +16,3 @@ pub(crate) async fn init_agentic_system() -> Result { .set_remote_exec_port(CoreRuntimeServicesProvider::remote_exec_port()); Ok(system) } - -pub(crate) async fn init_agentic_system_for_cli() -> Result { - initialize_global_config() - .await - .context("Failed to initialize global config service")?; - AIClientFactory::initialize_global() - .await - .context("Failed to initialize global AIClientFactory")?; - init_agentic_system().await -} diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index b84f5ae250..dbdfab9cfc 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -4,23 +4,52 @@ //! Event consumption is NOT done here — it's done in the chat/exec mode main loops. use anyhow::Result; -use std::path::PathBuf; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; use tokio::sync::Mutex; use super::Agent; -use bitfun_core::agentic::coordination::{ - ConversationCoordinator, DialogSubmissionPolicy, DialogTriggerSource, +use bitfun_agent_runtime::sdk::{ + AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, + AgentSessionListRequest, AgentTurnCancellationRequest, }; -use bitfun_core::agentic::core::SessionConfig; -use bitfun_core::agentic::events::EventQueue; +use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use bitfun_core::agentic::core::Message; +use bitfun_core::agentic::persistence::session_branch::SessionBranchResult; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; +use bitfun_core::service::session::DialogTurnData; +use bitfun_core::service::session_usage::{SessionUsageReport, SessionUsageReportRequest}; +use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy}; + +use crate::runtime::approval::CliApprovalPolicy; +use crate::runtime::events::CliAgentEventSource; +use crate::runtime::CliRuntimeContext; + +fn validated_session_summary( + sessions: &[AgentSessionSummary], + session_id: &str, + workspace_path: &Path, +) -> Result { + sessions + .iter() + .find(|summary| summary.session_id == session_id) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!( + "Session {session_id} was not found in the current workspace: {}", + workspace_path.display() + ) + }) +} /// Core-based Agent implementation. /// Stateless regarding agent_type — callers pass it per-call. pub(crate) struct CoreAgentAdapter { - coordinator: Arc, - event_queue: Arc, - workspace_path: Arc>>, + runtime: AgentRuntime, + compatibility: CoreAgentRuntimeCompatibility, + event_source: CliAgentEventSource, + approval_policy: CliApprovalPolicy, + workspace_path: Arc>>, /// Session ID — uses Mutex for interior mutability session_id: Arc>>, /// Current turn ID (for cancellation) @@ -28,35 +57,27 @@ pub(crate) struct CoreAgentAdapter { } impl CoreAgentAdapter { - pub(crate) fn new( - coordinator: Arc, - event_queue: Arc, - workspace_path: Option, - ) -> Self { + pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { Self { - coordinator, - event_queue, - workspace_path: Arc::new(Mutex::new(workspace_path)), + runtime: runtime.agent_runtime().clone(), + compatibility: runtime.compatibility().clone(), + event_source: runtime.agent_events().clone(), + approval_policy: runtime.approval_policy(), + workspace_path: Arc::new(RwLock::new(workspace_path)), session_id: Arc::new(Mutex::new(None)), current_turn_id: Arc::new(Mutex::new(None)), } } - /// Get the event queue (for external event consumption) - pub(crate) fn event_queue(&self) -> &Arc { - &self.event_queue - } - - /// Get the coordinator (for advanced operations like list_sessions, get_messages) - pub(crate) fn coordinator(&self) -> &Arc { - &self.coordinator + pub(crate) fn event_source(&self) -> &CliAgentEventSource { + &self.event_source } pub(crate) fn workspace_path_buf(&self) -> PathBuf { self.workspace_path - .try_lock() - .ok() - .and_then(|guard| guard.clone()) + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(|| PathBuf::from(".")) } @@ -65,9 +86,130 @@ impl CoreAgentAdapter { self.workspace_path_buf().to_string_lossy().to_string() } - pub(crate) async fn set_workspace_path(&self, workspace_path: Option) { - let mut guard = self.workspace_path.lock().await; - *guard = workspace_path; + fn current_workspace_path(&self) -> PathBuf { + self.workspace_path_buf() + } + + async fn list_sessions_in_workspace( + &self, + workspace_path: &Path, + ) -> Result> { + self.runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + pub(crate) async fn list_sessions(&self) -> Result> { + let workspace_path = self.current_workspace_path(); + self.list_sessions_in_workspace(&workspace_path).await + } + + pub(crate) async fn restore_session_in_current_workspace( + &self, + session_id: &str, + ) -> Result<(AgentSessionSummary, PathBuf)> { + tracing::info!("Restoring session: {}", session_id); + + let effective_workspace = self.current_workspace_path(); + let sessions = self + .list_sessions_in_workspace(&effective_workspace) + .await?; + let summary = validated_session_summary(&sessions, session_id, &effective_workspace)?; + + self.compatibility + .restore_session(&effective_workspace, session_id) + .await?; + + let mut session_id_guard = self.session_id.lock().await; + let mut turn_id_guard = self.current_turn_id.lock().await; + let mut workspace_guard = self + .workspace_path + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *workspace_guard = Some(effective_workspace.clone()); + *session_id_guard = Some(session_id.to_string()); + *turn_id_guard = None; + + Ok((summary, effective_workspace)) + } + + pub(crate) async fn delete_session(&self, session_id: &str) -> Result<()> { + self.runtime + .delete_session(AgentSessionDeleteRequest { + workspace_path: self.workspace_path_string(), + session_id: session_id.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + pub(crate) async fn get_messages(&self, session_id: &str) -> Result> { + self.compatibility + .get_messages(session_id) + .await + .map_err(Into::into) + } + + pub(crate) async fn update_session_model( + &self, + session_id: &str, + model_id: &str, + ) -> Result<()> { + self.compatibility + .update_session_model(session_id, model_id) + .await + .map_err(Into::into) + } + + pub(crate) async fn branch_session_at_latest_turn( + &self, + source_session_id: &str, + ) -> Result { + self.compatibility + .branch_session_at_latest_turn(&self.workspace_path_buf(), source_session_id) + .await + .map_err(Into::into) + } + + pub(crate) async fn generate_session_usage_report( + &self, + request: SessionUsageReportRequest, + ) -> Result { + self.compatibility + .generate_session_usage_report(request) + .await + .map_err(Into::into) + } + + pub(crate) async fn append_completed_local_command_turn( + &self, + session_id: &str, + content: String, + turn_id: Option, + timestamp_ms: Option, + metadata: Option, + ) -> Result { + self.compatibility + .append_completed_local_command_turn( + session_id, + content, + turn_id, + timestamp_ms, + metadata, + ) + .await + .map_err(Into::into) + } + + pub(crate) fn is_turn_processing(&self, session_id: &str, turn_id: &str) -> bool { + self.compatibility.is_turn_processing(session_id, turn_id) } fn build_default_session_name() -> String { @@ -89,22 +231,27 @@ impl CoreAgentAdapter { let mut effective_agent_type = agent_type.to_string(); let workspace = self.workspace_path_buf(); - if let Ok(sessions) = self.coordinator.list_sessions(&workspace).await { + if let Ok(sessions) = self + .runtime + .list_sessions(AgentSessionListRequest { + workspace_path: workspace.to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + { if let Some(summary) = sessions.iter().find(|s| s.session_id == session_id) { session_name = summary.session_name.clone(); effective_agent_type = summary.agent_type.clone(); } } - self.coordinator + self.compatibility .create_session_with_id( - Some(session_id.to_string()), + session_id.to_string(), session_name, effective_agent_type, - SessionConfig { - workspace_path: Some(self.workspace_path_string()), - ..Default::default() - }, + self.workspace_path_string(), ) .await?; @@ -113,23 +260,16 @@ impl CoreAgentAdapter { } async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { + let workspace = self.workspace_path_buf(); if self - .coordinator - .get_session_manager() - .get_session(session_id) - .is_some() + .compatibility + .is_session_loaded(&workspace, session_id) + .await? { return Ok(()); } - - tracing::warn!( - "Backend session not present in memory, attempting restore: {}", - session_id - ); - - let workspace = self.workspace_path_buf(); match self - .coordinator + .compatibility .restore_session(&workspace, session_id) .await { @@ -137,14 +277,14 @@ impl CoreAgentAdapter { tracing::info!("Backend session restored: {}", session_id); Ok(()) } - Err(restore_err) => { + Err(error) if Self::is_session_not_found_error(&error.to_string()) => { tracing::warn!( - "Restore failed, recreating backend session: {}, error={}", - session_id, - restore_err + "Session is unavailable, recreating backend session: {}", + session_id ); self.recreate_session_with_id(session_id, agent_type).await } + Err(error) => Err(anyhow::anyhow!(error.to_string())), } } @@ -156,15 +296,12 @@ impl CoreAgentAdapter { let mut session_id_guard = self.session_id.lock().await; let session = self - .coordinator + .compatibility .create_session_with_id( - Some(session_id.clone()), + session_id.clone(), Self::build_default_session_name(), agent_type.to_string(), - SessionConfig { - workspace_path: Some(self.workspace_path_string()), - ..Default::default() - }, + self.workspace_path_string(), ) .await?; @@ -187,16 +324,17 @@ impl Agent for CoreAgentAdapter { } let session = self - .coordinator - .create_session( - Self::build_default_session_name(), - agent_type.to_string(), - SessionConfig { - workspace_path: Some(self.workspace_path_string()), - ..Default::default() - }, - ) - .await?; + .runtime + .create_session(AgentSessionCreateRequest { + session_name: Self::build_default_session_name(), + agent_type: agent_type.to_string(), + workspace_path: Some(self.workspace_path_string()), + remote_connection_id: None, + remote_ssh_host: None, + metadata: serde_json::Map::new(), + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; let id = session.session_id.clone(); @@ -219,22 +357,31 @@ impl Agent for CoreAgentAdapter { *turn_guard = Some(turn_id.clone()); } - // Start the dialog turn — this is async, events will arrive via EventQueue - let start_result = self - .coordinator - .start_dialog_turn( - session_id.clone(), - message.clone(), - None, - Some(turn_id.clone()), - agent_type.to_string(), - Some(self.workspace_path_string()), - None, - None, - DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), - None, - ) - .await; + // Start the dialog turn; events arrive through the shared broadcast source. + let mut metadata = serde_json::Map::new(); + if self.approval_policy != CliApprovalPolicy::Ask { + metadata.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + serde_json::Value::Bool(false), + ); + } + let request = AgentDialogTurnRequest { + session_id: session_id.clone(), + message: message.clone(), + original_message: None, + turn_id: Some(turn_id.clone()), + agent_type: agent_type.to_string(), + workspace_path: Some(self.workspace_path_string()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(AgentSubmissionSource::Cli) + .with_skip_tool_confirmation(self.approval_policy == CliApprovalPolicy::Auto), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata, + }; + let start_result = self.runtime.submit_dialog_turn(request.clone()).await; if let Err(err) = start_result { if Self::is_session_not_found_error(&err.to_string()) { @@ -245,22 +392,12 @@ impl Agent for CoreAgentAdapter { ); self.ensure_backend_session_alive(&session_id, agent_type) .await?; - self.coordinator - .start_dialog_turn( - session_id, - message, - None, - Some(turn_id.clone()), - agent_type.to_string(), - Some(self.workspace_path_string()), - None, - None, - DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), - None, - ) - .await?; + self.runtime + .submit_dialog_turn(request) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; } else { - return Err(err.into()); + return Err(anyhow::anyhow!(err.to_string())); } } @@ -268,14 +405,27 @@ impl Agent for CoreAgentAdapter { } async fn cancel_current_turn(&self) -> Result<()> { - let session_id_guard = self.session_id.lock().await; - let turn_id_guard = self.current_turn_id.lock().await; + let session_id = self.session_id.lock().await.clone(); + let turn_id = self.current_turn_id.lock().await.clone(); - if let (Some(session_id), Some(turn_id)) = (&*session_id_guard, &*turn_id_guard) { + if let (Some(session_id), Some(turn_id)) = (session_id, turn_id) { tracing::info!("Cancelling turn: session={}, turn={}", session_id, turn_id); - self.coordinator - .cancel_dialog_turn(session_id, turn_id) - .await?; + self.runtime + .cancel_turn(AgentTurnCancellationRequest { + session_id, + turn_id: Some(turn_id.clone()), + source: Some(AgentSubmissionSource::Cli), + requester_session_id: None, + reason: Some("user_cancelled".to_string()), + wait_timeout_ms: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + + let mut turn_id_guard = self.current_turn_id.lock().await; + if turn_id_guard.as_deref() == Some(turn_id.as_str()) { + *turn_id_guard = None; + } } Ok(()) @@ -285,16 +435,17 @@ impl Agent for CoreAgentAdapter { let mut session_id_guard = self.session_id.lock().await; let session = self - .coordinator - .create_session( - Self::build_default_session_name(), - agent_type.to_string(), - SessionConfig { - workspace_path: Some(self.workspace_path_string()), - ..Default::default() - }, - ) - .await?; + .runtime + .create_session(AgentSessionCreateRequest { + session_name: Self::build_default_session_name(), + agent_type: agent_type.to_string(), + workspace_path: Some(self.workspace_path_string()), + remote_connection_id: None, + remote_ssh_host: None, + metadata: serde_json::Map::new(), + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; let id = session.session_id.clone(); @@ -305,15 +456,8 @@ impl Agent for CoreAgentAdapter { } async fn restore_session(&self, session_id: &str) -> Result<()> { - tracing::info!("Restoring session: {}", session_id); - let workspace = self.workspace_path_buf(); - self.coordinator - .restore_session(&workspace, session_id) + self.restore_session_in_current_workspace(session_id) .await?; - - let mut session_id_guard = self.session_id.lock().await; - *session_id_guard = Some(session_id.to_string()); - Ok(()) } @@ -323,7 +467,7 @@ impl Agent for CoreAgentAdapter { updated_input: Option, ) -> Result<()> { tracing::info!("Confirming tool execution: {}", tool_id); - self.coordinator + self.compatibility .confirm_tool(tool_id, updated_input) .await .map_err(|e| anyhow::anyhow!("Confirm tool failed: {}", e)) @@ -331,7 +475,7 @@ impl Agent for CoreAgentAdapter { async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()> { tracing::info!("Rejecting tool execution: {}, reason: {}", tool_id, reason); - self.coordinator + self.compatibility .reject_tool(tool_id, reason) .await .map_err(|e| anyhow::anyhow!("Reject tool failed: {}", e)) @@ -339,10 +483,58 @@ impl Agent for CoreAgentAdapter { async fn submit_user_answers(&self, tool_id: &str, answers: serde_json::Value) -> Result<()> { tracing::info!("Submitting user answers for tool: {}", tool_id); - use bitfun_core::agentic::tools::user_input_manager::get_user_input_manager; - let manager = get_user_input_manager(); - manager - .send_answer(tool_id, answers) + self.compatibility + .submit_user_answers(tool_id, answers) .map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e)) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use bitfun_runtime_ports::AgentSessionSummary; + + use super::validated_session_summary; + + fn session_summary(session_id: &str) -> AgentSessionSummary { + AgentSessionSummary { + session_id: session_id.to_string(), + session_name: "Workspace session".to_string(), + agent_type: "agentic".to_string(), + turn_count: 1, + created_at_ms: 1, + last_active_at_ms: 2, + } + } + + #[test] + fn workspace_restore_validation_accepts_listed_session() { + let sessions = vec![session_summary("session-in-workspace")]; + + let summary = validated_session_summary( + &sessions, + "session-in-workspace", + Path::new("D:/workspace/current"), + ) + .expect("listed session should be restorable"); + + assert_eq!(summary.session_id, "session-in-workspace"); + } + + #[test] + fn workspace_restore_validation_rejects_session_outside_current_workspace() { + let sessions = vec![session_summary("different-session")]; + + let error = validated_session_summary( + &sessions, + "session-from-another-workspace", + Path::new("D:/workspace/current"), + ) + .expect_err("a session absent from the workspace-scoped list must be rejected"); + + let message = error.to_string(); + assert!(message.contains("session-from-another-workspace")); + assert!(message.contains("D:/workspace/current")); + } +} diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 9bba494ad5..7ce5b44063 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -16,7 +16,7 @@ pub(crate) trait Agent: Send + Sync { async fn ensure_session(&self, agent_type: &str) -> Result; /// Send a message to start a new dialog turn. - /// Returns the turn_id. Events are consumed externally via EventQueue. + /// Returns the turn_id. Events are observed through the runtime event source. async fn send_message(&self, message: String, agent_type: &str) -> Result; /// Cancel the current dialog turn (if any) diff --git a/src/apps/cli/src/diagnostics.rs b/src/apps/cli/src/diagnostics.rs index 858ec0631f..ddd27cd356 100644 --- a/src/apps/cli/src/diagnostics.rs +++ b/src/apps/cli/src/diagnostics.rs @@ -10,8 +10,13 @@ pub(crate) enum ExitKind { SessionCreateFailed, SendMessageFailed, DialogTurnFailed, + PermissionRejected, + Cancelled, + EventStreamFailed, + SettlementTimedOut, SystemError, ExecError, + PatchUnavailable, PatchWriteFailed, } @@ -21,8 +26,13 @@ impl ExitKind { Self::SessionCreateFailed => "session_create_failed", Self::SendMessageFailed => "send_message_failed", Self::DialogTurnFailed => "dialog_turn_failed", + Self::PermissionRejected => "permission_rejected", + Self::Cancelled => "cancelled", + Self::EventStreamFailed => "event_stream_failed", + Self::SettlementTimedOut => "settlement_timed_out", Self::SystemError => "system_error", Self::ExecError => "exec_error", + Self::PatchUnavailable => "patch_unavailable", Self::PatchWriteFailed => "patch_write_failed", } } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 2658193738..30a25adf8e 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -21,18 +21,18 @@ mod plugin_diagnostics; mod product_assembly; mod prompts; mod root_handlers; +mod runtime; mod ui; use anyhow::{anyhow, Result}; use bitfun_core::service::remote_connect::DeviceIdentity; use clap::{Parser, Subcommand}; -use std::path::PathBuf; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::OnceLock; use config::CliConfig; use modes::chat::ChatMode; -use modes::exec::ExecOutputFormat; +use modes::exec::{ExecApprovalMode, ExecOutputFormat}; // ======================== Global MCP Service ======================== @@ -121,12 +121,18 @@ enum Commands { /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file + /// The snapshot is captured before writing an explicit output artifact; + /// the artifact itself is not included in the captured diff /// Example: --output-patch or --output-patch ./result.patch #[arg(long, num_args = 0..=1, default_missing_value = "-")] output_patch: Option, - /// Tool execution requires confirmation (default: no confirmation to avoid blocking non-interactive mode) - #[arg(long)] + /// Auto-approve tool permissions that are not explicitly denied + #[arg(long, conflicts_with = "confirm")] + auto: bool, + + /// Deprecated compatibility flag; confirmations are rejected in non-interactive mode + #[arg(long, hide = true, conflicts_with = "auto")] confirm: bool, }, @@ -346,6 +352,34 @@ enum SessionAction { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BootstrapProfile { + Interactive, + Execution, + Management, +} + +impl BootstrapProfile { + const fn starts_peer_host(self) -> bool { + matches!(self, Self::Interactive) + } + + const fn starts_mcp(self) -> bool { + matches!(self, Self::Interactive | Self::Execution) + } +} + +impl SessionAction { + const fn bootstrap_profile(&self) -> BootstrapProfile { + match self { + Self::Resume { .. } | Self::Continue => BootstrapProfile::Interactive, + Self::List | Self::Show { .. } | Self::Delete { .. } | Self::Fork { .. } => { + BootstrapProfile::Management + } + } + } +} + #[derive(Subcommand)] enum ConfigAction { /// Show configuration @@ -412,93 +446,100 @@ async fn initialize_terminal_service() { tracing::info!("Terminal service initialized"); } -/// Initialize all core services (config, AI client, agentic system). -/// Returns (agentic_system, original_skip_confirmation). +/// Initialize Core owners and assemble one invocation-scoped CLI runtime. async fn initialize_core_services( - skip_tool_confirmation: bool, -) -> Result<(agent::agentic_system::AgenticSystem, bool)> { + workspace_root: &std::path::Path, + approval_policy: runtime::approval::CliApprovalPolicy, + bootstrap_profile: BootstrapProfile, +) -> Result> { use bitfun_core::infrastructure::ai::AIClientFactory; bitfun_core::service::config::initialize_global_config() .await - .expect("Failed to initialize global config service"); + .map_err(|error| anyhow!("Failed to initialize global config service: {error}"))?; tracing::info!("Global config service initialized"); - // Save and override tool confirmation setting let config_service = bitfun_core::service::config::get_global_config_service() .await .ok(); - let original_skip_confirmation = if let Some(ref svc) = config_service { - let ai_config: bitfun_core::service::config::types::AIConfig = - svc.get_config(Some("ai")).await.unwrap_or_default(); - ai_config.skip_tool_confirmation - } else { - false - }; - if let Some(ref svc) = config_service { - let _ = svc - .set_config("ai.skip_tool_confirmation", skip_tool_confirmation) - .await; - } AIClientFactory::initialize_global() .await - .expect("Failed to initialize global AIClientFactory"); + .map_err(|error| anyhow!("Failed to initialize global AIClientFactory: {error}"))?; tracing::info!("Global AI client factory initialized"); initialize_terminal_service().await; let agentic_system = agent::agentic_system::init_agentic_system() .await - .expect("Failed to initialize agentic system"); + .map_err(|error| anyhow!("Failed to initialize agentic system: {error}"))?; tracing::info!("Agentic system initialized"); - if let Err(e) = peer_host::ensure_peer_host_ready(&agentic_system).await { - tracing::warn!("Failed to initialize CLI peer host services: {e}"); - } else { - tracing::info!("CLI peer host services initialized"); + let runtime = std::sync::Arc::new(runtime::CliRuntimeContext::build( + agentic_system, + workspace_root, + approval_policy, + )?); + debug_assert!(runtime + .product() + .service_availability() + .iter() + .all(|entry| { + runtime + .services() + .has_capability(entry.requirement().service_capability()) + })); + tracing::info!( + "CLI product runtime assembled: profile={}, services={}, harnesses={}, plugin_runtime={:?}", + runtime.product().plan().profile().id(), + runtime.product().service_availability().len(), + runtime.product().harness_provider_ids().len(), + runtime.product().plugin_runtime(), + ); + + if bootstrap_profile.starts_peer_host() { + if let Err(e) = peer_host::ensure_peer_host_ready(runtime.agentic_system()).await { + tracing::warn!("Failed to initialize CLI peer host services: {e}"); + } else { + tracing::info!("CLI peer host services initialized"); + } } // Initialize MCP service in background (non-blocking) - if let Some(ref cfg_svc) = config_service { - match bitfun_core::service::mcp::MCPService::new(cfg_svc.clone()) { - Ok(mcp_service) => { - let mcp_service = std::sync::Arc::new(mcp_service); - MCP_SERVICE.set(mcp_service.clone()).ok(); - - // Mark as in progress - get_mcp_init_status().store(1, Ordering::Relaxed); - - // Background async initialization - tokio::spawn(async move { - let result = mcp_service.server_manager().initialize_all().await; - match result { - Ok(_) => { - tracing::info!("MCP servers initialized successfully"); - get_mcp_init_status().store(2, Ordering::Relaxed); + if bootstrap_profile.starts_mcp() { + if let Some(ref cfg_svc) = config_service { + match bitfun_core::service::mcp::MCPService::new(cfg_svc.clone()) { + Ok(mcp_service) => { + let mcp_service = std::sync::Arc::new(mcp_service); + MCP_SERVICE.set(mcp_service.clone()).ok(); + + // Mark as in progress + get_mcp_init_status().store(1, Ordering::Relaxed); + + // Background async initialization + tokio::spawn(async move { + let result = mcp_service.server_manager().initialize_all().await; + match result { + Ok(_) => { + tracing::info!("MCP servers initialized successfully"); + get_mcp_init_status().store(2, Ordering::Relaxed); + } + Err(e) => { + tracing::warn!("Failed to initialize MCP servers: {}", e); + get_mcp_init_status().store(3, Ordering::Relaxed); + } } - Err(e) => { - tracing::warn!("Failed to initialize MCP servers: {}", e); - get_mcp_init_status().store(3, Ordering::Relaxed); - } - } - }); - } - Err(e) => { - tracing::warn!("Failed to create MCP service: {}", e); - get_mcp_init_status().store(3, Ordering::Relaxed); + }); + } + Err(e) => { + tracing::warn!("Failed to create MCP service: {}", e); + get_mcp_init_status().store(3, Ordering::Relaxed); + } } } } - Ok((agentic_system, original_skip_confirmation)) -} - -/// Restore original tool confirmation setting -async fn restore_tool_confirmation(original: bool) { - if let Ok(svc) = bitfun_core::service::config::get_global_config_service().await { - let _ = svc.set_config("ai.skip_tool_confirmation", original).await; - } + Ok(runtime) } /// Shutdown MCP servers gracefully @@ -528,10 +569,19 @@ async fn run_interactive( // 2. Set workspace path let workspace = setup_workspace(); + let workspace_path = workspace + .as_deref() + .map(std::path::PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); // 3. Initialize core services - let (agentic_system, original_skip_confirmation) = initialize_core_services(true).await?; - + let runtime = initialize_core_services( + &workspace_path, + runtime::approval::CliApprovalPolicy::Ask, + BootstrapProfile::Interactive, + ) + .await?; // 3.5 Restore persisted account session (if any) if let Some(user_id) = account::try_restore_session().await { tracing::info!("Restored account session for user {user_id}"); @@ -545,7 +595,8 @@ async fn run_interactive( // 4. Show startup page (with full command support) let mut startup_page = StartupPage::new( - agentic_system.coordinator.clone(), + runtime.agent_runtime().clone(), + runtime.compatibility().clone(), default_agent, workspace.clone(), ); @@ -553,7 +604,6 @@ async fn run_interactive( if let StartupResult::Exit = startup_result { shutdown_mcp_servers().await; - restore_tool_confirmation(original_skip_confirmation).await; ui::restore_terminal(terminal)?; println!("Goodbye!"); return Ok(()); @@ -570,18 +620,18 @@ async fn run_interactive( // Use the current project workspace selected at process start. let workspace = startup_page.workspace(); let config = startup_page.config().clone(); - let mut chat_mode = ChatMode::new(config, agent_type, workspace, &agentic_system); + let mut chat_mode = ChatMode::new(config, agent_type, workspace, runtime.clone()); if let Some(session_id) = restore_session_id { chat_mode = chat_mode.with_restore_session(session_id); } if let Some(prompt) = initial_prompt { chat_mode = chat_mode.with_initial_prompt(prompt); } - let _exit_reason = chat_mode.run(Some(terminal))?; + let chat_result = chat_mode.run(Some(terminal)); - // 6. Cleanup + // 6. Cleanup, including fatal event-stream exits. shutdown_mcp_servers().await; - restore_tool_confirmation(original_skip_confirmation).await; + let _exit_reason = chat_result?; println!("Goodbye!"); Ok(()) @@ -589,8 +639,41 @@ async fn run_interactive( // ======================== Main ======================== +#[derive(Debug)] +struct ReportedCliError { + exit_code: i32, +} + +impl std::fmt::Display for ReportedCliError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("CLI error was already reported") + } +} + +impl std::error::Error for ReportedCliError {} + async fn run_cli() -> Result<()> { - let cli = Cli::parse(); + let raw_args = std::env::args_os().collect::>(); + let cli = match Cli::try_parse_from(&raw_args) { + Ok(cli) => cli, + Err(error) + if exec_requests_json_output(&raw_args) + && matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + error.print()?; + return Ok(()); + } + Err(error) if exec_requests_json_output(&raw_args) => { + let exit_code = error.exit_code(); + let error = anyhow!(error.to_string()); + modes::exec::emit_preflight_json_error(ExecOutputFormat::Json, &error)?; + return Err(anyhow::Error::new(ReportedCliError { exit_code })); + } + Err(error) => error.exit(), + }; let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); let is_exec_mode = matches!(cli.command, Some(Commands::Exec { .. })); @@ -636,8 +719,19 @@ async fn run_cli() -> Result<()> { fork_session, output_format, output_patch, + auto, confirm, }) => { + let approval_mode = if auto { + ExecApprovalMode::Auto + } else { + if confirm { + eprintln!( + "Warning: --confirm is deprecated; non-interactive confirmations are rejected by default" + ); + } + ExecApprovalMode::Reject + }; root_handlers::handle_exec_command( config, root_handlers::ExecCommandArgs { @@ -650,15 +744,17 @@ async fn run_cli() -> Result<()> { fork_session, output_format, output_patch, - confirm, + approval_mode, }, ) .await?; } Some(Commands::Sessions { action }) => { - if let Some(session_id) = root_handlers::handle_session_action(action).await? { - run_interactive_with_session(config, session_id).await?; + if let Some((session_id, runtime)) = + root_handlers::handle_session_action(action).await? + { + run_interactive_with_session(config, session_id, runtime).await?; } } @@ -727,8 +823,21 @@ async fn run_cli() -> Result<()> { } Some(Commands::Doctor) => { - let product_plan = product_assembly::cli_product_assembly_plan(); - if !management::print_doctor(&product_plan).await? { + use std::sync::Arc; + + use runtime::approval::{CliApprovalPolicy, CliPermissionService}; + use runtime::services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; + + let workspace = std::env::current_dir()?; + let services = CliRuntimeServicesProvider::new( + &workspace, + Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), + Arc::new(CliRuntimeEventSink::new(16)), + Arc::new(CliClock), + )? + .build()?; + let product_runtime = product_assembly::assemble_cli_runtime_parts(services)?; + if !management::print_doctor(&product_runtime).await? { std::process::exit(1); } } @@ -810,28 +919,57 @@ async fn run_cli() -> Result<()> { Ok(()) } -async fn run_interactive_with_session(config: CliConfig, session_id: String) -> Result<()> { +fn exec_requests_json_output(args: &[std::ffi::OsString]) -> bool { + let values = args + .iter() + .skip(1) + .map(|value| value.to_string_lossy()) + .collect::>(); + if !values.iter().any(|value| value == "exec") { + return false; + } + + values.iter().enumerate().any(|(index, value)| { + value == "--output-format=json" + || (value == "--output-format" + && values.get(index + 1).is_some_and(|format| format == "json")) + }) +} + +async fn run_interactive_with_session( + config: CliConfig, + session_id: String, + runtime: std::sync::Arc, +) -> Result<()> { let mut terminal = ui::init_terminal()?; ui::render_loading(&mut terminal, "Initializing system, please wait...")?; - let workspace = setup_workspace(); - let (agentic_system, original_skip_confirmation) = initialize_core_services(true).await?; - let workspace_path = workspace - .clone() - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")); - let session = agentic_system - .coordinator - .restore_session(&workspace_path, &session_id) - .await?; + let workspace = Some(runtime.workspace_root().to_string_lossy().to_string()); + let sessions = runtime + .agent_runtime() + .list_sessions(bitfun_runtime_ports::AgentSessionListRequest { + workspace_path: runtime.workspace_root().to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let agent_type = sessions + .iter() + .find(|session| session.session_id == session_id) + .map(|session| session.agent_type.clone()) + .ok_or_else(|| { + anyhow::anyhow!( + "Session {session_id} was not found in the current workspace: {}", + runtime.workspace_root().display() + ) + })?; - let mut chat_mode = ChatMode::new(config, session.agent_type, workspace, &agentic_system) + let mut chat_mode = ChatMode::new(config, agent_type, workspace, runtime.clone()) .with_restore_session(session_id); let run_result = chat_mode.run(Some(terminal)); shutdown_mcp_servers().await; - restore_tool_confirmation(original_skip_confirmation).await; println!("Goodbye!"); run_result?; @@ -853,6 +991,9 @@ fn main() { match worker.join() { Ok(Ok(())) => {} Ok(Err(err)) => { + if let Some(reported) = err.downcast_ref::() { + std::process::exit(reported.exit_code); + } eprintln!("Error: {err}"); std::process::exit(1); } @@ -945,3 +1086,71 @@ mod plugin_command_tests { )); } } + +#[cfg(test)] +mod bootstrap_profile_tests { + use super::{exec_requests_json_output, BootstrapProfile, SessionAction}; + + #[test] + fn profiles_start_only_their_requested_background_services() { + let cases = [ + (BootstrapProfile::Interactive, true, true), + (BootstrapProfile::Execution, false, true), + (BootstrapProfile::Management, false, false), + ]; + + for (profile, starts_peer_host, starts_mcp) in cases { + assert_eq!(profile.starts_peer_host(), starts_peer_host); + assert_eq!(profile.starts_mcp(), starts_mcp); + } + } + + #[test] + fn session_resume_and_continue_use_interactive_bootstrap() { + let resume = SessionAction::Resume { + id: "session-1".to_string(), + }; + + assert_eq!(resume.bootstrap_profile(), BootstrapProfile::Interactive); + assert_eq!( + SessionAction::Continue.bootstrap_profile(), + BootstrapProfile::Interactive + ); + } + + #[test] + fn session_management_actions_use_management_bootstrap() { + let actions = [ + SessionAction::List, + SessionAction::Show { + id: "session-1".to_string(), + }, + SessionAction::Delete { + id: "session-1".to_string(), + }, + SessionAction::Fork { + id: "session-1".to_string(), + id_only: false, + }, + ]; + + for action in actions { + assert_eq!(action.bootstrap_profile(), BootstrapProfile::Management); + } + } + + #[test] + fn json_exec_parse_failures_are_detected_before_clap_exits() { + let args = [ + "bitfun", + "exec", + "task", + "--output-format", + "json", + "--unknown-option", + ] + .map(std::ffi::OsString::from); + + assert!(exec_requests_json_output(&args)); + } +} diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 4a6a13c27d..ab47c72245 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -3,7 +3,6 @@ use std::path::Path; use std::time::Duration; use bitfun_core::agentic::get_agent_registry; -use bitfun_core::agentic::persistence::PersistenceManager; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::plugin_runtime::{ activate_managed_plugin, deactivate_managed_plugin, preview_managed_plugin_activation, @@ -14,10 +13,11 @@ use bitfun_core::plugin_source::{ ManagedPluginSourceIssue, ManagedPluginSourceSnapshot, ManagedPluginTrustDecision, ManagedPluginTrustLevel, }; -use bitfun_core::product_assembly::ProductAssemblyPlan; +use bitfun_core::product_assembly::ProductRuntimeParts; +use bitfun_core::runtime_ports::PluginRuntimeAvailability; use bitfun_core::service::config::initialize_global_config; use bitfun_core::service::session_usage::{ - generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest, + render_usage_report_markdown, SessionUsageReportRequest, }; async fn ensure_global_config_service( @@ -217,36 +217,48 @@ pub(crate) async fn print_mcp_json_config() -> Result<()> { Ok(()) } +fn validate_usage_session_id(session_id: &str) -> Result<()> { + bitfun_agent_runtime::session_control::validate_session_id(session_id) + .map_err(anyhow::Error::msg) +} + pub(crate) async fn print_usage_report(session_id: Option<&str>) -> Result<()> { - let agentic_system = crate::agent::agentic_system::init_agentic_system_for_cli().await?; - let path_manager = try_get_path_manager_arc().map_err(|error| anyhow!(error.to_string()))?; - let persistence_manager = - PersistenceManager::new(path_manager).map_err(|error| anyhow!(error.to_string()))?; + if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) { + validate_usage_session_id(session_id)?; + } let workspace_path = std::env::current_dir().context("Failed to resolve current directory")?; - let coordinator = agentic_system.coordinator.clone(); + let runtime = crate::initialize_core_services( + &workspace_path, + crate::runtime::approval::CliApprovalPolicy::Reject, + crate::BootstrapProfile::Management, + ) + .await?; let resolved_session_id = match session_id { Some(session_id) if !session_id.trim().is_empty() => session_id.to_string(), - _ => coordinator - .list_sessions(&workspace_path) + _ => runtime + .agent_runtime() + .list_sessions(bitfun_runtime_ports::AgentSessionListRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) .await? .first() .map(|session| session.session_id.clone()) .ok_or_else(|| anyhow!("No history sessions for current project"))?, }; - let report = generate_session_usage_report( - &persistence_manager, - Some(agentic_system.token_usage_service.as_ref()), - SessionUsageReportRequest { + let report = runtime + .compatibility() + .generate_session_usage_report(SessionUsageReportRequest { session_id: resolved_session_id, workspace_path: Some(workspace_path.to_string_lossy().to_string()), remote_connection_id: None, remote_ssh_host: None, include_hidden_subagents: true, - }, - ) - .await - .map_err(|error| anyhow!(error.to_string()))?; + }) + .await + .map_err(|error| anyhow!(error.to_string()))?; println!("{}", render_usage_report_markdown(&report)); Ok(()) @@ -610,7 +622,7 @@ pub(crate) async fn print_mcp_config_summary() -> Result<()> { Ok(()) } -pub(crate) async fn print_doctor(product_plan: &ProductAssemblyPlan) -> Result { +pub(crate) async fn print_doctor(product_runtime: &ProductRuntimeParts) -> Result { let workspace = std::env::current_dir().context("Failed to resolve current directory")?; let config_dir = crate::config::CliConfig::config_dir()?; let config_service = ensure_global_config_service().await?; @@ -652,9 +664,28 @@ pub(crate) async fn print_doctor(product_plan: &ProductAssemblyPlan) -> Result { + println!("[info] Plugin runtime: disabled ({reason})"); + } + PluginRuntimeAvailability::ProjectionOnly { reason } => { + println!("[info] Plugin runtime: projection-only ({reason})"); + } + PluginRuntimeAvailability::Unavailable { reason } => { + println!("[info] Plugin runtime: unavailable ({reason})"); + } + PluginRuntimeAvailability::Available => { + println!("[ok] Plugin runtime: available"); + } + _ => { + println!("[info] Plugin runtime: unknown"); + } + } println!("[ok] Workspace: {}", workspace.display()); println!("[ok] Config directory: {}", config_dir.display()); println!("[ok] Agent modes: {}", modes.len()); @@ -703,3 +734,16 @@ pub(crate) async fn print_doctor(product_plan: &ProductAssemblyPlan) -> Result { + Some("Agent event stream closed; chat state can no longer be trusted".to_string()) + } + } +} + +fn mark_active_turn_failed(chat_state: &mut ChatState, error: &str) -> bool { + if chat_state.current_turn_id().is_none() { + return false; + } + + chat_state.handle_turn_failed(error); + true +} + /// Chat mode exit reason #[derive(Debug, Clone, PartialEq)] pub(crate) enum ChatExitReason { @@ -135,7 +153,7 @@ pub(crate) struct ChatMode { agent_type: String, workspace: Option, agent: Arc, - token_usage_service: Arc, + runtime: Arc, /// If set, restore this existing session instead of creating a new one restore_session_id: Option, /// If set, send this prompt automatically when the session starts @@ -159,11 +177,10 @@ impl ChatMode { config: CliConfig, agent_type: String, workspace: Option, - agentic_system: &AgenticSystem, + runtime: Arc, ) -> Self { let agent = Arc::new(CoreAgentAdapter::new( - agentic_system.coordinator.clone(), - agentic_system.event_queue.clone(), + runtime.as_ref(), workspace.clone().map(PathBuf::from), )); @@ -172,7 +189,7 @@ impl ChatMode { agent_type, workspace, agent, - token_usage_service: agentic_system.token_usage_service.clone(), + runtime, restore_session_id: None, initial_prompt: None, pending_mcp_op: None, @@ -266,7 +283,11 @@ impl ChatMode { rt_handle: &tokio::runtime::Handle, ) { let workspace = self.workspace_path_for_sync(chat_state); - crate::account_sync::start_auto_sync_background(is_first_login, workspace); + crate::account_sync::start_auto_sync_background( + self.runtime.compatibility().clone(), + is_first_login, + workspace, + ); self.open_account_panel(chat_view, rt_handle); chat_state.add_system_message(if is_first_login { "Sync started (use local / upload settings).".to_string() @@ -434,7 +455,7 @@ impl ChatMode { pub(crate) fn run( &mut self, - existing_terminal: Option>>, + existing_terminal: Option, ) -> Result { tracing::info!("Starting Chat mode, Agent: {}", self.agent_type); if let Some(ws) = &self.workspace { @@ -468,33 +489,22 @@ impl ChatMode { tracing::info!("Restoring session: {}", restore_id); let agent = self.agent.clone(); let rid = restore_id.clone(); - let agent_type = self.agent_type.clone(); - let workspace = self.workspace.clone(); tokio::task::block_in_place(|| { rt_handle.block_on(async { // Restore session in core (loads metadata, messages, managers) - agent.restore_session(&rid).await?; - - // Prefer session's stored workspace_path over startup workspace - let effective_workspace = agent - .coordinator() - .get_session_manager() - .get_session(&rid) - .and_then(|s| s.config.workspace_path.clone()) - .or(workspace); + let (summary, effective_workspace_path) = + agent.restore_session_in_current_workspace(&rid).await?; + let effective_workspace = + Some(effective_workspace_path.to_string_lossy().to_string()); // Load historical messages for UI display - let messages = agent - .coordinator() - .get_messages(&rid) - .await - .unwrap_or_default(); + let messages = agent.get_messages(&rid).await.unwrap_or_default(); let state = ChatState::from_core_messages( rid.clone(), - "Restored Session".to_string(), - agent_type, + summary.session_name, + summary.agent_type, effective_workspace, &messages, ); @@ -525,6 +535,7 @@ impl ChatMode { }; // Keep ChatMode workspace in sync with the session's effective workspace + self.agent_type = chat_state.agent_type.clone(); self.workspace = chat_state.workspace.clone(); // Load current model name for display @@ -544,6 +555,8 @@ impl ChatMode { } } + let mut event_rx = self.agent.event_source().subscribe(); + // Send initial prompt if provided (from startup page input) if let Some(prompt) = self.initial_prompt.take() { tracing::info!("Sending initial prompt: {}", prompt); @@ -570,14 +583,13 @@ impl ChatMode { } } - let event_queue = self.agent.event_queue().clone(); - let mut exit_reason = ChatExitReason::Quit; let mut should_quit = false; let mut needs_redraw = true; let mut subagent_parent_tools: HashMap = HashMap::new(); let mut last_spinner_redraw = Instant::now(); let mut pending_resize_at: Option = None; + let mut fatal_event_stream_error: Option = None; let spinner_redraw_interval = Duration::from_millis(SPINNER_REDRAW_INTERVAL_MS); let resize_redraw_debounce = Duration::from_millis(RESIZE_REDRAW_DEBOUNCE_MS); @@ -663,8 +675,37 @@ impl ChatMode { } // 2. Process core events (non-blocking) - let events = - tokio::task::block_in_place(|| rt_handle.block_on(event_queue.dequeue_batch(20))); + let mut events = Vec::with_capacity(20); + for _ in 0..20 { + match event_rx.try_recv() { + Ok(envelope) => events.push(envelope), + Err(error) => { + let Some(mut failure) = agent_event_stream_failure(error) else { + break; + }; + + // The adapter records the turn before DialogTurnStarted reaches the UI, + // so cancellation must not depend on ChatState having seen that event. + let agent = self.agent.clone(); + if let Err(cancel_error) = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.cancel_current_turn()) + }) { + failure = format!( + "{failure}; failed to cancel the active turn: {cancel_error}" + ); + } + mark_active_turn_failed(&mut chat_state, &failure); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("Error: {failure}"))); + tracing::error!("{failure}"); + fatal_event_stream_error = Some(failure); + break; + } + } + } + if fatal_event_stream_error.is_some() { + break; + } for envelope in events { let event = &envelope.event; @@ -753,6 +794,24 @@ impl ChatMode { ); continue; } + if let ToolEventData::ConfirmationNeeded { + tool_id, tool_name, .. + } = tool_event + { + if self.runtime.approval_controller().is_allowed(tool_name) { + let agent = self.agent.clone(); + let tool_id = tool_id.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.confirm_tool(&tool_id, None)) + }) { + Ok(()) => continue, + Err(error) => tracing::error!( + "Failed to confirm runtime-approved tool; showing the permission prompt again: {}", + error + ), + } + } + } chat_state.handle_tool_event(tool_event); chat_view.invalidate_lines_cache(); needs_redraw = true; @@ -982,7 +1041,16 @@ impl ChatMode { } } - restore_terminal(terminal)?; + let terminal_restore_result = restore_terminal(terminal); + if let Some(failure) = fatal_event_stream_error { + if let Err(restore_error) = terminal_restore_result { + return Err(anyhow!( + "{failure}; failed to restore the terminal: {restore_error}" + )); + } + return Err(anyhow!(failure)); + } + terminal_restore_result?; tracing::info!("Chat mode exited"); Ok(exit_reason) @@ -1006,55 +1074,63 @@ impl ChatMode { PermissionAction::AllowOnce => { let tool_id = prompt.tool_id.clone(); let agent = self.agent.clone(); - chat_state.permission_prompt = None; tracing::info!("User allowed tool once: {}", tool_id); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.confirm_tool(&tool_id, None).await { - tracing::error!("Failed to confirm tool: {}", e); - } - }) - }); - chat_view.set_status(Some("Tool confirmed".to_string())); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.confirm_tool(&tool_id, None)) + }) { + Ok(()) => { + chat_state.permission_prompt = None; + chat_view.set_status(Some("Tool confirmed".to_string())); + } + Err(error) => { + tracing::error!("Failed to confirm tool: {}", error); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } } PermissionAction::AllowAlways => { let tool_id = prompt.tool_id.clone(); + let tool_name = prompt.tool_name().to_string(); let agent = self.agent.clone(); - chat_state.permission_prompt = None; - tracing::info!("User allowed tool always: {}", tool_id); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.confirm_tool(&tool_id, None).await { - tracing::error!("Failed to confirm tool: {}", e); - } - // Skip all future tool confirmations - if let Ok(svc) = - bitfun_core::service::config::get_global_config_service().await - { - if let Err(e) = - svc.set_config("ai.skip_tool_confirmation", true).await - { - tracing::warn!("Failed to set skip_tool_confirmation: {}", e); - } - } - }) - }); - chat_view.set_status(Some("Tool confirmed (always)".to_string())); + tracing::info!( + "User allowed tool {}: tool_id={}, tool_name={}", + ALLOW_ALWAYS_RUNTIME_SCOPE, + tool_id, + tool_name + ); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.confirm_tool(&tool_id, None)) + }) { + Ok(()) => { + self.runtime.approval_controller().allow_always(&tool_name); + chat_state.permission_prompt = None; + chat_view.set_status(Some(format!( + "Tool approved {ALLOW_ALWAYS_RUNTIME_SCOPE}" + ))); + } + Err(error) => { + tracing::error!("Failed to confirm tool: {}", error); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } } PermissionAction::Reject(reason) => { let tool_id = prompt.tool_id.clone(); let agent = self.agent.clone(); - chat_state.permission_prompt = None; tracing::info!("User rejected tool: {}, reason: {}", tool_id, reason); let reason_clone = reason.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.reject_tool(&tool_id, reason_clone).await { - tracing::error!("Failed to reject tool: {}", e); - } - }) - }); - chat_view.set_status(Some(format!("Tool rejected: {}", reason))); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.reject_tool(&tool_id, reason_clone)) + }) { + Ok(()) => { + chat_state.permission_prompt = None; + chat_view.set_status(Some(format!("Tool rejected: {}", reason))); + } + Err(error) => { + tracing::error!("Failed to reject tool: {}", error); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } } PermissionAction::None => { // Permission prompt consumed the key, no further action @@ -2042,38 +2118,27 @@ impl ChatMode { .clone() .or_else(|| self.workspace.clone()) .or_else(|| Some(self.agent.workspace_path_string())); - let token_usage_service = self.token_usage_service.clone(); - let session_manager = self.agent.coordinator().get_session_manager(); + let agent = self.agent.clone(); let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); let workspace_path = workspace_path.clone(); - let token_usage_service = token_usage_service.clone(); - let session_manager = session_manager.clone(); + let agent = agent.clone(); rt_handle.block_on(async move { let workspace_path = workspace_path .filter(|path| !path.trim().is_empty()) .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; - let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() - .map_err(|error| anyhow!(error.to_string()))?; - let persistence_manager = PersistenceManager::new(path_manager) - .map_err(|error| anyhow!(error.to_string()))?; - - let report = generate_session_usage_report( - &persistence_manager, - Some(token_usage_service.as_ref()), - SessionUsageReportRequest { + let report = agent + .generate_session_usage_report(SessionUsageReportRequest { session_id: session_id.clone(), workspace_path: Some(workspace_path), remote_connection_id: None, remote_ssh_host: None, include_hidden_subagents: true, - }, - ) - .await - .map_err(|error| anyhow!(error.to_string()))?; + }) + .await?; let markdown = render_usage_report_markdown(&report); let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); @@ -2089,7 +2154,7 @@ impl ChatMode { "usageReportStatus": "completed", }); - session_manager + agent .append_completed_local_command_turn( &session_id, markdown, @@ -3010,52 +3075,21 @@ impl ChatMode { ) -> Result<()> { let agent = self.agent.clone(); let sid = new_session_id.to_string(); - let agent_type = self.agent_type.clone(); - let workspace = self.workspace.clone(); let (new_state, restored_agent_type) = tokio::task::block_in_place(|| { rt_handle.block_on(async { - // Restore session in core - agent.restore_session(&sid).await?; - - // Get session info for agent_type and workspace - let workspace_path = agent.workspace_path_buf(); - let sessions = agent - .coordinator() - .list_sessions(&workspace_path) - .await - .unwrap_or_default(); - let session_summary = sessions.iter().find(|s| s.session_id == sid); - let restored_agent_type = session_summary - .map(|s| s.agent_type.clone()) - .unwrap_or_else(|| agent_type.clone()); - let session_name = session_summary - .map(|s| s.session_name.clone()) - .unwrap_or_else(|| "Restored Session".to_string()); - - // Use the current workspace filtered by the session list; fall back to the - // workspace supplied when this chat view was created. - let effective_workspace = workspace - .clone() - .or_else(|| Some(workspace_path.to_string_lossy().to_string())); - - // Sync global workspace path from restored session - if let Some(ref ws) = effective_workspace { - agent - .set_workspace_path(Some(std::path::PathBuf::from(ws))) - .await; - } + let (session_summary, effective_workspace_path) = + agent.restore_session_in_current_workspace(&sid).await?; + let restored_agent_type = session_summary.agent_type.clone(); + let effective_workspace = + Some(effective_workspace_path.to_string_lossy().to_string()); // Load historical messages from core. - let messages = agent - .coordinator() - .get_messages(&sid) - .await - .unwrap_or_default(); + let messages = agent.get_messages(&sid).await.unwrap_or_default(); let state = ChatState::from_core_messages( sid.clone(), - session_name, + session_summary.session_name, restored_agent_type.clone(), effective_workspace, &messages, @@ -3559,13 +3593,7 @@ impl ChatMode { let current_session_id = chat_state.core_session_id.clone(); let sessions = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - agent - .coordinator() - .list_sessions(&agent.workspace_path_buf()) - .await - .unwrap_or_default() - }) + rt_handle.block_on(async { agent.list_sessions().await.unwrap_or_default() }) }); if sessions.is_empty() { @@ -3577,7 +3605,9 @@ impl ChatMode { .into_iter() .map(|s| { let last_activity = { - let elapsed = s.last_activity_at.elapsed().unwrap_or_default(); + let last_activity = + std::time::UNIX_EPOCH + Duration::from_millis(s.last_active_at_ms); + let elapsed = last_activity.elapsed().unwrap_or_default(); if elapsed.as_secs() < 60 { "just now".to_string() } else if elapsed.as_secs() < 3600 { @@ -3618,13 +3648,7 @@ impl ChatMode { let sid = item.session_id.clone(); let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let workspace_path = agent.workspace_path_buf(); - agent - .coordinator() - .delete_session(&workspace_path, &sid) - .await - }) + rt_handle.block_on(async { agent.delete_session(&sid).await }) }); match result { @@ -3898,3 +3922,47 @@ impl ChatMode { } } } + +#[cfg(test)] +mod tests { + use tokio::sync::broadcast::error::TryRecvError; + + use super::{agent_event_stream_failure, mark_active_turn_failed}; + use crate::chat_state::ChatState; + + #[test] + fn agent_event_stream_failure_ignores_empty_queue() { + assert_eq!(agent_event_stream_failure(TryRecvError::Empty), None); + } + + #[test] + fn agent_event_stream_failure_treats_lagged_and_closed_as_fatal() { + let lagged = agent_event_stream_failure(TryRecvError::Lagged(7)) + .expect("lagged stream must be fatal"); + assert!(lagged.contains("lagged by 7 events")); + assert!(lagged.contains("can no longer be trusted")); + + let closed = + agent_event_stream_failure(TryRecvError::Closed).expect("closed stream must be fatal"); + assert!(closed.contains("closed")); + assert!(closed.contains("can no longer be trusted")); + } + + #[test] + fn agent_event_stream_failure_marks_active_turn_failed() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "hello"); + + assert!(mark_active_turn_failed( + &mut state, + "Agent event stream closed; chat state can no longer be trusted" + )); + assert_eq!(state.current_turn_id(), None); + assert!(!state.is_processing); + } +} diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 57830998e1..9035f5fdd3 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,25 +1,26 @@ /// Exec mode implementation /// /// Single command execution mode (non-interactive). -/// Consumes core events directly from EventQueue. +/// Observes core events through an independent runtime broadcast subscription. use anyhow::Result; use clap::ValueEnum; -use serde_json::json; +use serde::Serialize; use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use bitfun_core::agentic::core::SessionState; use bitfun_events::AgenticEvent; use tokio::time::{sleep, Instant}; -use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; +use crate::agent::{core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; use crate::diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}; +use crate::runtime::CliRuntimeContext; const TOOL_START_INPUT_PREVIEW_CHARS: usize = 4_000; +const INTERRUPT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] pub(crate) enum ExecOutputFormat { @@ -28,6 +29,262 @@ pub(crate) enum ExecOutputFormat { StreamJson, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum ExecApprovalMode { + #[default] + Reject, + Auto, +} + +impl ExecApprovalMode { + pub(crate) const fn rejects_confirmation(self) -> bool { + matches!(self, Self::Reject) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct ExecTokenUsage { + input_tokens: usize, + #[serde(skip_serializing_if = "Option::is_none")] + output_tokens: Option, + total_tokens: usize, + #[serde(skip_serializing_if = "Option::is_none")] + cached_tokens: Option, +} + +impl ExecTokenUsage { + fn merge_round(&mut self, round: Self) { + self.input_tokens = self.input_tokens.saturating_add(round.input_tokens); + self.output_tokens = self + .output_tokens + .zip(round.output_tokens) + .map(|(current, next)| current.saturating_add(next)); + self.total_tokens = self.total_tokens.saturating_add(round.total_tokens); + self.cached_tokens = self + .cached_tokens + .zip(round.cached_tokens) + .map(|(current, next)| current.saturating_add(next)); + } + + fn accumulate_event<'a>( + aggregate: &mut Option, + event: &'a AgenticEvent, + expected_turn_id: &str, + ) -> Option<&'a str> { + let AgenticEvent::TokenUsageUpdated { + turn_id, + model_id, + input_tokens, + output_tokens, + total_tokens, + cached_tokens, + .. + } = event + else { + return None; + }; + if turn_id != expected_turn_id { + return None; + } + + let round = Self { + input_tokens: *input_tokens, + output_tokens: *output_tokens, + total_tokens: *total_tokens, + cached_tokens: *cached_tokens, + }; + if let Some(total) = aggregate.as_mut() { + total.merge_round(round); + } else { + *aggregate = Some(round); + } + Some(model_id) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(crate) struct ExecJsonResult { + #[serde(rename = "type")] + kind: &'static str, + subtype: &'static str, + is_error: bool, + result: String, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + patch: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct ExecPatchOutput { + target: String, + status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + patch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bytes: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExecTerminalStatus { + Success, + Error, + Cancelled, +} + +fn event_turn_id(event: &AgenticEvent) -> Option<&str> { + match event { + AgenticEvent::DialogTurnStarted { turn_id, .. } + | AgenticEvent::DialogTurnCompleted { turn_id, .. } + | AgenticEvent::DialogTurnCancelled { turn_id, .. } + | AgenticEvent::DialogTurnFailed { turn_id, .. } + | AgenticEvent::TokenUsageUpdated { turn_id, .. } + | AgenticEvent::ContextCompressionStarted { turn_id, .. } + | AgenticEvent::ContextCompressionCompleted { turn_id, .. } + | AgenticEvent::ContextCompressionFailed { turn_id, .. } + | AgenticEvent::ModelRoundStarted { turn_id, .. } + | AgenticEvent::ModelRoundCompleted { turn_id, .. } + | AgenticEvent::TextChunk { turn_id, .. } + | AgenticEvent::ThinkingChunk { turn_id, .. } + | AgenticEvent::ToolEvent { turn_id, .. } + | AgenticEvent::DeepReviewQueueStateChanged { turn_id, .. } + | AgenticEvent::UserSteeringInjected { turn_id, .. } => Some(turn_id), + _ => None, + } +} + +fn event_belongs_to_exec_turn(event: &AgenticEvent, session_id: &str, turn_id: &str) -> bool { + if event.session_id() != Some(session_id) { + return false; + } + match event_turn_id(event) { + Some(event_turn_id) => event_turn_id == turn_id, + None => true, + } +} + +fn completed_turn_failure( + success: Option, + finish_reason: Option<&str>, + has_final_response: Option, +) -> Option { + if success != Some(false) { + return None; + } + + let reason = finish_reason + .filter(|value| !value.trim().is_empty()) + .unwrap_or("unsuccessful_completion"); + Some(match has_final_response { + Some(false) => format!("Execution completed without a successful final response: {reason}"), + _ => format!("Execution completed unsuccessfully: {reason}"), + }) +} + +impl ExecJsonResult { + pub(crate) fn success( + session_id: impl Into, + turn_id: impl Into, + result: impl Into, + usage: Option, + ) -> Self { + Self::new( + "success", + false, + Some(session_id.into()), + Some(turn_id.into()), + result, + usage, + ) + } + + fn error( + session_id: impl Into, + turn_id: impl Into, + result: impl Into, + usage: Option, + ) -> Self { + Self::new( + "error", + true, + Some(session_id.into()), + Some(turn_id.into()), + result, + usage, + ) + } + + fn session_error(session_id: impl Into, result: impl Into) -> Self { + Self::new("error", true, Some(session_id.into()), None, result, None) + } + + fn preflight_error(result: impl Into) -> Self { + Self::new("error", true, None, None, result, None) + } + + fn cancelled( + session_id: impl Into, + turn_id: impl Into, + result: impl Into, + usage: Option, + ) -> Self { + Self::new( + "cancelled", + true, + Some(session_id.into()), + Some(turn_id.into()), + result, + usage, + ) + } + + fn new( + subtype: &'static str, + is_error: bool, + session_id: Option, + turn_id: Option, + result: impl Into, + usage: Option, + ) -> Self { + Self { + kind: "result", + subtype, + is_error, + result: result.into(), + session_id, + turn_id, + usage, + patch: None, + } + } + + fn with_patch(mut self, patch: Option) -> Self { + self.patch = patch; + self + } +} + +pub(crate) fn emit_preflight_json_error( + output_format: ExecOutputFormat, + error: &anyhow::Error, +) -> Result<()> { + if output_format == ExecOutputFormat::Json { + let result = ExecJsonResult::preflight_error(error.to_string()); + println!("{}", serde_json::to_string_pretty(&result)?); + } + Ok(()) +} + +pub(crate) fn serialize_stream_envelope( + envelope: &bitfun_events::AgenticEventEnvelope, +) -> Result { + Ok(serde_json::to_string(envelope)?) +} + #[derive(Debug, Clone, Default)] pub(crate) struct ExecSessionOptions { pub resume: Option, @@ -42,10 +299,12 @@ pub(crate) struct ExecMode { message: String, agent_type: String, agent: Arc, + _runtime: Arc, workspace_path: Option, /// None: no patch output, Some("-"): output to stdout, Some(path): save to file output_patch: Option, output_format: ExecOutputFormat, + approval_mode: ExecApprovalMode, session_options: ExecSessionOptions, } @@ -54,15 +313,19 @@ impl ExecMode { config: CliConfig, message: String, agent_type: String, - agentic_system: &AgenticSystem, + runtime: Arc, workspace_path: Option, output_patch: Option, output_format: ExecOutputFormat, session_options: ExecSessionOptions, ) -> Self { + let approval_mode = match runtime.approval_policy() { + crate::runtime::approval::CliApprovalPolicy::Auto => ExecApprovalMode::Auto, + crate::runtime::approval::CliApprovalPolicy::Ask + | crate::runtime::approval::CliApprovalPolicy::Reject => ExecApprovalMode::Reject, + }; let agent = Arc::new(CoreAgentAdapter::new( - agentic_system.coordinator.clone(), - agentic_system.event_queue.clone(), + runtime.as_ref(), workspace_path.clone(), )); @@ -71,9 +334,11 @@ impl ExecMode { message, agent_type, agent, + _runtime: runtime, workspace_path, output_patch, output_format, + approval_mode, session_options, } } @@ -140,36 +405,121 @@ impl ExecMode { let input_preview = Self::tool_input_preview(params); self.print_text(|| { - println!("\nTool call: {}", tool_name); - println!(" Started at: {}", started_at); - println!(" Tool ID: {}", tool_id); - println!(" CWD: {}", cwd); - println!(" Input: {}", input_preview); - std::io::stdout().flush().ok(); + eprintln!("\nTool call: {}", tool_name); + eprintln!(" Started at: {}", started_at); + eprintln!(" Tool ID: {}", tool_id); + eprintln!(" CWD: {}", cwd); + eprintln!(" Input: {}", input_preview); }); } fn get_git_diff(&self) -> Option { let workspace = self.workspace_path.as_ref()?; + Self::get_git_diff_for_workspace(workspace, self.output_patch.as_deref()) + } - let git_dir = workspace.join(".git"); - if !git_dir.exists() { + fn get_git_diff_for_workspace( + workspace: &std::path::Path, + output_target: Option<&str>, + ) -> Option { + let repo_root_output = bitfun_core::util::process_manager::create_command("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(workspace) + .output() + .ok()?; + if !repo_root_output.status.success() { eprintln!("Warning: Workspace is not a git repository, cannot generate patch"); return None; } + let repo_root = PathBuf::from( + String::from_utf8_lossy(&repo_root_output.stdout) + .trim() + .to_string(), + ); - let output = bitfun_core::util::process_manager::create_command("git") - .args(["diff", "--no-color"]) - .current_dir(workspace) + let excluded_output = output_target + .filter(|target| *target != "-") + .and_then(|target| { + let repo_root = std::fs::canonicalize(&repo_root).ok()?; + let output_path = + Self::canonicalize_path_allowing_missing(std::path::Path::new(target))?; + let relative = output_path.strip_prefix(repo_root).ok()?; + (!relative.as_os_str().is_empty()) + .then(|| relative.to_string_lossy().replace('\\', "/")) + }); + + let mut tracked_command = bitfun_core::util::process_manager::create_command("git"); + tracked_command + .args(["diff", "--binary", "--no-color", "HEAD", "--", "."]) + .current_dir(&repo_root); + if let Some(relative_path) = excluded_output.as_ref() { + tracked_command.arg(format!(":(exclude,top,literal){relative_path}")); + } + let tracked = tracked_command.output().ok()?; + if !tracked.status.success() { + eprintln!("Warning: git diff execution failed"); + return None; + } + + let untracked = bitfun_core::util::process_manager::create_command("git") + .args(["ls-files", "--others", "--exclude-standard", "-z"]) + .current_dir(&repo_root) .output() .ok()?; + if !untracked.status.success() { + eprintln!("Warning: git untracked file discovery failed"); + return None; + } - if output.status.success() { - Some(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - eprintln!("Warning: git diff execution failed"); - None + let mut patch = String::from_utf8_lossy(&tracked.stdout).to_string(); + for relative_path in untracked.stdout.split(|byte| *byte == 0) { + if relative_path.is_empty() { + continue; + } + let relative_path = String::from_utf8_lossy(relative_path).to_string(); + if excluded_output.as_deref() == Some(relative_path.as_str()) { + continue; + } + let untracked_patch = bitfun_core::util::process_manager::create_command("git") + .args([ + "diff", + "--no-index", + "--binary", + "--no-color", + "--", + "/dev/null", + &relative_path, + ]) + .current_dir(&repo_root) + .output() + .ok()?; + if !matches!(untracked_patch.status.code(), Some(0 | 1)) { + eprintln!("Warning: failed to generate patch for untracked file {relative_path}"); + return None; + } + if !patch.is_empty() && !patch.ends_with('\n') { + patch.push('\n'); + } + patch.push_str(&String::from_utf8_lossy(&untracked_patch.stdout)); + } + + Some(patch) + } + + fn canonicalize_path_allowing_missing(path: &std::path::Path) -> Option { + let absolute = std::path::absolute(path).ok()?; + let mut existing = absolute.as_path(); + let mut missing = Vec::new(); + while !existing.exists() { + missing.push(existing.file_name()?.to_os_string()); + existing = existing.parent()?; + } + + let mut resolved = std::fs::canonicalize(existing).ok()?; + for component in missing.into_iter().rev() { + resolved.push(component); } + Some(resolved) } pub(crate) async fn run(&mut self) -> Result<()> { @@ -180,73 +530,171 @@ impl ExecMode { "Executing command" ); - let session_id = self.prepare_session().await.inspect_err(|error| { - emit_exit_diagnostic( - ExitKind::SessionCreateFailed, - &error.to_string(), - &self.exit_context(None, None), - ); - })?; + let session_id = match self.prepare_session().await { + Ok(session_id) => session_id, + Err(error) => { + emit_exit_diagnostic( + ExitKind::SessionCreateFailed, + &error.to_string(), + &self.exit_context(None, None), + ); + if self.output_format == ExecOutputFormat::Json { + let result = ExecJsonResult::preflight_error(error.to_string()); + println!("{}", serde_json::to_string_pretty(&result)?); + } + return Err(error); + } + }; tracing::info!(session_id = %session_id, "Session ready"); - let event_queue = self.agent.event_queue().clone(); + let mut event_rx = self.agent.event_source().subscribe(); - self.emit(json!({ - "type": "session", - "session_id": session_id, - "agent": self.agent_type, - }))?; self.print_text(|| { - println!("Executing: {}", self.message); - println!(); - println!("Session: {}", session_id); - println!("Thinking..."); + eprintln!("Executing: {}", self.message); + eprintln!(); + eprintln!("Session: {}", session_id); + eprintln!("Thinking..."); }); - let turn_id = self + let turn_id = match self .agent .send_message(self.message.clone(), &self.agent_type) .await - .inspect_err(|error| { + { + Ok(turn_id) => turn_id, + Err(error) => { emit_exit_diagnostic( ExitKind::SendMessageFailed, &error.to_string(), &self.exit_context(Some(&session_id), None), ); - })?; + if self.output_format == ExecOutputFormat::Json { + let result = ExecJsonResult::session_error(&session_id, error.to_string()); + println!("{}", serde_json::to_string_pretty(&result)?); + } + return Err(error); + } + }; tracing::info!(session_id = %session_id, turn_id = %turn_id, "Message sent"); - // Consume events from EventQueue until turn completes + // Observe the shared Agentic event stream without consuming other clients' events. let mut total_tool_calls = 0usize; - let mut subagent_parent_sessions: HashMap = HashMap::new(); + let mut subagent_parent_turns: HashMap = HashMap::new(); let mut terminal_outcome: Option> = None; - - loop { - // Wait for events (efficient, uses Notify internally) - event_queue.wait_for_events().await; - let events = event_queue.dequeue_batch(20).await; + let mut terminal_status: Option = None; + let mut terminal_message: Option = None; + let mut assistant_text = String::new(); + let mut usage: Option = None; + + 'event_loop: loop { + let envelope = tokio::select! { + result = event_rx.recv() => match result { + Ok(envelope) => envelope, + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + let mut message = format!( + "Agentic event stream lost {skipped} events; execution state is no longer reliable" + ); + if let Err(error) = self.agent.cancel_current_turn().await { + message.push_str(&format!("; failed to cancel active turn: {error}")); + } + emit_exit_diagnostic( + ExitKind::EventStreamFailed, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break 'event_loop; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + let mut message = + "Agentic event stream closed before execution settled".to_string(); + if let Err(error) = self.agent.cancel_current_turn().await { + message.push_str(&format!("; failed to cancel active turn: {error}")); + } + emit_exit_diagnostic( + ExitKind::EventStreamFailed, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break 'event_loop; + } + }, + signal = tokio::signal::ctrl_c() => { + let interrupted = signal.is_ok(); + let mut message = match signal { + Ok(()) => "Execution cancelled by interrupt".to_string(), + Err(error) => format!("Failed to listen for execution interrupt: {error}"), + }; + if let Err(error) = self.agent.cancel_current_turn().await { + message.push_str(&format!("; failed to cancel active turn: {error}")); + } + if interrupted { + self.print_text(|| eprintln!("\nCancelling execution...")); + if let Err(error) = self + .drain_interrupted_turn_events(&mut event_rx, &session_id, &turn_id) + .await + { + message.push_str(&format!("; {error}")); + } + } + emit_exit_diagnostic( + if interrupted { ExitKind::Cancelled } else { ExitKind::ExecError }, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(if interrupted { + ExecTerminalStatus::Cancelled + } else { + ExecTerminalStatus::Error + }); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break 'event_loop; + } + }; + let events = [envelope]; for envelope in events { let event = &envelope.event; if let AgenticEvent::SubagentSessionLinked { session_id: subagent_session_id, + subagent_dialog_turn_id, parent_session_id, + parent_dialog_turn_id, .. } = event { - subagent_parent_sessions - .insert(subagent_session_id.clone(), parent_session_id.clone()); + if parent_session_id == &session_id && parent_dialog_turn_id == &turn_id { + subagent_parent_turns.insert( + subagent_session_id.clone(), + (parent_session_id.clone(), subagent_dialog_turn_id.clone()), + ); + self.emit_stream_envelope(&envelope)?; + } continue; } // Only process events for our session if event.session_id() != Some(&session_id) { // Check if this is a subagent event whose parent is in our session - if let AgenticEvent::ToolEvent { tool_event, .. } = event { - let parent_session_id = event.session_id().and_then(|event_session_id| { - subagent_parent_sessions.get(event_session_id) + if let AgenticEvent::ToolEvent { + turn_id: event_turn_id, + tool_event, + .. + } = event + { + let parent_turn = event.session_id().and_then(|event_session_id| { + subagent_parent_turns.get(event_session_id) }); - if parent_session_id.map(String::as_str) == Some(session_id.as_str()) { + if parent_turn.is_some_and(|(parent_session_id, subagent_turn_id)| { + parent_session_id == &session_id && subagent_turn_id == event_turn_id + }) { + self.emit_stream_envelope(&envelope)?; use bitfun_events::ToolEventData; match tool_event { ToolEventData::Started { @@ -255,66 +703,37 @@ impl ExecMode { params, .. } => { - self.emit(json!({ - "type": "subagent_tool_start", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "input": params, - }))?; self.print_text(|| { let started_at = chrono::Utc::now().to_rfc3339(); let input_preview = Self::tool_input_preview(params); - println!(" [subagent] {}", tool_name); - println!(" Started at: {}", started_at); - println!(" Tool ID: {}", tool_id); - println!(" CWD: {}", self.workspace_display()); - println!(" Input: {}", input_preview); - std::io::stdout().flush().ok(); + eprintln!(" [subagent] {}", tool_name); + eprintln!(" Started at: {}", started_at); + eprintln!(" Tool ID: {}", tool_id); + eprintln!(" CWD: {}", self.workspace_display()); + eprintln!(" Input: {}", input_preview); }); } ToolEventData::Completed { tool_name, - tool_id, result_for_assistant, result, - duration_ms, .. } => { let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); - self.emit(json!({ - "type": "subagent_tool_result", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "duration_ms": duration_ms, - "result": result, - "summary": summary, - }))?; self.print_text(|| { - println!( + eprintln!( " [subagent] {} completed: {}", tool_name, summary ) }); } ToolEventData::Failed { - tool_name, - tool_id, - error, - .. + tool_name, error, .. } => { - self.emit(json!({ - "type": "subagent_tool_error", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "error": error, - }))?; self.print_text(|| { - println!(" [subagent] {} failed: {}", tool_name, error) + eprintln!(" [subagent] {} failed: {}", tool_name, error) }); } _ => {} @@ -324,25 +743,38 @@ impl ExecMode { continue; } + if !event_belongs_to_exec_turn(event, &session_id, &turn_id) { + continue; + } + + self.emit_stream_envelope(&envelope)?; + + if let Some(model_id) = + ExecTokenUsage::accumulate_event(&mut usage, event, &turn_id) + { + self.record_resolved_model_id(&session_id, model_id).await; + } + match event { AgenticEvent::ModelRoundStarted { + turn_id: event_turn_id, model_id: Some(model_id), .. } | AgenticEvent::ModelRoundCompleted { + turn_id: event_turn_id, model_id: Some(model_id), .. - } - | AgenticEvent::TokenUsageUpdated { model_id, .. } => { + } if event_turn_id == &turn_id => { self.record_resolved_model_id(&session_id, model_id).await; } - AgenticEvent::TextChunk { text, .. } => { - self.emit(json!({ - "type": "text", - "session_id": session_id, - "text": text, - }))?; + AgenticEvent::TextChunk { + turn_id: event_turn_id, + text, + .. + } if event_turn_id == &turn_id => { + assistant_text.push_str(text); self.print_text(|| { print!("{}", text); use std::io::Write; @@ -350,57 +782,120 @@ impl ExecMode { }); } - AgenticEvent::ThinkingChunk { content, .. } => { - self.emit(json!({ - "type": "thinking", - "session_id": session_id, - "text": content, - }))?; + AgenticEvent::ThinkingChunk { + turn_id: event_turn_id, + content, + .. + } if event_turn_id == &turn_id => { self.print_text(|| { - print!("\x1b[2m{}\x1b[0m", content); - use std::io::Write; - std::io::stdout().flush().ok(); + eprint!("\x1b[2m{}\x1b[0m", content); + std::io::stderr().flush().ok(); }); } - AgenticEvent::ToolEvent { tool_event, .. } => { + AgenticEvent::ToolEvent { + turn_id: event_turn_id, + tool_event, + .. + } if event_turn_id == &turn_id => { use bitfun_events::ToolEventData; match tool_event { + ToolEventData::ConfirmationNeeded { + tool_id, tool_name, .. + } => { + if self.approval_mode.rejects_confirmation() { + let mut message = format!( + "Permission rejected for {tool_name}; rerun with --auto to approve tool requests" + ); + if let Err(error) = + self.agent.reject_tool(tool_id, message.clone()).await + { + message.push_str(&format!( + "; failed to deliver tool rejection: {error}" + )); + } + if let Err(error) = self.agent.cancel_current_turn().await { + message.push_str(&format!( + "; failed to cancel active turn: {error}" + )); + } + if self.output_format == ExecOutputFormat::StreamJson { + if let Err(error) = self + .drain_interrupted_turn_events( + &mut event_rx, + &session_id, + &turn_id, + ) + .await + { + message.push_str(&format!( + "; failed to drain terminal event: {error}" + )); + } + } + self.print_text(|| eprintln!("{message}")); + emit_exit_diagnostic( + ExitKind::PermissionRejected, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break; + } else { + if let Err(error) = self.agent.confirm_tool(tool_id, None).await + { + let mut message = format!( + "Failed to approve tool request for {tool_name}: {error}" + ); + if let Err(cancel_error) = + self.agent.cancel_current_turn().await + { + message.push_str(&format!( + "; failed to cancel active turn: {cancel_error}" + )); + } + if self.output_format == ExecOutputFormat::StreamJson { + if let Err(drain_error) = self + .drain_interrupted_turn_events( + &mut event_rx, + &session_id, + &turn_id, + ) + .await + { + message.push_str(&format!( + "; failed to drain terminal event: {drain_error}" + )); + } + } + emit_exit_diagnostic( + ExitKind::PermissionRejected, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break; + } + } + } ToolEventData::Started { tool_name, tool_id, params, .. } => { - self.emit(json!({ - "type": "tool_start", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "input": params, - }))?; self.print_tool_start_details(tool_name, tool_id, params); total_tool_calls += 1; } - ToolEventData::Progress { - tool_name, - tool_id, - message, - percentage, - } => { - self.emit(json!({ - "type": "tool_progress", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "message": message, - "percentage": percentage, - }))?; - self.print_text(|| println!(" In progress: {}", message)); + ToolEventData::Progress { message, .. } => { + self.print_text(|| eprintln!(" In progress: {}", message)); } ToolEventData::Completed { tool_name, - tool_id, result_for_assistant, result, duration_ms, @@ -409,103 +904,104 @@ impl ExecMode { let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); - self.emit(json!({ - "type": "tool_result", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "duration_ms": duration_ms, - "result": result, - "summary": summary, - }))?; self.print_text(|| { - println!( + eprintln!( " [+] {} ({}ms): {}", tool_name, duration_ms, summary ) }); } ToolEventData::Failed { - tool_name, - tool_id, - error, - .. + tool_name, error, .. } => { - self.emit(json!({ - "type": "tool_error", - "session_id": session_id, - "tool_id": tool_id, - "tool_name": tool_name, - "error": error, - }))?; - self.print_text(|| println!(" [x] {}: {}", tool_name, error)); + self.print_text(|| eprintln!(" [x] {}: {}", tool_name, error)); } _ => {} } } - AgenticEvent::DialogTurnCompleted { .. } => { - self.emit(json!({ - "type": "done", - "session_id": session_id, - "status": "completed", - "tool_calls": total_tool_calls, - }))?; + AgenticEvent::DialogTurnCompleted { + turn_id: event_turn_id, + success, + finish_reason, + has_final_response, + .. + } if event_turn_id == &turn_id => { + if let Some(message) = completed_turn_failure( + *success, + finish_reason.as_deref(), + *has_final_response, + ) { + self.print_text(|| eprintln!("\nExecution failed: {message}")); + emit_exit_diagnostic( + ExitKind::DialogTurnFailed, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break; + } self.print_text(|| { - println!("\n"); - println!("Execution complete"); + eprintln!("\n"); + eprintln!("Execution complete"); if total_tool_calls > 0 { - println!( + eprintln!( "\nTool call statistics: {} tools invoked", total_tool_calls ); } }); + terminal_status = Some(ExecTerminalStatus::Success); terminal_outcome = Some(Ok(())); break; } - AgenticEvent::DialogTurnFailed { error, .. } => { - self.emit(json!({ - "type": "error", - "session_id": session_id, - "message": error, - }))?; + AgenticEvent::DialogTurnFailed { + turn_id: event_turn_id, + error, + .. + } if event_turn_id == &turn_id => { self.print_text(|| eprintln!("\nExecution failed: {}", error)); emit_exit_diagnostic( ExitKind::DialogTurnFailed, error, &self.exit_context(Some(&session_id), Some(&turn_id)), ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(error.clone()); terminal_outcome = Some(Err(anyhow::anyhow!("Execution failed: {}", error))); break; } - AgenticEvent::DialogTurnCancelled { .. } => { - self.emit(json!({ - "type": "done", - "session_id": session_id, - "status": "cancelled", - "tool_calls": total_tool_calls, - }))?; - self.print_text(|| println!("\nExecution cancelled")); - terminal_outcome = Some(Ok(())); + AgenticEvent::DialogTurnCancelled { + turn_id: event_turn_id, + .. + } if event_turn_id == &turn_id => { + self.print_text(|| eprintln!("\nExecution cancelled")); + let message = "Execution cancelled".to_string(); + emit_exit_diagnostic( + ExitKind::Cancelled, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Cancelled); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); break; } AgenticEvent::SystemError { error, .. } => { - self.emit(json!({ - "type": "error", - "session_id": session_id, - "message": error, - }))?; self.print_text(|| eprintln!("\nSystem error: {}", error)); emit_exit_diagnostic( ExitKind::SystemError, error, &self.exit_context(Some(&session_id), Some(&turn_id)), ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(error.clone()); terminal_outcome = Some(Err(anyhow::anyhow!("System error: {}", error))); break; } @@ -519,9 +1015,48 @@ impl ExecMode { } } - self.wait_for_turn_settlement(&session_id, &turn_id).await; - self.output_patch_if_needed(); - terminal_outcome.unwrap_or(Ok(())) + if let Err(error) = self.wait_for_turn_settlement(&session_id, &turn_id).await { + let message = match terminal_message.take() { + Some(existing) => format!("{existing}; {error}"), + None => error.to_string(), + }; + emit_exit_diagnostic( + ExitKind::SettlementTimedOut, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + } + let (patch, patch_error) = self.output_patch_if_needed(); + if let Some(error) = patch_error { + let message = match terminal_message.take() { + Some(existing) => format!("{existing}; {error}"), + None => error.to_string(), + }; + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + } + if self.output_format == ExecOutputFormat::Json { + let result_text = terminal_message.unwrap_or(assistant_text); + let result = match terminal_status.unwrap_or(ExecTerminalStatus::Error) { + ExecTerminalStatus::Success => { + ExecJsonResult::success(&session_id, &turn_id, result_text, usage) + } + ExecTerminalStatus::Error => { + ExecJsonResult::error(&session_id, &turn_id, result_text, usage) + } + ExecTerminalStatus::Cancelled => { + ExecJsonResult::cancelled(&session_id, &turn_id, result_text, usage) + } + } + .with_patch(patch); + println!("{}", serde_json::to_string_pretty(&result)?); + } + terminal_outcome + .unwrap_or_else(|| Err(anyhow::anyhow!("Execution ended without a terminal event"))) } async fn record_resolved_model_id(&self, session_id: &str, model_id: &str) { @@ -530,12 +1065,7 @@ impl ExecMode { return; } - if let Err(error) = self - .agent - .coordinator() - .update_session_model(session_id, trimmed) - .await - { + if let Err(error) = self.agent.update_session_model(session_id, trimmed).await { tracing::debug!( "Failed to persist resolved CLI model id: session_id={}, model_id={}, error={}", session_id, @@ -547,14 +1077,9 @@ impl ExecMode { async fn prepare_session(&self) -> Result { let resume_id = self.session_options.resume.as_deref(); - let workspace = self - .workspace_path - .clone() - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")); let resolved_resume = if self.session_options.continue_last || resume_id == Some("last") { - let sessions = self.agent.coordinator().list_sessions(&workspace).await?; + let sessions = self.agent.list_sessions().await?; Some( sessions .first() @@ -572,28 +1097,9 @@ impl ExecMode { .ok_or_else(|| { anyhow::anyhow!("--fork-session requires --continue, --resume, or --session") })?; - let (_session, turns) = self + let result = self .agent - .coordinator() - .restore_session_view(&workspace, &source_session_id) - .await?; - let source_turn_id = turns - .last() - .map(|turn| turn.turn_id.clone()) - .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; - let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let persistence_manager = - bitfun_core::agentic::persistence::PersistenceManager::new(path_manager) - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let result = persistence_manager - .branch_session( - &workspace, - &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { - source_session_id: source_session_id.clone(), - source_turn_id, - }, - ) + .branch_session_at_latest_turn(&source_session_id) .await?; self.agent.restore_session(&result.session_id).await?; return Ok(result.session_id); @@ -614,15 +1120,12 @@ impl ExecMode { self.agent.ensure_session(&self.agent_type).await } - fn emit(&self, value: serde_json::Value) -> Result<()> { - match self.output_format { - ExecOutputFormat::Text => {} - ExecOutputFormat::StreamJson => { - println!("{}", serde_json::to_string(&value)?); - } - ExecOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&value)?); - } + fn emit_stream_envelope(&self, envelope: &bitfun_events::AgenticEventEnvelope) -> Result<()> { + if self.output_format == ExecOutputFormat::StreamJson { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + writeln!(stdout, "{}", serialize_stream_envelope(envelope)?)?; + stdout.flush()?; } Ok(()) } @@ -633,110 +1136,140 @@ impl ExecMode { } } - fn output_patch_if_needed(&self) { - if let Some(ref output_target) = self.output_patch { - if let Some(patch) = self.get_git_diff() { - let status = if patch.trim().is_empty() { - "empty" - } else { - "generated" - }; - let patch_value = json!({ - "type": "patch", - "target": output_target, - "status": status, - "patch": if output_target == "-" { Some(patch.as_str()) } else { None }, - "bytes": patch.len(), - }); - - if self.emit(patch_value).is_err() { - eprintln!("Failed to emit patch event"); - } + fn output_patch_if_needed(&self) -> (Option, Option) { + let Some(output_target) = self.output_patch.as_ref() else { + return (None, None); + }; + if self.output_format == ExecOutputFormat::StreamJson && output_target == "-" { + let error = anyhow::anyhow!( + "--output-patch with --output-format stream-json requires an explicit file path" + ); + emit_exit_diagnostic( + ExitKind::PatchUnavailable, + &error.to_string(), + &self.exit_context(None, None), + ); + return ( + Some(ExecPatchOutput { + target: output_target.clone(), + status: "unavailable", + patch: None, + bytes: None, + }), + Some(error), + ); + } + let Some(patch) = self.get_git_diff() else { + self.print_text(|| eprintln!("Unable to generate patch")); + let error = anyhow::anyhow!("Unable to generate requested git patch"); + emit_exit_diagnostic( + ExitKind::PatchUnavailable, + &error.to_string(), + &self.exit_context(None, None), + ); + return ( + Some(ExecPatchOutput { + target: output_target.clone(), + status: "unavailable", + patch: None, + bytes: None, + }), + Some(error), + ); + }; - if self.output_format != ExecOutputFormat::Text { - if output_target != "-" && !patch.trim().is_empty() { - if let Err(e) = write_patch_to_path(output_target, &patch) { - emit_exit_diagnostic( - ExitKind::PatchWriteFailed, - &e.to_string(), - &self.exit_context(None, None), - ); - eprintln!("Failed to save patch: {}", e); - } - } - return; - } + let is_empty = patch.trim().is_empty(); + let status = if is_empty { "empty" } else { "generated" }; + if output_target != "-" { + if let Err(error) = write_patch_to_path(output_target, &patch) { + emit_exit_diagnostic( + ExitKind::PatchWriteFailed, + &error.to_string(), + &self.exit_context(None, None), + ); + eprintln!("Failed to save patch: {error}"); + return ( + Some(ExecPatchOutput { + target: output_target.clone(), + status: "write_failed", + patch: None, + bytes: Some(patch.len()), + }), + Some(anyhow::anyhow!("Failed to save requested patch: {error}")), + ); + } + } - println!("\n--- Generating Patch ---"); - if patch.trim().is_empty() { - println!("(No file modifications)"); - } else if output_target == "-" { - println!("---PATCH_START---"); - println!("{}", patch); - println!("---PATCH_END---"); - } else { - match write_patch_to_path(output_target, &patch) { - Ok(_) => { - println!("Patch saved to: {}", output_target); - println!("({} bytes)", patch.len()); - } - Err(e) => { - emit_exit_diagnostic( - ExitKind::PatchWriteFailed, - &e.to_string(), - &self.exit_context(None, None), - ); - eprintln!("Failed to save patch: {}", e); - println!("---PATCH_START---"); - println!("{}", patch); - println!("---PATCH_END---"); - } - } - } + if self.output_format == ExecOutputFormat::Text { + if is_empty { + eprintln!("No file modifications"); + } else if output_target == "-" { + println!("---PATCH_START---"); + println!("{patch}"); + println!("---PATCH_END---"); } else { - let value = json!({ - "type": "patch", - "target": output_target, - "status": "unavailable", - }); - if self.emit(value).is_err() { - eprintln!("Failed to emit patch event"); - } - self.print_text(|| println!("(Unable to generate patch)")); + eprintln!("Patch saved to: {output_target} ({} bytes)", patch.len()); } } + + ( + Some(ExecPatchOutput { + target: output_target.clone(), + status, + patch: (self.output_format == ExecOutputFormat::Json && output_target == "-") + .then_some(patch.clone()), + bytes: Some(patch.len()), + }), + None, + ) } - async fn wait_for_turn_settlement(&self, session_id: &str, turn_id: &str) { - let session_manager = self.agent.coordinator().get_session_manager().clone(); + async fn wait_for_turn_settlement(&self, session_id: &str, turn_id: &str) -> Result<()> { let deadline = Instant::now() + Duration::from_secs(5); loop { - let Some(session) = session_manager.get_session(session_id) else { - return; - }; - - let still_processing = matches!( - &session.state, - SessionState::Processing { current_turn_id, .. } if current_turn_id == turn_id - ); - - if !still_processing { - return; + if !self.agent.is_turn_processing(session_id, turn_id) { + return Ok(()); } if Instant::now() >= deadline { - tracing::warn!( - "Timed out waiting for exec turn settlement: session_id={}, turn_id={}", - session_id, - turn_id - ); - return; + return Err(anyhow::anyhow!( + "Timed out waiting for exec turn settlement: session_id={session_id}, turn_id={turn_id}" + )); } sleep(Duration::from_millis(50)).await; } } + + async fn drain_interrupted_turn_events( + &self, + event_rx: &mut tokio::sync::broadcast::Receiver, + session_id: &str, + turn_id: &str, + ) -> Result<()> { + let deadline = Instant::now() + INTERRUPT_EVENT_DRAIN_TIMEOUT; + loop { + let envelope = tokio::time::timeout_at(deadline, event_rx.recv()) + .await + .map_err(|_| anyhow::anyhow!("timed out draining the cancelled turn event"))? + .map_err(|error| { + anyhow::anyhow!("failed to drain the cancelled turn event: {error}") + })?; + if !event_belongs_to_exec_turn(&envelope.event, session_id, turn_id) { + continue; + } + self.emit_stream_envelope(&envelope)?; + if matches!( + envelope.event, + AgenticEvent::DialogTurnCompleted { .. } + | AgenticEvent::DialogTurnCancelled { .. } + | AgenticEvent::DialogTurnFailed { .. } + ) { + return Ok(()); + } + } + } } pub(crate) fn write_patch_to_path(output_target: &str, patch: &str) -> std::io::Result<()> { @@ -753,7 +1286,14 @@ pub(crate) fn write_patch_to_path(output_target: &str, patch: &str) -> std::io:: #[cfg(test)] mod patch_tests { - use super::{write_patch_to_path, ExecMode, TOOL_START_INPUT_PREVIEW_CHARS}; + use std::process::Command; + + use super::{ + completed_turn_failure, event_belongs_to_exec_turn, event_turn_id, + serialize_stream_envelope, write_patch_to_path, ExecApprovalMode, ExecJsonResult, ExecMode, + ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, + }; + use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; use serde_json::json; #[test] @@ -767,6 +1307,137 @@ mod patch_tests { assert_eq!(written, "diff content"); } + #[test] + fn write_patch_to_path_creates_an_explicit_empty_patch_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let patch_path = temp.path().join("empty.patch"); + + write_patch_to_path(patch_path.to_str().expect("utf8 path"), "") + .expect("write empty patch"); + + assert!(patch_path.is_file()); + assert_eq!(std::fs::read_to_string(patch_path).expect("read patch"), ""); + } + + #[test] + fn git_patch_includes_staged_and_untracked_files_from_a_repo_subdirectory() { + let temp = tempfile::tempdir().expect("tempdir"); + let repo = temp.path(); + let run_git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + }; + run_git(&["init", "--quiet"]); + run_git(&["config", "user.email", "cli-tests@example.invalid"]); + run_git(&["config", "user.name", "CLI Tests"]); + std::fs::write(repo.join("tracked.txt"), "before\n").expect("tracked file"); + run_git(&["add", "tracked.txt"]); + run_git(&["commit", "--quiet", "-m", "initial"]); + + std::fs::write(repo.join("tracked.txt"), "after\n").expect("modify tracked file"); + run_git(&["add", "tracked.txt"]); + std::fs::write(repo.join("untracked.txt"), "new\n").expect("untracked file"); + std::fs::create_dir_all(repo.join("nested")).expect("nested directory"); + + let patch = ExecMode::get_git_diff_for_workspace(&repo.join("nested"), None) + .expect("workspace patch"); + + assert!(patch.contains("tracked.txt"), "{patch}"); + assert!(patch.contains("untracked.txt"), "{patch}"); + assert!(patch.contains("+after"), "{patch}"); + assert!(patch.contains("+new"), "{patch}"); + } + + #[test] + fn git_patch_excludes_a_preexisting_output_artifact_inside_the_repository() { + let temp = tempfile::tempdir().expect("tempdir"); + let repo = temp.path(); + let run_git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + }; + run_git(&["init", "--quiet"]); + run_git(&["config", "user.email", "cli-tests@example.invalid"]); + run_git(&["config", "user.name", "CLI Tests"]); + std::fs::write(repo.join("tracked.txt"), "before\n").expect("tracked file"); + run_git(&["add", "tracked.txt"]); + run_git(&["commit", "--quiet", "-m", "initial"]); + + std::fs::write(repo.join("tracked.txt"), "after\n").expect("modify tracked file"); + let output_artifact = repo.join("result.patch"); + std::fs::write(&output_artifact, "old recursive patch payload\n") + .expect("preexisting output artifact"); + + let patch = ExecMode::get_git_diff_for_workspace( + repo, + Some(output_artifact.to_str().expect("utf8 artifact path")), + ) + .expect("workspace patch"); + + assert!(patch.contains("tracked.txt"), "{patch}"); + assert!(!patch.contains("result.patch"), "{patch}"); + assert!(!patch.contains("old recursive patch payload"), "{patch}"); + } + + #[test] + fn git_patch_excludes_a_tracked_output_artifact_inside_the_repository() { + let temp = tempfile::tempdir().expect("tempdir"); + let repo = temp.path(); + let run_git = |args: &[&str]| { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + }; + run_git(&["init", "--quiet"]); + run_git(&["config", "user.email", "cli-tests@example.invalid"]); + run_git(&["config", "user.name", "CLI Tests"]); + std::fs::write(repo.join("tracked.txt"), "before\n").expect("tracked file"); + std::fs::write(repo.join("result.patch"), "old patch\n").expect("tracked artifact"); + run_git(&["add", "tracked.txt", "result.patch"]); + run_git(&["commit", "--quiet", "-m", "initial"]); + + std::fs::write(repo.join("tracked.txt"), "after\n").expect("modify tracked file"); + let output_artifact = repo.join("result.patch"); + std::fs::write(&output_artifact, "new recursive patch payload\n") + .expect("modify tracked artifact"); + + let patch = ExecMode::get_git_diff_for_workspace( + repo, + Some(output_artifact.to_str().expect("utf8 artifact path")), + ) + .expect("workspace patch"); + + assert!(patch.contains("tracked.txt"), "{patch}"); + assert!(!patch.contains("result.patch"), "{patch}"); + assert!(!patch.contains("recursive patch payload"), "{patch}"); + } + #[test] fn tool_input_preview_redacts_data_urls() { let preview = ExecMode::tool_input_preview(&json!({ @@ -790,4 +1461,221 @@ mod patch_tests { assert!(preview.ends_with("... [truncated]")); assert!(preview.len() < TOOL_START_INPUT_PREVIEW_CHARS + 100); } + + #[test] + fn json_output_is_one_competitor_aligned_result_object() { + let result = ExecJsonResult::success( + "session-1", + "turn-1", + "completed work", + Some(ExecTokenUsage { + input_tokens: 10, + output_tokens: Some(5), + total_tokens: 15, + cached_tokens: Some(3), + }), + ); + + let encoded = serde_json::to_string(&result).expect("serialize result"); + let value: serde_json::Value = serde_json::from_str(&encoded).expect("one JSON object"); + + assert_eq!(value["type"], "result"); + assert_eq!(value["subtype"], "success"); + assert_eq!(value["is_error"], false); + assert_eq!(value["result"], "completed work"); + assert_eq!(value["session_id"], "session-1"); + assert_eq!(value["turn_id"], "turn-1"); + assert_eq!(value["usage"]["total_tokens"], 15); + } + + #[test] + fn json_usage_accumulates_all_model_round_updates_for_the_turn() { + let events = [ + AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + model_id: "model".to_string(), + input_tokens: 100, + output_tokens: Some(25), + total_tokens: 125, + max_context_tokens: Some(200_000), + is_subagent: false, + cached_tokens: Some(40), + token_details: None, + }, + AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + model_id: "model".to_string(), + input_tokens: 200, + output_tokens: Some(50), + total_tokens: 250, + max_context_tokens: Some(200_000), + is_subagent: false, + cached_tokens: Some(80), + token_details: None, + }, + ]; + let mut usage = None; + + for event in &events { + assert_eq!( + ExecTokenUsage::accumulate_event(&mut usage, event, "turn-1"), + Some("model") + ); + } + + let value = serde_json::to_value(ExecJsonResult::success( + "session-1", + "turn-1", + "done", + usage, + )) + .expect("serialize result"); + assert_eq!(value["usage"]["input_tokens"], 300); + assert_eq!(value["usage"]["output_tokens"], 75); + assert_eq!(value["usage"]["total_tokens"], 375); + assert_eq!(value["usage"]["cached_tokens"], 120); + } + + #[test] + fn json_usage_omits_optional_totals_when_any_round_does_not_report_them() { + let events = [ + AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + model_id: "model".to_string(), + input_tokens: 100, + output_tokens: None, + total_tokens: 100, + max_context_tokens: None, + is_subagent: false, + cached_tokens: Some(20), + token_details: None, + }, + AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + model_id: "model".to_string(), + input_tokens: 50, + output_tokens: Some(10), + total_tokens: 60, + max_context_tokens: None, + is_subagent: false, + cached_tokens: None, + token_details: None, + }, + ]; + let mut usage = None; + + for event in &events { + ExecTokenUsage::accumulate_event(&mut usage, event, "turn-1"); + } + + let value = serde_json::to_value(ExecJsonResult::success( + "session-1", + "turn-1", + "done", + usage, + )) + .expect("serialize result"); + assert_eq!(value["usage"]["input_tokens"], 150); + assert_eq!(value["usage"]["total_tokens"], 160); + assert!(value["usage"].get("output_tokens").is_none()); + assert!(value["usage"].get("cached_tokens").is_none()); + } + + #[test] + fn preflight_json_error_omits_unknown_runtime_ids() { + let result = ExecJsonResult::preflight_error("invalid arguments"); + let value = serde_json::to_value(result).expect("serialize result"); + + assert_eq!(value["subtype"], "error"); + assert_eq!(value["is_error"], true); + assert!(value.get("session_id").is_none()); + assert!(value.get("turn_id").is_none()); + } + + #[test] + fn cancelled_json_result_is_an_error_outcome() { + let result = ExecJsonResult::cancelled("session-1", "turn-1", "cancelled", None); + let value = serde_json::to_value(result).expect("serialize result"); + + assert_eq!(value["subtype"], "cancelled"); + assert_eq!(value["is_error"], true); + } + + #[test] + fn stream_json_reuses_the_existing_agentic_envelope() { + let envelope = AgenticEventEnvelope::new( + AgenticEvent::SessionStateChanged { + session_id: "session-1".to_string(), + new_state: "idle".to_string(), + }, + AgenticEventPriority::Normal, + ); + + let encoded = serialize_stream_envelope(&envelope).expect("serialize envelope"); + let value: serde_json::Value = serde_json::from_str(&encoded).expect("JSONL record"); + + assert_eq!(value["id"], envelope.id); + assert_eq!(value["event"]["type"], "SessionStateChanged"); + assert!(value.get("schema_version").is_none()); + assert!(value.get("sequence").is_none()); + } + + #[test] + fn default_exec_policy_rejects_confirmation_events() { + assert!(ExecApprovalMode::Reject.rejects_confirmation()); + assert!(!ExecApprovalMode::Auto.rejects_confirmation()); + } + + #[test] + fn unsuccessful_completed_turn_is_an_error_outcome() { + assert_eq!( + completed_turn_failure(Some(false), Some("empty_round"), Some(false)).as_deref(), + Some("Execution completed without a successful final response: empty_round") + ); + assert!(completed_turn_failure(Some(true), Some("stop"), Some(true)).is_none()); + assert!(completed_turn_failure(None, None, None).is_none()); + } + + #[test] + fn exec_turn_filter_rejects_other_turn_events_in_the_same_session() { + let event = AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-other".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "unrelated".to_string(), + }; + + assert_eq!(event_turn_id(&event), Some("turn-other")); + assert!(!event_belongs_to_exec_turn( + &event, + "session-1", + "turn-current" + )); + } + + #[test] + fn exec_turn_filter_accepts_session_correlated_system_errors() { + let event = AgenticEvent::SystemError { + session_id: Some("session-1".to_string()), + error: "another turn failed".to_string(), + recoverable: false, + }; + + assert!(event_belongs_to_exec_turn( + &event, + "session-1", + "turn-current" + )); + assert!(!event_belongs_to_exec_turn( + &event, + "session-other", + "turn-current" + )); + } } diff --git a/src/apps/cli/src/product_assembly.rs b/src/apps/cli/src/product_assembly.rs index 878b8bf253..9dc5045fe5 100644 --- a/src/apps/cli/src/product_assembly.rs +++ b/src/apps/cli/src/product_assembly.rs @@ -1,22 +1,33 @@ use bitfun_core::product_assembly::{ - product_assembly_plan_for_profile, DeliveryProfile, ProductAssemblyPlan, + DeliveryProfile, ProductAssembler, ProductAssemblyError, ProductAssemblyInput, + ProductRuntimeParts, }; +use bitfun_runtime_services::RuntimeServices; -pub(crate) fn cli_product_assembly_plan() -> ProductAssemblyPlan { - product_assembly_plan_for_profile(DeliveryProfile::Cli) +pub(crate) fn assemble_cli_runtime_parts( + services: RuntimeServices, +) -> Result { + ProductAssembler::new().assemble(ProductAssemblyInput::new(DeliveryProfile::Cli, services)) } #[cfg(test)] mod tests { - use super::cli_product_assembly_plan; - use bitfun_core::product_assembly::DeliveryProfile; + use std::sync::Arc; + + use super::assemble_cli_runtime_parts; + use crate::runtime::{ + approval::{CliApprovalPolicy, CliPermissionService}, + services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}, + }; + use bitfun_core::product_assembly::ProductServiceCapabilityStatus; + use bitfun_core::product_assembly::{product_assembly_plan_for_profile, DeliveryProfile}; use bitfun_runtime_ports::{ PluginRuntimeAvailability, PluginRuntimeUnavailableReason, RuntimeServiceCapability, }; #[test] fn cli_product_plan_declares_required_services_without_constructing_runtime_parts() { - let plan = cli_product_assembly_plan(); + let plan = product_assembly_plan_for_profile(DeliveryProfile::Cli); assert_eq!(plan.profile(), DeliveryProfile::Cli); assert!(!plan.capability_assembly().service_requirements().is_empty()); @@ -46,4 +57,34 @@ mod tests { } ); } + + #[test] + fn cli_product_assembly_consumes_production_runtime_services() { + let workspace = tempfile::tempdir().expect("workspace"); + let services = CliRuntimeServicesProvider::new( + workspace.path(), + Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), + Arc::new(CliRuntimeEventSink::new(8)), + Arc::new(CliClock), + ) + .expect("provider") + .build() + .expect("runtime services"); + + let parts = assemble_cli_runtime_parts(services).expect("CLI product runtime parts"); + + assert_eq!(parts.plan().profile(), DeliveryProfile::Cli); + assert!(parts.missing_service_requirements().is_empty()); + assert!(parts + .service_availability() + .iter() + .all(|entry| entry.status() == ProductServiceCapabilityStatus::Available)); + assert!(matches!( + parts.plugin_runtime().availability(), + PluginRuntimeAvailability::Disabled { + reason: PluginRuntimeUnavailableReason::NotBuilt + } + )); + assert!(!parts.harness_registry().provider_ids().is_empty()); + } } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 153620a3e0..26478ef054 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -6,7 +6,9 @@ use std::path::Path; use crate::{ config::CliConfig, diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}, - modes::exec::{ExecMode, ExecOutputFormat, ExecSessionOptions}, + modes::exec::{ + emit_preflight_json_error, ExecApprovalMode, ExecMode, ExecOutputFormat, ExecSessionOptions, + }, ui::string_utils::truncate_str, ConfigAction, SessionAction, }; @@ -21,7 +23,7 @@ pub(crate) struct ExecCommandArgs { pub fork_session: bool, pub output_format: ExecOutputFormat, pub output_patch: Option, - pub confirm: bool, + pub approval_mode: ExecApprovalMode, } pub(crate) async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Result<()> { @@ -31,42 +33,94 @@ pub(crate) async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs tracing::info!("Workspace path set: {:?}", ws_path); } - let message = resolve_exec_message(args.message)?; + let message = match resolve_exec_message(args.message) { + Ok(message) => message, + Err(error) => return exec_preflight_error(args.output_format, error), + }; let resume = match (args.resume, args.session) { (Some(_), Some(_)) => { - anyhow::bail!("Use only one of --resume or --session"); + return exec_preflight_error( + args.output_format, + anyhow::anyhow!("Use only one of --resume or --session"), + ); } (Some(value), None) | (None, Some(value)) => Some(value), (None, None) => None, }; + if args.continue_last && resume.is_some() { + return exec_preflight_error( + args.output_format, + anyhow::anyhow!("--continue cannot be combined with --resume or --session"), + ); + } + if let Some(session_id) = resume.as_deref().filter(|session_id| *session_id != "last") { + if let Err(error) = bitfun_agent_runtime::session_control::validate_session_id(session_id) { + return exec_preflight_error(args.output_format, anyhow::anyhow!(error)); + } + } + if let Some(session_id) = args.session_id.as_deref() { + if let Err(error) = bitfun_agent_runtime::session_control::validate_session_id(session_id) { + return exec_preflight_error(args.output_format, anyhow::anyhow!(error)); + } + } if args.session_id.is_some() && (args.continue_last || resume.is_some()) { - anyhow::bail!("--session-id cannot be combined with --continue, --resume, or --session"); + return exec_preflight_error( + args.output_format, + anyhow::anyhow!( + "--session-id cannot be combined with --continue, --resume, or --session" + ), + ); } if args.fork_session && args.session_id.is_some() { - anyhow::bail!("--fork-session cannot be combined with --session-id"); + return exec_preflight_error( + args.output_format, + anyhow::anyhow!("--fork-session cannot be combined with --session-id"), + ); + } + if args.output_format == ExecOutputFormat::StreamJson + && args.output_patch.as_deref() == Some("-") + { + return exec_preflight_error( + args.output_format, + anyhow::anyhow!( + "--output-patch with --output-format stream-json requires an explicit file path" + ), + ); } - let skip_confirmation = !args.confirm; - let (agentic_system, original_skip_confirmation) = - crate::initialize_core_services(skip_confirmation) - .await - .inspect_err(|error| { - emit_exit_diagnostic( - ExitKind::ExecError, - &error.to_string(), - &ExitContext { - agent_type: Some(args.agent.as_str()), - workspace: workspace_path_resolved.as_deref(), - ..Default::default() - }, - ); - })?; + let approval_policy = match args.approval_mode { + ExecApprovalMode::Reject => crate::runtime::approval::CliApprovalPolicy::Reject, + ExecApprovalMode::Auto => crate::runtime::approval::CliApprovalPolicy::Auto, + }; + let runtime = match crate::initialize_core_services( + workspace_path_resolved + .as_deref() + .unwrap_or_else(|| Path::new(".")), + approval_policy, + crate::BootstrapProfile::Execution, + ) + .await + { + Ok(runtime) => runtime, + Err(error) => { + emit_exit_diagnostic( + ExitKind::ExecError, + &error.to_string(), + &ExitContext { + agent_type: Some(args.agent.as_str()), + workspace: workspace_path_resolved.as_deref(), + ..Default::default() + }, + ); + return exec_preflight_error(args.output_format, error); + } + }; let mut exec_mode = ExecMode::new( config, message, args.agent, - &agentic_system, + runtime.clone(), workspace_path_resolved, args.output_patch, args.output_format, @@ -80,11 +134,15 @@ pub(crate) async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs let run_result = exec_mode.run().await; crate::shutdown_mcp_servers().await; - crate::restore_tool_confirmation(original_skip_confirmation).await; run_result } +fn exec_preflight_error(output_format: ExecOutputFormat, error: anyhow::Error) -> Result { + emit_preflight_json_error(output_format, &error)?; + Err(error) +} + fn resolve_exec_message(message: Option) -> Result { let mut combined = message.unwrap_or_default(); if !std::io::stdin().is_terminal() { @@ -110,15 +168,24 @@ fn resolve_exec_message(message: Option) -> Result { Ok(message) } -pub(crate) async fn handle_session_action(action: SessionAction) -> Result> { - let agentic_system = crate::agent::agentic_system::init_agentic_system_for_cli().await?; - - let coordinator = agentic_system.coordinator.clone(); +pub(crate) async fn handle_session_action( + action: SessionAction, +) -> Result)>> { let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let approval_policy = match &action { + SessionAction::Resume { .. } | SessionAction::Continue => { + crate::runtime::approval::CliApprovalPolicy::Ask + } + _ => crate::runtime::approval::CliApprovalPolicy::Reject, + }; + let bootstrap_profile = action.bootstrap_profile(); + let runtime = + crate::initialize_core_services(&workspace_path, approval_policy, bootstrap_profile) + .await?; match action { SessionAction::List => { - let sessions = coordinator.list_sessions(&workspace_path).await?; + let sessions = list_cli_sessions(runtime.agent_runtime(), &workspace_path).await?; if sessions.is_empty() { println!( @@ -136,12 +203,9 @@ pub(crate) async fn handle_session_action(action: SessionAction) -> Result Result { - let sessions = coordinator.list_sessions(&workspace_path).await?; + let session_id = + resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; - let session_id = if id == "last" { - sessions - .first() - .map(|s| s.session_id.clone()) - .ok_or_else(|| anyhow::anyhow!("No history sessions"))? - } else { - id - }; - - let session = coordinator + let session = runtime + .compatibility() .restore_session(&workspace_path, &session_id) .await?; - let messages = coordinator.get_messages(&session_id).await?; + let messages = runtime.compatibility().get_messages(&session_id).await?; println!("Session Details\n"); println!("Name: {}", session.session_name); @@ -226,42 +283,39 @@ pub(crate) async fn handle_session_action(action: SessionAction) -> Result { - coordinator.delete_session(&workspace_path, &id).await?; + bitfun_agent_runtime::session_control::validate_session_id(&id) + .map_err(anyhow::Error::msg)?; + runtime + .agent_runtime() + .delete_session(bitfun_runtime_ports::AgentSessionDeleteRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + session_id: id.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))?; println!("Deleted session from current project: {}", id); } SessionAction::Resume { id } => { - let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; - return Ok(Some(session_id)); + let session_id = + resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; + return Ok(Some((session_id, runtime))); } SessionAction::Continue => { - let session_id = resolve_cli_session_id(&coordinator, &workspace_path, "last").await?; - return Ok(Some(session_id)); + let session_id = + resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, "last").await?; + return Ok(Some((session_id, runtime))); } SessionAction::Fork { id, id_only } => { - let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; - let (_session, turns) = coordinator - .restore_session_view(&workspace_path, &session_id) - .await?; - let source_turn_id = turns - .last() - .map(|turn| turn.turn_id.clone()) - .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; - let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let persistence_manager = - bitfun_core::agentic::persistence::PersistenceManager::new(path_manager) - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let result = persistence_manager - .branch_session( - &workspace_path, - &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { - source_session_id: session_id.clone(), - source_turn_id, - }, - ) + let session_id = + resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; + let result = runtime + .compatibility() + .branch_session_at_latest_turn(&workspace_path, &session_id) .await?; if id_only { @@ -280,21 +334,36 @@ pub(crate) async fn handle_session_action(action: SessionAction) -> Result, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, workspace_path: &Path, id: &str, ) -> Result { if id == "last" { - let sessions = coordinator.list_sessions(workspace_path).await?; + let sessions = list_cli_sessions(runtime, workspace_path).await?; return sessions .first() .map(|session| session.session_id.clone()) .ok_or_else(|| anyhow::anyhow!("No history sessions")); } + bitfun_agent_runtime::session_control::validate_session_id(id).map_err(anyhow::Error::msg)?; Ok(id.to_string()) } +async fn list_cli_sessions( + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + workspace_path: &Path, +) -> Result> { + runtime + .list_sessions(bitfun_runtime_ports::AgentSessionListRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) +} + pub(crate) fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { match action { ConfigAction::Show => { @@ -337,8 +406,44 @@ pub(crate) fn handle_config_action(action: ConfigAction, config: &CliConfig) -> } pub(crate) fn handle_health_command() -> Result<()> { - println!("BitFun CLI is running normally"); + use std::sync::Arc; + + use bitfun_core::runtime_ports::PluginRuntimeAvailability; + + use crate::runtime::approval::{CliApprovalPolicy, CliPermissionService}; + use crate::runtime::services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; + + let workspace = std::env::current_dir().context("Failed to resolve current directory")?; + let services = CliRuntimeServicesProvider::new( + &workspace, + Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), + Arc::new(CliRuntimeEventSink::new(16)), + Arc::new(CliClock), + )? + .build()?; + let product_runtime = crate::product_assembly::assemble_cli_runtime_parts(services)?; + + println!("BitFun CLI health"); println!("Version: {}", env!("CARGO_PKG_VERSION")); + println!( + "Product runtime: {} assembly-ready", + product_runtime.plan().profile().id() + ); + println!("Runtime capability registrations: complete"); + println!("Execution owner: bitfun-core compatibility"); + match product_runtime.plugin_runtime().availability() { + PluginRuntimeAvailability::Disabled { reason } => { + println!("Plugin runtime: disabled ({reason})"); + } + PluginRuntimeAvailability::ProjectionOnly { reason } => { + println!("Plugin runtime: projection-only ({reason})"); + } + PluginRuntimeAvailability::Unavailable { reason } => { + println!("Plugin runtime: unavailable ({reason})"); + } + PluginRuntimeAvailability::Available => println!("Plugin runtime: available"), + _ => println!("Plugin runtime: unknown"), + } println!("Config directory: {:?}", CliConfig::config_dir()?); Ok(()) } diff --git a/src/apps/cli/src/runtime/approval.rs b/src/apps/cli/src/runtime/approval.rs new file mode 100644 index 0000000000..f5fe2d6230 --- /dev/null +++ b/src/apps/cli/src/runtime/approval.rs @@ -0,0 +1,154 @@ +use std::collections::HashSet; +use std::sync::RwLock; + +use bitfun_runtime_ports::{ + PermissionDecision, PermissionPort, PermissionRequest, PortResult, RuntimeServiceCapability, + RuntimeServicePort, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CliApprovalPolicy { + Ask, + Reject, + Auto, +} + +#[derive(Debug, Default)] +pub(crate) struct CliApprovalController { + allowed_tools: RwLock>, +} + +impl CliApprovalController { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn allow_always(&self, tool_name: &str) { + let tool_name = normalize_tool_name(tool_name); + if tool_name.is_empty() { + return; + } + + self.allowed_tools + .write() + .expect("CLI approval controller lock poisoned") + .insert(tool_name); + } + + pub(crate) fn is_allowed(&self, tool_name: &str) -> bool { + let tool_name = normalize_tool_name(tool_name); + !tool_name.is_empty() + && self + .allowed_tools + .read() + .expect("CLI approval controller lock poisoned") + .contains(&tool_name) + } +} + +fn normalize_tool_name(tool_name: &str) -> String { + tool_name.trim().to_ascii_lowercase() +} + +#[derive(Debug)] +pub(crate) struct CliPermissionService { + policy: CliApprovalPolicy, +} + +impl CliPermissionService { + pub(crate) const fn new(policy: CliApprovalPolicy) -> Self { + Self { policy } + } +} + +impl RuntimeServicePort for CliPermissionService { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Permission + } +} + +#[async_trait::async_trait] +impl PermissionPort for CliPermissionService { + async fn request_permission( + &self, + request: PermissionRequest, + ) -> PortResult { + Ok(match self.policy { + CliApprovalPolicy::Auto => PermissionDecision::Allow, + CliApprovalPolicy::Reject => PermissionDecision::Deny { + reason: format!( + "non-interactive permission rejected: {}:{}", + request.scope, request.action + ), + }, + CliApprovalPolicy::Ask => PermissionDecision::Deny { + reason: format!( + "interactive approval required: {}:{}", + request.scope, request.action + ), + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{CliApprovalController, CliApprovalPolicy, CliPermissionService}; + use bitfun_runtime_ports::{PermissionDecision, PermissionPort, PermissionRequest}; + + fn request() -> PermissionRequest { + PermissionRequest { + scope: "tool".to_string(), + action: "run_terminal_cmd".to_string(), + metadata: serde_json::Map::new(), + } + } + + #[tokio::test] + async fn non_interactive_permission_policy_is_invocation_scoped() { + let reject = CliPermissionService::new(CliApprovalPolicy::Reject); + assert!(matches!( + reject + .request_permission(request()) + .await + .expect("decision"), + PermissionDecision::Deny { .. } + )); + + let auto = CliPermissionService::new(CliApprovalPolicy::Auto); + assert_eq!( + auto.request_permission(request()).await.expect("decision"), + PermissionDecision::Allow + ); + } + + #[tokio::test] + async fn interactive_policy_never_silently_approves() { + let service = CliPermissionService::new(CliApprovalPolicy::Ask); + + let decision = service + .request_permission(request()) + .await + .expect("decision"); + + assert!( + matches!(decision, PermissionDecision::Deny { reason } if reason.contains("interactive")) + ); + } + + #[test] + fn allow_always_is_scoped_to_one_controller_and_tool_pattern() { + let controller = CliApprovalController::new(); + assert!(!controller.is_allowed("run_terminal_cmd")); + + controller.allow_always("run_terminal_cmd"); + + assert!(controller.is_allowed("run_terminal_cmd")); + assert!(controller.is_allowed("RUN_TERMINAL_CMD")); + assert!(!controller.is_allowed("write_file")); + assert!( + !CliApprovalController::new().is_allowed("run_terminal_cmd"), + "approval must not survive a runtime context" + ); + } +} diff --git a/src/apps/cli/src/runtime/events.rs b/src/apps/cli/src/runtime/events.rs new file mode 100644 index 0000000000..f61e85ef15 --- /dev/null +++ b/src/apps/cli/src/runtime/events.rs @@ -0,0 +1,116 @@ +use std::sync::Arc; + +use bitfun_core::agentic::events::EventQueue; +use bitfun_events::AgenticEventEnvelope; +use tokio::sync::broadcast; + +struct EventQueueDrain { + task: tokio::task::JoinHandle<()>, +} + +impl EventQueueDrain { + fn start(queue: Arc) -> Self { + let task = tokio::spawn(async move { + loop { + queue.wait_for_events().await; + loop { + if queue.dequeue_configured_batch().await.is_empty() { + break; + } + } + } + }); + Self { task } + } +} + +impl Drop for EventQueueDrain { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +pub(crate) struct CliAgentEventSource { + queue: Arc, + _drain: Arc, +} + +impl CliAgentEventSource { + pub(crate) fn new(queue: Arc) -> Self { + Self { + _drain: Arc::new(EventQueueDrain::start(queue.clone())), + queue, + } + } + + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.queue.subscribe() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bitfun_core::agentic::events::{EventQueue, EventQueueConfig}; + use bitfun_events::AgenticEvent; + + use super::CliAgentEventSource; + + #[tokio::test] + async fn subscribers_observe_every_event_while_the_legacy_queue_stays_bounded() { + let queue = Arc::new(EventQueue::new(EventQueueConfig { + max_queue_size: 4, + batch_size: 2, + })); + let source = CliAgentEventSource::new(queue.clone()); + let mut first = source.subscribe(); + let mut second = source.subscribe(); + + for index in 0..32 { + queue + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: "session-1".to_string(), + new_state: format!("state-{index}"), + }, + None, + ) + .await + .expect("enqueue event"); + } + + let mut first_event = None; + let mut second_event = None; + for _ in 0..32 { + first_event = Some( + tokio::time::timeout(std::time::Duration::from_secs(1), first.recv()) + .await + .expect("first subscriber must not stall") + .expect("first subscriber event"), + ); + second_event = Some( + tokio::time::timeout(std::time::Duration::from_secs(1), second.recv()) + .await + .expect("second subscriber must not stall") + .expect("second subscriber event"), + ); + } + + let first_event = first_event.expect("last first event"); + let second_event = second_event.expect("last second event"); + assert_eq!(first_event.id, second_event.id); + assert!(matches!( + first_event.event, + AgenticEvent::SessionStateChanged { ref new_state, .. } if new_state == "state-31" + )); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !queue.is_empty().await { + tokio::task::yield_now().await; + } + }) + .await + .expect("queue drainer must keep the legacy queue bounded"); + } +} diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs new file mode 100644 index 0000000000..d7ade23d93 --- /dev/null +++ b/src/apps/cli/src/runtime/mod.rs @@ -0,0 +1,181 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use bitfun_agent_runtime::sdk::AgentRuntime; +use bitfun_core::agentic::coordination::{self, DialogScheduler}; +use bitfun_core::agentic::system::AgenticSystem; +use bitfun_core::product_assembly::{ProductAssemblyPlan, ProductServiceCapabilityAvailability}; +use bitfun_core::product_runtime::{CoreAgentRuntimeCompatibility, CoreProductAgentRuntime}; +use bitfun_core::runtime_ports::PluginRuntimeAvailability; +use bitfun_runtime_services::RuntimeServices; + +use crate::product_assembly::assemble_cli_runtime_parts; + +pub(crate) mod approval; +pub(crate) mod events; +pub(crate) mod services; + +use approval::{CliApprovalController, CliApprovalPolicy, CliPermissionService}; +use events::CliAgentEventSource; +use services::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; + +const RUNTIME_EVENT_BUFFER: usize = 256; + +#[derive(Debug, Clone)] +pub(crate) struct CliProductRuntimeState { + plan: ProductAssemblyPlan, + service_availability: Vec, + plugin_runtime: PluginRuntimeAvailability, + harness_provider_ids: Vec, +} + +impl CliProductRuntimeState { + pub(crate) fn plan(&self) -> &ProductAssemblyPlan { + &self.plan + } + + pub(crate) fn service_availability(&self) -> &[ProductServiceCapabilityAvailability] { + &self.service_availability + } + + pub(crate) const fn plugin_runtime(&self) -> PluginRuntimeAvailability { + self.plugin_runtime + } + + pub(crate) fn harness_provider_ids(&self) -> &[String] { + &self.harness_provider_ids + } +} + +#[derive(Clone)] +pub(crate) struct CliRuntimeContext { + workspace_root: PathBuf, + agentic_system: AgenticSystem, + agent_runtime: AgentRuntime, + compatibility: CoreAgentRuntimeCompatibility, + agent_events: CliAgentEventSource, + services: RuntimeServices, + product: CliProductRuntimeState, + approval_policy: CliApprovalPolicy, + approval_controller: Arc, +} + +impl CliRuntimeContext { + pub(crate) fn build( + agentic_system: AgenticSystem, + workspace_root: impl AsRef, + approval_policy: CliApprovalPolicy, + ) -> Result { + let scheduler = ensure_dialog_scheduler(&agentic_system); + let runtime_events = Arc::new(CliRuntimeEventSink::new(RUNTIME_EVENT_BUFFER)); + let provider = CliRuntimeServicesProvider::new( + workspace_root, + Arc::new(CliPermissionService::new(approval_policy)), + runtime_events.clone(), + Arc::new(CliClock), + )?; + let workspace_root = provider.workspace_root().to_path_buf(); + let parts = assemble_cli_runtime_parts(provider.build()?) + .context("Failed to assemble CLI product runtime")?; + + let product = CliProductRuntimeState { + plan: parts.plan().clone(), + service_availability: parts.service_availability().to_vec(), + plugin_runtime: parts.plugin_runtime().availability(), + harness_provider_ids: parts + .harness_registry() + .provider_ids() + .into_iter() + .map(ToOwned::to_owned) + .collect(), + }; + let (services, harness_registry, _disabled_plugin_runtime) = parts.into_runtime_parts(); + let agent_runtime = CoreProductAgentRuntime::build( + agentic_system.coordinator.clone(), + scheduler.clone(), + services.clone(), + harness_registry, + ) + .map_err(anyhow::Error::msg) + .context("Failed to build CLI Agent Runtime SDK")?; + let compatibility = CoreAgentRuntimeCompatibility::build( + agentic_system.coordinator.clone(), + agentic_system.token_usage_service.clone(), + ); + + debug_assert_eq!( + agent_runtime.harness_provider_ids(), + product + .harness_provider_ids + .iter() + .map(String::as_str) + .collect::>() + ); + + Ok(Self { + workspace_root, + agent_events: CliAgentEventSource::new(agentic_system.event_queue.clone()), + agentic_system, + agent_runtime, + compatibility, + services, + product, + approval_policy, + approval_controller: Arc::new(CliApprovalController::new()), + }) + } + + pub(crate) fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub(crate) fn agentic_system(&self) -> &AgenticSystem { + &self.agentic_system + } + + pub(crate) fn agent_runtime(&self) -> &AgentRuntime { + &self.agent_runtime + } + + pub(crate) fn compatibility(&self) -> &CoreAgentRuntimeCompatibility { + &self.compatibility + } + + pub(crate) fn agent_events(&self) -> &CliAgentEventSource { + &self.agent_events + } + + pub(crate) fn services(&self) -> &RuntimeServices { + &self.services + } + + pub(crate) fn product(&self) -> &CliProductRuntimeState { + &self.product + } + + pub(crate) const fn approval_policy(&self) -> CliApprovalPolicy { + self.approval_policy + } + + pub(crate) fn approval_controller(&self) -> &Arc { + &self.approval_controller + } +} + +fn ensure_dialog_scheduler(agentic_system: &AgenticSystem) -> Arc { + if let Some(scheduler) = coordination::get_global_scheduler() { + return scheduler; + } + + let session_manager = agentic_system.coordinator.get_session_manager().clone(); + let scheduler = DialogScheduler::new(agentic_system.coordinator.clone(), session_manager); + agentic_system + .coordinator + .set_scheduler_notifier(scheduler.outcome_sender()); + agentic_system + .coordinator + .set_round_injection_source(scheduler.round_injection_monitor()); + coordination::set_global_scheduler(scheduler.clone()); + scheduler +} diff --git a/src/apps/cli/src/runtime/services.rs b/src/apps/cli/src/runtime/services.rs new file mode 100644 index 0000000000..123be451ab --- /dev/null +++ b/src/apps/cli/src/runtime/services.rs @@ -0,0 +1,257 @@ +use std::fmt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use bitfun_core::product_runtime::CoreRuntimeServicesProvider; +use bitfun_runtime_ports::{ + ClockPort, FileSystemPort, PermissionPort, PortResult, RuntimeEventEnvelope, RuntimeEventSink, + RuntimeServiceCapability, RuntimeServicePort, WorkspacePort, +}; +use bitfun_runtime_services::{ + RuntimeServices, RuntimeServicesBuilder, RuntimeServicesError, RuntimeServicesProvider, + RuntimeServicesRegistry, +}; +use tokio::sync::broadcast; + +#[derive(Debug)] +pub(crate) struct CliFileSystemService { + workspace_root: PathBuf, +} + +impl CliFileSystemService { + fn workspace_root(&self) -> &Path { + &self.workspace_root + } +} + +impl RuntimeServicePort for CliFileSystemService { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::FileSystem + } +} + +impl FileSystemPort for CliFileSystemService {} + +#[derive(Debug)] +pub(crate) struct CliWorkspaceService { + workspace_root: PathBuf, +} + +impl CliWorkspaceService { + fn workspace_root(&self) -> &Path { + &self.workspace_root + } +} + +impl RuntimeServicePort for CliWorkspaceService { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Workspace + } +} + +impl WorkspacePort for CliWorkspaceService {} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct CliClock; + +impl RuntimeServicePort for CliClock { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::Clock + } +} + +impl ClockPort for CliClock { + fn now_unix_millis(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct CliRuntimeEventSink { + tx: broadcast::Sender, +} + +impl CliRuntimeEventSink { + pub(crate) fn new(capacity: usize) -> Self { + let (tx, _) = broadcast::channel(capacity.max(1)); + Self { tx } + } + + #[cfg(test)] + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +#[async_trait::async_trait] +impl RuntimeEventSink for CliRuntimeEventSink { + async fn publish_runtime_event(&self, event: RuntimeEventEnvelope) -> PortResult<()> { + let _ = self.tx.send(event); + Ok(()) + } +} + +#[derive(Clone)] +pub(crate) struct CliRuntimeServicesProvider { + workspace_root: PathBuf, + filesystem: Arc, + workspace: Arc, + permission: Arc, + events: Arc, + clock: Arc, +} + +impl fmt::Debug for CliRuntimeServicesProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CliRuntimeServicesProvider") + .field("workspace_root", &self.workspace_root) + .finish_non_exhaustive() + } +} + +impl CliRuntimeServicesProvider { + pub(crate) fn new( + workspace_root: impl AsRef, + permission: Arc, + events: Arc, + clock: Arc, + ) -> anyhow::Result { + let requested_root = workspace_root.as_ref(); + let canonical_root = dunce::canonicalize(requested_root).map_err(|error| { + anyhow::anyhow!( + "workspace root is not available ({}): {error}", + requested_root.display() + ) + })?; + if !canonical_root.is_dir() { + anyhow::bail!( + "workspace root is not a directory: {}", + canonical_root.display() + ); + } + + Ok(Self { + workspace_root: canonical_root.clone(), + filesystem: Arc::new(CliFileSystemService { + workspace_root: canonical_root.clone(), + }), + workspace: Arc::new(CliWorkspaceService { + workspace_root: canonical_root, + }), + permission, + events, + clock, + }) + } + + pub(crate) fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub(crate) fn build(&self) -> Result { + RuntimeServicesRegistry::new() + .with_provider(CoreRuntimeServicesProvider::new()) + .with_provider(self.clone()) + .build(RuntimeServicesBuilder::new()) + } +} + +impl RuntimeServicesProvider for CliRuntimeServicesProvider { + fn register(&self, builder: RuntimeServicesBuilder) -> RuntimeServicesBuilder { + debug_assert_eq!(self.filesystem.workspace_root(), self.workspace_root); + debug_assert_eq!(self.workspace.workspace_root(), self.workspace_root); + let filesystem: Arc = self.filesystem.clone(); + let workspace: Arc = self.workspace.clone(); + builder + .with_filesystem(filesystem) + .with_workspace(workspace) + .with_permission(self.permission.clone()) + .with_events(self.events.clone()) + .with_clock(self.clock.clone()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bitfun_runtime_ports::{ + AgentSubmissionSource, RuntimeEventEnvelope, RuntimeEventType, RuntimeServiceCapability, + }; + + use super::{CliClock, CliRuntimeEventSink, CliRuntimeServicesProvider}; + use crate::runtime::approval::{CliApprovalPolicy, CliPermissionService}; + + #[tokio::test] + async fn provider_registers_required_capability_contracts() { + let workspace = tempfile::tempdir().expect("workspace"); + let events = Arc::new(CliRuntimeEventSink::new(8)); + let provider = CliRuntimeServicesProvider::new( + workspace.path(), + Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), + events.clone(), + Arc::new(CliClock), + ) + .expect("provider"); + + let services = provider.build().expect("runtime services"); + + assert_eq!( + provider.workspace_root(), + dunce::canonicalize(workspace.path()).expect("canonical workspace") + ); + for capability in [ + RuntimeServiceCapability::FileSystem, + RuntimeServiceCapability::Workspace, + RuntimeServiceCapability::SessionStore, + RuntimeServiceCapability::Permission, + RuntimeServiceCapability::Events, + RuntimeServiceCapability::Clock, + RuntimeServiceCapability::Terminal, + RuntimeServiceCapability::Network, + RuntimeServiceCapability::Git, + ] { + assert!( + services.has_capability(capability), + "missing runtime capability registration {capability}" + ); + } + assert!(services.clock.now_unix_millis() > 0); + + let mut receiver = events.subscribe(); + let envelope = RuntimeEventEnvelope { + session_id: "session-1".to_string(), + turn_id: Some("turn-1".to_string()), + source: Some(AgentSubmissionSource::Cli), + event_type: RuntimeEventType::TurnStarted, + payload: serde_json::json!({ "ready": true }), + }; + services + .events + .publish_runtime_event(envelope.clone()) + .await + .expect("publish runtime event"); + assert_eq!(receiver.recv().await.expect("runtime event"), envelope); + } + + #[test] + fn provider_rejects_a_missing_workspace_root() { + let temp = tempfile::tempdir().expect("tempdir"); + let missing = temp.path().join("missing"); + + let error = CliRuntimeServicesProvider::new( + &missing, + Arc::new(CliPermissionService::new(CliApprovalPolicy::Reject)), + Arc::new(CliRuntimeEventSink::new(8)), + Arc::new(CliClock), + ) + .expect_err("missing workspace must fail"); + + assert!(error.to_string().contains("workspace"), "{error}"); + } +} diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 3b7243d042..caf4b4f9a4 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -42,33 +42,112 @@ use ratatui::{ Terminal, }; use std::io; +use std::ops::{Deref, DerefMut}; + +type CliTerminal = Terminal>; + +pub(crate) struct TerminalGuard { + terminal: Option, +} + +impl Deref for TerminalGuard { + type Target = CliTerminal; + + fn deref(&self) -> &Self::Target { + self.terminal + .as_ref() + .expect("terminal guard must own a terminal") + } +} + +impl DerefMut for TerminalGuard { + fn deref_mut(&mut self) -> &mut Self::Target { + self.terminal + .as_mut() + .expect("terminal guard must own a terminal") + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if let Some(mut terminal) = self.terminal.take() { + let _ = restore_terminal_inner(&mut terminal); + } + } +} /// Initialize terminal -pub(crate) fn init_terminal() -> Result>> { +pub(crate) fn init_terminal() -> Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!( + if let Err(error) = execute!( stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste - )?; + ) { + let _ = disable_raw_mode(); + let _ = execute!( + stdout, + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + ); + return Err(error.into()); + } let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - Ok(terminal) + let terminal = match Terminal::new(backend) { + Ok(terminal) => terminal, + Err(error) => { + let mut stdout = io::stdout(); + let _ = disable_raw_mode(); + let _ = execute!( + stdout, + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + ); + return Err(error.into()); + } + }; + Ok(TerminalGuard { + terminal: Some(terminal), + }) } /// Restore terminal -pub(crate) fn restore_terminal(mut terminal: Terminal>) -> Result<()> { - disable_raw_mode()?; - execute!( +pub(crate) fn restore_terminal(mut guard: TerminalGuard) -> Result<()> { + let result = guard + .terminal + .as_mut() + .map(restore_terminal_inner) + .unwrap_or(Ok(())); + guard.terminal.take(); + result +} + +fn restore_terminal_inner(terminal: &mut CliTerminal) -> Result<()> { + let mut errors = Vec::new(); + if let Err(error) = disable_raw_mode() { + errors.push(format!("disable raw mode: {error}")); + } + if let Err(error) = execute!( terminal.backend_mut(), DisableBracketedPaste, DisableMouseCapture, LeaveAlternateScreen - )?; - terminal.show_cursor()?; - Ok(()) + ) { + errors.push(format!("restore terminal screen: {error}")); + } + if let Err(error) = terminal.show_cursor() { + errors.push(format!("show terminal cursor: {error}")); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(anyhow::anyhow!(errors.join("; "))) + } } /// Render a loading/status message on the terminal (stays in alternate screen) diff --git a/src/apps/cli/src/ui/permission.rs b/src/apps/cli/src/ui/permission.rs index b0c1f14f47..ac33f42e46 100644 --- a/src/apps/cli/src/ui/permission.rs +++ b/src/apps/cli/src/ui/permission.rs @@ -3,7 +3,7 @@ /// Inspired by opencode TUI's PermissionPrompt component. /// Three-level permission system: /// - Allow once: execute this tool call only -/// - Allow always: auto-approve this tool type for the session +/// - Allow always: auto-approve this tool type until the CLI runtime exits /// - Reject: deny execution (optionally with a reason) use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ @@ -17,6 +17,12 @@ use ratatui::{ use super::string_utils::truncate_str; use super::theme::{tool_icon, StyleKind, Theme}; +pub(crate) const ALLOW_ALWAYS_RUNTIME_SCOPE: &str = "until this CLI runtime exits"; + +fn allow_always_confirmation_text(tool_name: &str) -> String { + format!("This will auto-approve '{tool_name}' tool calls {ALLOW_ALWAYS_RUNTIME_SCOPE}.") +} + // ============ Data Types ============ /// Permission prompt stage @@ -69,6 +75,10 @@ impl PermissionPrompt { } } + pub(crate) fn tool_name(&self) -> &str { + &self.tool_name + } + /// Handle a key event. Returns a PermissionAction if the user made a decision. pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> PermissionAction { if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { @@ -302,10 +312,7 @@ fn render_confirm_always(frame: &mut Frame, prompt: &PermissionPrompt, theme: &T ]), Line::from(""), Line::from(Span::styled( - format!( - "This will auto-approve '{}' tool calls for this session.", - prompt.tool_name - ), + allow_always_confirmation_text(&prompt.tool_name), theme.style(StyleKind::Muted), )), ]; @@ -555,3 +562,16 @@ fn extract_first_param(params: &serde_json::Value) -> String { } String::new() } + +#[cfg(test)] +mod tests { + use super::allow_always_confirmation_text; + + #[test] + fn allow_always_copy_describes_cli_runtime_lifetime() { + let text = allow_always_confirmation_text("run_terminal_cmd"); + + assert!(text.contains("until this CLI runtime exits")); + assert!(!text.contains("session")); + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index ea0f454f76..5d2e451d5e 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -33,13 +33,12 @@ use ratatui::{ widgets::{Block, Paragraph}, Frame, Terminal, }; -use std::sync::Arc; use std::time::Duration; +use bitfun_agent_runtime::sdk::AgentRuntime; use bitfun_core::agentic::agents::{ get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, }; -use bitfun_core::agentic::coordination::ConversationCoordinator; use bitfun_core::agentic::tools::implementations::skills::{ mode_overrides::{ load_project_mode_skills_document_local, save_project_mode_skills_document_local, @@ -48,6 +47,7 @@ use bitfun_core::agentic::tools::implementations::skills::{ registry::SkillRegistry, ModeSkillInfo, SkillInfo, }; +use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; use bitfun_core::service::config::GlobalConfigManager; /// Types of popups that can be shown on the startup page @@ -195,7 +195,8 @@ pub(crate) struct StartupPage { theme_preview_original: Option, // ── System context ── - coordinator: Arc, + agent_runtime: AgentRuntime, + compatibility: CoreAgentRuntimeCompatibility, // ── State ── /// Selected agent type (can be changed via /agents or Tab) @@ -215,7 +216,8 @@ pub(crate) struct StartupPage { impl StartupPage { pub(crate) fn new( - coordinator: Arc, + agent_runtime: AgentRuntime, + compatibility: CoreAgentRuntimeCompatibility, default_agent: String, workspace: Option, ) -> Self { @@ -268,7 +270,8 @@ impl StartupPage { model_config_form: ModelConfigFormState::new(), login_form: LoginFormState::new(), theme_preview_original: None, - coordinator, + agent_runtime, + compatibility, agent_type: default_agent, model_display_name: String::new(), workspace_display: workspace.unwrap_or_else(|| { @@ -1300,7 +1303,11 @@ impl StartupPage { fn start_sync_and_show_account(&mut self, is_first_login: bool) { let workspace = self.workspace_path_for_sync(); - crate::account_sync::start_auto_sync_background(is_first_login, workspace); + crate::account_sync::start_auto_sync_background( + self.compatibility.clone(), + is_first_login, + workspace, + ); self.open_account_panel(); self.status = Some(if is_first_login { "Sync started (use local / upload settings).".to_string() @@ -1372,12 +1379,16 @@ impl StartupPage { fn show_session_selector(&mut self) { self.push_current_popup_to_stack(); - let coordinator = self.coordinator.clone(); + let agent_runtime = self.agent_runtime.clone(); let sessions = tokio::task::block_in_place(|| { let workspace_path = self.workspace_path_buf(); tokio::runtime::Handle::current().block_on(async { - coordinator - .list_sessions(&workspace_path) + agent_runtime + .list_sessions(bitfun_runtime_ports::AgentSessionListRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) .await .unwrap_or_default() }) @@ -1392,7 +1403,9 @@ impl StartupPage { .into_iter() .map(|s| { let last_activity = { - let elapsed = s.last_activity_at.elapsed().unwrap_or_default(); + let last_activity = + std::time::UNIX_EPOCH + Duration::from_millis(s.last_active_at_ms); + let elapsed = last_activity.elapsed().unwrap_or_default(); if elapsed.as_secs() < 60 { "just now".to_string() } else if elapsed.as_secs() < 3600 { @@ -1416,13 +1429,21 @@ impl StartupPage { } fn handle_session_delete(&mut self, item: &SessionItem) { - let coordinator = self.coordinator.clone(); + let agent_runtime = self.agent_runtime.clone(); let sid = item.session_id.clone(); let result = tokio::task::block_in_place(|| { let workspace_path = self.workspace_path_buf(); - tokio::runtime::Handle::current() - .block_on(async { coordinator.delete_session(&workspace_path, &sid).await }) + tokio::runtime::Handle::current().block_on(async { + agent_runtime + .delete_session(bitfun_runtime_ports::AgentSessionDeleteRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + session_id: sid, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + }) }); match result { diff --git a/src/apps/cli/tests/exec_cli_contracts.rs b/src/apps/cli/tests/exec_cli_contracts.rs new file mode 100644 index 0000000000..6b2b3dbe62 --- /dev/null +++ b/src/apps/cli/tests/exec_cli_contracts.rs @@ -0,0 +1,156 @@ +use std::process::{Command, Output}; + +fn run_cli(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + .args(args) + .output() + .expect("run bitfun-cli") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +#[test] +fn exec_help_uses_competitor_aligned_output_and_approval_flags() { + let output = run_cli(&["exec", "--help"]); + let stdout = stdout(&output); + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout.contains("--auto"), "{stdout}"); + assert!(stdout.contains("--output-format"), "{stdout}"); + for format in ["text", "json", "stream-json"] { + assert!(stdout.contains(format), "missing {format}: {stdout}"); + } + assert!(!stdout.contains("--output-schema"), "{stdout}"); + assert!( + !stdout.contains("--confirm"), + "deprecated compatibility flag must stay out of public help: {stdout}" + ); +} + +#[test] +fn exec_accepts_hidden_confirm_compatibility_flag() { + let output = run_cli(&["exec", "--confirm", "--help"]); + + assert!(output.status.success(), "{}", stderr(&output)); +} + +#[test] +fn exec_rejects_auto_with_legacy_confirm() { + let output = run_cli(&["exec", "task", "--auto", "--confirm"]); + let stderr = stderr(&output); + + assert!(!output.status.success(), "{}", stdout(&output)); + assert!(stderr.contains("cannot be used with"), "{stderr}"); + assert!(stderr.contains("--auto"), "{stderr}"); + assert!(stderr.contains("--confirm"), "{stderr}"); +} + +#[test] +fn exec_json_clap_failure_is_one_result_document() { + let output = run_cli(&[ + "exec", + "task", + "--output-format", + "json", + "--auto", + "--confirm", + ]); + let stdout = stdout(&output); + + assert!(!output.status.success(), "{stdout}"); + assert_eq!(output.status.code(), Some(2), "{}", stderr(&output)); + assert!(stderr(&output).is_empty(), "{}", stderr(&output)); + let value: serde_json::Value = + serde_json::from_str(&stdout).expect("one JSON parser error result"); + assert_eq!(value["type"], "result"); + assert_eq!(value["subtype"], "error"); + assert_eq!(value["is_error"], true); + assert!(value["result"] + .as_str() + .is_some_and(|message| message.contains("--auto") && message.contains("--confirm"))); +} + +#[test] +fn exec_json_help_preserves_clap_success_semantics() { + let output = run_cli(&["exec", "--output-format", "json", "--help"]); + let stdout = stdout(&output); + + assert!(output.status.success(), "{}", stderr(&output)); + assert!(stdout.contains("Usage:"), "{stdout}"); + assert!(stdout.contains("--output-format"), "{stdout}"); + assert!(!stdout.contains("\"subtype\": \"error\""), "{stdout}"); + assert!(stderr(&output).is_empty(), "{}", stderr(&output)); +} + +#[test] +fn exec_json_preflight_failure_is_one_result_document() { + let output = run_cli(&[ + "exec", + "task", + "--output-format", + "json", + "--continue", + "--session-id", + "fixed-id", + ]); + let stdout = stdout(&output); + + assert!(!output.status.success(), "{stdout}"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("one JSON result object"); + assert_eq!(value["type"], "result"); + assert_eq!(value["subtype"], "error"); + assert_eq!(value["is_error"], true); + assert!(value.get("session_id").is_none()); + assert!(value.get("turn_id").is_none()); + assert!(value["result"] + .as_str() + .is_some_and(|message| message.contains("--session-id"))); +} + +#[test] +fn exec_json_rejects_continue_with_an_explicit_resume() { + let output = run_cli(&[ + "exec", + "task", + "--output-format", + "json", + "--continue", + "--resume", + "session-1", + ]); + let stdout = stdout(&output); + + assert!(!output.status.success(), "{stdout}"); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("one JSON error result"); + assert!(value["result"] + .as_str() + .is_some_and(|message| message.contains("--continue") && message.contains("--resume"))); +} + +#[test] +fn stream_json_rejects_stdout_patch_before_starting_runtime() { + let output = run_cli(&[ + "exec", + "task", + "--output-format", + "stream-json", + "--output-patch", + ]); + + assert!(!output.status.success(), "{}", stdout(&output)); + assert!( + stdout(&output).is_empty(), + "protocol stdout must stay empty" + ); + assert!( + stderr(&output).contains("requires an explicit file path"), + "{}", + stderr(&output) + ); +} diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index 48c6e397aa..b153886a16 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -1,7 +1,7 @@ use std::process::Command; #[test] -fn doctor_reports_the_cli_product_plan_without_claiming_runtime_availability() { +fn doctor_reports_the_validated_cli_runtime_assembly() { let temp = tempfile::tempdir().expect("tempdir"); let workspace = temp.path().join("workspace"); let user_root = temp.path().join("user-root"); @@ -27,26 +27,71 @@ fn doctor_reports_the_cli_product_plan_without_claiming_runtime_availability() { let stderr = String::from_utf8_lossy(&output.stderr); assert!(output.status.success(), "{stderr}"); assert!( - stdout.contains( - "[info] Product profile: cli (static plan only; runtime readiness not evaluated)" - ), + stdout.contains("[ok] Product runtime: cli assembly-ready"), + "{stdout}" + ); + assert!( + stdout.contains("[ok] Runtime capability registrations: complete"), + "{stdout}" + ); + assert!( + stdout.contains("[info] Execution owner: bitfun-core compatibility"), + "{stdout}" + ); + assert!( + stdout.contains("[info] Plugin runtime: disabled (not_built)"), "{stdout}" ); - for internal_state in [ - "Product assembly requirements:", - "runtime services not connected", - "Plugin runtime plan:", - "not_built", - "projection_only", - ] { - assert!(!stdout.contains(internal_state), "{stdout}"); - } assert!( stdout.contains(&format!("[ok] Config directory: {}", user_root.display())), "{stdout}" ); } +#[test] +fn health_reports_assembly_and_compatibility_boundaries() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let user_root = temp.path().join("user-root"); + let home_root = temp.path().join("home-root"); + let config_root = temp.path().join("host-config"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + + let output = Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) + .arg("health") + .current_dir(&workspace) + .env_remove("BITFUN_USER_ROOT") + .env_remove("BITFUN_HOME") + .env("BITFUN_E2E_STORAGE_GUARD", "1") + .env("BITFUN_E2E_USER_ROOT", &user_root) + .env("BITFUN_E2E_HOME", &home_root) + .env("APPDATA", &config_root) + .env("XDG_CONFIG_HOME", &config_root) + .env("HOME", &home_root) + .output() + .expect("run bitfun-cli health"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stderr}"); + assert!( + stdout.contains("Product runtime: cli assembly-ready"), + "{stdout}" + ); + assert!( + stdout.contains("Runtime capability registrations: complete"), + "{stdout}" + ); + assert!( + stdout.contains("Execution owner: bitfun-core compatibility"), + "{stdout}" + ); + assert!( + stdout.contains("Plugin runtime: disabled (not_built)"), + "{stdout}" + ); +} + #[test] fn doctor_rejects_incomplete_e2e_storage_roots() { for (case_name, provide_user_root, provide_home_root) in @@ -93,3 +138,34 @@ fn doctor_rejects_incomplete_e2e_storage_roots() { ); } } + +#[test] +fn cli_local_persistence_stays_behind_core_compatibility_facade() { + const ACCOUNT_SYNC: &str = include_str!("../src/account_sync.rs"); + const STARTUP_PAGE: &str = include_str!("../src/ui/startup.rs"); + const CORE_RUNTIME_SERVICES: &str = + include_str!("../../../crates/assembly/core/src/product_runtime/runtime_services.rs"); + + for (path, source) in [ + ("account_sync.rs", ACCOUNT_SYNC), + ("ui/startup.rs", STARTUP_PAGE), + ] { + assert!( + !source.contains("PersistenceManager"), + "{path} must not import or name Core's concrete persistence manager" + ); + } + + assert!( + ACCOUNT_SYNC.contains("CoreAgentRuntimeCompatibility"), + "account sync must receive the narrow Core compatibility facade" + ); + assert!( + STARTUP_PAGE.contains("CoreAgentRuntimeCompatibility"), + "startup must pass the initialized Core compatibility facade to account sync" + ); + assert!( + !CORE_RUNTIME_SERVICES.contains("pub fn persistence_manager"), + "runtime services provider must not expose a concrete persistence factory" + ); +} diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index cec07106e3..8375c57184 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -63,6 +63,7 @@ use bitfun_agent_runtime::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, }; +use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentSessionWorkspaceBinding, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, DelegationPolicy, RemoteExecPort, SessionStoragePathRequest, @@ -109,6 +110,20 @@ fn turn_review_manifest_for_agent( .cloned() } +fn metadata_bool(metadata: Option<&serde_json::Value>, key: &str) -> Option { + metadata + .and_then(|metadata| metadata.get(key)) + .and_then(serde_json::Value::as_bool) +} + +fn should_require_tool_confirmation( + policy: DialogSubmissionPolicy, + user_message_metadata: Option<&serde_json::Value>, +) -> bool { + policy.requires_tool_confirmation() + && metadata_bool(user_message_metadata, "acp_transport") != Some(true) +} + /// Subagent execution result /// /// Contains the text response after subagent execution @@ -3040,27 +3055,40 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet additional_prepended_messages: Vec, suppress_session_title_generation: bool, ) -> BitFunResult<()> { + let requested_restore_path = match workspace_path.as_deref() { + Some(workspace_path) => Some( + Self::resolve_session_restore_path( + workspace_path, + remote_connection_id.as_deref(), + remote_ssh_host.as_deref(), + ) + .await?, + ), + None => None, + }; + // Get latest session, restoring from persistence on demand so every entry - // point can use the same start_dialog_turn flow. + // point can use the same start_dialog_turn flow. A loaded session must keep + // the same storage identity as this invocation. let session = match self.session_manager.get_session(&session_id) { - Some(session) => session, + Some(session) => { + if let Some(restore_path) = requested_restore_path.as_deref() { + self.session_manager + .ensure_session_storage_path(&session_id, restore_path)?; + } + session + } None => { debug!( "Session not found in memory, attempting restore before starting dialog: session_id={}", session_id ); - let workspace_path = workspace_path.clone().ok_or_else(|| { + let restore_path = requested_restore_path.ok_or_else(|| { BitFunError::Validation(format!( "workspace_path is required when restoring session: {}", session_id )) })?; - let restore_path = Self::resolve_session_restore_path( - &workspace_path, - remote_connection_id.as_deref(), - remote_ssh_host.as_deref(), - ) - .await?; self.session_manager .restore_session_from_storage_path(&restore_path, &session_id) .await? @@ -3535,14 +3563,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet run_manifest.to_string(), ); } - if user_message_metadata - .as_ref() - .and_then(|metadata| metadata.get("acp_transport")) - .and_then(|value| value.as_bool()) - .unwrap_or(false) - { + if metadata_bool(user_message_metadata.as_ref(), "acp_transport") == Some(true) { context_vars.insert("acp_transport".to_string(), "true".to_string()); } + if let Some(user_input_available) = metadata_bool( + user_message_metadata.as_ref(), + USER_INPUT_AVAILABLE_CONTEXT_KEY, + ) { + context_vars.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + user_input_available.to_string(), + ); + } if needs_computer_links_for_source(submission_policy.trigger_source) { context_vars.insert( TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), @@ -3555,6 +3587,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "true".to_string(), ); } + if should_require_tool_confirmation(submission_policy, user_message_metadata.as_ref()) { + context_vars.insert("require_tool_confirmation".to_string(), "true".to_string()); + } let session_workspace_path = session_workspace .as_ref() .map(|workspace| workspace.root_path_string()); @@ -3815,6 +3850,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + /// Strict maintenance barrier for callers that must not overlap an older + /// turn's tail writes. Unlike normal interactive cancellation, timeout is + /// returned as an error instead of being treated as best effort. + pub(crate) async fn ensure_session_execution_drained( + &self, + session_id: &str, + max_wait: Duration, + ) -> BitFunResult<()> { + let pending = self.wait_session_drained(session_id, max_wait).await; + if pending == 0 { + return Ok(()); + } + Err(BitFunError::Timeout(format!( + "Session execution did not drain before maintenance: session_id={session_id}, pending={pending}, timeout_ms={}", + max_wait.as_millis() + ))) + } + async fn cancel_active_subagents_for_parent_turn( &self, parent_session_id: &str, @@ -4065,8 +4118,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet parent_dialog_turn_ids: &HashSet, ) -> BitFunResult> { let session_ids = self - .session_manager - .collect_hidden_subagent_cascade_for_parent_turns( + .collect_hidden_subagent_sessions_for_parent_turns( workspace_path, parent_session_id, parent_dialog_turn_ids, @@ -4076,23 +4128,48 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut deleted_session_ids = Vec::new(); for session_id in session_ids { - if let Err(e) = self - .cancel_active_turn_for_session(&session_id, Duration::from_secs(2)) - .await - { - warn!( - "Failed to cancel hidden subagent session before deletion: session_id={}, parent_session_id={}, error={}", - session_id, parent_session_id, e - ); - } - - self.delete_session(workspace_path, &session_id).await?; + self.delete_hidden_subagent_session(workspace_path, parent_session_id, &session_id) + .await?; deleted_session_ids.push(session_id); } Ok(deleted_session_ids) } + pub(crate) async fn collect_hidden_subagent_sessions_for_parent_turns( + &self, + workspace_path: &Path, + parent_session_id: &str, + parent_dialog_turn_ids: &HashSet, + ) -> BitFunResult> { + self.session_manager + .collect_hidden_subagent_cascade_for_parent_turns( + workspace_path, + parent_session_id, + parent_dialog_turn_ids, + ) + .await + } + + pub(crate) async fn delete_hidden_subagent_session( + &self, + workspace_path: &Path, + parent_session_id: &str, + session_id: &str, + ) -> BitFunResult<()> { + if let Err(e) = self + .cancel_active_turn_for_session(session_id, Duration::from_secs(2)) + .await + { + warn!( + "Failed to cancel hidden subagent session before deletion: session_id={}, parent_session_id={}, error={}", + session_id, parent_session_id, e + ); + } + + self.delete_session(workspace_path, session_id).await + } + /// Restore session pub async fn restore_session( &self, @@ -6971,7 +7048,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } /// Emit event - async fn emit_event(&self, event: AgenticEvent) { + pub(crate) async fn emit_event(&self, event: AgenticEvent) { let _ = self .event_queue .enqueue(event, Some(EventPriority::Normal)) @@ -7207,6 +7284,7 @@ fn runtime_session_summary(session: SessionSummary) -> bitfun_runtime_ports::Age session_id: session.session_id, session_name: session.session_name, agent_type: session.agent_type, + turn_count: session.turn_count, created_at_ms: runtime_session_time_ms(session.created_at), last_active_at_ms: runtime_session_time_ms(session.last_activity_at), } @@ -7285,6 +7363,12 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato &self, request: bitfun_runtime_ports::AgentSessionDeleteRequest, ) -> bitfun_runtime_ports::PortResult<()> { + bitfun_core_types::validate_session_id(&request.session_id).map_err(|message| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + message, + ) + })?; let effective_storage_path = Self::resolve_session_restore_path( &request.workspace_path, request.remote_connection_id.as_deref(), @@ -7579,7 +7663,8 @@ mod tests { use super::{ merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, - turn_review_manifest_for_agent, ConversationCoordinator, SubagentExecutionRequest, + should_require_tool_confirmation, turn_review_manifest_for_agent, ConversationCoordinator, + SubagentExecutionRequest, }; use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageRole, MessageSemanticKind, @@ -7604,7 +7689,8 @@ mod tests { use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use bitfun_runtime_ports::{ AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionRequest, - AgentSubmissionSource, DelegationPolicy, SubagentContextMode, + AgentSubmissionSource, DelegationPolicy, DialogQueuePriority, DialogSubmissionPolicy, + SubagentContextMode, }; use std::collections::HashMap; use std::sync::Arc; @@ -7669,6 +7755,21 @@ mod tests { assert_state_port::(); } + #[test] + fn local_cli_confirmation_override_excludes_acp_transport() { + let policy = DialogSubmissionPolicy::new( + AgentSubmissionSource::Cli, + DialogQueuePriority::Normal, + false, + ); + + assert!(should_require_tool_confirmation(policy, None)); + assert!(!should_require_tool_confirmation( + policy, + Some(&serde_json::json!({ "acp_transport": true })), + )); + } + #[tokio::test] async fn coordinator_test_fixture_injects_terminal_port() { let (coordinator, _) = test_coordinator(); diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 255c3f8893..b57d825692 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -15,12 +15,14 @@ use super::coordinator::{ }; use super::turn_outcome::TurnOutcome; use crate::agentic::core::{InternalReminderKind, Message, SessionState}; +use crate::agentic::events::AgenticEvent; use crate::agentic::goal_mode::{ goal_continuation_submit_retry_delay_ms, goal_internal_context_message, goal_objective_updated_message, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::init_agents_md::build_init_agents_md_user_input; +use crate::agentic::keyed_lock::{KeyedAsyncLock, KeyedAsyncLockGuard}; use crate::agentic::round_preempt::{DialogRoundInjectionSource, SessionRoundInjectionBuffer}; use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::agentic::session::SessionManager; @@ -42,10 +44,10 @@ use bitfun_agent_runtime::scheduler::{ resolve_agent_session_reply_action, resolve_background_delivery_action, resolve_background_delivery_injection, resolve_dialog_start_route, resolve_dialog_steering_action, resolve_turn_outcome_lifecycle_plan, ActiveDialogTurn, - ActiveDialogTurnStore, AgentSessionReplyAction, AgentSessionReplyPlan, - BackgroundDeliveryAction, BackgroundDeliveryFacts, BackgroundInjectionKind, - DialogReplySuppressionSet, DialogStartRoute, DialogStartRouteFacts, DialogSteeringAction, - DialogTurnQueue, GoalContinuationAfterTurnAction, SessionAbortFlags, + ActiveDialogTurnStore, ActiveDialogTurnTakeResult, AgentSessionReplyAction, + AgentSessionReplyPlan, BackgroundDeliveryAction, BackgroundDeliveryFacts, + BackgroundInjectionKind, DialogReplySuppressionSet, DialogStartRoute, DialogStartRouteFacts, + DialogSteeringAction, DialogTurnQueue, GoalContinuationAfterTurnAction, SessionAbortFlags, ThreadGoalDeliveryReminder, ThreadGoalDeliveryReminderKind, TurnOutcomeQueueAction, TurnOutcomeStatus, }; @@ -89,6 +91,14 @@ pub(crate) enum QueuedTurnExecution { HiddenSubagent(HiddenSubagentQueuedExecution), } +fn remove_queued_turn_by_id( + queues: &DialogTurnQueue, + session_id: &str, + turn_id: &str, +) -> Option { + queues.remove_first_matching(session_id, |turn| turn.turn_id.as_deref() == Some(turn_id)) +} + #[derive(Debug, Clone)] pub(crate) struct HiddenSubagentQueuedExecution { request: HiddenSubagentExecutionRequest, @@ -176,12 +186,21 @@ pub struct DialogScheduler { session_manager: Arc, /// Per-session priority message queues. queues: Arc>, + /// Serializes submit, dispatch, and targeted cancellation for one session. + /// This closes the dequeue-to-start gap where cancellation could otherwise + /// miss both the queue and the coordinator's active execution. + session_operation_locks: KeyedAsyncLock, /// Currently active turn metadata keyed by target session ID active_turns: Arc, active_internal_turns: Arc>, /// Turns whose cancelled auto-reply should be suppressed because the source /// agent explicitly cancelled its own outstanding SessionMessage request. suppressed_cancelled_replies: Arc, + /// Exact outcomes retired by destructive session deletion. The outcome + /// channel may receive them only after the deletion permit releases its + /// per-session operation lock; tombstoning prevents them from mutating a + /// newly created session that reuses the same explicit ID. + retired_deletion_outcomes: Arc, /// Set when the user cancels an in-flight turn; aborts goal-continuation submit retries. goal_continuation_abort: Arc, /// Cloneable sender given to ConversationCoordinator for turn outcome notifications @@ -191,6 +210,42 @@ pub struct DialogScheduler { round_injection_buffer: Arc, } +/// Holds the scheduler's exclusive session-operation boundary while a caller +/// performs maintenance that must not overlap turn dispatch. +pub(crate) struct SessionMaintenancePermit { + _operation_guard: KeyedAsyncLockGuard, +} + +fn take_active_turn_for_outcome( + active_turns: &ActiveDialogTurnStore, + retired_deletion_outcomes: &DialogReplySuppressionSet, + session_id: &str, + turn_id: &str, +) -> Option { + if retired_deletion_outcomes.take(session_id, turn_id) { + None + } else { + Some(active_turns.take_for_outcome(session_id, turn_id)) + } +} + +fn queued_submission_outcome( + session_id: String, + resolved_turn_id: String, + started_turn_id: Option, +) -> DialogSubmitOutcome { + match started_turn_id { + Some(turn_id) if turn_id == resolved_turn_id => DialogSubmitOutcome::Started { + session_id, + turn_id, + }, + _ => DialogSubmitOutcome::Queued { + session_id, + turn_id: resolved_turn_id, + }, + } +} + impl DialogScheduler { /// Create a new DialogScheduler and start its background outcome handler. /// @@ -207,9 +262,11 @@ impl DialogScheduler { coordinator, session_manager, queues: Arc::new(DialogTurnQueue::default()), + session_operation_locks: KeyedAsyncLock::default(), active_turns: Arc::new(ActiveDialogTurnStore::default()), active_internal_turns: Arc::new(dashmap::DashMap::new()), suppressed_cancelled_replies: Arc::new(DialogReplySuppressionSet::default()), + retired_deletion_outcomes: Arc::new(DialogReplySuppressionSet::default()), goal_continuation_abort: Arc::new(SessionAbortFlags::default()), outcome_tx, round_injection_buffer: Arc::new(SessionRoundInjectionBuffer::default()), @@ -228,6 +285,10 @@ impl DialogScheduler { self.outcome_tx.clone() } + async fn lock_session_operation(&self, session_id: &str) -> KeyedAsyncLockGuard { + self.session_operation_locks.lock(session_id).await + } + /// Pass to [`ConversationCoordinator::set_round_injection_source`](super::coordinator::ConversationCoordinator::set_round_injection_source). pub fn round_injection_monitor(&self) -> Arc { self.round_injection_buffer.clone() @@ -675,36 +736,17 @@ impl DialogScheduler { handle: &HiddenSubagentQueueCancelHandle, ) { handle.cancellation.cancel(); - let removed_turn = self - .queues - .remove_first_matching(&handle.session_id, |turn| { - turn.turn_id.as_deref() == Some(handle.turn_id.as_str()) - }); - if let Some(removed_turn) = removed_turn { - if let QueuedTurnExecution::HiddenSubagent(execution) = removed_turn.execution { - self.coordinator - .cleanup_prepared_hidden_subagent_session_if_unsubmitted(&execution.request) - .await; - } - handle.result_tx.send(Err(BitFunError::Cancelled( - "Subagent task has been cancelled".to_string(), - ))); - debug!( - "Removed queued hidden subagent turn after cancellation: session_id={}, turn_id={}", - handle.session_id, handle.turn_id - ); - return; - } - if let Err(error) = self - .coordinator - .cancel_dialog_turn(&handle.session_id, &handle.turn_id) + .cancel_queued_or_active_turn(&handle.session_id, &handle.turn_id) .await { debug!( "Hidden subagent turn cancellation request did not hit an active turn: session_id={}, turn_id={}, error={}", handle.session_id, handle.turn_id, error ); + handle.result_tx.send(Err(BitFunError::Cancelled( + "Subagent task has been cancelled".to_string(), + ))); } } @@ -768,6 +810,18 @@ impl DialogScheduler { resolved_turn_id: String, queued_turn: QueuedTurn, ) -> Result { + let _operation_guard = self.lock_session_operation(&session_id).await; + if let Some(workspace_path) = queued_turn.workspace_path.as_deref() { + let requested_storage_path = Self::resolve_session_restore_path( + workspace_path, + queued_turn.remote_connection_id.as_deref(), + queued_turn.remote_ssh_host.as_deref(), + ) + .await?; + self.session_manager + .validate_session_storage_path_binding(&session_id, &requested_storage_path) + .map_err(|error| error.to_string())?; + } let state = self .session_manager .get_session(&session_id) @@ -797,7 +851,7 @@ impl DialogScheduler { } DialogSubmitQueueAction::ClearQueueAndStartImmediately => { - self.clear_queue(&session_id); + self.clear_queue(&session_id).await; let tid = self.start_turn(&session_id, &queued_turn).await?; self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; @@ -811,17 +865,9 @@ impl DialogScheduler { self.enqueue(&session_id, queued_turn.clone())?; self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; - let started_tid = self.try_start_next_queued(&session_id).await?; - let outcome = match started_tid { - Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { - session_id: session_id.clone(), - turn_id: tid, - }, - _ => DialogSubmitOutcome::Queued { - session_id: session_id.clone(), - turn_id: resolved_turn_id, - }, - }; + let started_tid = self.try_start_next_queued_locked(&session_id).await?; + let outcome = + queued_submission_outcome(session_id.clone(), resolved_turn_id, started_tid); Ok(outcome) } @@ -856,6 +902,68 @@ impl DialogScheduler { self.queues.depth(session_id) } + async fn finish_removed_queued_turn(&self, session_id: &str, removed_turn: QueuedTurn) { + match removed_turn.execution { + QueuedTurnExecution::Standard => { + if let Some(turn_id) = removed_turn.turn_id { + self.coordinator + .emit_event(AgenticEvent::DialogTurnCancelled { + session_id: session_id.to_string(), + turn_id, + }) + .await; + } else { + warn!("Removed queued dialog turn without a turn id: session_id={session_id}"); + } + } + QueuedTurnExecution::HiddenSubagent(execution) => { + execution.cancellation.cancel(); + self.coordinator + .cleanup_prepared_hidden_subagent_session_if_unsubmitted(&execution.request) + .await; + execution.result_tx.send(Err(BitFunError::Cancelled( + "Subagent task has been cancelled".to_string(), + ))); + } + } + } + + /// Cancel one queued or active turn without allowing it to cross the + /// scheduler's dequeue-to-coordinator transition. + /// + /// Returns `true` when the turn was removed before it started. `false` + /// means cancellation was delivered to the active coordinator execution. + pub async fn cancel_queued_or_active_turn( + &self, + session_id: &str, + turn_id: &str, + ) -> Result { + let _operation_guard = self.lock_session_operation(session_id).await; + let removed_turn = remove_queued_turn_by_id(&self.queues, session_id, turn_id); + if let Some(removed_turn) = removed_turn { + self.finish_removed_queued_turn(session_id, removed_turn) + .await; + debug!( + "Removed queued turn after targeted cancellation: session_id={}, turn_id={}", + session_id, turn_id + ); + return Ok(true); + } + + if !self.active_turns.matches_turn(session_id, turn_id) { + debug!( + "Ignoring cancellation for a turn that is not active in the requested session: session_id={}, turn_id={}", + session_id, turn_id + ); + return Ok(false); + } + + self.coordinator + .cancel_dialog_turn(session_id, turn_id) + .await?; + Ok(false) + } + /// Cancel the target session's active turn on behalf of a requester session. /// /// If the requester is the same source session that originally sent the @@ -867,6 +975,7 @@ impl DialogScheduler { requester_session_id: &str, wait_timeout: Duration, ) -> crate::util::errors::BitFunResult> { + let _operation_guard = self.lock_session_operation(target_session_id).await; let suppression_key = self .active_turns .suppression_key_for_requester(target_session_id, requester_session_id); @@ -905,6 +1014,66 @@ impl DialogScheduler { } } + /// Cancel the current active turn without allowing submit or outcome + /// dispatch to cross the cancellation boundary for this session. + pub async fn cancel_active_turn_for_session( + &self, + session_id: &str, + wait_timeout: Duration, + ) -> BitFunResult> { + let _operation_guard = self.lock_session_operation(session_id).await; + abort_thread_goal_continuation_for_session(session_id); + self.coordinator + .cancel_active_turn_for_session(session_id, wait_timeout) + .await + } + + /// Quiesce one session for deletion. Queued turns receive an explicit + /// cancelled lifecycle event before active execution is cancelled and + /// drained, so no accepted turn disappears silently. + pub(crate) async fn begin_session_deletion( + &self, + session_id: &str, + requested_storage_path: &std::path::Path, + wait_timeout: Duration, + ) -> BitFunResult { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + let operation_guard = self.lock_session_operation(session_id).await; + self.session_manager + .validate_session_storage_path_binding(session_id, requested_storage_path)?; + if self.queue_depth(session_id) > 0 { + self.clear_queue(session_id).await; + } + abort_thread_goal_continuation_for_session(session_id); + self.coordinator + .cancel_active_turn_for_session(session_id, wait_timeout) + .await?; + self.coordinator + .ensure_session_execution_drained(session_id, wait_timeout) + .await?; + self.retire_active_turn_for_deletion(session_id); + Ok(SessionMaintenancePermit { + _operation_guard: operation_guard, + }) + } + + fn retire_active_turn_for_deletion(&self, session_id: &str) { + let Some(active_turn) = self.active_turns.remove(session_id) else { + return; + }; + let turn_id = active_turn.turn_id().to_string(); + self.retired_deletion_outcomes.mark(session_id, &turn_id); + self.active_internal_turns.remove(session_id); + let _drained = self + .round_injection_buffer + .drain_for_turn(session_id, &turn_id); + self.take_suppressed_cancelled_reply(session_id, &turn_id); + debug!( + "Retired active turn before session deletion: session_id={}, turn_id={}", + session_id, turn_id + ); + } + // ── Private helpers ────────────────────────────────────────────────────── fn enqueue(&self, session_id: &str, queued_turn: QueuedTurn) -> Result<(), String> { @@ -928,21 +1097,39 @@ impl DialogScheduler { Ok(()) } - fn clear_queue(&self, session_id: &str) { + async fn clear_queue(&self, session_id: &str) { let cleared_turns = self.queues.clear(session_id); let count = cleared_turns.len(); for queued_turn in cleared_turns { - if let QueuedTurnExecution::HiddenSubagent(execution) = queued_turn.execution { - let coordinator = self.coordinator.clone(); - tokio::spawn(async move { - coordinator - .cleanup_prepared_hidden_subagent_session_if_unsubmitted(&execution.request) - .await; - execution.result_tx.send(Err(BitFunError::Cancelled( - "Subagent task was cancelled because a previous queued turn failed" - .to_string(), - ))); - }); + match queued_turn.execution { + QueuedTurnExecution::Standard => { + if let Some(turn_id) = queued_turn.turn_id { + self.coordinator + .emit_event(AgenticEvent::DialogTurnCancelled { + session_id: session_id.to_string(), + turn_id, + }) + .await; + } else { + warn!( + "Cleared queued dialog turn without a turn id: session_id={session_id}" + ); + } + } + QueuedTurnExecution::HiddenSubagent(execution) => { + let coordinator = self.coordinator.clone(); + tokio::spawn(async move { + coordinator + .cleanup_prepared_hidden_subagent_session_if_unsubmitted( + &execution.request, + ) + .await; + execution.result_tx.send(Err(BitFunError::Cancelled( + "Subagent task was cancelled because a previous queued turn failed" + .to_string(), + ))); + }); + } } } if count > 0 { @@ -963,6 +1150,14 @@ impl DialogScheduler { } async fn try_start_next_queued(&self, session_id: &str) -> Result, String> { + let _operation_guard = self.lock_session_operation(session_id).await; + self.try_start_next_queued_locked(session_id).await + } + + async fn try_start_next_queued_locked( + &self, + session_id: &str, + ) -> Result, String> { let state = self .session_manager .get_session(session_id) @@ -1087,21 +1282,13 @@ impl DialogScheduler { res.map_err(|e| e.to_string())?; - let resolved = self - .session_manager - .get_session(session_id) - .and_then(|s| match &s.state { - SessionState::Processing { - current_turn_id, .. - } => Some(current_turn_id.clone()), - _ => None, - }) - .ok_or_else(|| { - format!( - "Failed to resolve turn_id after starting dialog: session_id={}", - session_id - ) - })?; + // Standard scheduler submissions resolve and persist their turn ID + // before entering the coordinator. Reading SessionState here races a + // very fast terminal transition and can incorrectly turn an accepted, + // completed turn into a submit error. + let resolved = queued_turn.turn_id.clone().ok_or_else(|| { + format!("Scheduled dialog turn is missing turn_id: session_id={session_id}") + })?; self.active_turns.insert( session_id, @@ -1151,14 +1338,19 @@ impl DialogScheduler { self.coordinator .cleanup_prepared_hidden_subagent_session_if_unsubmitted(&execution.request) .await; - let _ = outcome_tx - .send(( - session_id_owned, - TurnOutcome::Cancelled { - turn_id: turn_id.clone(), - }, - )) - .await; + // This path can run while the caller holds the session operation + // permit. Never await the bounded outcome channel here: its + // receiver may be waiting for the same permit. + tokio::spawn(async move { + let _ = outcome_tx + .send(( + session_id_owned, + TurnOutcome::Cancelled { + turn_id: turn_id_for_task, + }, + )) + .await; + }); result_tx.send(Err(BitFunError::Cancelled( "Subagent task has been cancelled".to_string(), ))); @@ -1272,6 +1464,7 @@ impl DialogScheduler { InternalReminderKind::SessionMessageReply, plan.reminder_text, )]; + let user_message_metadata = plan.user_message_metadata; if let Err(error) = self .submit_with_prepended_messages( @@ -1285,7 +1478,7 @@ impl DialogScheduler { target_remote_ssh_host, DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), None, - None, + user_message_metadata, prepended_messages, None, ) @@ -1310,11 +1503,59 @@ impl DialogScheduler { /// Background loop that receives turn outcome notifications from the coordinator. async fn run_outcome_handler(&self, mut outcome_rx: mpsc::Receiver<(String, TurnOutcome)>) { while let Some((session_id, outcome)) = outcome_rx.recv().await { - let lifecycle_plan = resolve_turn_outcome_lifecycle_plan( - &outcome, - self.active_turns.contains(&session_id), - ); - + let (active_turn, active_internal_turn, lifecycle_plan) = { + let _operation_guard = self.lock_session_operation(&session_id).await; + let Some(active_turn_result) = take_active_turn_for_outcome( + &self.active_turns, + &self.retired_deletion_outcomes, + &session_id, + outcome.turn_id(), + ) else { + let _drained = self + .round_injection_buffer + .drain_for_turn(&session_id, outcome.turn_id()); + self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); + debug!( + "Ignoring outcome retired by session deletion: session_id={}, turn_id={}", + session_id, + outcome.turn_id() + ); + continue; + }; + let active_turn = match active_turn_result { + ActiveDialogTurnTakeResult::Matched(turn) => Some(turn), + ActiveDialogTurnTakeResult::Absent => None, + ActiveDialogTurnTakeResult::DifferentTurn => { + let _drained = self + .round_injection_buffer + .drain_for_turn(&session_id, outcome.turn_id()); + self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); + debug!( + "Ignoring stale turn outcome: session_id={}, turn_id={}", + session_id, + outcome.turn_id() + ); + continue; + } + }; + let active_internal_turn = active_turn.as_ref().and_then(|_| { + self.active_internal_turns + .remove(&session_id) + .map(|(_, turn)| turn) + }); + let lifecycle_plan = + resolve_turn_outcome_lifecycle_plan(&outcome, active_turn.is_some()); + if lifecycle_plan.queue_action == TurnOutcomeQueueAction::ClearQueue { + debug!( + "Turn {}, clearing queue: session_id={}", + lifecycle_plan.status, session_id + ); + self.clear_queue(&session_id).await; + } + (active_turn, active_internal_turn, lifecycle_plan) + }; + let status = lifecycle_plan.status; + let queue_action = lifecycle_plan.queue_action; // Only drop steering messages targeted at the *finished* turn. We // must NOT clear the entire session buffer here: a user might have // legitimately submitted steering against a brand-new follow-up @@ -1328,12 +1569,6 @@ impl DialogScheduler { } let suppressed_cancelled_reply = self.take_suppressed_cancelled_reply(&session_id, outcome.turn_id()); - - let active_turn = self.active_turns.remove(&session_id); - let active_internal_turn = self - .active_internal_turns - .remove(&session_id) - .map(|(_, turn)| turn); let is_internal_turn = active_internal_turn.is_some(); if !is_internal_turn { if let Some(active_turn) = active_turn.as_ref() { @@ -1358,13 +1593,6 @@ impl DialogScheduler { } } - let status = lifecycle_plan.status; - let queue_action = lifecycle_plan.queue_action; - if queue_action == TurnOutcomeQueueAction::ClearQueue { - debug!("Turn {}, clearing queue: session_id={}", status, session_id); - self.clear_queue(&session_id); - } - if !is_internal_turn { if let Some(active_turn) = active_turn.as_ref() { match lifecycle_plan.goal_continuation { @@ -1727,8 +1955,7 @@ impl AgentTurnCancellationPort for DialogScheduler { let wait_timeout = Duration::from_millis(request.wait_timeout_ms.unwrap_or(1500)); let cancelled_turn_id = if let Some(turn_id) = request.turn_id { - self.coordinator - .cancel_dialog_turn(&session_id, &turn_id) + self.cancel_queued_or_active_turn(&session_id, &turn_id) .await .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))?; Some(turn_id) @@ -1741,8 +1968,7 @@ impl AgentTurnCancellationPort for DialogScheduler { .await .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))? } else { - self.coordinator - .cancel_active_turn_for_session(&session_id, wait_timeout) + self.cancel_active_turn_for_session(&session_id, wait_timeout) .await .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))? }; @@ -1798,7 +2024,75 @@ pub fn clear_thread_goal_continuation_abort(session_id: &str) { #[cfg(test)] mod tests { use super::*; + use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; + use crate::agentic::execution::{ + ExecutionEngine, ExecutionEngineConfig, RoundExecutor, StreamProcessor, + }; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{ + compression::{CompressionConfig, ContextCompressor}, + PromptCachePolicy, SessionContextStore, SessionManagerConfig, + }; + use crate::agentic::tools::registry::ToolRegistry; + use crate::agentic::tools::{ToolPipeline, ToolStateManager}; + use crate::infrastructure::PathManager; use bitfun_runtime_ports::{AgentDialogPrependedReminder, AgentInputAttachment, PortErrorKind}; + use tokio::sync::RwLock as TokioRwLock; + + fn test_scheduler() -> ( + Arc, + Arc, + Arc, + tempfile::TempDir, + ) { + let root = tempfile::tempdir().expect("test root"); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + Arc::new( + PersistenceManager::new(Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + ))) + .expect("persistence manager"), + ), + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = Arc::new(ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue.clone(), + Arc::new(EventRouter::new()), + )); + ( + DialogScheduler::new(coordinator, session_manager.clone()), + session_manager, + event_queue, + root, + ) + } #[test] fn queued_turn_execution_default_is_standard() { @@ -1808,6 +2102,172 @@ mod tests { )); } + fn standard_queued_turn(turn_id: &str) -> QueuedTurn { + QueuedTurn { + user_input: "queued".to_string(), + original_user_input: None, + prepended_messages: Vec::new(), + turn_id: Some(turn_id.to_string()), + agent_type: "agentic".to_string(), + workspace_path: Some("/workspace".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi), + reply_route: None, + user_message_metadata: None, + image_contexts: None, + enqueued_at: SystemTime::now(), + execution: QueuedTurnExecution::Standard, + } + } + + #[test] + fn targeted_queue_removal_cancels_a_standard_turn_by_id() { + let queues = DialogTurnQueue::default(); + let queued_turn = standard_queued_turn("turn-queued"); + queues + .enqueue("session-1", queued_turn, DialogQueuePriority::Normal) + .expect("standard turn should enqueue"); + + let removed = remove_queued_turn_by_id(&queues, "session-1", "turn-queued") + .expect("targeted cancellation should remove the queued turn"); + + assert!(matches!(removed.execution, QueuedTurnExecution::Standard)); + assert_eq!(queues.depth("session-1"), 0); + } + + #[tokio::test] + async fn targeted_standard_queue_cancellation_emits_one_terminal_event() { + let (scheduler, _, event_queue, _root) = test_scheduler(); + let mut events = event_queue.subscribe(); + scheduler + .queues + .enqueue( + "session", + standard_queued_turn("turn-queued"), + DialogQueuePriority::Normal, + ) + .expect("queue standard turn"); + + assert!(scheduler + .cancel_queued_or_active_turn("session", "turn-queued") + .await + .expect("cancel queued turn")); + let event = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .expect("terminal event timeout") + .expect("terminal event"); + assert!(matches!( + event.event, + AgenticEvent::DialogTurnCancelled { session_id, turn_id } + if session_id == "session" && turn_id == "turn-queued" + )); + assert!( + tokio::time::timeout(Duration::from_millis(20), events.recv()) + .await + .is_err() + ); + } + + #[test] + fn queued_submission_without_started_turn_reports_queued() { + assert_eq!( + queued_submission_outcome("session".to_string(), "turn-submitted".to_string(), None,), + DialogSubmitOutcome::Queued { + session_id: "session".to_string(), + turn_id: "turn-submitted".to_string(), + } + ); + } + + fn desktop_active_turn(turn_id: &str) -> ActiveDialogTurn { + ActiveDialogTurn::new( + turn_id.to_string(), + Some("/workspace".to_string()), + None, + None, + "agentic".to_string(), + "hello".to_string(), + None, + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi), + None, + ) + } + + #[tokio::test] + async fn explicit_cancel_cannot_cross_session_by_reusing_a_turn_id() { + let (scheduler, _, _, _root) = test_scheduler(); + scheduler + .active_turns + .insert("session-a", desktop_active_turn("shared-turn")); + + let removed = scheduler + .cancel_queued_or_active_turn("session-b", "shared-turn") + .await + .expect("stale cancellation is idempotent"); + + assert!(!removed); + assert!(scheduler + .active_turns + .matches_turn("session-a", "shared-turn")); + } + + #[tokio::test] + async fn wrong_workspace_deletion_leaves_active_and_queued_turns_untouched() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "session-bound-to-a"; + let storage_a = root.path().join("workspace-a-sessions"); + let storage_b = root.path().join("workspace-b-sessions"); + session_manager + .ensure_session_storage_path(session_id, &storage_a) + .expect("bind session storage"); + scheduler + .queues + .enqueue( + session_id, + standard_queued_turn("turn-queued"), + DialogQueuePriority::Normal, + ) + .expect("queue turn"); + scheduler + .active_turns + .insert(session_id, desktop_active_turn("turn-active")); + + let error = scheduler + .begin_session_deletion(session_id, &storage_b, Duration::ZERO) + .await + .err() + .expect("wrong workspace must be rejected before quiescence"); + + assert!(matches!(error, BitFunError::Validation(_))); + assert_eq!(scheduler.queue_depth(session_id), 1); + assert!(scheduler + .active_turns + .matches_turn(session_id, "turn-active")); + } + + #[test] + fn retired_delete_outcome_cannot_mutate_a_recreated_session_generation() { + let active_turns = ActiveDialogTurnStore::default(); + let retired = DialogReplySuppressionSet::default(); + let session_id = "reused-session"; + active_turns.insert(session_id, desktop_active_turn("turn-old")); + let old = active_turns + .remove(session_id) + .expect("old active turn should be present"); + retired.mark(session_id, old.turn_id()); + active_turns.insert(session_id, desktop_active_turn("turn-new")); + + assert!( + take_active_turn_for_outcome(&active_turns, &retired, session_id, "turn-old").is_none() + ); + assert!(active_turns.matches_turn(session_id, "turn-new")); + assert!(matches!( + take_active_turn_for_outcome(&active_turns, &retired, session_id, "turn-new"), + Some(ActiveDialogTurnTakeResult::Matched(_)) + )); + } + fn agent_session_active_turn(source_session_id: &str) -> ActiveDialogTurn { ActiveDialogTurn::new( "turn_1".to_string(), diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 279026ed43..4bded3b12f 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -28,7 +28,8 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use bitfun_agent_runtime::tool_confirmation::{ - resolve_tool_confirmation_gate, ToolConfirmationGateFacts, + resolve_tool_confirmation_policy_gate, ToolConfirmationContextPolicy, + ToolConfirmationPolicyGateFacts, }; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; use bitfun_ai_adapters::{ @@ -776,8 +777,25 @@ impl RoundExecutor { .get("skip_tool_confirmation") .map(|v| v == "true") .unwrap_or(false); + let require_from_context = context + .context_vars + .get("require_tool_confirmation") + .map(|v| v == "true") + .unwrap_or(false); + let context_policy = if require_from_context { + ToolConfirmationContextPolicy::Require + } else if skip_from_context { + ToolConfirmationContextPolicy::Skip + } else { + ToolConfirmationContextPolicy::Inherit + }; - let any_tool_needs_permission = if skip_confirmation || skip_from_context { + let skips_confirmation = match context_policy { + ToolConfirmationContextPolicy::Require => false, + ToolConfirmationContextPolicy::Skip => true, + ToolConfirmationContextPolicy::Inherit => skip_confirmation, + }; + let any_tool_needs_permission = if skips_confirmation { false } else { let registry = get_global_tool_registry(); @@ -790,12 +808,13 @@ impl RoundExecutor { .unwrap_or(false) }) }; - let needs_confirm = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { - global_skip_tool_confirmation: skip_confirmation, - context_skip_tool_confirmation: skip_from_context, - any_tool_needs_permission, - }) - .confirm_before_run(); + let needs_confirm = + resolve_tool_confirmation_policy_gate(ToolConfirmationPolicyGateFacts { + global_skip_tool_confirmation: skip_confirmation, + context_policy, + any_tool_needs_permission, + }) + .confirm_before_run(); (needs_confirm, exec_timeout, confirm_timeout, task_policy) }; diff --git a/src/crates/assembly/core/src/agentic/keyed_lock.rs b/src/crates/assembly/core/src/agentic/keyed_lock.rs new file mode 100644 index 0000000000..92544c3ced --- /dev/null +++ b/src/crates/assembly/core/src/agentic/keyed_lock.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex, Weak}; + +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard}; + +pub(crate) struct KeyedAsyncLockGuard { + guard: Option>, + registry: Arc>>>>, + key: String, + generation: Weak>, +} + +impl Drop for KeyedAsyncLockGuard { + fn drop(&mut self) { + // Release the async lock before removing its dead weak entry. A new + // waiter either upgrades this generation first or creates the next one + // after removal; two live generations cannot overlap. + drop(self.guard.take()); + let mut registry = self + .registry + .lock() + .unwrap_or_else(|error| error.into_inner()); + if registry + .get(&self.key) + .is_some_and(|current| current.ptr_eq(&self.generation) && current.upgrade().is_none()) + { + registry.remove(&self.key); + } + } +} + +/// Race-safe keyed async lock registry. +/// +/// The registry owns only weak references. Waiters keep the lock generation +/// alive through their strong references, while dead keys are reclaimed on the +/// next acquisition without allowing two live generations for one key. +#[derive(Clone, Default)] +pub(crate) struct KeyedAsyncLock { + registry: Arc>>>>, +} + +impl KeyedAsyncLock { + pub(crate) async fn lock(&self, key: &str) -> KeyedAsyncLockGuard { + let (lock, generation) = { + let mut registry = self + .registry + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(lock) = registry.get(key).and_then(Weak::upgrade) { + let generation = Arc::downgrade(&lock); + (lock, generation) + } else { + let lock = Arc::new(AsyncMutex::new(())); + let generation = Arc::downgrade(&lock); + registry.insert(key.to_string(), generation.clone()); + (lock, generation) + } + }; + let guard = lock.lock_owned().await; + KeyedAsyncLockGuard { + guard: Some(guard), + registry: self.registry.clone(), + key: key.to_string(), + generation, + } + } + + #[cfg(test)] + fn registry_len(&self) -> usize { + self.registry + .lock() + .unwrap_or_else(|error| error.into_inner()) + .len() + } +} + +#[cfg(test)] +mod tests { + use super::KeyedAsyncLock; + + #[tokio::test] + async fn dead_key_generations_are_reclaimed() { + let locks = KeyedAsyncLock::default(); + for index in 0..64 { + drop(locks.lock(&format!("missing-{index}")).await); + } + + let survivor = locks.lock("survivor").await; + + assert_eq!(locks.registry_len(), 1); + drop(survivor); + assert_eq!(locks.registry_len(), 0); + } +} diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs index 04c204bd82..ac8230b483 100644 --- a/src/crates/assembly/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -34,6 +34,7 @@ pub mod round_preempt; // Image analysis module pub mod image_analysis; +pub(crate) mod keyed_lock; pub mod memories; // Ephemeral side-question module (used by desktop /btw overlay) diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 471e11ad36..18911de87b 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -10,7 +10,9 @@ use crate::agentic::core::{ use crate::agentic::memories::db::{MemoryDatabase, MEMORY_PHASE2_GLOBAL_JOB_KEY}; use crate::agentic::memories::external_context::dialog_turn_uses_external_context; use crate::agentic::session::transcript_render::{render_transcript, transcript_fingerprint}; -use crate::agentic::session::{SessionPromptCache, TokenAnchor, PROMPT_CACHE_SCHEMA_VERSION}; +use crate::agentic::session::{ + CoreSessionStorePort, SessionPromptCache, TokenAnchor, PROMPT_CACHE_SCHEMA_VERSION, +}; use crate::agentic::skill_agent_snapshot::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; use crate::service::config::get_global_config_service; @@ -292,6 +294,10 @@ impl PersistenceManager { }) } + fn validate_session_id(session_id: &str) -> BitFunResult<()> { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation) + } + /// Get PathManager reference pub fn path_manager(&self) -> &Arc { &self.path_manager @@ -314,20 +320,8 @@ impl PersistenceManager { self.path_manager.project_sessions_dir(workspace_path) } - fn is_resolved_sessions_dir(&self, path: &Path) -> bool { - if path.file_name().and_then(|value| value.to_str()) != Some("sessions") { - return false; - } - - let remote_mirror_root = self.path_manager.remote_ssh_mirror_root_dir(); - if path.starts_with(&remote_mirror_root) { - return true; - } - - let projects_root = self.path_manager.projects_root(); - path.parent() - .and_then(|runtime_root| runtime_root.parent()) - .is_some_and(|candidate| candidate == projects_root.as_path()) + pub(crate) fn is_resolved_sessions_dir(&self, path: &Path) -> bool { + CoreSessionStorePort::resolved_sessions_dir_kind(self.path_manager.as_ref(), path).is_some() } fn metadata_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { @@ -421,6 +415,18 @@ impl PersistenceManager { SessionStorageLayout::new(self.project_sessions_dir(workspace_path)) } + pub(crate) fn session_storage_exists( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + Self::validate_session_id(session_id)?; + Ok(self + .session_layout(workspace_path) + .session_dir(session_id) + .exists()) + } + fn session_metadata_store(&self, workspace_path: &Path) -> SessionMetadataStore { SessionMetadataStore::new(self.project_sessions_dir(workspace_path)) } @@ -860,6 +866,7 @@ impl PersistenceManager { workspace_path: &Path, metadata: &SessionMetadata, ) -> BitFunResult<()> { + Self::validate_session_id(&metadata.session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.session_metadata_store(workspace_path) .save_metadata(metadata) @@ -873,6 +880,7 @@ impl PersistenceManager { session_id: &str, mode: SessionMemoryMode, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; let metadata_update_lock = self .get_session_metadata_update_lock(workspace_path, session_id) .await; @@ -892,6 +900,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; let metadata_update_lock = self .get_session_metadata_update_lock(workspace_path, session_id) .await; @@ -937,6 +946,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; self.session_metadata_store(workspace_path) .load_metadata(session_id) .await @@ -969,6 +979,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; Ok(self .read_json_optional::( &self.prompt_cache_path(workspace_path, session_id), @@ -983,6 +994,7 @@ impl PersistenceManager { session_id: &str, cache: &SessionPromptCache, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_session_dir(workspace_path, session_id).await?; @@ -1001,6 +1013,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; match fs::remove_file(self.prompt_cache_path(workspace_path, session_id)).await { Ok(()) => Ok(()), Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), @@ -1016,6 +1029,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult>> { + Self::validate_session_id(session_id)?; Ok(self .read_json_optional::( &self.token_anchors_path(workspace_path, session_id), @@ -1030,6 +1044,7 @@ impl PersistenceManager { session_id: &str, anchors: &[TokenAnchor], ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_session_dir(workspace_path, session_id).await?; @@ -1049,6 +1064,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; match fs::remove_file(self.token_anchors_path(workspace_path, session_id)).await { Ok(()) => Ok(()), Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), @@ -1068,6 +1084,7 @@ impl PersistenceManager { turn_index: usize, messages: &[Message], ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_snapshots_dir(workspace_path, session_id) .await?; @@ -1092,6 +1109,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult>> { + Self::validate_session_id(session_id)?; let snapshot = self .read_json_optional::(&self.context_snapshot_path( workspace_path, @@ -1107,6 +1125,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult)>> { + Self::validate_session_id(session_id)?; let started_at = Instant::now(); let dir = self.snapshots_dir(workspace_path, session_id); if !dir.exists() { @@ -1185,6 +1204,7 @@ impl PersistenceManager { turn_index: usize, snapshot: &TurnSkillAgentSnapshot, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_snapshots_dir(workspace_path, session_id) .await?; @@ -1207,6 +1227,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; let stored = self .read_json_optional::( &self.skill_agent_snapshot_path(workspace_path, session_id, turn_index), @@ -1221,6 +1242,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; let dir = self.snapshots_dir(workspace_path, session_id); if !dir.exists() { return Ok(()); @@ -1261,6 +1283,7 @@ impl PersistenceManager { session_id: &str, snapshot: &TurnSkillAgentSnapshot, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_snapshots_dir(workspace_path, session_id) .await?; @@ -1281,6 +1304,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; let stored = self .read_json_optional::( &self.skill_agent_baseline_override_path(workspace_path, session_id), @@ -1295,6 +1319,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; let dir = self.snapshots_dir(workspace_path, session_id); if !dir.exists() { return Ok(()); @@ -1337,6 +1362,7 @@ impl PersistenceManager { /// Save session pub async fn save_session(&self, workspace_path: &Path, session: &Session) -> BitFunResult<()> { + Self::validate_session_id(&session.session_id)?; self.ensure_runtime_for_write(workspace_path).await?; self.ensure_session_dir(workspace_path, &session.session_id) .await?; @@ -1368,6 +1394,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { + Self::validate_session_id(session_id)?; let (session, _) = self .load_session_with_turns(workspace_path, session_id) .await?; @@ -1442,6 +1469,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + Self::validate_session_id(session_id)?; self.load_session_with_turns_timed(workspace_path, session_id) .await .map(|(session, turns, _)| (session, turns)) @@ -1452,6 +1480,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec, SessionTurnLoadTiming)> { + Self::validate_session_id(session_id)?; let request = SessionTurnLoadRequest { workspace_path: workspace_path.to_path_buf(), session_id: session_id.to_string(), @@ -1537,6 +1566,7 @@ impl PersistenceManager { session_id: &str, tail_turn_count: usize, ) -> BitFunResult<(Session, Vec, usize)> { + Self::validate_session_id(session_id)?; self.load_session_with_tail_turns_timed(workspace_path, session_id, tail_turn_count) .await .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) @@ -1548,6 +1578,7 @@ impl PersistenceManager { session_id: &str, tail_turn_count: usize, ) -> BitFunResult<(Session, Vec, usize, SessionTurnLoadTiming)> { + Self::validate_session_id(session_id)?; let request = SessionTurnLoadRequest { workspace_path: workspace_path.to_path_buf(), session_id: session_id.to_string(), @@ -1675,6 +1706,7 @@ impl PersistenceManager { session_id: &str, state: &SessionState, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.ensure_runtime_for_write(workspace_path).await?; let mut stored_state = self .load_stored_session_state(workspace_path, session_id) @@ -1703,6 +1735,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; self.session_metadata_store(workspace_path) .delete_session_dir_and_index(session_id) .await @@ -1747,6 +1780,7 @@ impl PersistenceManager { workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { + Self::validate_session_id(&turn.session_id)?; let save_started_at = Instant::now(); self.ensure_runtime_for_write(workspace_path).await?; let metadata_update_lock = self @@ -1860,6 +1894,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; Ok(self .read_json_optional::(&self.turn_path( workspace_path, @@ -1953,6 +1988,7 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; let started_at = Instant::now(); let scan_started_at = Instant::now(); let indexed_paths = self @@ -1991,6 +2027,7 @@ impl PersistenceManager { session_id: &str, count: usize, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; if count == 0 { return Ok(Vec::new()); } @@ -2084,6 +2121,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; if !self.turns_dir(workspace_path, session_id).exists() { return Ok(()); } @@ -2118,6 +2156,7 @@ impl PersistenceManager { session_id: &str, count: usize, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; let turns = self.load_session_turns(workspace_path, session_id).await?; let start = turns.len().saturating_sub(count); Ok(turns[start..].to_vec()) @@ -2148,6 +2187,7 @@ impl PersistenceManager { compression_id: &str, trigger: &str, ) -> BitFunResult> { + Self::validate_session_id(session_id)?; let all_turns = self.load_session_turns(workspace_path, session_id).await?; let selected_indices = all_turns .iter() @@ -2296,6 +2336,7 @@ impl PersistenceManager { session_id: &str, start_turn_index: usize, ) -> BitFunResult { + Self::validate_session_id(session_id)?; let dir = self.compression_transcripts_dir(workspace_path, session_id); if !dir.exists() { return Ok(0); @@ -2339,6 +2380,8 @@ impl PersistenceManager { target_session_id: &str, end_turn_index: usize, ) -> BitFunResult { + Self::validate_session_id(source_session_id)?; + Self::validate_session_id(target_session_id)?; let source_dir = self.compression_transcripts_dir(workspace_path, source_session_id); if !source_dir.exists() { return Ok(0); @@ -2392,6 +2435,7 @@ impl PersistenceManager { session_id: &str, options: &SessionTranscriptExportOptions, ) -> BitFunResult { + Self::validate_session_id(session_id)?; if self .load_session_metadata(workspace_path, session_id) .await? @@ -2501,6 +2545,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult { + Self::validate_session_id(session_id)?; let turns = self.load_session_turns(workspace_path, session_id).await?; let mut deleted = 0usize; @@ -2542,6 +2587,7 @@ impl PersistenceManager { session_id: &str, turn_index: usize, ) -> BitFunResult { + Self::validate_session_id(session_id)?; let turns = self.load_session_turns(workspace_path, session_id).await?; let mut deleted = 0usize; @@ -2578,6 +2624,7 @@ impl PersistenceManager { } pub async fn touch_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + Self::validate_session_id(session_id)?; if let Some(mut metadata) = self .load_session_metadata(workspace_path, session_id) .await? @@ -2641,6 +2688,20 @@ mod tests { } } + #[tokio::test] + async fn unsafe_session_ids_are_rejected_before_turn_path_resolution() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + + let error = manager + .load_session_turns(workspace.path(), "../another-project/session") + .await + .expect_err("path-like session id must be rejected"); + + assert!(error.to_string().contains("session_id"), "{error}"); + } + #[tokio::test] async fn token_anchors_save_load_and_delete_roundtrip() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs index 67062edc6f..88675b4363 100644 --- a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -12,6 +12,8 @@ impl PersistenceManager { workspace_path: &Path, request: &SessionBranchRequest, ) -> BitFunResult { + bitfun_core_types::validate_session_id(&request.source_session_id) + .map_err(BitFunError::Validation)?; let source_session = self .load_session(workspace_path, &request.source_session_id) .await?; 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 ed52aa86c8..fe63a4e46e 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -8,6 +8,7 @@ use crate::agentic::core::{ SessionKind, SessionState, SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; +use crate::agentic::keyed_lock::{KeyedAsyncLock, KeyedAsyncLockGuard}; use crate::agentic::memories::db::{MemoryDatabase, MEMORY_PHASE2_GLOBAL_JOB_KEY}; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::session_store_port::CoreSessionStorePort; @@ -47,7 +48,7 @@ use bitfun_services_core::session::{ set_deep_review_run_manifest, set_review_target_evidence, set_session_relationship, SessionStorageLayout, }; -use dashmap::DashMap; +use dashmap::{mapref::entry::Entry, DashMap}; use log::{debug, error, info, warn}; use serde_json::json; use std::collections::HashSet; @@ -129,7 +130,14 @@ pub struct SessionManager { /// or resolve workspace-bound operations that only receive a session_id. /// This cache is intentionally retained across memory eviction, but should /// be cleared when a session is explicitly deleted. - session_storage_path_index: Arc>, + session_storage_path_index: Arc>, + + /// Serializes create, restore, and delete mutations for one session ID. + /// + /// Storage-path claims prevent cross-workspace identity collisions, while + /// this permit prevents a slower restore from replacing a session that a + /// concurrent operation has already made active. + session_mutation_locks: KeyedAsyncLock, /// Sub-components context_store: Arc, @@ -161,7 +169,196 @@ struct SessionCleanupCandidate { last_activity_at: SystemTime, } +#[derive(Clone, Debug)] +struct SessionStoragePathBinding { + path: PathBuf, + pending_claims: usize, + committed: bool, +} + impl SessionManager { + async fn lock_session_mutation(&self, session_id: &str) -> KeyedAsyncLockGuard { + self.session_mutation_locks.lock(session_id).await + } + + pub(crate) async fn acquire_session_mutation( + &self, + session_id: &str, + ) -> BitFunResult { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + Ok(self.lock_session_mutation(session_id).await) + } + + fn normalize_session_storage_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + } + + fn claim_session_storage_path( + &self, + session_id: &str, + requested_path: &Path, + allow_existing_same_path: bool, + ) -> BitFunResult { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + let requested_path = Self::normalize_session_storage_path(requested_path); + match self + .session_storage_path_index + .entry(session_id.to_string()) + { + Entry::Vacant(entry) => { + entry.insert(SessionStoragePathBinding { + path: requested_path, + pending_claims: 1, + committed: false, + }); + Ok(true) + } + Entry::Occupied(mut entry) => { + let existing_path = Self::normalize_session_storage_path(&entry.get().path); + if existing_path != requested_path { + return Err(BitFunError::Validation(format!( + "Session ID is already bound to another workspace: session_id={}, existing_storage_path={}, requested_storage_path={}", + session_id, + existing_path.display(), + requested_path.display() + ))); + } + if !allow_existing_same_path { + return Err(BitFunError::Validation(format!( + "Session ID already exists: {session_id}" + ))); + } + if entry.get().committed { + Ok(false) + } else { + entry.get_mut().pending_claims += 1; + Ok(true) + } + } + } + } + + fn commit_session_storage_path_claim( + &self, + session_id: &str, + requested_path: &Path, + claimed: bool, + ) { + if !claimed { + return; + } + let requested_path = Self::normalize_session_storage_path(requested_path); + if let Entry::Occupied(mut entry) = self + .session_storage_path_index + .entry(session_id.to_string()) + { + if Self::normalize_session_storage_path(&entry.get().path) == requested_path { + let binding = entry.get_mut(); + binding.committed = true; + binding.pending_claims = 0; + } + } + } + + fn release_failed_session_storage_path_claim( + &self, + session_id: &str, + requested_path: &Path, + claimed: bool, + ) { + if !claimed { + return; + } + let requested_path = Self::normalize_session_storage_path(requested_path); + let session_exists = self.sessions.contains_key(session_id); + if let Entry::Occupied(mut entry) = self + .session_storage_path_index + .entry(session_id.to_string()) + { + if Self::normalize_session_storage_path(&entry.get().path) != requested_path { + return; + } + if session_exists { + let binding = entry.get_mut(); + binding.committed = true; + binding.pending_claims = 0; + return; + } + let binding = entry.get_mut(); + binding.pending_claims = binding.pending_claims.saturating_sub(1); + if binding.pending_claims == 0 && !binding.committed { + entry.remove(); + } + } + } + + fn bind_session_storage_path_committed(&self, session_id: &str, path: PathBuf) { + self.session_storage_path_index.insert( + session_id.to_string(), + SessionStoragePathBinding { + path, + pending_claims: 0, + committed: true, + }, + ); + } + + pub(crate) fn ensure_session_storage_path( + &self, + session_id: &str, + requested_path: &Path, + ) -> BitFunResult<()> { + let claimed = self.claim_session_storage_path(session_id, requested_path, true)?; + self.commit_session_storage_path_claim(session_id, requested_path, claimed); + Ok(()) + } + + pub(crate) fn validate_session_storage_path_binding( + &self, + session_id: &str, + requested_path: &Path, + ) -> BitFunResult<()> { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + let requested_path = Self::normalize_session_storage_path(requested_path); + let Some(binding) = self.session_storage_path_index.get(session_id) else { + return Ok(()); + }; + let existing_path = Self::normalize_session_storage_path(&binding.path); + if existing_path != requested_path { + return Err(BitFunError::Validation(format!( + "Session ID is already bound to another workspace: session_id={}, existing_storage_path={}, requested_storage_path={}", + session_id, + existing_path.display(), + requested_path.display() + ))); + } + Ok(()) + } + + pub(crate) async fn is_session_loaded_for_workspace_path( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + let storage_path = self + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; + self.is_session_loaded_from_storage_path(&storage_path, session_id) + } + + pub(crate) fn is_session_loaded_from_storage_path( + &self, + storage_path: &Path, + session_id: &str, + ) -> BitFunResult { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + if !self.sessions.contains_key(session_id) { + return Ok(false); + } + self.ensure_session_storage_path(session_id, storage_path)?; + Ok(true) + } + async fn load_ai_config_for_model_resolution() -> Option { let config_service = get_global_config_service().await.ok()?; @@ -471,6 +668,12 @@ impl SessionManager { } async fn effective_storage_path_for_workspace_path(&self, workspace_path: &Path) -> PathBuf { + if self + .persistence_manager + .is_resolved_sessions_dir(workspace_path) + { + return workspace_path.to_path_buf(); + } let tmp_config = SessionConfig { workspace_path: Some(workspace_path.to_string_lossy().to_string()), ..Default::default() @@ -494,6 +697,24 @@ impl SessionManager { session_storage_path } + async fn resolve_storage_path_for_restore_workspace_path( + &self, + workspace_path: &Path, + ) -> BitFunResult { + if self + .persistence_manager + .is_resolved_sessions_dir(workspace_path) + { + return Err(BitFunError::Validation(format!( + "Expected a workspace path, received a resolved sessions directory: {}", + workspace_path.display() + ))); + } + Ok(self + .resolve_storage_path_for_workspace_path(workspace_path) + .await) + } + async fn resolve_storage_path_for_request( &self, request: SessionStoragePathRequest, @@ -546,7 +767,7 @@ impl SessionManager { .or_else(|| { self.session_storage_path_index .get(session_id) - .map(|entry| entry.value().clone()) + .map(|entry| entry.value().path.clone()) }) .ok_or_else(|| { BitFunError::Validation(format!( @@ -592,7 +813,7 @@ impl SessionManager { .or_else(|| { self.session_storage_path_index .get(session_id) - .map(|entry| entry.value().clone()) + .map(|entry| entry.value().path.clone()) })?; Some(SessionStorageLayout::new(storage_path).request_traces_dir(session_id)) @@ -614,7 +835,7 @@ impl SessionManager { let indexed_storage_path = self .session_storage_path_index .get(session_id) - .map(|entry| entry.clone()); + .map(|entry| entry.value().path.clone()); if let Some(session_storage_path) = indexed_storage_path { if let Some(binding) = self .resolve_persisted_session_workspace_binding( @@ -643,8 +864,17 @@ impl SessionManager { ) .await { - self.session_storage_path_index - .insert(session_id.to_string(), session_storage_path); + if let Err(error) = + self.ensure_session_storage_path(session_id, &session_storage_path) + { + debug!( + "Ignoring conflicting persisted session workspace binding: session_id={}, storage_path={}, error={}", + session_id, + session_storage_path.display(), + error + ); + continue; + } return Some(binding); } } @@ -1232,6 +1462,7 @@ impl SessionManager { let manager = Self { sessions: Arc::new(DashMap::new()), session_storage_path_index: Arc::new(DashMap::new()), + session_mutation_locks: KeyedAsyncLock::default(), context_store, prompt_cache_store: Arc::new(SessionPromptCacheStore::new()), token_anchor_store: Arc::new(TokenAnchorStore::new()), @@ -1254,6 +1485,10 @@ impl SessionManager { manager } + pub(crate) fn persistence_manager(&self) -> Arc { + self.persistence_manager.clone() + } + pub fn append_evidence_event(&self, event: EvidenceLedgerEvent) -> EvidenceLedgerEvent { self.evidence_ledger.append(event) } @@ -1423,6 +1658,7 @@ impl SessionManager { fn spawn_model_reconciliation_listener(&self) { let sessions = self.sessions.clone(); let session_storage_path_index = self.session_storage_path_index.clone(); + let session_mutation_locks = self.session_mutation_locks.clone(); let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); let token_anchor_store = self.token_anchor_store.clone(); @@ -1449,6 +1685,7 @@ impl SessionManager { let manager = Self { sessions, session_storage_path_index, + session_mutation_locks, context_store, prompt_cache_store, token_anchor_store, @@ -1587,11 +1824,47 @@ impl SessionManager { session.created_by = created_by; session.kind = kind; let session_id = session.session_id.clone(); + let _mutation_guard = self.lock_session_mutation(&session_id).await; + + // Claim both the runtime session ID and its workspace storage identity before + // exposing the session. Persistent sessions must never reuse an on-disk ID: + // overwriting the header would retain old turns and silently mix histories. + if self.sessions.contains_key(&session_id) { + return Err(BitFunError::Validation(format!( + "Session ID already exists: {session_id}" + ))); + } + if self.config.enable_persistence + && Self::should_persist_session(&session) + && self + .persistence_manager + .session_storage_exists(&session_storage_path, &session_id)? + { + return Err(BitFunError::Validation(format!( + "Persisted session ID already exists: {session_id}" + ))); + } + let storage_claim = + self.claim_session_storage_path(&session_id, &session_storage_path, true)?; // 1. Add to memory - self.sessions.insert(session_id.clone(), session.clone()); - self.session_storage_path_index - .insert(session_id.clone(), session_storage_path.clone()); + match self.sessions.entry(session_id.clone()) { + Entry::Vacant(entry) => { + entry.insert(session.clone()); + } + Entry::Occupied(entry) => { + drop(entry); + self.release_failed_session_storage_path_claim( + &session_id, + &session_storage_path, + storage_claim, + ); + return Err(BitFunError::Validation(format!( + "Session ID already exists: {session_id}" + ))); + } + } + self.commit_session_storage_path_claim(&session_id, &session_storage_path, storage_claim); // 2. Initialize the in-memory context cache. self.context_store.create_session(&session_id); @@ -2357,6 +2630,16 @@ impl SessionManager { /// Update session title (in-memory + persistence) pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { let normalized_title = Self::normalize_session_title_input(title)?; + let _mutation_guard = self.acquire_session_mutation(session_id).await?; + self.update_session_title_locked(session_id, normalized_title) + .await + } + + async fn update_session_title_locked( + &self, + session_id: &str, + normalized_title: String, + ) -> BitFunResult<()> { let workspace_path = self.effective_session_storage_path(session_id).await; { @@ -2408,6 +2691,8 @@ impl SessionManager { expected_current_title: &str, title: &str, ) -> BitFunResult { + let normalized_title = Self::normalize_session_title_input(title)?; + let _mutation_guard = self.acquire_session_mutation(session_id).await?; let Some(session) = self.sessions.get(session_id) else { return Err(BitFunError::NotFound(format!( "Session not found: {}", @@ -2424,7 +2709,8 @@ impl SessionManager { } drop(session); - self.update_session_title(session_id, title).await?; + self.update_session_title_locked(session_id, normalized_title) + .await?; Ok(true) } @@ -2591,7 +2877,7 @@ impl SessionManager { let session_storage_path = self .session_storage_path_index .get(session_id) - .map(|entry| entry.clone()); + .map(|entry| entry.value().path.clone()); if let Some(session_storage_path) = session_storage_path { debug!( "Session evicted from memory, restoring for model update: session_id={}", @@ -2603,6 +2889,12 @@ impl SessionManager { } } + // Restore owns the same keyed lock internally, so acquire the mutation + // permit only after the optional restore completes. From here through + // persistence, explicit deletion cannot remove and then be recreated by + // a late model update. + let _mutation_guard = self.acquire_session_mutation(session_id).await?; + if let Some(mut session) = self.sessions.get_mut(session_id) { session.config.model_id = Some(model_id.to_string()); if let Some(ai_config) = ai_config.as_ref() { @@ -2668,17 +2960,65 @@ impl SessionManager { } } + async fn resolve_session_cleanup_workspace_path( + &self, + session_storage_path: &Path, + session_id: &str, + fallback: &Path, + ) -> PathBuf { + if let Some(workspace_path) = self + .sessions + .get(session_id) + .and_then(|session| session.config.workspace_path.as_deref().map(PathBuf::from)) + { + return workspace_path; + } + + if self.config.enable_persistence { + if let Ok(Some(metadata)) = self + .persistence_manager + .load_session_metadata(session_storage_path, session_id) + .await + { + if let Some(workspace_path) = metadata.workspace_path { + return PathBuf::from(workspace_path); + } + } + } + + fallback.to_path_buf() + } + /// Delete session (cascade delete all resources) pub async fn delete_session( &self, workspace_path: &Path, session_id: &str, ) -> BitFunResult<()> { - self.delete_session_from_paths(workspace_path, workspace_path, session_id) - .await + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + let _mutation_guard = self.lock_session_mutation(session_id).await; + let session_storage_path = self + .resolve_storage_path_for_workspace_path(workspace_path) + .await; + self.validate_session_storage_path_binding(session_id, &session_storage_path)?; + let cleanup_workspace_path = self + .resolve_session_cleanup_workspace_path( + &session_storage_path, + session_id, + workspace_path, + ) + .await; + self.delete_session_from_paths_locked( + &cleanup_workspace_path, + &session_storage_path, + session_id, + ) + .await } pub(crate) async fn delete_session_by_id(&self, session_id: &str) -> BitFunResult<()> { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; + let _mutation_guard = self.lock_session_mutation(session_id).await; let session = self .sessions .get(session_id) @@ -2689,12 +3029,12 @@ impl SessionManager { .or_else(|| { self.session_storage_path_index .get(session_id) - .map(|entry| entry.value().clone()) + .map(|entry| entry.value().path.clone()) }) } else { self.session_storage_path_index .get(session_id) - .map(|entry| entry.value().clone()) + .map(|entry| entry.value().path.clone()) }; let Some(session_storage_path) = session_storage_path else { return Err(BitFunError::NotFound(format!( @@ -2702,22 +3042,23 @@ impl SessionManager { session_id ))); }; - let cleanup_workspace_path = session - .as_ref() - .and_then(|session| session.config.workspace_path.as_deref()) - .map(PathBuf::from) - .or_else(|| { - self.session_storage_path_index - .get(session_id) - .map(|entry| entry.value().clone()) - }) - .unwrap_or_else(|| session_storage_path.clone()); - - self.delete_session_from_paths(&cleanup_workspace_path, &session_storage_path, session_id) - .await + self.validate_session_storage_path_binding(session_id, &session_storage_path)?; + let cleanup_workspace_path = self + .resolve_session_cleanup_workspace_path( + &session_storage_path, + session_id, + &session_storage_path, + ) + .await; + self.delete_session_from_paths_locked( + &cleanup_workspace_path, + &session_storage_path, + session_id, + ) + .await } - async fn delete_session_from_paths( + async fn delete_session_from_paths_locked( &self, cleanup_workspace_path: &Path, session_storage_path: &Path, @@ -2732,6 +3073,25 @@ impl SessionManager { self.config.enable_persistence ); + // Persisted deletion is the only fallible required stage. Complete it + // before mutating loaded runtime state so a storage failure leaves the + // active session usable and retryable. + if self.config.enable_persistence { + let persistence_stage_started_at = Instant::now(); + debug!( + "Session deletion stage starting: session_id={}, stage=persistence_delete", + session_id + ); + self.persistence_manager + .delete_session(session_storage_path, session_id) + .await?; + debug!( + "Session deletion stage completed: session_id={}, stage=persistence_delete, duration_ms={}", + session_id, + elapsed_ms_u64(persistence_stage_started_at) + ); + } + // 1. Clean up snapshot system resources (including physical snapshot files) let snapshot_stage_started_at = Instant::now(); debug!( @@ -2776,23 +3136,6 @@ impl SessionManager { elapsed_ms_u64(context_stage_started_at) ); - // 2. Delete persisted data - if self.config.enable_persistence { - let persistence_stage_started_at = Instant::now(); - debug!( - "Session deletion stage starting: session_id={}, stage=persistence_delete", - session_id - ); - self.persistence_manager - .delete_session(session_storage_path, session_id) - .await?; - debug!( - "Session deletion stage completed: session_id={}, stage=persistence_delete, duration_ms={}", - session_id, - elapsed_ms_u64(persistence_stage_started_at) - ); - } - if let Some(cron) = crate::service::cron::get_global_cron_service() { let cron_stage_started_at = Instant::now(); debug!( @@ -2884,8 +3227,8 @@ impl SessionManager { session_id: &str, ) -> BitFunResult { let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; self.restore_session_from_storage_path(&session_storage_path, session_id) .await } @@ -2906,8 +3249,8 @@ impl SessionManager { session_id: &str, ) -> BitFunResult { let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; self.restore_internal_session_from_storage_path(&session_storage_path, session_id) .await } @@ -2980,8 +3323,8 @@ impl SessionManager { ) -> BitFunResult<(Session, Vec, SessionViewRestoreTiming)> { let storage_path_started_at = Instant::now(); let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; let resolve_storage_path_duration_ms = elapsed_ms_u64(storage_path_started_at); let (session, turns, mut timing) = self .restore_session_view_from_storage_path_timed(&session_storage_path, session_id) @@ -3022,8 +3365,8 @@ impl SessionManager { ) -> BitFunResult<(Session, Vec, SessionViewRestoreTiming)> { let storage_path_started_at = Instant::now(); let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; let resolve_storage_path_duration_ms = elapsed_ms_u64(storage_path_started_at); let (session, turns, mut timing) = self .restore_internal_session_view_from_storage_path_timed( @@ -3077,8 +3420,8 @@ impl SessionManager { )> { let storage_path_started_at = Instant::now(); let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; let resolve_storage_path_duration_ms = elapsed_ms_u64(storage_path_started_at); let (session, turns, total_turn_count, mut timing) = self .restore_session_view_from_storage_path_tail_timed( @@ -3115,8 +3458,8 @@ impl SessionManager { )> { let storage_path_started_at = Instant::now(); let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; let resolve_storage_path_duration_ms = elapsed_ms_u64(storage_path_started_at); let (session, turns, total_turn_count, mut timing) = self .restore_internal_session_view_from_storage_path_tail_timed( @@ -3211,6 +3554,7 @@ impl SessionManager { usize, SessionViewRestoreTiming, )> { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation)?; let restore_started_at = Instant::now(); let resolve_storage_path_duration_ms = 0; debug!( @@ -3321,8 +3665,8 @@ impl SessionManager { session_id: &str, ) -> BitFunResult<(Session, Vec)> { let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; self.restore_session_with_turns_from_storage_path(&session_storage_path, session_id) .await } @@ -3343,8 +3687,8 @@ impl SessionManager { session_id: &str, ) -> BitFunResult<(Session, Vec)> { let session_storage_path = self - .resolve_storage_path_for_workspace_path(workspace_path) - .await; + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; self.restore_internal_session_with_turns_from_storage_path( &session_storage_path, session_id, @@ -3397,13 +3741,58 @@ impl SessionManager { session_id: &str, include_internal: bool, ) -> BitFunResult<(Session, Vec)> { - let restore_started_at = Instant::now(); - // Check if session is already in memory - let session_already_in_memory = self.sessions.contains_key(session_id); + let _mutation_guard = self.lock_session_mutation(session_id).await; - debug!( - "Session restore phase completed: session_id={}, phase=use_storage_path, duration_ms=0", - session_id + if self.is_session_loaded_from_storage_path(session_storage_path, session_id)? { + let session = self.get_session(session_id).ok_or_else(|| { + BitFunError::NotFound(format!( + "Session not found after identity check: {session_id}" + )) + })?; + let (_, turns, _) = if include_internal { + self.restore_internal_session_view_from_storage_path_timed( + session_storage_path, + session_id, + ) + .await? + } else { + self.restore_session_view_from_storage_path_timed(session_storage_path, session_id) + .await? + }; + return Ok((session, turns)); + } + + let claimed = self.claim_session_storage_path(session_id, session_storage_path, true)?; + let result = self + .restore_session_with_turns_from_claimed_storage_path_internal( + session_storage_path, + session_id, + include_internal, + ) + .await; + if result.is_err() { + self.release_failed_session_storage_path_claim( + session_id, + session_storage_path, + claimed, + ); + } + result + } + + async fn restore_session_with_turns_from_claimed_storage_path_internal( + &self, + session_storage_path: &Path, + session_id: &str, + include_internal: bool, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + // Check if session is already in memory + let session_already_in_memory = self.sessions.contains_key(session_id); + + debug!( + "Session restore phase completed: session_id={}, phase=use_storage_path, duration_ms=0", + session_id ); let metadata_started_at = Instant::now(); @@ -3661,8 +4050,7 @@ impl SessionManager { // 4. Add to memory (will overwrite if already exists) self.sessions .insert(session_id.to_string(), session.clone()); - self.session_storage_path_index - .insert(session_id.to_string(), session_storage_path.to_path_buf()); + self.bind_session_storage_path_committed(session_id, session_storage_path.to_path_buf()); Ok((session, persisted_turns)) } @@ -3674,10 +4062,40 @@ impl SessionManager { session_id: &str, target_turn: usize, ) -> BitFunResult<()> { - // Ensure session is in memory (restore from persistence if necessary) + let session_storage_path = self + .resolve_storage_path_for_restore_workspace_path(workspace_path) + .await?; if !self.sessions.contains_key(session_id) && self.config.enable_persistence { - let _ = self.restore_session(workspace_path, session_id).await; + self.restore_session_from_storage_path(&session_storage_path, session_id) + .await?; } + let _mutation_guard = self.lock_session_mutation(session_id).await; + self.validate_session_storage_path_binding(session_id, &session_storage_path)?; + self.rollback_context_to_turn_start_locked(&session_storage_path, session_id, target_turn) + .await + } + + pub(crate) async fn rollback_context_to_turn_start_locked( + &self, + session_storage_path: &Path, + session_id: &str, + target_turn: usize, + ) -> BitFunResult<()> { + let workspace_path = session_storage_path; + + self.validate_rollback_context_to_turn_start_locked( + session_storage_path, + session_id, + target_turn, + ) + .await?; + let surviving_turns = if target_turn == 0 { + Vec::new() + } else { + self.persistence_manager + .load_session_turns(workspace_path, session_id) + .await? + }; // Rollback may load a historical snapshot from before the latest rebuilt baseline. In // that case we must strip all listing diff reminders before the snapshot re-enters @@ -3727,10 +4145,6 @@ impl SessionManager { let last_user_dialog_agent_type = if target_turn == 0 { None } else { - let surviving_turns = self - .persistence_manager - .load_session_turns(workspace_path, session_id) - .await?; let kept_turns = surviving_turns .into_iter() .take(target_turn) @@ -3800,6 +4214,37 @@ impl SessionManager { Ok(()) } + pub(crate) async fn validate_rollback_context_to_turn_start_locked( + &self, + session_storage_path: &Path, + session_id: &str, + target_turn: usize, + ) -> BitFunResult<()> { + if !self.config.enable_persistence { + return Ok(()); + } + self.persistence_manager + .load_session_metadata(session_storage_path, session_id) + .await?; + self.persistence_manager + .load_session_turns(session_storage_path, session_id) + .await?; + if target_turn > 0 + && self + .persistence_manager + .load_turn_context_snapshot(session_storage_path, session_id, target_turn - 1) + .await? + .is_none() + { + return Err(BitFunError::NotFound(format!( + "turn context snapshot not found: session_id={} turn={}", + session_id, + target_turn - 1 + ))); + } + Ok(()) + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { if self.config.enable_persistence { @@ -4301,6 +4746,7 @@ impl SessionManager { timestamp_ms: Option, user_message_metadata: Option, ) -> BitFunResult { + let _mutation_guard = self.lock_session_mutation(session_id).await; let session = self .get_session(session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; @@ -5265,6 +5711,7 @@ impl SessionManager { fn spawn_auto_save_task(&self) { let sessions = self.sessions.clone(); let persistence = self.persistence_manager.clone(); + let session_mutation_locks = self.session_mutation_locks.clone(); let interval = self.config.auto_save_interval; tokio::spawn(async move { @@ -5274,6 +5721,7 @@ impl SessionManager { ticker.tick().await; for snapshot in Self::collect_auto_save_snapshots(&sessions) { + let _mutation_guard = session_mutation_locks.lock(&snapshot.session_id).await; if !Self::auto_save_snapshot_is_current(&sessions, &snapshot) { continue; } @@ -5310,6 +5758,7 @@ impl SessionManager { let timeout = self.config.session_idle_timeout; let persistence = self.persistence_manager.clone(); let enable_persistence = self.config.enable_persistence; + let session_mutation_locks = self.session_mutation_locks.clone(); let context_store = self.context_store.clone(); let prompt_cache_store = self.prompt_cache_store.clone(); let token_anchor_store = self.token_anchor_store.clone(); @@ -5328,6 +5777,7 @@ impl SessionManager { let candidates = Self::collect_expired_session_candidates(&sessions, now, timeout); for candidate in candidates { + let _mutation_guard = session_mutation_locks.lock(&candidate.session_id).await; debug!( "Cleaning up expired session: session_id={}", candidate.session_id @@ -5499,101 +5949,192 @@ mod tests { ) } - fn test_model(id: &str, context_window: u32) -> ServiceAIModelConfig { - ServiceAIModelConfig { - id: id.to_string(), - name: id.to_string(), - model_name: id.to_string(), - enabled: true, - context_window: Some(context_window), - ..Default::default() - } - } + #[tokio::test] + async fn session_storage_identity_rejects_same_id_in_another_workspace() { + let workspace = TestWorkspace::new(); + let other_workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); + let session_id = "shared-session-id"; - #[test] - fn sync_session_context_window_refreshes_stale_explicit_model_window() { - let ai_config = ServiceAIConfig { - models: vec![test_model("deepseek-v4-pro", 1_000_000)], - ..Default::default() - }; + assert!(manager + .claim_session_storage_path(session_id, workspace.path(), true) + .expect("first workspace claim")); + let error = manager + .claim_session_storage_path(session_id, other_workspace.path(), true) + .expect_err("a second workspace must not reuse an active session id"); - let mut session = Session::new_with_id( - "session-804".to_string(), - "DeepSeek session".to_string(), - "agentic".to_string(), - SessionConfig { - model_id: Some("deepseek-v4-pro".to_string()), - max_context_tokens: 256_000, - ..Default::default() - }, + let message = error.to_string(); + assert!(message.contains(session_id)); + assert!(message.contains("another workspace")); + } + + #[tokio::test] + async fn failed_claim_does_not_release_a_concurrent_same_workspace_claim() { + let workspace = TestWorkspace::new(); + let other_workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); + let manager = test_manager(persistence_manager); + let session_id = "concurrent-restore-session"; - let resolved = - SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + let first_claim = manager + .claim_session_storage_path(session_id, workspace.path(), true) + .expect("first restore claim"); + manager + .claim_session_storage_path(session_id, workspace.path(), true) + .expect("concurrent restore in the same workspace"); - assert_eq!(resolved, Some(1_000_000)); - assert_eq!(session.config.max_context_tokens, 1_000_000); + manager.release_failed_session_storage_path_claim( + session_id, + workspace.path(), + first_claim, + ); + + let error = manager + .claim_session_storage_path(session_id, other_workspace.path(), true) + .expect_err("a concurrent same-workspace restore must retain the binding"); + assert!(error.to_string().contains("another workspace")); } - #[test] - fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { - let mut ai_config = ServiceAIConfig { - models: vec![ - test_model("primary-model", 512_000), - test_model("agent-model", 1_000_000), - ], + #[tokio::test] + async fn ephemeral_session_creation_rejects_active_duplicate_but_allows_evicted_id_reuse() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session_id = "reusable-session-id"; + let config = SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; - ai_config.default_models.primary = Some("primary-model".to_string()); - ai_config - .agent_models - .insert("agentic".to_string(), "agent-model".to_string()); - - let mut session = Session::new_with_id( - "session-auto".to_string(), - "Auto session".to_string(), - "agentic".to_string(), - SessionConfig { - model_id: Some("auto".to_string()), - max_context_tokens: 256_000, - ..Default::default() - }, - ); - let resolved = - SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + manager + .create_session_with_id_and_details( + Some(session_id.to_string()), + "Original".to_string(), + "agentic".to_string(), + config.clone(), + None, + SessionKind::EphemeralChild, + ) + .await + .expect("first session should create"); + let duplicate = manager + .create_session_with_id_and_details( + Some(session_id.to_string()), + "Duplicate".to_string(), + "agentic".to_string(), + config.clone(), + None, + SessionKind::EphemeralChild, + ) + .await + .expect_err("an active duplicate must fail"); + assert!(duplicate.to_string().contains("already exists")); - assert_eq!(resolved, Some(1_000_000)); - assert_eq!(session.config.max_context_tokens, 1_000_000); + manager.sessions.remove(session_id); + manager + .create_session_with_id_and_details( + Some(session_id.to_string()), + "Recreated".to_string(), + "agentic".to_string(), + config, + None, + SessionKind::EphemeralChild, + ) + .await + .expect("an evicted same-workspace session id should be reusable"); + } - ai_config.agent_models.clear(); - session.config.max_context_tokens = 256_000; + #[tokio::test] + async fn persistent_session_creation_rejects_an_evicted_on_disk_id_without_overwriting_turns() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + let manager = test_manager(persistence_manager); + let session_id = "persisted-session-id"; + let config = SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }; - let resolved = - SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + manager + .create_session_with_id( + Some(session_id.to_string()), + "Original".to_string(), + "agentic".to_string(), + config.clone(), + ) + .await + .expect("first persistent session should create"); + let turns_dir = sessions_dir.join(session_id).join("turns"); + std::fs::create_dir_all(&turns_dir).expect("turns directory"); + let sentinel = turns_dir.join("existing-turn.json"); + std::fs::write(&sentinel, b"existing history").expect("persisted turn sentinel"); + manager.sessions.remove(session_id); + + let error = manager + .create_session_with_id( + Some(session_id.to_string()), + "Replacement".to_string(), + "agentic".to_string(), + config, + ) + .await + .expect_err("an evicted persistent session id must not be reused"); - assert_eq!(resolved, Some(512_000)); - assert_eq!(session.config.max_context_tokens, 512_000); + assert!(error.to_string().contains("already exists")); + assert_eq!( + std::fs::read(&sentinel).expect("existing turns must remain untouched"), + b"existing history" + ); + assert!(manager.get_session(session_id).is_none()); } #[tokio::test] - async fn auto_save_interval_waits_before_first_tick() { - let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); - let started = tokio::time::Instant::now(); + async fn invalid_fixed_session_id_does_not_claim_or_insert_runtime_state() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let invalid_id = "../other-session"; - ticker.tick().await; + let error = manager + .create_session_with_id( + Some(invalid_id.to_string()), + "Invalid".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect_err("path-like session ids must be rejected"); - assert!(started.elapsed() >= Duration::from_millis(30)); + assert!(error.to_string().contains("session_id")); + assert!(manager.get_session(invalid_id).is_none()); + assert!(manager.session_storage_path_index.get(invalid_id).is_none()); } #[tokio::test] - async fn auto_save_snapshot_collection_releases_session_map_guards() { + async fn background_title_update_cannot_recreate_storage_during_deletion() { let workspace = TestWorkspace::new(); - let manager = in_memory_test_manager(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + let manager = Arc::new(test_manager(persistence_manager.clone())); let session = manager .create_session( - "Auto-save snapshot".to_string(), - "agent".to_string(), + "Original".to_string(), + "agentic".to_string(), SessionConfig { workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() @@ -5601,29 +6142,328 @@ mod tests { ) .await .expect("session should create"); + let session_id = session.session_id.clone(); + let deletion_guard = manager + .acquire_session_mutation(&session_id) + .await + .expect("deletion mutation guard"); - let snapshots = SessionManager::collect_auto_save_snapshots(&manager.sessions); - assert!(snapshots - .iter() - .any(|snapshot| snapshot.session_id == session.session_id)); + let title_manager = manager.clone(); + let title_session_id = session_id.clone(); + let title_update = tokio::spawn(async move { + title_manager + .update_session_title_if_current(&title_session_id, "Original", "Generated title") + .await + }); + tokio::task::yield_now().await; + assert!( + !title_update.is_finished(), + "title persistence must wait for the deletion mutation boundary" + ); - match manager.sessions.try_get_mut(&session.session_id) { - TryResult::Present(_) => {} - TryResult::Absent => panic!("session should remain present"), - TryResult::Locked => panic!("snapshot collection should not retain session map guards"), - }; + persistence_manager + .delete_session(&sessions_dir, &session_id) + .await + .expect("persistence deletion"); + manager.sessions.remove(&session_id); + manager.session_storage_path_index.remove(&session_id); + drop(deletion_guard); + + let error = title_update + .await + .expect("title task should not panic") + .expect_err("deleted session title update must fail"); + assert!(error.to_string().contains("not found")); + assert!(!sessions_dir.join(&session_id).exists()); } #[tokio::test] - async fn reset_session_state_if_processing_ignores_a_newer_turn() { + async fn loaded_session_identity_check_preserves_processing_state() { + let workspace = TestWorkspace::new(); let manager = in_memory_test_manager(); - let session_id = Uuid::new_v4().to_string(); - let mut session = Session::new_with_id( - session_id.clone(), - "Active session".to_string(), - "agent".to_string(), - SessionConfig::default(), - ); + let session = manager + .create_session( + "Active".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let storage_path = manager + .session_storage_path_index + .get(&session.session_id) + .expect("storage binding") + .path + .clone(); + manager + .sessions + .get_mut(&session.session_id) + .expect("active session") + .state = SessionState::Processing { + current_turn_id: "turn-active".to_string(), + phase: ProcessingPhase::Thinking, + }; + + assert!(manager + .is_session_loaded_from_storage_path(&storage_path, &session.session_id) + .expect("identity check")); + assert!(matches!( + manager.get_session(&session.session_id).expect("session").state, + SessionState::Processing { ref current_turn_id, .. } + if current_turn_id == "turn-active" + )); + } + + #[tokio::test] + async fn restoring_an_already_loaded_session_preserves_processing_state() { + 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( + "Active".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("active session") + .state = SessionState::Processing { + current_turn_id: "turn-active".to_string(), + phase: ProcessingPhase::Thinking, + }; + + manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("idempotent restore"); + + assert!(matches!( + manager.get_session(&session.session_id).expect("session").state, + SessionState::Processing { ref current_turn_id, .. } + if current_turn_id == "turn-active" + )); + } + + #[tokio::test] + async fn session_creation_waits_for_the_same_session_mutation_permit() { + let workspace = TestWorkspace::new(); + let manager = Arc::new(in_memory_test_manager()); + let session_id = "serialized-create-session"; + let guard = manager.lock_session_mutation(session_id).await; + let manager_for_create = manager.clone(); + let workspace_path = workspace.path().to_string_lossy().to_string(); + + let create_task = tokio::spawn(async move { + manager_for_create + .create_session_with_id( + Some(session_id.to_string()), + "Serialized".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path), + ..Default::default() + }, + ) + .await + }); + tokio::task::yield_now().await; + assert!(!create_task.is_finished()); + + drop(guard); + create_task + .await + .expect("create task should join") + .expect("create should continue after the permit is released"); + } + + #[tokio::test] + async fn session_restore_waits_for_the_same_session_mutation_permit() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = Arc::new(test_manager(persistence_manager)); + let session = manager + .create_session( + "Persisted".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.remove(&session.session_id); + + let guard = manager.lock_session_mutation(&session.session_id).await; + let manager_for_restore = manager.clone(); + let session_id = session.session_id.clone(); + let workspace_path = workspace.path().to_path_buf(); + let restore_task = tokio::spawn(async move { + manager_for_restore + .restore_session(&workspace_path, &session_id) + .await + }); + tokio::task::yield_now().await; + assert!(!restore_task.is_finished()); + + drop(guard); + restore_task + .await + .expect("restore task should join") + .expect("restore should continue after the permit is released"); + } + + #[tokio::test] + async fn persistence_manager_accessor_reuses_runtime_owner() { + let persistence_manager = + Arc::new(PersistenceManager::new(test_path_manager()).expect("persistence manager")); + let manager = test_manager(persistence_manager.clone()); + + assert!(Arc::ptr_eq( + &persistence_manager, + &manager.persistence_manager() + )); + } + + fn test_model(id: &str, context_window: u32) -> ServiceAIModelConfig { + ServiceAIModelConfig { + id: id.to_string(), + name: id.to_string(), + model_name: id.to_string(), + enabled: true, + context_window: Some(context_window), + ..Default::default() + } + } + + #[test] + fn sync_session_context_window_refreshes_stale_explicit_model_window() { + let ai_config = ServiceAIConfig { + models: vec![test_model("deepseek-v4-pro", 1_000_000)], + ..Default::default() + }; + + let mut session = Session::new_with_id( + "session-804".to_string(), + "DeepSeek session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("deepseek-v4-pro".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + } + + #[test] + fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { + let mut ai_config = ServiceAIConfig { + models: vec![ + test_model("primary-model", 512_000), + test_model("agent-model", 1_000_000), + ], + ..Default::default() + }; + ai_config.default_models.primary = Some("primary-model".to_string()); + ai_config + .agent_models + .insert("agentic".to_string(), "agent-model".to_string()); + + let mut session = Session::new_with_id( + "session-auto".to_string(), + "Auto session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("auto".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + + ai_config.agent_models.clear(); + session.config.max_context_tokens = 256_000; + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(512_000)); + assert_eq!(session.config.max_context_tokens, 512_000); + } + + #[tokio::test] + async fn auto_save_interval_waits_before_first_tick() { + let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); + let started = tokio::time::Instant::now(); + + ticker.tick().await; + + assert!(started.elapsed() >= Duration::from_millis(30)); + } + + #[tokio::test] + async fn auto_save_snapshot_collection_releases_session_map_guards() { + let workspace = TestWorkspace::new(); + let manager = in_memory_test_manager(); + let session = manager + .create_session( + "Auto-save snapshot".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let snapshots = SessionManager::collect_auto_save_snapshots(&manager.sessions); + assert!(snapshots + .iter() + .any(|snapshot| snapshot.session_id == session.session_id)); + + match manager.sessions.try_get_mut(&session.session_id) { + TryResult::Present(_) => {} + TryResult::Absent => panic!("session should remain present"), + TryResult::Locked => panic!("snapshot collection should not retain session map guards"), + }; + } + + #[tokio::test] + async fn reset_session_state_if_processing_ignores_a_newer_turn() { + let manager = in_memory_test_manager(); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Active session".to_string(), + "agent".to_string(), + SessionConfig::default(), + ); session.state = SessionState::Processing { current_turn_id: "turn-2".to_string(), phase: ProcessingPhase::Thinking, @@ -5791,6 +6631,51 @@ mod tests { assert_eq!(metadata.turn_count, 1); } + #[tokio::test] + async fn append_completed_local_command_turn_waits_for_session_mutation() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = Arc::new(test_manager(persistence_manager)); + let session = manager + .create_session( + "Serialized local command".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let mutation = manager + .acquire_session_mutation(&session.session_id) + .await + .expect("hold mutation boundary"); + let append_manager = manager.clone(); + let append_session_id = session.session_id.clone(); + let append = tokio::spawn(async move { + append_manager + .append_completed_local_command_turn( + &append_session_id, + "usage report".to_string(), + Some("usage-turn".to_string()), + Some(1), + None, + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!(!append.is_finished()); + drop(mutation); + append + .await + .expect("append task should join") + .expect("append should succeed after mutation releases"); + } + #[tokio::test] async fn restore_session_resets_processing_state_without_marking_unread_completion() { let workspace = TestWorkspace::new(); @@ -6715,6 +7600,137 @@ mod tests { assert_eq!(metadata.turn_count, 1); } + #[tokio::test] + async fn rollback_context_failure_preserves_turn_history() { + 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( + "Rollback failure".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + for index in 0..2 { + let turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session.session_id.clone(), + UserMessageData { + id: format!("turn-{index}-user"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-0".to_string(), "turn-1".to_string()]; + + let error = manager + .rollback_context_to_turn_start(workspace.path(), &session.session_id, 1) + .await + .expect_err("missing context snapshot must fail rollback"); + + assert!(error.to_string().contains("context snapshot"), "{error}"); + assert_eq!( + manager + .get_session(&session.session_id) + .expect("session remains loaded") + .dialog_turn_ids, + vec!["turn-0".to_string(), "turn-1".to_string()] + ); + assert_eq!( + persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should remain") + .len(), + 2 + ); + } + + #[tokio::test] + async fn rollback_context_waits_for_the_session_mutation_boundary() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = Arc::new(test_manager(persistence_manager.clone())); + let session = manager + .create_session( + "Serialized rollback".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let turn = DialogTurnData::new( + "turn-0".to_string(), + 0, + session.session_id.clone(), + UserMessageData { + id: "turn-0-user".to_string(), + content: "prompt".to_string(), + timestamp: 0, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + manager + .sessions + .get_mut(&session.session_id) + .expect("session should be active") + .dialog_turn_ids = vec!["turn-0".to_string()]; + + let mutation = manager + .acquire_session_mutation(&session.session_id) + .await + .expect("hold mutation boundary"); + let rollback_manager = manager.clone(); + let rollback_workspace = workspace.path().to_path_buf(); + let rollback_session_id = session.session_id.clone(); + let rollback = tokio::spawn(async move { + rollback_manager + .rollback_context_to_turn_start(&rollback_workspace, &rollback_session_id, 0) + .await + }); + tokio::task::yield_now().await; + assert!(!rollback.is_finished()); + + drop(mutation); + rollback + .await + .expect("rollback task should join") + .expect("rollback should succeed after mutation releases"); + assert!(persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load") + .is_empty()); + } + #[tokio::test] async fn latest_skill_agent_snapshot_scans_persistence_beyond_stale_cache_hit() { use crate::agentic::skill_agent_snapshot::{ @@ -7237,7 +8253,7 @@ mod tests { .session_storage_path_index .get(&session.session_id) .as_deref() - .map(|entry| entry.to_path_buf()), + .map(|entry| entry.path.clone()), Some(expected_storage_path) ); @@ -7252,6 +8268,156 @@ mod tests { .is_none()); } + #[tokio::test] + async fn delete_session_accepts_an_already_resolved_sessions_directory() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let resolved_sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Resolved storage session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + manager + .delete_session(&resolved_sessions_dir, &session.session_id) + .await + .expect("resolved sessions path should be idempotent"); + + assert!(manager.get_session(&session.session_id).is_none()); + assert!(!resolved_sessions_dir.join(&session.session_id).exists()); + } + + #[tokio::test] + async fn evicted_session_uses_persisted_workspace_identity_for_snapshot_cleanup() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let resolved_sessions_dir = persistence_manager + .path_manager() + .project_sessions_dir(workspace.path()); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Evicted cleanup session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + manager.sessions.remove(&session.session_id); + + let cleanup_workspace_path = manager + .resolve_session_cleanup_workspace_path( + &resolved_sessions_dir, + &session.session_id, + &resolved_sessions_dir, + ) + .await; + + assert_eq!( + dunce::canonicalize(cleanup_workspace_path).expect("cleanup workspace should exist"), + dunce::canonicalize(workspace.path()).expect("workspace should exist") + ); + } + + #[tokio::test] + async fn delete_session_rejects_a_loaded_session_from_another_workspace() { + let workspace = TestWorkspace::new(); + let other_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( + "Bound session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let error = manager + .delete_session(other_workspace.path(), &session.session_id) + .await + .expect_err("cross-workspace deletion must be rejected"); + + assert!(error.to_string().contains("another workspace")); + assert!(manager.get_session(&session.session_id).is_some()); + assert!(manager + .session_storage_path_index + .contains_key(&session.session_id)); + } + + #[tokio::test] + async fn persistence_delete_failure_preserves_loaded_runtime_context() { + 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( + "Failure atomic session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + manager.context_store.add_message( + &session.session_id, + Message::user("runtime context must survive".to_string()), + ); + let storage_path = manager + .session_storage_path_index + .get(&session.session_id) + .expect("storage binding") + .path + .clone(); + let index_path = storage_path.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 + .delete_session(workspace.path(), &session.session_id) + .await + .expect_err("persistence failure should abort runtime cleanup"); + + assert!(manager.get_session(&session.session_id).is_some()); + assert_eq!( + manager + .context_store + .get_context_messages(&session.session_id) + .len(), + 1 + ); + assert!(manager + .session_storage_path_index + .contains_key(&session.session_id)); + } + #[test] fn build_messages_from_turns_skips_model_invisible_turns() { use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs index b1466f36a8..1cd6c974e8 100644 --- a/src/crates/assembly/core/src/agentic/session/session_store_port.rs +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use bitfun_runtime_ports::{ @@ -52,16 +52,71 @@ impl CoreSessionStorePort { .ok() } - fn resolved_sessions_dir_kind( + fn has_parent_traversal(path: &Path) -> bool { + path.components() + .any(|component| matches!(component, Component::ParentDir)) + } + + fn nearest_existing_ancestor(path: &Path) -> Option<&Path> { + let mut candidate = Some(path); + while let Some(current) = candidate { + if current.exists() { + return Some(current); + } + candidate = current.parent(); + } + None + } + + fn is_confined_to_managed_root(root: &Path, path: &Path) -> bool { + if Self::has_parent_traversal(path) || !path.starts_with(root) { + return false; + } + + if !root.exists() { + return true; + } + + let Ok(canonical_root) = dunce::canonicalize(root) else { + return false; + }; + let Some(existing_ancestor) = Self::nearest_existing_ancestor(path) else { + return false; + }; + let Ok(canonical_ancestor) = dunce::canonicalize(existing_ancestor) else { + return false; + }; + + canonical_ancestor == canonical_root || canonical_ancestor.starts_with(canonical_root) + } + + fn looks_like_resolved_sessions_dir(path_manager: &PathManager, path: &Path) -> bool { + if path.file_name().and_then(|value| value.to_str()) != Some("sessions") { + return false; + } + + if path.starts_with(path_manager.remote_ssh_mirror_root_dir()) { + return true; + } + + let projects_root = path_manager.projects_root(); + path.parent() + .and_then(Path::parent) + .is_some_and(|candidate| candidate == projects_root) + } + + pub(crate) fn resolved_sessions_dir_kind( path_manager: &PathManager, - path: &std::path::Path, + path: &Path, ) -> Option { - if path.file_name().and_then(|value| value.to_str()) != Some("sessions") { + if Self::has_parent_traversal(path) + || path.file_name().and_then(|value| value.to_str()) != Some("sessions") + { return None; } let remote_mirror_root = path_manager.remote_ssh_mirror_root_dir(); - if path.starts_with(&remote_mirror_root) { + if Self::is_confined_to_managed_root(&remote_mirror_root, path) { return Some( if path .components() @@ -75,9 +130,11 @@ impl CoreSessionStorePort { } let projects_root = path_manager.projects_root(); - path.parent() + let has_local_shape = path + .parent() .and_then(|runtime_root| runtime_root.parent()) - .is_some_and(|candidate| candidate == projects_root.as_path()) + .is_some_and(|candidate| candidate == projects_root.as_path()); + (has_local_shape && Self::is_confined_to_managed_root(&projects_root, path)) .then_some(SessionStorageKind::Local) } } @@ -95,6 +152,12 @@ impl SessionStorePort for CoreSessionStorePort { request: SessionStoragePathRequest, ) -> PortResult { let path_manager = self.path_manager(); + if Self::has_parent_traversal(&request.workspace_path) { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Session workspace_path must not contain parent-directory traversal", + )); + } if let Some(storage_kind) = Self::resolved_sessions_dir_kind(&path_manager, &request.workspace_path) { @@ -106,6 +169,12 @@ impl SessionStorePort for CoreSessionStorePort { request.remote_ssh_host, )); } + if Self::looks_like_resolved_sessions_dir(&path_manager, &request.workspace_path) { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "Resolved session storage path is outside its managed root", + )); + } let workspace_path = request.workspace_path.to_string_lossy().to_string(); let identity = resolve_workspace_session_identity( @@ -163,3 +232,68 @@ impl SessionStorePort for CoreSessionStorePort { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn test_port() -> (CoreSessionStorePort, PathBuf) { + let test_root = + std::env::temp_dir().join(format!("bitfun-session-store-port-{}", Uuid::new_v4())); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + test_root.join("user"), + )); + ( + CoreSessionStorePort::with_path_manager_for_tests(path_manager), + test_root, + ) + } + + #[tokio::test] + async fn resolved_sessions_path_rejects_parent_directory_traversal() { + let (port, test_root) = test_port(); + let remote_root = port.path_manager().remote_ssh_mirror_root_dir(); + let path = remote_root + .join("example-host") + .join("repo") + .join("..") + .join("outside") + .join("sessions"); + + let result = port + .resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: path, + remote_connection_id: None, + remote_ssh_host: None, + }) + .await; + + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(test_root); + } + + #[cfg(unix)] + #[tokio::test] + async fn resolved_sessions_path_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let (port, test_root) = test_port(); + let remote_root = port.path_manager().remote_ssh_mirror_root_dir(); + let outside = test_root.join("outside"); + std::fs::create_dir_all(outside.join("sessions")).expect("outside sessions directory"); + std::fs::create_dir_all(&remote_root).expect("remote root"); + symlink(&outside, remote_root.join("escape")).expect("escape symlink"); + + let result = port + .resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: remote_root.join("escape").join("sessions"), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await; + + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(test_root); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs index a9ae9b65f6..3d11a5a34c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -4,8 +4,9 @@ use async_trait::async_trait; use bitfun_agent_runtime::user_questions::{ - ask_user_question_available_for_acp_transport, build_answered_user_question_result, + ask_user_question_available_in_context, build_answered_user_question_result, build_cancelled_user_question_result, validate_ask_user_question_input, AskUserQuestionInput, + USER_INPUT_AVAILABLE_CONTEXT_KEY, }; use log::{debug, warn}; use serde_json::{json, Value}; @@ -31,8 +32,9 @@ impl AskUserQuestionTool { } fn is_available_for_tool_context(context: Option<&ToolUseContext>) -> bool { - ask_user_question_available_for_acp_transport( + ask_user_question_available_in_context( context.and_then(|ctx| ctx.custom_data.get("acp_transport")), + context.and_then(|ctx| ctx.custom_data.get(USER_INPUT_AVAILABLE_CONTEXT_KEY)), ) } @@ -175,6 +177,12 @@ Usage notes: input: &Value, context: &ToolUseContext, ) -> BitFunResult> { + if !Self::is_available_for_tool_context(Some(context)) { + return Err(crate::util::errors::BitFunError::tool( + "AskUserQuestion is unavailable because this execution surface cannot accept interactive user input", + )); + } + // 1. Parse input parameters let tool_input: AskUserQuestionInput = serde_json::from_value(input.clone()).map_err(|e| { @@ -296,6 +304,46 @@ mod tests { assert!(tool.is_available_in_context(Some(&context)).await); } + #[tokio::test] + async fn ask_user_question_is_hidden_when_human_input_is_unavailable() { + let tool = AskUserQuestionTool::new(); + let context = context_with_custom_data(HashMap::from([( + "user_input_available".to_string(), + serde_json::Value::Bool(false), + )])); + + assert!(!tool.is_available_in_context(Some(&context)).await); + } + + #[tokio::test] + async fn ask_user_question_fails_without_waiting_when_human_input_is_unavailable() { + let tool = AskUserQuestionTool::new(); + let context = context_with_custom_data(HashMap::from([( + "user_input_available".to_string(), + serde_json::Value::Bool(false), + )])); + let input = serde_json::json!({ + "questions": [{ + "question": "Continue?", + "header": "Continue", + "options": [ + { "label": "Yes", "description": "Continue" }, + { "label": "No", "description": "Stop" } + ] + }] + }); + + let error = tokio::time::timeout( + std::time::Duration::from_millis(100), + tool.call(&input, &context), + ) + .await + .expect("non-interactive question must not wait") + .expect_err("non-interactive question must fail"); + + assert!(error.to_string().contains("cannot accept interactive")); + } + #[test] fn ask_user_question_schema_defaults_multi_select_to_false() { let schema = AskUserQuestionTool::new().input_schema(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 2b7c1445f8..9ef685e336 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -35,24 +35,7 @@ impl CronTool { } fn validate_session_id(session_id: &str) -> Result<(), String> { - if session_id.is_empty() { - return Err("session_id cannot be empty".to_string()); - } - if session_id == "." || session_id == ".." { - return Err("session_id cannot be '.' or '..'".to_string()); - } - if session_id.contains('/') || session_id.contains('\\') { - return Err("session_id cannot contain path separators".to_string()); - } - if !session_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return Err( - "session_id can only contain ASCII letters, numbers, '-' and '_'".to_string(), - ); - } - Ok(()) + bitfun_core_types::validate_session_id(session_id) } fn validate_job_id(job_id: &str) -> Result<(), String> { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index b28201a96c..af4df4a7cf 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -520,7 +520,16 @@ Arguments: self.ensure_session_exists(&runtime, &workspace, session_id) .await?; - runtime + let scheduler = get_global_scheduler().ok_or_else(|| { + BitFunError::tool("scheduler not initialized for session deletion".to_string()) + })?; + let deletion_runtime = CoreServiceAgentRuntime::agent_runtime_with_scheduler_ports( + coordinator.clone(), + scheduler, + ) + .map_err(BitFunError::tool)?; + + deletion_runtime .delete_session(AgentSessionDeleteRequest { workspace_path: workspace.display_workspace.clone(), session_id: session_id.to_string(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index 53639fbde6..b0339ec773 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -26,24 +26,7 @@ impl SessionHistoryTool { } fn validate_session_id(session_id: &str) -> Result<(), String> { - if session_id.is_empty() { - return Err("session_id cannot be empty".to_string()); - } - if session_id == "." || session_id == ".." { - return Err("session_id cannot be '.' or '..'".to_string()); - } - if session_id.contains('/') || session_id.contains('\\') { - return Err("session_id cannot contain path separators".to_string()); - } - if !session_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return Err( - "session_id can only contain ASCII letters, numbers, '-' and '_'".to_string(), - ); - } - Ok(()) + bitfun_core_types::validate_session_id(session_id) } fn resolve_session_id(&self, session_id: &str) -> BitFunResult { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index cf9bdcd0e0..d53db6d2b3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -40,24 +40,23 @@ impl SessionMessageTool { } fn validate_session_id(session_id: &str) -> Result<(), String> { - if session_id.is_empty() { - return Err("session_id cannot be empty".to_string()); - } - if session_id == "." || session_id == ".." { - return Err("session_id cannot be '.' or '..'".to_string()); - } - if session_id.contains('/') || session_id.contains('\\') { - return Err("session_id cannot contain path separators".to_string()); - } - if !session_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + bitfun_core_types::validate_session_id(session_id) + } + + fn forwarded_user_input_metadata(context: &ToolUseContext) -> serde_json::Map { + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + let mut metadata = serde_json::Map::new(); + if let Some(value @ (Value::Bool(_) | Value::String(_))) = + context.custom_data.get(USER_INPUT_AVAILABLE_CONTEXT_KEY) { - return Err( - "session_id can only contain ASCII letters, numbers, '-' and '_'".to_string(), - ); + let is_boolean_fact = matches!(value, Value::Bool(_)) + || matches!(value, Value::String(text) if matches!(text.as_str(), "true" | "false")); + if is_boolean_fact { + metadata.insert(USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), value.clone()); + } } - Ok(()) + metadata } fn resolve_workspace(&self, workspace: &str, context: &ToolUseContext) -> BitFunResult { @@ -676,7 +675,7 @@ Allowed agent types when creating a session: }), prepended_reminders: prepended_messages, attachments: Vec::new(), - metadata: serde_json::Map::new(), + metadata: Self::forwarded_user_input_metadata(context), }) .await .map_err(|error| { @@ -810,6 +809,24 @@ mod tests { ); } + #[test] + fn session_message_forwards_noninteractive_user_input_fact() { + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + let mut context = empty_context(); + context.custom_data.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + Value::Bool(false), + ); + + let metadata = SessionMessageTool::forwarded_user_input_metadata(&context); + + assert_eq!( + metadata.get(USER_INPUT_AVAILABLE_CONTEXT_KEY), + Some(&Value::Bool(false)) + ); + } + #[test] fn target_agent_type_uses_resolved_agent_type() { assert_eq!( @@ -825,6 +842,7 @@ mod tests { session_id: "worker_1".to_string(), session_name: "Worker".to_string(), agent_type: "agentic".to_string(), + turn_count: 0, created_at_ms: 1, last_active_at_ms: 2, }]; @@ -841,6 +859,7 @@ mod tests { session_id: "worker_1".to_string(), session_name: "Worker".to_string(), agent_type: " ".to_string(), + turn_count: 0, created_at_ms: 1, last_active_at_ms: 2, }]; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 6758aa2df2..6654921bd6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -29,6 +29,23 @@ fn build_deep_review_subagent_context( values } +fn forward_user_input_availability( + context: &ToolUseContext, + subagent_context: &mut HashMap, +) { + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + let Some(value) = context.custom_data.get(USER_INPUT_AVAILABLE_CONTEXT_KEY) else { + return; + }; + let value = match value { + Value::Bool(value) => value.to_string(), + Value::String(value) if matches!(value.as_str(), "true" | "false") => value.clone(), + _ => return, + }; + subagent_context.insert(USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), value); +} + struct BackgroundTaskStartRequest<'a> { coordinator: &'a std::sync::Arc, context: &'a ToolUseContext, @@ -508,13 +525,17 @@ impl TaskTool { ); } - let subagent_context = deep_review_subagent_role.map(|role| { - build_deep_review_subagent_context( - role, - subagent_type.as_deref(), - deep_review_run_manifest.as_ref(), - ) - }); + let mut subagent_context = deep_review_subagent_role + .map(|role| { + build_deep_review_subagent_context( + role, + subagent_type.as_deref(), + deep_review_run_manifest.as_ref(), + ) + }) + .unwrap_or_default(); + forward_user_input_availability(context, &mut subagent_context); + let subagent_context = (!subagent_context.is_empty()).then_some(subagent_context); let prepared_prompt = prompt; if run_in_background { return Self::start_background_task(BackgroundTaskStartRequest { @@ -1007,4 +1028,30 @@ mod target_context_tests { .expect("target evidence should exist"); assert!(evidence.allows_live_repository_context()); } + + #[test] + fn child_context_preserves_non_interactive_user_input_boundary() { + let mut parent = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + }; + parent.custom_data.insert( + bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + Value::Bool(false), + ); + let mut child = HashMap::new(); + + forward_user_input_availability(&parent, &mut child); + + assert_eq!(child["user_input_available"], "false"); + } } diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index bd55ec63e8..53bd7fbcaf 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -6,5 +6,305 @@ mod runtime_services; +use std::path::Path; +use std::sync::Arc; + +use bitfun_agent_runtime::sdk::AgentRuntime; +use bitfun_harness::HarnessRegistry; +use bitfun_runtime_services::RuntimeServices; + +use crate::agentic::coordination::{ConversationCoordinator, DialogScheduler}; +use crate::agentic::core::{Message, Session, SessionConfig, SessionState}; +use crate::agentic::persistence::session_branch::{SessionBranchRequest, SessionBranchResult}; +use crate::agentic::persistence::PersistenceManager; +use crate::service::session::{DialogTurnData, SessionMetadata}; +use crate::service::session_usage::{ + generate_session_usage_report, SessionUsageReport, SessionUsageReportRequest, +}; +use crate::service::token_usage::TokenUsageService; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::util::errors::{BitFunError, BitFunResult}; + pub use bitfun_product_capabilities::ProductRuntimeAssembly as CoreProductRuntimeAssembly; pub use runtime_services::CoreRuntimeServicesProvider; + +fn validate_persisted_session_id(session_id: &str) -> BitFunResult<()> { + bitfun_core_types::validate_session_id(session_id).map_err(BitFunError::Validation) +} + +/// Product-assembly entry for the public Agent Runtime SDK. +/// +/// Concrete coordinator and scheduler ownership remains in Core. Product +/// surfaces receive only the SDK runtime assembled from validated services and +/// harnesses; plugin-host bindings are deliberately not part of this API. +pub struct CoreProductAgentRuntime; + +impl CoreProductAgentRuntime { + pub fn build( + coordinator: Arc, + scheduler: Arc, + services: RuntimeServices, + harness_registry: HarnessRegistry, + ) -> Result { + CoreServiceAgentRuntime::product_agent_runtime( + coordinator, + scheduler, + services, + harness_registry, + ) + } +} + +/// Core-owned compatibility boundary for product operations not yet exposed by +/// the public Agent Runtime SDK. +/// +/// This facade does not own execution. It delegates to the same coordinator, +/// session manager, persistence manager, and user-input channels used by Core. +#[derive(Clone)] +pub struct CoreAgentRuntimeCompatibility { + coordinator: Arc, + persistence: Arc, + token_usage_service: Arc, +} + +impl CoreAgentRuntimeCompatibility { + pub fn build( + coordinator: Arc, + token_usage_service: Arc, + ) -> Self { + let persistence = coordinator.get_session_manager().persistence_manager(); + + Self { + coordinator, + persistence, + token_usage_service, + } + } + + pub async fn create_session_with_id( + &self, + session_id: String, + session_name: String, + agent_type: String, + workspace_path: String, + ) -> BitFunResult { + self.coordinator + .create_session_with_id( + Some(session_id), + session_name, + agent_type, + SessionConfig { + workspace_path: Some(workspace_path), + ..Default::default() + }, + ) + .await + } + + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.coordinator + .restore_session(workspace_path, session_id) + .await + } + + pub async fn is_session_loaded( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.coordinator + .get_session_manager() + .is_session_loaded_for_workspace_path(workspace_path, session_id) + .await + } + + pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { + self.coordinator.get_messages(session_id).await + } + + pub async fn update_session_model(&self, session_id: &str, model_id: &str) -> BitFunResult<()> { + self.coordinator + .update_session_model(session_id, model_id) + .await + } + + pub async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> BitFunResult<()> { + self.coordinator.confirm_tool(tool_id, updated_input).await + } + + pub async fn reject_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { + self.coordinator.reject_tool(tool_id, reason).await + } + + pub fn submit_user_answers( + &self, + tool_id: &str, + answers: serde_json::Value, + ) -> BitFunResult<()> { + crate::agentic::tools::user_input_manager::get_user_input_manager() + .send_answer(tool_id, answers) + .map_err(BitFunError::tool) + } + + pub async fn branch_session_at_latest_turn( + &self, + workspace_path: &Path, + source_session_id: &str, + ) -> BitFunResult { + let (_, turns) = self + .coordinator + .restore_session_view(workspace_path, source_session_id) + .await?; + let source_turn_id = turns + .last() + .map(|turn| turn.turn_id.clone()) + .ok_or_else(|| { + BitFunError::Validation("Session has no persisted turns to fork".to_string()) + })?; + + self.persistence + .branch_session( + workspace_path, + &SessionBranchRequest { + source_session_id: source_session_id.to_string(), + source_turn_id, + }, + ) + .await + } + + pub async fn generate_session_usage_report( + &self, + request: SessionUsageReportRequest, + ) -> BitFunResult { + validate_persisted_session_id(&request.session_id)?; + generate_session_usage_report( + self.persistence.as_ref(), + Some(self.token_usage_service.as_ref()), + request, + ) + .await + } + + pub async fn list_persisted_sessions( + &self, + workspace_path: &Path, + ) -> BitFunResult> { + self.persistence.list_session_metadata(workspace_path).await + } + + pub async fn load_persisted_session_turns( + &self, + workspace_path: &Path, + session_id: &str, + limit: Option, + ) -> BitFunResult> { + validate_persisted_session_id(session_id)?; + if let Some(limit) = limit { + self.persistence + .load_recent_turns(workspace_path, session_id, limit) + .await + } else { + self.persistence + .load_session_turns(workspace_path, session_id) + .await + } + } + + pub async fn append_completed_local_command_turn( + &self, + session_id: &str, + content: String, + turn_id: Option, + timestamp_ms: Option, + user_message_metadata: Option, + ) -> BitFunResult { + self.coordinator + .get_session_manager() + .append_completed_local_command_turn( + session_id, + content, + turn_id, + timestamp_ms, + user_message_metadata, + ) + .await + } + + pub fn is_turn_processing(&self, session_id: &str, turn_id: &str) -> bool { + self.coordinator + .get_session_manager() + .get_session(session_id) + .is_some_and(|session| { + matches!( + session.state, + SessionState::Processing { current_turn_id, .. } if current_turn_id == turn_id + ) + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bitfun_agent_runtime::sdk::AgentRuntime; + use bitfun_harness::HarnessRegistry; + use bitfun_runtime_services::RuntimeServices; + + use super::{ + validate_persisted_session_id, CoreAgentRuntimeCompatibility, CoreProductAgentRuntime, + }; + use crate::agentic::coordination::{ConversationCoordinator, DialogScheduler}; + use crate::service::token_usage::TokenUsageService; + + #[test] + fn product_agent_runtime_has_one_sdk_safe_builder_boundary() { + fn build( + coordinator: Arc, + scheduler: Arc, + services: RuntimeServices, + harness_registry: HarnessRegistry, + ) -> Result { + CoreProductAgentRuntime::build(coordinator, scheduler, services, harness_registry) + } + + let _ = build; + } + + #[test] + fn compatibility_operations_have_one_core_owned_facade() { + fn build( + coordinator: Arc, + token_usage_service: Arc, + ) -> CoreAgentRuntimeCompatibility { + CoreAgentRuntimeCompatibility::build(coordinator, token_usage_service) + } + + let _ = build; + let _ = CoreAgentRuntimeCompatibility::create_session_with_id; + let _ = CoreAgentRuntimeCompatibility::restore_session; + let _ = CoreAgentRuntimeCompatibility::get_messages; + let _ = CoreAgentRuntimeCompatibility::branch_session_at_latest_turn; + let _ = CoreAgentRuntimeCompatibility::generate_session_usage_report; + let _ = CoreAgentRuntimeCompatibility::list_persisted_sessions; + let _ = CoreAgentRuntimeCompatibility::load_persisted_session_turns; + let _ = CoreAgentRuntimeCompatibility::is_turn_processing; + } + + #[test] + fn persisted_session_compatibility_rejects_path_like_ids() { + let error = validate_persisted_session_id("../../other-project/session") + .expect_err("compatibility boundary must reject path-like session ids"); + + assert!(error.to_string().contains("session_id"), "{error}"); + } +} diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 139342245b..02e93ce510 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -1,6 +1,7 @@ use crate::agentic::persistence::PersistenceManager; use crate::service::session::{ - DialogTurnData, DialogTurnKind, ModelRoundData, ToolItemData, TurnStatus, + collect_hidden_subagent_cascade, DialogTurnData, DialogTurnKind, ModelRoundData, + SessionMetadata, ToolItemData, TurnStatus, }; use crate::service::session_usage::classifier::classify_tool_usage; use crate::service::session_usage::redaction::{ @@ -44,16 +45,24 @@ pub async fn generate_session_usage_report( let turns = persistence_manager .load_session_turns(Path::new(&workspace_path), &request.session_id) .await?; + let (session_ids, subagent_scope_complete) = token_usage_session_scope( + persistence_manager, + Path::new(&workspace_path), + &request, + &turns, + ) + .await; + let (token_turn_scope, turn_scope_complete) = token_usage_turn_scope( + persistence_manager, + Path::new(&workspace_path), + &request, + &turns, + &session_ids, + ) + .await; let token_records = if let Some(service) = token_usage_service { service - .query_records(TokenUsageQuery { - model_id: None, - session_id: Some(request.session_id.clone()), - time_range: TimeRange::All, - limit: None, - offset: None, - include_subagent: request.include_hidden_subagents, - }) + .query_records_for_sessions(token_usage_query(&request), &session_ids) .await .map_err(|error| { BitFunError::service(format!("Failed to query token usage records: {}", error)) @@ -64,15 +73,144 @@ pub async fn generate_session_usage_report( let snapshot_facts = load_snapshot_facts(&request).await; - Ok(build_session_usage_report_from_sources( + Ok(build_session_usage_report_from_sources_with_scope( request, &turns, &token_records, &snapshot_facts, Utc::now().timestamp_millis(), + &token_turn_scope, + subagent_scope_complete && turn_scope_complete, )) } +fn token_usage_query(request: &SessionUsageReportRequest) -> TokenUsageQuery { + TokenUsageQuery { + model_id: None, + // Hidden subagents use their own session IDs. The storage call receives + // the exact parent/child session set separately and scans history once. + session_id: None, + time_range: TimeRange::All, + limit: None, + offset: None, + include_subagent: request.include_hidden_subagents, + } +} + +async fn token_usage_session_scope( + persistence_manager: &PersistenceManager, + workspace_path: &Path, + request: &SessionUsageReportRequest, + turns: &[DialogTurnData], +) -> (HashSet, bool) { + if !request.include_hidden_subagents { + return (HashSet::from([request.session_id.clone()]), true); + } + let metadata = persistence_manager + .list_session_metadata_including_internal(workspace_path) + .await + .ok(); + token_usage_session_ids(request, turns, metadata.as_deref()) +} + +fn token_usage_session_ids( + request: &SessionUsageReportRequest, + turns: &[DialogTurnData], + metadata: Option<&[SessionMetadata]>, +) -> (HashSet, bool) { + let reportable_turns = turns + .iter() + .filter(|turn| is_reportable_usage_turn(turn)) + .cloned() + .collect::>(); + let mut session_ids = HashSet::from([request.session_id.clone()]); + if !request.include_hidden_subagents { + return (session_ids, true); + } + + let direct_session_ids = iter_tools(&reportable_turns) + .filter_map(|tool| tool.subagent_session_id.as_ref()) + .cloned() + .collect::>(); + let parent_turn_ids = reportable_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect::>(); + let persisted_cascade = metadata + .map(|metadata| { + collect_hidden_subagent_cascade( + metadata.iter().cloned(), + &request.session_id, + &parent_turn_ids, + ) + .into_iter() + .collect::>() + }) + .unwrap_or_default(); + let complete = metadata.is_some() && direct_session_ids.is_subset(&persisted_cascade); + session_ids.extend(persisted_cascade); + session_ids.extend(direct_session_ids); + (session_ids, complete) +} + +type TokenUsageTurnScope = HashMap>; + +fn extend_token_usage_turn_scope( + scope: &mut TokenUsageTurnScope, + session_id: &str, + turns: &[DialogTurnData], +) { + let reportable_turns = turns + .iter() + .filter(|turn| is_reportable_usage_turn(turn)) + .cloned() + .collect::>(); + scope + .entry(session_id.to_string()) + .or_default() + .extend(reportable_turns.iter().map(|turn| turn.turn_id.clone())); + for tool in iter_tools(&reportable_turns) { + if let (Some(child_session_id), Some(child_turn_id)) = ( + tool.subagent_session_id.as_ref(), + tool.subagent_dialog_turn_id.as_ref(), + ) { + scope + .entry(child_session_id.clone()) + .or_default() + .insert(child_turn_id.clone()); + } + } +} + +async fn token_usage_turn_scope( + persistence_manager: &PersistenceManager, + workspace_path: &Path, + request: &SessionUsageReportRequest, + parent_turns: &[DialogTurnData], + session_ids: &HashSet, +) -> (TokenUsageTurnScope, bool) { + let mut scope = TokenUsageTurnScope::new(); + extend_token_usage_turn_scope(&mut scope, &request.session_id, parent_turns); + if !request.include_hidden_subagents { + return (scope, true); + } + + let mut complete = true; + for session_id in session_ids { + if session_id == &request.session_id { + continue; + } + match persistence_manager + .load_session_turns(workspace_path, session_id) + .await + { + Ok(turns) => extend_token_usage_turn_scope(&mut scope, session_id, &turns), + Err(_) => complete = false, + } + } + (scope, complete) +} + pub fn build_session_usage_report_from_turns( request: SessionUsageReportRequest, turns: &[DialogTurnData], @@ -94,6 +232,29 @@ pub fn build_session_usage_report_from_sources( token_records: &[TokenUsageRecord], snapshot_facts: &UsageSnapshotFacts, generated_at: i64, +) -> SessionUsageReport { + let (_, subagent_scope_complete) = token_usage_session_ids(&request, turns, None); + let mut token_turn_scope = TokenUsageTurnScope::new(); + extend_token_usage_turn_scope(&mut token_turn_scope, &request.session_id, turns); + build_session_usage_report_from_sources_with_scope( + request, + turns, + token_records, + snapshot_facts, + generated_at, + &token_turn_scope, + subagent_scope_complete, + ) +} + +fn build_session_usage_report_from_sources_with_scope( + request: SessionUsageReportRequest, + turns: &[DialogTurnData], + token_records: &[TokenUsageRecord], + snapshot_facts: &UsageSnapshotFacts, + generated_at: i64, + token_turn_scope: &TokenUsageTurnScope, + subagent_scope_complete: bool, ) -> SessionUsageReport { let reportable_turns: Vec = turns .iter() @@ -101,11 +262,35 @@ pub fn build_session_usage_report_from_sources( .cloned() .collect(); let turns = reportable_turns.as_slice(); + // Token usage is stored globally and session IDs are only unique within a + // workspace. Join records back to the exact parent/child lineage loaded + // from this workspace so equal identifiers elsewhere cannot contaminate + // the report. + let scoped_token_records: Vec = token_records + .iter() + .filter(|record| { + token_turn_scope + .get(&record.session_id) + .is_some_and(|turn_ids| turn_ids.contains(&record.turn_id)) + && (record.session_id == request.session_id || request.include_hidden_subagents) + }) + .cloned() + .collect(); + let includes_subagent_records = scoped_token_records + .iter() + .any(|record| record.session_id != request.session_id); + let token_records = scoped_token_records.as_slice(); let mut report = SessionUsageReport::partial_unavailable(&request.session_id, generated_at); report.report_id = format!("usage-{}-{}", request.session_id, generated_at); report.workspace = build_workspace(&request); - report.scope = build_scope(turns, request.include_hidden_subagents); - report.coverage = build_coverage(&request, turns, token_records, snapshot_facts); + report.scope = build_scope(turns, includes_subagent_records); + report.coverage = build_coverage( + &request, + turns, + token_records, + snapshot_facts, + subagent_scope_complete, + ); report.time = build_time_breakdown(turns, generated_at); report.tokens = build_token_breakdown(token_records); report.models = build_model_breakdown(turns, token_records); @@ -200,9 +385,10 @@ fn build_coverage( turns: &[DialogTurnData], token_records: &[TokenUsageRecord], snapshot_facts: &UsageSnapshotFacts, + subagent_scope_complete: bool, ) -> UsageCoverage { let mut available = vec![UsageCoverageKey::WorkspaceIdentity]; - if request.include_hidden_subagents { + if request.include_hidden_subagents && subagent_scope_complete { available.push(UsageCoverageKey::SubagentScope); } if turns @@ -269,7 +455,14 @@ fn build_coverage( ); } if missing.contains(&UsageCoverageKey::SubagentScope) { - notes.push("Subagent rows are excluded from this report scope.".to_string()); + if request.include_hidden_subagents { + notes.push( + "Subagent coverage is partial; only token records linked by persisted session lineage are included." + .to_string(), + ); + } else { + notes.push("Subagent rows are excluded from this report scope.".to_string()); + } } if snapshot_facts.source_available { notes.push( @@ -1379,6 +1572,206 @@ mod tests { ); } + #[test] + fn report_excludes_token_records_not_owned_by_loaded_turns() { + let request = test_request(None); + let mut unrelated_record = test_token_record("model-b", 200, 40, 0); + unrelated_record.turn_id = "turn-from-another-workspace".to_string(); + + let report = build_session_usage_report_from_turns( + request, + &[test_turn("turn-1", 0, DialogTurnKind::UserDialog)], + &[test_token_record("model-a", 100, 20, 0), unrelated_record], + 1_778_347_200_000, + ); + + assert_eq!(report.tokens.total_tokens, Some(120)); + assert_eq!(report.models.len(), 1); + assert_eq!(report.models[0].model_id, "model-a"); + } + + #[test] + fn report_excludes_equal_turn_id_from_another_session() { + let request = test_request(None); + let mut unrelated_record = test_token_record("model-b", 200, 40, 0); + unrelated_record.session_id = "session-from-another-workspace".to_string(); + + let report = build_session_usage_report_from_turns( + request, + &[test_turn("turn-1", 0, DialogTurnKind::UserDialog)], + &[test_token_record("model-a", 100, 20, 0), unrelated_record], + 1_778_347_200_000, + ); + + assert_eq!(report.tokens.total_tokens, Some(120)); + assert_eq!(report.models.len(), 1); + assert_eq!(report.models[0].model_id, "model-a"); + } + + #[test] + fn report_includes_only_linked_hidden_subagent_records_when_requested() { + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + let tool = &mut turn.model_rounds[0].tool_items[0]; + tool.subagent_session_id = Some("child-session".to_string()); + tool.subagent_dialog_turn_id = Some("child-turn".to_string()); + + let root_record = test_token_record("model-a", 100, 20, 0); + let mut child_record = test_token_record("model-b", 50, 10, 0); + child_record.session_id = "child-session".to_string(); + child_record.turn_id = "child-turn".to_string(); + child_record.is_subagent = true; + let records = [root_record, child_record]; + + let included = build_session_usage_report_from_turns( + test_request(None), + std::slice::from_ref(&turn), + &records, + 1_778_347_200_000, + ); + let mut excluded_request = test_request(None); + excluded_request.include_hidden_subagents = false; + let excluded = build_session_usage_report_from_turns( + excluded_request, + &[turn], + &records, + 1_778_347_200_000, + ); + + assert_eq!(included.tokens.total_tokens, Some(180)); + assert_eq!(included.models.len(), 2); + assert_eq!(excluded.tokens.total_tokens, Some(120)); + assert_eq!(excluded.models.len(), 1); + } + + #[test] + fn report_excludes_unverifiable_legacy_child_records_and_marks_lineage_partial() { + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + turn.model_rounds[0].tool_items[0].subagent_session_id = Some("child-session".to_string()); + + let mut child_record = test_token_record("model-b", 50, 10, 0); + child_record.session_id = "child-session".to_string(); + child_record.turn_id = "legacy-child-turn".to_string(); + child_record.is_subagent = true; + let report = build_session_usage_report_from_turns( + test_request(None), + &[turn], + &[test_token_record("model-a", 100, 20, 0), child_record], + 1_778_347_200_000, + ); + + assert_eq!(report.tokens.total_tokens, Some(120)); + assert!(!report.scope.includes_subagents); + assert!(report + .coverage + .missing + .contains(&UsageCoverageKey::SubagentScope)); + assert!(report + .coverage + .notes + .iter() + .any(|note| note.contains("Subagent coverage is partial"))); + } + + #[test] + fn report_excludes_same_child_session_id_with_unowned_turn_id() { + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + let tool = &mut turn.model_rounds[0].tool_items[0]; + tool.subagent_session_id = Some("child-session".to_string()); + tool.subagent_dialog_turn_id = Some("owned-child-turn".to_string()); + + let mut owned = test_token_record("model-b", 50, 10, 0); + owned.session_id = "child-session".to_string(); + owned.turn_id = "owned-child-turn".to_string(); + owned.is_subagent = true; + let mut collision = test_token_record("model-c", 500, 100, 0); + collision.session_id = "child-session".to_string(); + collision.turn_id = "other-workspace-turn".to_string(); + collision.is_subagent = true; + + let report = build_session_usage_report_from_turns( + test_request(None), + &[turn], + &[test_token_record("model-a", 100, 20, 0), owned, collision], + 1_778_347_200_000, + ); + + assert_eq!(report.tokens.total_tokens, Some(180)); + assert_eq!( + report + .models + .iter() + .map(|model| model.model_id.as_str()) + .collect::>(), + BTreeSet::from(["model-a", "model-b"]) + ); + } + + #[test] + fn usage_scope_resolves_persisted_hidden_subagent_cascade() { + let request = test_request(None); + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + turn.model_rounds[0].tool_items[0].subagent_session_id = Some("child-session".to_string()); + + let mut child = SessionMetadata::new( + "child-session".to_string(), + "Child".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + child.relationship = Some(crate::service::session::SessionRelationship { + kind: Some(crate::service::session::SessionRelationshipKind::Subagent), + parent_session_id: Some("session-1".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("turn-1".to_string()), + parent_turn_index: Some(0), + parent_tool_call_id: Some("tool-1".to_string()), + subagent_type: Some("Explore".to_string()), + }); + let mut grandchild = SessionMetadata::new( + "grandchild-session".to_string(), + "Grandchild".to_string(), + "Explore".to_string(), + "model".to_string(), + ); + grandchild.relationship = Some(crate::service::session::SessionRelationship { + kind: Some(crate::service::session::SessionRelationshipKind::Subagent), + parent_session_id: Some("child-session".to_string()), + parent_request_id: None, + parent_dialog_turn_id: Some("child-turn".to_string()), + parent_turn_index: Some(0), + parent_tool_call_id: Some("child-tool".to_string()), + subagent_type: Some("Explore".to_string()), + }); + + let (session_ids, complete) = + token_usage_session_ids(&request, &[turn], Some(&[child, grandchild])); + + assert!(complete); + assert_eq!( + session_ids, + HashSet::from([ + "session-1".to_string(), + "child-session".to_string(), + "grandchild-session".to_string(), + ]) + ); + } + + #[test] + fn usage_query_bounds_storage_results_to_parent_and_linked_children() { + let request = test_request(None); + let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); + turn.model_rounds[0].tool_items[0].subagent_session_id = Some("child-session".to_string()); + + let (session_ids, complete) = token_usage_session_ids(&request, &[turn], None); + + assert!(!complete); + assert_eq!( + session_ids, + HashSet::from(["session-1".to_string(), "child-session".to_string()]) + ); + } + #[test] fn report_active_runtime_uses_active_span_union() { let request = test_request(None); diff --git a/src/crates/assembly/core/src/service/token_usage/service.rs b/src/crates/assembly/core/src/service/token_usage/service.rs index 353bd28c98..0153e41177 100644 --- a/src/crates/assembly/core/src/service/token_usage/service.rs +++ b/src/crates/assembly/core/src/service/token_usage/service.rs @@ -6,7 +6,7 @@ use super::types::{ }; use crate::infrastructure::PathManager; use anyhow::Result; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -90,6 +90,17 @@ impl TokenUsageService { .map_err(anyhow::Error::msg) } + pub(crate) async fn query_records_for_sessions( + &self, + query: TokenUsageQuery, + session_ids: &HashSet, + ) -> Result> { + self.inner + .query_records_for_sessions(query, session_ids) + .await + .map_err(anyhow::Error::msg) + } + pub async fn get_summary(&self, query: TokenUsageQuery) -> Result { self.inner .get_summary(query) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 471e4e3fd7..fab44b29e9 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -36,6 +36,7 @@ use bitfun_services_integrations::remote_connect::{ }; use log::{debug, error, info}; use std::sync::Arc; +use std::time::Duration; use crate::agentic::coordination::{ get_global_coordinator, get_global_scheduler, ConversationCoordinator, DialogQueuePriority, @@ -389,6 +390,108 @@ fn core_agent_runtime_builder( .with_agent_registry(agent_registry) } +#[derive(Clone)] +struct ScheduledSessionManagementPort { + coordinator: Arc, + scheduler: Arc, +} + +impl ScheduledSessionManagementPort { + fn new(coordinator: Arc, scheduler: Arc) -> Self { + Self { + coordinator, + scheduler, + } + } +} + +#[async_trait::async_trait] +impl AgentSessionManagementPort for ScheduledSessionManagementPort { + async fn list_sessions( + &self, + request: bitfun_runtime_ports::AgentSessionListRequest, + ) -> bitfun_runtime_ports::PortResult> { + AgentSessionManagementPort::list_sessions(self.coordinator.as_ref(), request).await + } + + async fn delete_session( + &self, + request: bitfun_runtime_ports::AgentSessionDeleteRequest, + ) -> bitfun_runtime_ports::PortResult<()> { + bitfun_core_types::validate_session_id(&request.session_id).map_err(|message| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + message, + ) + })?; + let storage_path = CoreSessionStorePort::default() + .resolve_session_storage_path(SessionStoragePathRequest { + workspace_path: std::path::PathBuf::from(&request.workspace_path), + remote_connection_id: request.remote_connection_id.clone(), + remote_ssh_host: request.remote_ssh_host.clone(), + }) + .await + .map(|resolution| resolution.effective_storage_path) + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + error.to_string(), + ) + })?; + self.coordinator + .get_session_manager() + .validate_session_storage_path_binding(&request.session_id, &storage_path) + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + error.to_string(), + ) + })?; + let _maintenance = self + .scheduler + .begin_session_deletion(&request.session_id, &storage_path, Duration::from_secs(2)) + .await + .map_err(|error| { + let kind = match error { + crate::util::errors::BitFunError::Validation(_) => { + bitfun_runtime_ports::PortErrorKind::InvalidRequest + } + crate::util::errors::BitFunError::NotFound(_) => { + bitfun_runtime_ports::PortErrorKind::NotFound + } + crate::util::errors::BitFunError::Timeout(_) => { + bitfun_runtime_ports::PortErrorKind::Timeout + } + crate::util::errors::BitFunError::Cancelled(_) => { + bitfun_runtime_ports::PortErrorKind::Cancelled + } + _ => bitfun_runtime_ports::PortErrorKind::Backend, + }; + bitfun_runtime_ports::PortError::new(kind, error.to_string()) + })?; + AgentSessionManagementPort::delete_session(self.coordinator.as_ref(), request).await + } + + async fn resolve_session_workspace_binding( + &self, + request: bitfun_runtime_ports::AgentSessionWorkspaceRequest, + ) -> bitfun_runtime_ports::PortResult> + { + AgentSessionManagementPort::resolve_session_workspace_binding( + self.coordinator.as_ref(), + request, + ) + .await + } +} + +fn scheduled_session_management_port( + coordinator: Arc, + scheduler: Arc, +) -> Arc { + Arc::new(ScheduledSessionManagementPort::new(coordinator, scheduler)) +} + pub(crate) struct CoreServiceAgentRuntime; impl CoreServiceAgentRuntime { @@ -674,7 +777,8 @@ impl CoreServiceAgentRuntime { scheduler: Arc, ) -> Result { let submission: Arc = coordinator.clone(); - let session_management: Arc = coordinator.clone(); + let session_management = + scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator; let dialog_turn: Arc = scheduler.clone(); @@ -696,7 +800,8 @@ impl CoreServiceAgentRuntime { scheduler: Arc, ) -> Result { let submission: Arc = coordinator.clone(); - let session_management: Arc = coordinator.clone(); + let session_management = + scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator; let lifecycle_delivery: Arc = scheduler; @@ -716,7 +821,8 @@ impl CoreServiceAgentRuntime { scheduler: Arc, ) -> Result { let submission: Arc = coordinator.clone(); - let session_management: Arc = coordinator.clone(); + let session_management = + scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let thread_goal_management: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let dialog_turn: Arc = scheduler.clone(); @@ -733,6 +839,34 @@ impl CoreServiceAgentRuntime { .map_err(|error| error.to_string()) } + pub(crate) fn product_agent_runtime( + coordinator: Arc, + scheduler: Arc, + services: bitfun_runtime_services::RuntimeServices, + harness_registry: bitfun_harness::HarnessRegistry, + ) -> Result { + let submission: Arc = coordinator.clone(); + let session_management = + scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let thread_goal_management: Arc = coordinator; + let cancellation: Arc = scheduler.clone(); + let dialog_turn: Arc = scheduler.clone(); + let lifecycle_delivery: Arc = scheduler; + + core_agent_runtime_builder( + submission, + session_management, + thread_goal_management, + cancellation, + ) + .with_dialog_turn_port(dialog_turn) + .with_lifecycle_delivery_port(lifecycle_delivery) + .with_services(services) + .with_harness_registry(Arc::new(harness_registry)) + .build() + .map_err(|error| error.to_string()) + } + pub(crate) fn global_agent_runtime_with_lifecycle_delivery() -> Result { let coordinator = get_global_coordinator() .ok_or_else(|| "Desktop session system not ready".to_string())?; diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index 1f21a9966d..cab4210991 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -16,7 +16,7 @@ pub use ai::{ ToolCallResponseInfo, ToolDefinition, }; pub use errors::{AiErrorDetail, ErrorCategory}; -pub use session::SessionKind; +pub use session::{validate_session_id, SessionKind}; pub use surface::{ ApprovalSource, CapabilityRequest, CapabilityRequestKind, PermissionDecision, PermissionScope, RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, diff --git a/src/crates/contracts/core-types/src/session.rs b/src/crates/contracts/core-types/src/session.rs index 86444a5fbd..87f2d28659 100644 --- a/src/crates/contracts/core-types/src/session.rs +++ b/src/crates/contracts/core-types/src/session.rs @@ -8,3 +8,59 @@ pub enum SessionKind { Subagent, EphemeralChild, } + +pub fn validate_session_id(session_id: &str) -> Result<(), String> { + if session_id.is_empty() { + return Err("session_id cannot be empty".to_string()); + } + if session_id == "." || session_id == ".." { + return Err("session_id cannot be '.' or '..'".to_string()); + } + if session_id.contains('/') || session_id.contains('\\') { + return Err("session_id cannot contain path separators".to_string()); + } + if session_id.chars().any(char::is_control) { + return Err("session_id cannot contain control characters".to_string()); + } + let bytes = session_id.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Err("session_id cannot use a drive-relative path prefix".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_session_id; + + #[test] + fn session_ids_are_single_safe_path_components() { + for valid in [ + "session-1", + "session_2", + "A9", + "id with space", + "会话-1", + "miniapp-customize:builtin-gomoku:1", + ] { + validate_session_id(valid).expect("valid session id"); + } + for invalid in [ + "", + ".", + "..", + "../outside", + "a/b", + "a\\b", + "C:\\outside", + "C:outside", + "miniapp-customize:../outside:1", + "control\0character", + ] { + assert!( + validate_session_id(invalid).is_err(), + "unsafe session id must fail: {invalid}" + ); + } + } +} diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index eb9e89377e..6ebab6823d 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -981,6 +981,8 @@ pub struct AgentSessionSummary { pub session_id: String, pub session_name: String, pub agent_type: String, + #[serde(default)] + pub turn_count: usize, pub created_at_ms: u64, pub last_active_at_ms: u64, } @@ -1169,6 +1171,10 @@ impl DialogSubmissionPolicy { self.skip_tool_confirmation = skip_tool_confirmation; self } + + pub const fn requires_tool_confirmation(self) -> bool { + matches!(self.trigger_source, DialogTriggerSource::Cli) && !self.skip_tool_confirmation + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1952,6 +1958,25 @@ mod tests { let cli = DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli); assert_eq!(cli.queue_priority, DialogQueuePriority::Normal); assert!(!cli.skip_tool_confirmation); + assert!(cli.requires_tool_confirmation()); + let cli_json = serde_json::to_value(cli).expect("serialize cli policy"); + assert!(cli_json.get("requireToolConfirmation").is_none()); + + let auto = cli.with_skip_tool_confirmation(true); + assert!(auto.skip_tool_confirmation); + assert!(!auto.requires_tool_confirmation()); + } + + #[test] + fn legacy_cli_policy_without_require_field_still_requires_confirmation() { + let policy: DialogSubmissionPolicy = serde_json::from_value(serde_json::json!({ + "triggerSource": "cli", + "queuePriority": "normal", + "skipToolConfirmation": false + })) + .expect("legacy policy"); + + assert!(policy.requires_tool_confirmation()); } #[test] @@ -2541,6 +2566,7 @@ mod tests { session_id: "session_1".to_string(), session_name: "Main".to_string(), agent_type: "agentic".to_string(), + turn_count: 3, created_at_ms: 1000, last_active_at_ms: 2000, }; @@ -2572,6 +2598,7 @@ mod tests { assert_eq!(list_json["remoteConnectionId"], "conn-1"); assert_eq!(list_json["remoteSshHost"], "host-1"); assert_eq!(summary_json["sessionId"], "session_1"); + assert_eq!(summary_json["turnCount"], 3); assert_eq!(summary_json["createdAtMs"], 1000); assert_eq!(summary_json["lastActiveAtMs"], 2000); assert_eq!(delete_json["sessionId"], "session_1"); diff --git a/src/crates/execution/agent-runtime/src/event_queue.rs b/src/crates/execution/agent-runtime/src/event_queue.rs index 7e3dbaab11..72cbf3b28a 100644 --- a/src/crates/execution/agent-runtime/src/event_queue.rs +++ b/src/crates/execution/agent-runtime/src/event_queue.rs @@ -10,7 +10,7 @@ use std::collections::BinaryHeap; use std::sync::Arc; use tokio::sync::{broadcast, Mutex, Notify}; -const EVENT_BROADCAST_BUFFER: usize = 1024; +const MIN_EVENT_BROADCAST_BUFFER: usize = 1024; const SLOW_EVENT_QUEUE_LATENCY_MS: u128 = 250; /// Event queue configuration @@ -62,7 +62,11 @@ pub struct EventQueue { impl EventQueue { pub fn new(config: EventQueueConfig) -> Self { - let (broadcast_tx, _) = broadcast::channel(EVENT_BROADCAST_BUFFER); + // Keep subscriber backlog capacity at least as large as the existing + // dequeue queue budget so switching a consumer to broadcast does not + // reduce the amount of burst traffic it can tolerate. + let broadcast_capacity = config.max_queue_size.max(MIN_EVENT_BROADCAST_BUFFER); + let (broadcast_tx, _) = broadcast::channel(broadcast_capacity); Self { queue: Arc::new(Mutex::new(BinaryHeap::new())), notify: Arc::new(Notify::new()), @@ -82,17 +86,23 @@ impl EventQueue { let envelope = EventEnvelope::new(event, priority); let event_id = envelope.id.clone(); - let queue_len = { + let (queue_len, queued) = { let mut queue = self.queue.lock().await; if queue.len() >= self.config.max_queue_size { - warn!("Event queue full, dropping event: event_id={}", event_id); - return Ok(event_id); + warn!( + "Event queue full, skipping legacy queue storage: event_id={}", + event_id + ); + (queue.len(), false) + } else { + queue.push(std::cmp::Reverse(envelope.clone())); + (queue.len(), true) } - - queue.push(std::cmp::Reverse(envelope.clone())); - queue.len() }; + // Broadcast delivery is authoritative for non-consuming runtime + // subscribers and must not depend on capacity in the legacy dequeue + // buffer. let _ = self.broadcast_tx.send(envelope); { @@ -101,8 +111,9 @@ impl EventQueue { stats.pending_events = queue_len; } - // Notify waiting consumers - self.notify.notify_one(); + if queued { + self.notify.notify_one(); + } trace!( "Event enqueued: event_id={}, priority={:?}", @@ -226,3 +237,121 @@ impl StreamEventSink for EventQueue { let _ = EventQueue::enqueue(self, event, priority).await; } } + +#[cfg(test)] +mod tests { + use super::{EventQueue, EventQueueConfig}; + use bitfun_events::AgenticEvent; + use std::sync::Arc; + use tokio::sync::Barrier; + + #[tokio::test] + async fn full_legacy_queue_does_not_drop_broadcast_delivery() { + let queue = EventQueue::new(EventQueueConfig { + max_queue_size: 1, + batch_size: 1, + }); + let mut events = queue.subscribe(); + + for session_id in ["first", "second"] { + queue + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: session_id.to_string(), + new_state: "idle".to_string(), + }, + None, + ) + .await + .expect("event should enqueue"); + } + + assert_eq!(queue.len().await, 1); + assert_eq!( + events + .recv() + .await + .expect("first broadcast") + .event + .session_id(), + Some("first") + ); + assert_eq!( + events + .recv() + .await + .expect("second broadcast") + .event + .session_id(), + Some("second") + ); + } + + #[tokio::test] + async fn default_sized_broadcast_preserves_bursts_above_legacy_1024_limit() { + let queue = EventQueue::new(EventQueueConfig::default()); + let mut events = queue.subscribe(); + const EVENT_COUNT: usize = 2048; + + for index in 0..EVENT_COUNT { + queue + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: "session".to_string(), + new_state: index.to_string(), + }, + None, + ) + .await + .expect("event should enqueue"); + } + + for expected in 0..EVENT_COUNT { + let envelope = events.recv().await.expect("burst event must be retained"); + assert!(matches!( + envelope.event, + AgenticEvent::SessionStateChanged { ref new_state, .. } + if new_state == &expected.to_string() + )); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_publishers_have_one_order_for_all_subscribers() { + const EVENT_COUNT: usize = 64; + let queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let mut first = queue.subscribe(); + let mut second = queue.subscribe(); + let barrier = Arc::new(Barrier::new(EVENT_COUNT)); + let mut tasks = Vec::with_capacity(EVENT_COUNT); + + for index in 0..EVENT_COUNT { + let queue = queue.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + queue + .enqueue( + AgenticEvent::SessionStateChanged { + session_id: format!("event-{index}"), + new_state: "idle".to_string(), + }, + None, + ) + .await + .expect("event should enqueue") + })); + } + for task in tasks { + task.await.expect("publisher should complete"); + } + + let mut first_ids = Vec::with_capacity(EVENT_COUNT); + let mut second_ids = Vec::with_capacity(EVENT_COUNT); + for _ in 0..EVENT_COUNT { + first_ids.push(first.recv().await.expect("first broadcast").id); + second_ids.push(second.recv().await.expect("second broadcast").id); + } + assert_eq!(first_ids, second_ids); + } +} diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index ebcd10811a..32a0639d6b 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -815,6 +815,7 @@ mod tests { session_id: "session_1".to_string(), session_name: "Main".to_string(), agent_type: "agentic".to_string(), + turn_count: 3, created_at_ms: 1000, last_active_at_ms: 2000, }]) diff --git a/src/crates/execution/agent-runtime/src/scheduler.rs b/src/crates/execution/agent-runtime/src/scheduler.rs index 49d5638452..d75eec80aa 100644 --- a/src/crates/execution/agent-runtime/src/scheduler.rs +++ b/src/crates/execution/agent-runtime/src/scheduler.rs @@ -126,6 +126,13 @@ pub struct ActiveDialogTurnStore { inner: dashmap::DashMap, } +#[derive(Debug)] +pub enum ActiveDialogTurnTakeResult { + Matched(ActiveDialogTurn), + Absent, + DifferentTurn, +} + impl ActiveDialogTurnStore { pub fn insert(&self, session_id: &str, turn: ActiveDialogTurn) { self.inner.insert(session_id.to_string(), turn); @@ -135,10 +142,28 @@ impl ActiveDialogTurnStore { self.inner.remove(session_id).map(|(_, turn)| turn) } + /// Atomically take the active metadata only when it belongs to the + /// outcome's turn generation. + pub fn take_for_outcome(&self, session_id: &str, turn_id: &str) -> ActiveDialogTurnTakeResult { + match self.inner.entry(session_id.to_string()) { + dashmap::mapref::entry::Entry::Occupied(entry) if entry.get().turn_id() == turn_id => { + ActiveDialogTurnTakeResult::Matched(entry.remove()) + } + dashmap::mapref::entry::Entry::Occupied(_) => ActiveDialogTurnTakeResult::DifferentTurn, + dashmap::mapref::entry::Entry::Vacant(_) => ActiveDialogTurnTakeResult::Absent, + } + } + pub fn contains(&self, session_id: &str) -> bool { self.inner.contains_key(session_id) } + pub fn matches_turn(&self, session_id: &str, turn_id: &str) -> bool { + self.inner + .get(session_id) + .is_some_and(|turn| turn.turn_id() == turn_id) + } + pub fn suppression_key_for_requester( &self, target_session_id: &str, @@ -297,20 +322,27 @@ impl DialogTurnQueue { } pub fn dequeue_next(&self, session_id: &str) -> Option { - self.inner + let turn = self + .inner .get_mut(session_id) - .and_then(|mut q| q.pop_front().map(|item| item.turn)) + .and_then(|mut queue| queue.pop_front().map(|item| item.turn)); + self.inner + .remove_if(session_id, |_, queue| queue.is_empty()); + turn } pub fn remove_first_matching(&self, session_id: &str, mut predicate: F) -> Option where F: FnMut(&T) -> bool, { - self.inner.get_mut(session_id).and_then(|mut q| { + let turn = self.inner.get_mut(session_id).and_then(|mut q| { q.iter() .position(|item| predicate(&item.turn)) .and_then(|index| q.remove(index).map(|item| item.turn)) - }) + }); + self.inner + .remove_if(session_id, |_, queue| queue.is_empty()); + turn } pub fn requeue_front(&self, session_id: &str, turn: T, priority: DialogQueuePriority) { @@ -329,6 +361,7 @@ pub struct AgentSessionReplyPlan { pub target_remote_ssh_host: Option, pub user_input: String, pub reminder_text: String, + pub user_message_metadata: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -872,6 +905,7 @@ From session: {responder_session_id}\n\ From workspace: {responder_workspace}\n\ Status: {status}" ), + user_message_metadata: active_turn.user_message_metadata().cloned(), }) } @@ -915,6 +949,59 @@ pub fn resolve_dialog_steering_action( mod tests { use super::*; + fn active_turn(turn_id: &str) -> ActiveDialogTurn { + ActiveDialogTurn::new( + turn_id.to_string(), + None, + None, + None, + "agentic".to_string(), + "input".to_string(), + None, + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + None, + ) + } + + #[test] + fn active_turn_store_ignores_an_outcome_from_an_older_turn_generation() { + let store = ActiveDialogTurnStore::default(); + store.insert("session-1", active_turn("turn-new")); + + assert!(matches!( + store.take_for_outcome("session-1", "turn-old"), + ActiveDialogTurnTakeResult::DifferentTurn + )); + let ActiveDialogTurnTakeResult::Matched(turn) = + store.take_for_outcome("session-1", "turn-new") + else { + panic!("current turn should be removed"); + }; + assert_eq!(turn.turn_id(), "turn-new"); + assert!(matches!( + store.take_for_outcome("session-1", "turn-new"), + ActiveDialogTurnTakeResult::Absent + )); + } + + #[test] + fn dialog_turn_queue_reclaims_empty_session_entries() { + let queue = DialogTurnQueue::with_max_depth(4); + queue + .enqueue("dequeue", 1, DialogQueuePriority::Normal) + .expect("enqueue"); + queue + .enqueue("remove", 2, DialogQueuePriority::Normal) + .expect("enqueue"); + + assert_eq!(queue.dequeue_next("dequeue"), Some(1)); + assert_eq!( + queue.remove_first_matching("remove", |turn| *turn == 2), + Some(2) + ); + assert!(queue.inner.is_empty()); + } + #[test] fn outcome_lifecycle_dispatches_completed_turn_and_verifies_goal() { let outcome = TurnOutcome::Completed { diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index 7a5e83460b..bf0e4e44b7 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -106,22 +106,7 @@ fn invalid(message: impl Into) -> SessionControlValidationResult { } pub fn validate_session_id(session_id: &str) -> Result<(), String> { - if session_id.is_empty() { - return Err("session_id cannot be empty".to_string()); - } - if session_id == "." || session_id == ".." { - return Err("session_id cannot be '.' or '..'".to_string()); - } - if session_id.contains('/') || session_id.contains('\\') { - return Err("session_id cannot contain path separators".to_string()); - } - if !session_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return Err("session_id can only contain ASCII letters, numbers, '-' and '_'".to_string()); - } - Ok(()) + bitfun_core_types::validate_session_id(session_id) } pub fn default_session_name() -> &'static str { diff --git a/src/crates/execution/agent-runtime/src/tool_confirmation.rs b/src/crates/execution/agent-runtime/src/tool_confirmation.rs index db47283c8f..4ef7584951 100644 --- a/src/crates/execution/agent-runtime/src/tool_confirmation.rs +++ b/src/crates/execution/agent-runtime/src/tool_confirmation.rs @@ -22,6 +22,20 @@ pub struct ToolConfirmationGateFacts { pub any_tool_needs_permission: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolConfirmationPolicyGateFacts { + pub global_skip_tool_confirmation: bool, + pub context_policy: ToolConfirmationContextPolicy, + pub any_tool_needs_permission: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolConfirmationContextPolicy { + Inherit, + Require, + Skip, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolConfirmationGatePlan { SkipByPolicy, @@ -125,11 +139,33 @@ impl ToolConfirmationChannelStore { pub fn resolve_tool_confirmation_gate( facts: ToolConfirmationGateFacts, ) -> ToolConfirmationGatePlan { - if facts.global_skip_tool_confirmation || facts.context_skip_tool_confirmation { + resolve_tool_confirmation_gate_from_skip( + facts.global_skip_tool_confirmation || facts.context_skip_tool_confirmation, + facts.any_tool_needs_permission, + ) +} + +pub fn resolve_tool_confirmation_policy_gate( + facts: ToolConfirmationPolicyGateFacts, +) -> ToolConfirmationGatePlan { + let skip_by_policy = match facts.context_policy { + ToolConfirmationContextPolicy::Require => false, + ToolConfirmationContextPolicy::Skip => true, + ToolConfirmationContextPolicy::Inherit => facts.global_skip_tool_confirmation, + }; + + resolve_tool_confirmation_gate_from_skip(skip_by_policy, facts.any_tool_needs_permission) +} + +fn resolve_tool_confirmation_gate_from_skip( + skip_by_policy: bool, + any_tool_needs_permission: bool, +) -> ToolConfirmationGatePlan { + if skip_by_policy { return ToolConfirmationGatePlan::SkipByPolicy; } - if facts.any_tool_needs_permission { + if any_tool_needs_permission { ToolConfirmationGatePlan::AwaitPermissionedTool } else { ToolConfirmationGatePlan::SkipNoPermissionedTool diff --git a/src/crates/execution/agent-runtime/src/user_questions.rs b/src/crates/execution/agent-runtime/src/user_questions.rs index 51a0e982c8..e3f195f2fa 100644 --- a/src/crates/execution/agent-runtime/src/user_questions.rs +++ b/src/crates/execution/agent-runtime/src/user_questions.rs @@ -107,10 +107,20 @@ pub fn get_user_input_manager() -> &'static UserInputManager { &USER_INPUT_MANAGER } +pub const USER_INPUT_AVAILABLE_CONTEXT_KEY: &str = "user_input_available"; + pub fn ask_user_question_available_for_acp_transport(acp_transport: Option<&Value>) -> bool { !acp_transport.is_some_and(|value| value == "true" || value == &json!(true)) } +pub fn ask_user_question_available_in_context( + acp_transport: Option<&Value>, + user_input_available: Option<&Value>, +) -> bool { + ask_user_question_available_for_acp_transport(acp_transport) + && !user_input_available.is_some_and(|value| value == "false" || value == &json!(false)) +} + pub fn validate_ask_user_question_input(input: &AskUserQuestionInput) -> Result<(), String> { if input.questions.is_empty() { return Err("At least one question is required".to_string()); diff --git a/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs b/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs index 784200fff8..e5d3cdad24 100644 --- a/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/scheduler_contracts.rs @@ -439,6 +439,10 @@ fn agent_session_reply_action_forwards_completed_outcome_with_legacy_reminder_te assert_eq!(plan.target_remote_connection_id.as_deref(), Some("conn-1")); assert_eq!(plan.target_remote_ssh_host.as_deref(), Some("host-1")); assert_eq!(plan.user_input, "done"); + assert_eq!( + plan.user_message_metadata, + Some(serde_json::json!({"kind": "session_message"})) + ); assert_eq!( plan.reminder_text, "This message is an automated reply to a previous SessionMessage call, not a human user message.\n\ diff --git a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs index 599ad58ce1..035ef1c38e 100644 --- a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs @@ -1,7 +1,8 @@ use bitfun_agent_runtime::tool_confirmation::{ resolve_confirmation_failure, resolve_confirmation_wait_result, resolve_tool_confirmation_gate, - resolve_tool_confirmation_plan, ConfirmationFailureKind, ToolConfirmationGateFacts, - ToolConfirmationGatePlan, ToolConfirmationOutcome, ToolConfirmationPlan, + resolve_tool_confirmation_plan, resolve_tool_confirmation_policy_gate, ConfirmationFailureKind, + ToolConfirmationContextPolicy, ToolConfirmationGateFacts, ToolConfirmationGatePlan, + ToolConfirmationOutcome, ToolConfirmationPlan, ToolConfirmationPolicyGateFacts, ToolConfirmationRequestFacts, ToolConfirmationWaitResult, }; use std::time::{Duration, UNIX_EPOCH}; @@ -26,6 +27,18 @@ fn confirmation_gate_preserves_skip_policy_precedence() { ); } +#[test] +fn invocation_require_policy_overrides_the_global_skip_default() { + let plan = resolve_tool_confirmation_policy_gate(ToolConfirmationPolicyGateFacts { + global_skip_tool_confirmation: true, + context_policy: ToolConfirmationContextPolicy::Require, + any_tool_needs_permission: true, + }); + + assert_eq!(plan, ToolConfirmationGatePlan::AwaitPermissionedTool); + assert!(plan.confirm_before_run()); +} + #[test] fn confirmation_gate_requires_confirmation_only_for_permissioned_tools() { let permissioned = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { diff --git a/src/crates/execution/agent-runtime/tests/user_question_tool_contracts.rs b/src/crates/execution/agent-runtime/tests/user_question_tool_contracts.rs index 36bf4ded6a..90dd971169 100644 --- a/src/crates/execution/agent-runtime/tests/user_question_tool_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/user_question_tool_contracts.rs @@ -1,7 +1,7 @@ use bitfun_agent_runtime::user_questions::{ - ask_user_question_available_for_acp_transport, build_answered_user_question_result, - build_cancelled_user_question_result, validate_ask_user_question_input, AskUserQuestionInput, - Question, QuestionOption, + ask_user_question_available_for_acp_transport, ask_user_question_available_in_context, + build_answered_user_question_result, build_cancelled_user_question_result, + validate_ask_user_question_input, AskUserQuestionInput, Question, QuestionOption, }; fn question() -> Question { @@ -64,6 +64,26 @@ fn ask_user_question_available_flag_matches_acp_transport_contract() { assert!(ask_user_question_available_for_acp_transport(None)); } +#[test] +fn ask_user_question_availability_honors_non_interactive_surface_fact() { + assert!(!ask_user_question_available_in_context( + None, + Some(&serde_json::json!(false)), + )); + assert!(!ask_user_question_available_in_context( + None, + Some(&serde_json::json!("false")), + )); + assert!(ask_user_question_available_in_context( + None, + Some(&serde_json::json!(true)), + )); + assert!(!ask_user_question_available_in_context( + Some(&serde_json::json!(true)), + Some(&serde_json::json!(true)), + )); +} + #[test] fn ask_user_question_answered_and_cancelled_results_keep_wire_shape() { let input = AskUserQuestionInput { diff --git a/src/crates/services/services-core/src/session/metadata_store.rs b/src/crates/services/services-core/src/session/metadata_store.rs index e26f2d19e7..6b28033904 100644 --- a/src/crates/services/services-core/src/session/metadata_store.rs +++ b/src/crates/services/services-core/src/session/metadata_store.rs @@ -12,6 +12,7 @@ use super::page::{build_session_metadata_page, empty_session_metadata_page}; use super::types::{SessionMetadata, StoredSessionIndexFile, StoredSessionMetadataFile}; use super::SessionMetadataPage; use crate::json_store::{JsonFileStore, JsonFileStoreError}; +use bitfun_core_types::validate_session_id; use log::warn; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -52,6 +53,16 @@ pub enum SessionMetadataStoreError { #[source] source: std::io::Error, }, + #[error("Invalid session ID: {0}")] + InvalidSessionId(String), + #[error("Failed to resolve session storage path {path}: {source}")] + ResolveSessionStoragePath { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("Session path escapes the sessions root: path={path}, root={root}")] + UnsafeSessionStoragePath { path: PathBuf, root: PathBuf }, } impl SessionMetadataStoreError { @@ -357,6 +368,8 @@ impl SessionMetadataStore { &self, metadata: &SessionMetadata, ) -> Result<(), SessionMetadataStoreError> { + validate_session_id(&metadata.session_id) + .map_err(SessionMetadataStoreError::InvalidSessionId)?; self.ensure_session_dir(&metadata.session_id).await?; let metadata_path = self.metadata_path(&metadata.session_id); let file = StoredSessionMetadataFile::new(metadata.clone()); @@ -381,6 +394,7 @@ impl SessionMetadataStore { &self, session_id: &str, ) -> Result, SessionMetadataStoreError> { + validate_session_id(session_id).map_err(SessionMetadataStoreError::InvalidSessionId)?; let path = self.metadata_path(session_id); Ok(self .read_json_optional::(&path) @@ -392,11 +406,32 @@ impl SessionMetadataStore { &self, session_id: &str, ) -> Result<(), SessionMetadataStoreError> { + validate_session_id(session_id).map_err(SessionMetadataStoreError::InvalidSessionId)?; let lock = self.get_index_lock().await; let _guard = lock.lock().await; let dir = self.session_dir(session_id); let metadata_file_removed = self.metadata_path(session_id).exists(); if dir.exists() { + let root = fs::canonicalize(self.sessions_root()) + .await + .map_err( + |source| SessionMetadataStoreError::ResolveSessionStoragePath { + path: self.sessions_root().to_path_buf(), + source, + }, + )?; + let resolved_dir = fs::canonicalize(&dir).await.map_err(|source| { + SessionMetadataStoreError::ResolveSessionStoragePath { + path: dir.clone(), + source, + } + })?; + if resolved_dir == root || !resolved_dir.starts_with(&root) { + return Err(SessionMetadataStoreError::UnsafeSessionStoragePath { + path: resolved_dir, + root, + }); + } fs::remove_dir_all(&dir) .await .map_err(|source| SessionMetadataStoreError::DeleteSessionDir { source })?; @@ -595,4 +630,68 @@ mod tests { .expect("list after delete") .is_empty()); } + + #[cfg(not(windows))] + #[tokio::test] + async fn metadata_store_preserves_existing_non_traversing_component_ids() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + let session_id = "legacy:session:1"; + + store + .save_metadata(&metadata(session_id, 10)) + .await + .expect("save legacy metadata"); + assert!(store + .load_metadata(session_id) + .await + .expect("load legacy metadata") + .is_some()); + store + .delete_session_dir_and_index(session_id) + .await + .expect("delete legacy session"); + assert!(!dir.path().join(session_id).exists()); + } + + #[tokio::test] + async fn metadata_store_rejects_session_delete_path_traversal() { + let parent = tempdir().expect("parent tempdir"); + let sessions_root = parent.path().join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("sessions root"); + let sentinel = parent.path().join("sentinel"); + std::fs::create_dir_all(&sentinel).expect("sentinel"); + std::fs::write(sentinel.join("keep.txt"), "keep").expect("sentinel file"); + let store = SessionMetadataStore::new(&sessions_root); + + for unsafe_id in ["..", "../sentinel", "C:\\sentinel"] { + assert!( + store.delete_session_dir_and_index(unsafe_id).await.is_err(), + "unsafe session id must fail: {unsafe_id}" + ); + } + + assert_eq!( + std::fs::read_to_string(sentinel.join("keep.txt")).expect("sentinel remains"), + "keep" + ); + } + + #[tokio::test] + async fn metadata_store_rejects_path_like_ids_for_reads_and_writes() { + let dir = tempdir().expect("tempdir"); + let store = SessionMetadataStore::new(dir.path()); + + assert!(store.load_metadata("../outside").await.is_err()); + assert!(store + .save_metadata(&metadata("../outside", 10)) + .await + .is_err()); + assert!(!dir + .path() + .parent() + .expect("parent") + .join("outside") + .exists()); + } } diff --git a/src/crates/services/services-core/src/token_usage/service.rs b/src/crates/services/services-core/src/token_usage/service.rs index 14693039f6..83fa401a0a 100644 --- a/src/crates/services/services-core/src/token_usage/service.rs +++ b/src/crates/services/services-core/src/token_usage/service.rs @@ -5,7 +5,7 @@ use super::types::{ use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc}; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::fs; @@ -359,45 +359,76 @@ impl TokenUsageService { pub async fn query_records( &self, query: TokenUsageQuery, + ) -> Result, String> { + self.query_records_filtered(query, None).await + } + + /// Query records while bounding the returned and retained set to the + /// supplied session identities. The date files are still scanned once, + /// but unrelated records never accumulate in the request's working set. + pub async fn query_records_for_sessions( + &self, + query: TokenUsageQuery, + session_ids: &HashSet, + ) -> Result, String> { + self.query_records_filtered(query, Some(session_ids)).await + } + + async fn query_records_filtered( + &self, + query: TokenUsageQuery, + session_ids: Option<&HashSet>, ) -> Result, String> { let _usage_guard = self.usage_lifecycle.read().await; - let mut all_records = Vec::new(); let record_paths = self.record_paths_for_range(&query.time_range).await?; + let offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(usize::MAX); + if limit == 0 { + return Ok(Vec::new()); + } + let mut matched = 0usize; + let mut records = Vec::new(); for path in record_paths { let content = fs::read_to_string(&path) .await .map_err(|e| format!("Failed to read token usage records: {}", e))?; if let Ok(batch) = serde_json::from_str::(&content) { - all_records.extend(batch.records); - } - } - - let include_subagent = query.include_subagent; - let filtered: Vec = all_records - .into_iter() - .filter(|r| { - if !include_subagent && r.is_subagent { - return false; - } - if let Some(ref model_id) = query.model_id { - if &r.model_id != model_id { - return false; + for record in batch.records { + if !query.include_subagent && record.is_subagent { + continue; } - } - if let Some(ref session_id) = query.session_id { - if &r.session_id != session_id { - return false; + if query + .model_id + .as_ref() + .is_some_and(|model_id| &record.model_id != model_id) + { + continue; + } + if query + .session_id + .as_ref() + .is_some_and(|session_id| &record.session_id != session_id) + { + continue; + } + if session_ids + .is_some_and(|session_ids| !session_ids.contains(&record.session_id)) + { + continue; + } + if matched < offset { + matched += 1; + continue; + } + records.push(record); + if records.len() == limit { + return Ok(records); } } - true - }) - .collect(); - - let offset = query.offset.unwrap_or(0); - let limit = query.limit.unwrap_or(usize::MAX); - - Ok(filtered.into_iter().skip(offset).take(limit).collect()) + } + } + Ok(records) } async fn record_paths_for_range(&self, time_range: &TimeRange) -> Result, String> { @@ -624,6 +655,64 @@ impl TokenUsageService { } } +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[tokio::test] + async fn query_records_for_sessions_filters_before_returning_records() { + let dir = tempfile::tempdir().expect("temp dir"); + let service = TokenUsageService::new(dir.path().to_path_buf()) + .await + .expect("token usage service"); + service + .record_usage( + "model-a".to_string(), + "parent-session".to_string(), + "parent-turn".to_string(), + 10, + 2, + None, + None, + false, + ) + .await + .expect("parent record"); + service + .record_usage( + "model-b".to_string(), + "unrelated-session".to_string(), + "unrelated-turn".to_string(), + 20, + 4, + None, + None, + false, + ) + .await + .expect("unrelated record"); + + let records = service + .query_records_for_sessions( + TokenUsageQuery { + model_id: None, + session_id: None, + time_range: TimeRange::All, + limit: None, + offset: None, + include_subagent: true, + }, + &HashSet::from(["parent-session".to_string()]), + ) + .await + .expect("scoped records"); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].session_id, "parent-session"); + } +} + fn records_date_key(date: DateTime) -> String { date.format("%Y-%m-%d").to_string() } diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts index 80e15b1151..5eb612c665 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.test.ts @@ -1,14 +1,17 @@ import { describe, expect, it } from 'vitest'; -import { buildMiniAppCustomizationSessionRequest } from './miniAppCustomizationSession'; +import { + buildMiniAppCustomizationSessionRequest, + createMiniAppCustomizationSessionId, +} from './miniAppCustomizationSession'; describe('buildMiniAppCustomizationSessionRequest', () => { - it('creates a hidden non-persisted agent session for MiniApp customization', () => { + it('creates a hidden subagent session request for MiniApp customization', () => { expect(buildMiniAppCustomizationSessionRequest({ - sessionId: 'miniapp-customize:builtin-gomoku:1', + sessionId: 'miniapp-customize-builtin-gomoku-1', sessionName: 'Customize Gomoku', workspacePath: 'D:/workspace/BitFun', })).toMatchObject({ - sessionId: 'miniapp-customize:builtin-gomoku:1', + sessionId: 'miniapp-customize-builtin-gomoku-1', sessionName: 'Customize Gomoku', agentType: 'agentic', workspacePath: 'D:/workspace/BitFun', @@ -21,4 +24,10 @@ describe('buildMiniAppCustomizationSessionRequest', () => { }, }); }); + + it('generates a portable session identifier', () => { + expect(createMiniAppCustomizationSessionId('builtin-gomoku')).toMatch( + /^miniapp-customize-builtin-gomoku-\d+$/, + ); + }); }); diff --git a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts index cafcaa0722..6ade033d69 100644 --- a/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts +++ b/src/web-ui/src/app/scenes/miniapps/customization/miniAppCustomizationSession.ts @@ -27,8 +27,8 @@ export function buildMiniAppCustomizationSessionRequest( }; } -function createSessionId(appId: string): string { - return `miniapp-customize:${appId}:${Date.now()}`; +export function createMiniAppCustomizationSessionId(appId: string): string { + return `miniapp-customize-${appId}-${Date.now()}`; } export async function launchMiniAppCustomizationSession(params: { @@ -49,7 +49,7 @@ export async function launchMiniAppCustomizationSession(params: { import('@/flow_chat/store/FlowChatStore'), ]); const request = buildMiniAppCustomizationSessionRequest({ - sessionId: createSessionId(params.appId), + sessionId: createMiniAppCustomizationSessionId(params.appId), sessionName: params.sessionName, workspacePath: params.workspacePath, });