diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 962649f85..3ed6b77bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: cli-test: name: CLI Tests (ubuntu-latest) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v5 @@ -138,7 +139,7 @@ jobs: - name: Run Windows ConPTY CLI smoke test if: runner.os == 'Windows' - run: cargo test --locked -p bitfun-cli --test tui_terminal_process + run: cargo test --locked -p bitfun-cli --test terminal_process_contracts - name: Run core and desktop Rust tests run: cargo test --locked -p bitfun-core -p bitfun-desktop diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 627ebee51..e58de1329 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -111,7 +111,8 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 启动页进程冒烟测试已验证 resize 后仍可交互、 多行输入、空闲 Ctrl+C 和可观察的终端清理序列;Chat 活动 turn 的 resize 静默期已有状态单测,窄屏流式 reflow 已有 TestBackend 回归,Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消已有本地确定性流式模型夹具进程测试; - OS 级初始化失败与异常退出仍需独立验收。 + `exec stream-json` 的 Ctrl+C 也由真实 PTY/ConPTY 进程验证断流、非零退出和单一取消终态。OS 级初始化失败与 + 异常退出仍需独立验收。 - Startup 与 Chat 共用 CLI 私有输入读取器;一次读取同时受 256 个事件和 50ms 限制,跨批次仅延续快速文本尾部, 短批次普通按键保持原有路由。被识别为粘贴的文本按批次写入输入缓冲,每批只刷新一次命令菜单;粘贴内容中的 Tab 明确转换为四个空格。 @@ -148,7 +149,7 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 第一条闭环只完成一个无外部依赖的契约样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | | 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;启动页及本地确定性流式模型夹具驱动的活动 turn 由 PTY/ConPTY 进程测试覆盖 resize、取消和恢复可编辑状态,`stream-json` 覆盖 Patch 写入失败且不会泄漏成功终态;发布归档上传前完成 SHA-256 与解压执行验证 | 真实供应商审批流和 OS 级终端初始化故障注入仍可能晚于 PR 发现 | 只按剩余真实故障补进程契约;避免为同一依赖图重复建立三平台编译矩阵。 | +| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;本地确定性模型夹具覆盖 HTTP 403 授权拒绝、流中断后的重试失败和 Patch 写入失败,真实 PTY/ConPTY 进程覆盖启动页、Chat resize/取消恢复及 `exec` Ctrl+C 的断流和单一取消终态;发布归档上传前完成 SHA-256 与解压执行验证 | 真实供应商审批交互和 OS 级终端初始化故障注入仍可能晚于 PR 发现 | 只按剩余真实故障补进程契约;避免为同一依赖图重复建立三平台编译矩阵。 | ## 3. 分阶段产品需求 @@ -160,15 +161,15 @@ CLI-P0 不是一个统一重构 PR。静态 profile、真实 Runtime Services、 本地 Agent 纵向入口已接入;旧门面仅在后续 owner 迁移的行为等价成立后退出。配置解释、产品定制消费和 TUI 进一步拆分仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 SDK runtime;启动页 PTY/ConPTY 生命周期、Chat 活动 turn 的 resize/取消和发布归档冒烟测试已存在;resize 静默期与窄屏流式 reflow -分别有确定性状态单测和 TestBackend 回归,Patch 写入失败也由真实 `stream-json` 进程保护。真实供应商审批流、 -OS 级终端初始化故障注入与权限失败等完整进程级验收仍需另行完成。 +分别有确定性状态单测和 TestBackend 回归。真实 `stream-json` 进程已保护 Patch 写入失败、本地模型 HTTP 403 授权拒绝、 +流中断后的重试失败和 `exec` Ctrl+C 单一取消终态;真实供应商审批交互与 OS 级终端初始化故障注入仍需另行完成。 其余工作独立立项,不能与 profile 迁移互相充当完成条件: | 切片 | 范围 | 退出条件 | |---|---|---| | 调用级审批 | 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 写入失败;写入失败返回非零并只发送结构化错误,不提前发送成功终态;真实信号与模型权限失败仍需进程级契约 | +| 输出协议 | 保持通用 `text/json/stream-json` 心智,复用现有 Agentic envelope,不新建 CLI schema | 已覆盖结果/envelope 序列化、参数与前置 JSON 失败、失败完成、同会话跨 turn 隔离、stream-json/Patch stdout 冲突及 Patch 写入失败;写入失败返回非零并只发送结构化错误,不提前发送成功终态;真实 PTY/ConPTY 信号、本地模型 HTTP 403 授权拒绝和流中断后的重试失败已有进程契约,真实供应商审批交互另行验收 | | 配置解释 | Canonical Config 层级、全局/项目持续来源、加载状态和兼容导入 dry-run | 不自动写入;冲突、未知字段、待确认能力和凭据引用可解释 | | 产品定制 | 消费最小产品定义、组装结果和已注册 TUI layout/theme ID | 第二个真实 CLI 产品复用后再提升公共字段 | | TUI 边界 | 增量提取终端恢复守卫、命令分发和副作用边界 | 不改版视觉设计;Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消、恢复可编辑状态和正常退出清理可单独验证,macOS 活动 turn 与 OS 级初始化失败注入另行补齐 | @@ -214,12 +215,12 @@ CLI-P1 应保证: |---|---| | `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。成功的 `DialogTurnCompleted` 只在精确结算和 Patch 生成完成后发布;结算或 Patch 失败改为发布 `SystemError` 并以非零状态退出,避免消费者提前确认成功。 | +| `stream-json` | 每行直接序列化一个现有 `AgenticEventEnvelope`;不增加 `schema_version`、`sequence` 或第二套 CLI 事件 taxonomy。所有 terminal envelope 都只在精确结算和 Patch 交付完成后发布一次;结算失败优先于 Patch 失败,Patch 失败优先于 turn 终态,前两者统一改为 `SystemError` 并以非零状态退出。一次执行最多发布一个 terminal envelope 和一条 `BITFUN_EXIT` 分类。 | | 事件范围 | 只输出本次 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。 | +| 终止 | terminal event 决定结果;`success=false` 不能映射为成功。`Ctrl+C` 请求取消,终态观察与精确结算共享同一有界等待;若取消与完成/失败竞争,以实际观察到的 terminal event 为准,若结算失败则只发布替代的 `SystemError`。窗口内未观察到 terminal envelope 时发布结构化错误,不能无终态退出。当前进程仍以通用错误码 `1` 退出;只有实际取消终态使用 stderr 的 `BITFUN_EXIT: cancelled:` 稳定分类。当前公开契约不新增 Agent turn 总时限参数;调用方可使用进程级期限,只有出现真实消费方时才单独设计 deadline。 | CLI 不提供 `--output-schema v1`。Codex/Claude 同类参数表达的是调用方提供的 JSON Schema,用于约束最终模型 响应,不是协议版本选择;如未来支持,应复用该语义并独立设计,不能借此重定义事件 envelope。 @@ -607,7 +608,7 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes `cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。Linux 启动页 PTY 生命周期冒烟随独立 CLI 测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;发布归档在上传前完成 SHA-256 与解压执行 验证。真实供应商模型进程级交互、macOS 活动 PTY 与 OS 级终端故障进程矩阵仍按对应切片补入门禁;Linux PTY -与 Windows ConPTY 的 Chat 活动 turn resize/取消已由本地确定性流式模型夹具覆盖,不能用它替代上述验收。 +与 Windows ConPTY 的 Chat 活动 turn resize/取消及 `exec` Ctrl+C 已由本地确定性流式模型夹具覆盖,不能用它替代上述验收。 ### 10.2 阶段退出条件 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 67fc77d08..d429c49ca 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -28,7 +28,7 @@ | Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v1 preview 门面和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用 envelope 或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded 宿主逻辑仍在 assembly 兼容路径 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位是独立后续工作 | -| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期及本地确定性流式模型夹具驱动的活动 turn 进程测试,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;resize 静默期、活动流式内容窄屏 reflow、Linux PTY/Windows ConPTY 活动 turn resize/取消及 Patch I/O 失败已有分层回归,真实供应商审批流、macOS 活动 PTY 与 OS 级终端故障注入仍需补齐 | +| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期及本地确定性流式模型夹具驱动的活动 turn 进程测试,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;本地模型 HTTP 403 授权拒绝、流中断后的重试失败、Linux PTY/Windows ConPTY Chat resize/取消、`exec` Ctrl+C 及 Patch I/O 失败已有分层回归,真实供应商审批交互、macOS 活动 PTY 与 OS 级终端故障注入仍需补齐 | ## 3. 目标依赖与归属 @@ -75,11 +75,12 @@ Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立 1. 以真实调用方和行为等价测试逐项缩小快照及 Peer Host/ACP 持久化维护兼容面;远程分支另行定义身份和存储语义,模型目录与配置仍保留在产品入口。 2. 继续迁移 ACP 尚未接入 SDK 的持久化历史、模型目录/模式和 MCP 操作;ACP stdio 与协议投影生命周期保留在接口入口。 -3. 继续按真实故障样例拆分 TUI 副作用边界;当前切片已覆盖本地确定性流式模型夹具驱动的 Linux PTY/Windows ConPTY 活动 turn resize/取消、 - `stream-json` Patch 写入失败和终端恢复错误聚合,不以大规模重写替代现有回归保护。 +3. 继续按真实故障样例拆分 TUI 副作用边界;当前切片已覆盖本地确定性流式模型夹具驱动的 Linux PTY/Windows ConPTY Chat resize/取消、 + `exec` Ctrl+C、本地模型 HTTP 403 授权拒绝、流中断后的重试失败、`stream-json` Patch 写入失败和终端恢复错误聚合, + 不以大规模重写替代现有回归保护。 当前 assembly 切换条件已经满足:CLI 生产入口消费真实组装结果,目标链路没有第二套状态,独立测试与三平台 -编译门禁存在,启动页 PTY/ConPTY 生命周期、活动 turn resize/取消、Patch I/O 失败与发布归档冒烟测试已接入门禁。 +编译门禁存在,启动页 PTY/ConPTY 生命周期、Chat resize/取消、`exec` Ctrl+C、本地模型 403/断流失败终态、Patch I/O 失败与发布归档冒烟测试已接入门禁。 CLI-P0 整体退出条件尚未满足;真实供应商审批流、OS 级终端初始化故障注入、兼容门面退出以及 ACP/Desktop 切换仍需分别验收。 ### 4.3 依次切换 ACP 与 Desktop diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 52978cc03..ae55a1a34 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -81,6 +81,10 @@ before product-definition, TUI layout, branding, packaging, runtime, or plugin a stable capability requests instead of reimplementing behavior per entrypoint. - `json` is one result document; `stream-json` is one complete event per line. Keep protocol stdout free of logs and preserve schema/exit-code compatibility. +- Keep `src/modes/exec.rs` as the stable module facade. The current private split + keeps lifecycle/event settlement in `exec/lifecycle.rs` and Patch capture/write + behavior in `exec/patch.rs`; further private splits are allowed when they keep + one executor, one output schema, and one lifecycle owner. - Approval policy is invocation-scoped: interactive TUI defaults to ask; non-interactive execution fails when confirmation is required unless an explicit argument or managed policy approves it. Do not mutate a global diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 630629b40..fe7bdd808 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,1832 +1,8 @@ -/// Exec mode implementation -/// -/// Single command execution mode (non-interactive). -/// Observes core events through an independent runtime broadcast subscription. -use anyhow::Result; -use clap::ValueEnum; -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_agent_runtime::sdk::{PortErrorKind, RuntimeError}; -use bitfun_agent_tools::effective_tool_invocation; -use bitfun_events::{AgenticEvent, ToolEventIdentity}; -use tokio::time::Instant; - -use crate::agent::runtime_client::CliAgentRuntimeClient; -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); - -fn effective_event_invocation<'a>( - identity: &'a ToolEventIdentity, - params: &'a serde_json::Value, -) -> (&'a str, &'a serde_json::Value) { - let (derived_name, effective_input) = effective_tool_invocation(&identity.tool_name, params); - debug_assert_eq!(identity.effective_name(), derived_name); - (derived_name, effective_input) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] -pub(crate) enum ExecOutputFormat { - Text, - Json, - 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_config_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_config_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}"), - }) -} - -fn is_successful_exec_terminal(event: &AgenticEvent, turn_id: &str) -> bool { - matches!( - event, - AgenticEvent::DialogTurnCompleted { - turn_id: event_turn_id, - success, - finish_reason, - has_final_response, - .. - } if event_turn_id == turn_id - && completed_turn_failure( - *success, - finish_reason.as_deref(), - *has_final_response, - ) - .is_none() - ) -} - -fn settlement_failure(error: RuntimeError, session_id: &str, turn_id: &str) -> (ExitKind, String) { - let is_timeout = matches!( - &error, - RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::Timeout - ); - let detail = error.into_message(); - if is_timeout { - ( - ExitKind::SettlementTimedOut, - format!( - "Timed out waiting for exec turn settlement: session_id={session_id}, turn_id={turn_id}: {detail}" - ), - ) - } else { - ( - ExitKind::SystemError, - format!( - "Failed to wait for exec turn settlement: session_id={session_id}, turn_id={turn_id}: {detail}" - ), - ) - } -} - -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, - pub continue_last: bool, - pub session_id: Option, - pub fork_session: bool, -} - -pub(crate) struct ExecMode { - #[allow(dead_code)] - config: CliConfig, - 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, -} - -impl ExecMode { - pub(crate) fn new( - config: CliConfig, - message: String, - agent_type: String, - 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(CliAgentRuntimeClient::new( - runtime.as_ref(), - workspace_path.clone(), - )); - - Self { - config, - message, - agent_type, - agent, - _runtime: runtime, - workspace_path, - output_patch, - output_format, - approval_mode, - session_options, - } - } - - fn exit_context<'a>( - &'a self, - session_id: Option<&'a str>, - turn_id: Option<&'a str>, - ) -> ExitContext<'a> { - ExitContext { - session_id, - turn_id, - agent_type: Some(self.agent_type.as_str()), - workspace: self.workspace_path.as_deref(), - } - } - - fn workspace_display(&self) -> String { - self.workspace_path - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| { - std::env::current_dir() - .map(|path| path.display().to_string()) - .unwrap_or_else(|_| ".".to_string()) - }) - } - - fn redact_large_inline_data(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - if map.remove("data_url").is_some() { - map.insert("has_data_url".to_string(), serde_json::json!(true)); - } - for child in map.values_mut() { - Self::redact_large_inline_data(child); - } - } - serde_json::Value::Array(items) => { - for child in items { - Self::redact_large_inline_data(child); - } - } - _ => {} - } - } - - fn tool_input_preview(params: &serde_json::Value) -> String { - let mut redacted = params.clone(); - Self::redact_large_inline_data(&mut redacted); - let raw = - serde_json::to_string(&redacted).unwrap_or_else(|_| "".to_string()); - if raw.chars().count() <= TOOL_START_INPUT_PREVIEW_CHARS { - return raw; - } - - let preview: String = raw.chars().take(TOOL_START_INPUT_PREVIEW_CHARS).collect(); - format!("{preview}... [truncated]") - } - - fn print_tool_start_details(&self, tool_name: &str, tool_id: &str, params: &serde_json::Value) { - let started_at = chrono::Utc::now().to_rfc3339(); - let cwd = self.workspace_display(); - let input_preview = Self::tool_input_preview(params); - - self.print_text(|| { - 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()) - } - - 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 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; - } - - 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<()> { - tracing::info!( - agent_type = %self.agent_type, - message_len = self.message.len(), - workspace = ?self.workspace_path, - "Executing command" - ); - - 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 mut event_rx = self.agent.event_source().subscribe(); - - self.print_text(|| { - eprintln!("Executing: {}", self.message); - eprintln!(); - eprintln!("Session: {}", session_id); - eprintln!("Thinking..."); - }); - - let turn_id = match self - .agent - .send_message(self.message.clone(), &self.agent_type) - .await - { - 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"); - - // Observe the shared Agentic event stream without consuming other clients' events. - let mut total_tool_calls = 0usize; - let mut subagent_parent_turns: HashMap = HashMap::new(); - let mut terminal_outcome: Option> = None; - let mut terminal_status: Option = None; - let mut terminal_message: Option = None; - let mut assistant_text = String::new(); - let mut usage: Option = None; - let mut deferred_success_envelope: 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 - { - 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 { - 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_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 { - identity, params, .. - } => { - let (tool_name, input) = - effective_event_invocation(identity, params); - self.print_text(|| { - let started_at = chrono::Utc::now().to_rfc3339(); - let input_preview = Self::tool_input_preview(input); - eprintln!(" [subagent] {}", tool_name); - eprintln!(" Started at: {}", started_at); - eprintln!(" Tool ID: {}", identity.tool_id); - eprintln!(" CWD: {}", self.workspace_display()); - eprintln!(" Input: {}", input_preview); - }); - } - ToolEventData::Completed { - identity, - result_for_assistant, - result, - .. - } => { - let tool_name = identity.effective_name(); - let summary = result_for_assistant - .clone() - .unwrap_or_else(|| result.to_string()); - self.print_text(|| { - eprintln!( - " [subagent] {} completed: {}", - tool_name, summary - ) - }); - } - ToolEventData::Failed { - identity, error, .. - } => { - let tool_name = identity.effective_name(); - self.print_text(|| { - eprintln!(" [subagent] {} failed: {}", tool_name, error) - }); - } - _ => {} - } - } - } - continue; - } - - if !event_belongs_to_exec_turn(event, &session_id, &turn_id) { - continue; - } - - if is_successful_exec_terminal(event, &turn_id) { - deferred_success_envelope = Some(envelope.clone()); - } else { - self.emit_stream_envelope(&envelope)?; - } - - if let Some(model_config_id) = - ExecTokenUsage::accumulate_event(&mut usage, event, &turn_id) - { - self.record_resolved_model_config_id(&session_id, model_config_id) - .await; - } - - match event { - AgenticEvent::ModelRoundStarted { - turn_id: event_turn_id, - model_config_id, - .. - } - | AgenticEvent::ModelRoundCompleted { - turn_id: event_turn_id, - model_config_id, - .. - } if event_turn_id == &turn_id => { - self.record_resolved_model_config_id(&session_id, model_config_id) - .await; - } - - 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; - std::io::stdout().flush().ok(); - }); - } - - AgenticEvent::ThinkingChunk { - turn_id: event_turn_id, - content, - .. - } if event_turn_id == &turn_id => { - self.print_text(|| { - eprint!("\x1b[2m{}\x1b[0m", content); - std::io::stderr().flush().ok(); - }); - } - - AgenticEvent::ToolEvent { - turn_id: event_turn_id, - tool_event, - .. - } if event_turn_id == &turn_id => { - use bitfun_events::ToolEventData; - match tool_event { - ToolEventData::ConfirmationNeeded { identity, .. } => { - let tool_id = &identity.tool_id; - let tool_name = identity.effective_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 { - identity, params, .. - } => { - let (tool_name, input) = - effective_event_invocation(identity, params); - self.print_tool_start_details(tool_name, &identity.tool_id, input); - total_tool_calls += 1; - } - ToolEventData::Progress { message, .. } => { - self.print_text(|| eprintln!(" In progress: {}", message)); - } - ToolEventData::Completed { - identity, - result_for_assistant, - result, - duration_ms, - .. - } => { - let tool_name = identity.effective_name(); - let summary = result_for_assistant - .clone() - .unwrap_or_else(|| result.to_string()); - self.print_text(|| { - eprintln!( - " [+] {} ({}ms): {}", - tool_name, duration_ms, summary - ) - }); - } - ToolEventData::Failed { - identity, error, .. - } => { - let tool_name = identity.effective_name(); - self.print_text(|| eprintln!(" [x] {}: {}", tool_name, error)); - } - _ => {} - } - } - - 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(|| { - eprintln!("\n"); - eprintln!("Execution complete"); - if total_tool_calls > 0 { - eprintln!( - "\nTool call statistics: {} tools invoked", - total_tool_calls - ); - } - }); - terminal_status = Some(ExecTerminalStatus::Success); - terminal_outcome = Some(Ok(())); - break; - } - - 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 { - 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.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; - } - - _ => {} - } - } - - if terminal_outcome.is_some() { - break; - } - } - - let turn_settled = match self.wait_for_turn_settlement(&session_id, &turn_id).await { - Ok(()) => true, - Err(error) => { - let (exit_kind, settlement_message) = - settlement_failure(error, &session_id, &turn_id); - let message = match terminal_message.take() { - Some(existing) => format!("{existing}; {settlement_message}"), - None => settlement_message, - }; - emit_exit_diagnostic( - exit_kind, - &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))); - self.emit_stream_error( - &session_id, - terminal_message.as_deref().unwrap_or_default(), - )?; - false - } - }; - let (patch, patch_error) = if turn_settled { - self.output_patch_if_needed() - } else { - (None, None) - }; - 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))); - self.emit_stream_error(&session_id, terminal_message.as_deref().unwrap_or_default())?; - } - if terminal_status == Some(ExecTerminalStatus::Success) { - if let Some(envelope) = deferred_success_envelope.as_ref() { - self.emit_stream_envelope(envelope)?; - } - } - 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_config_id(&self, session_id: &str, model_config_id: &str) { - let trimmed = model_config_id.trim(); - if trimmed.is_empty() || matches!(trimmed, "auto" | "default" | "primary" | "fast") { - return; - } - - if let Err(error) = self.agent.update_session_model(session_id, trimmed).await { - tracing::debug!( - "Failed to persist resolved CLI model config id: session_id={}, model_config_id={}, error={}", - session_id, - trimmed, - error - ); - } - } - - async fn prepare_session(&self) -> Result { - let resume_id = self.session_options.resume.as_deref(); - - let resolved_resume = if self.session_options.continue_last || resume_id == Some("last") { - let sessions = self.agent.list_sessions().await?; - Some( - sessions - .first() - .map(|session| session.session_id.clone()) - .ok_or_else(|| anyhow::anyhow!("No history sessions for current project"))?, - ) - } else { - resume_id.map(ToString::to_string) - }; - - if self.session_options.fork_session { - let source_session_id = resolved_resume - .clone() - .or_else(|| self.session_options.session_id.clone()) - .ok_or_else(|| { - anyhow::anyhow!("--fork-session requires --continue, --resume, or --session") - })?; - let result = self - .agent - .branch_session_at_latest_turn(&source_session_id) - .await?; - self.agent.restore_session(&result.session_id).await?; - return Ok(result.session_id); - } - - if let Some(session_id) = resolved_resume.as_deref() { - self.agent.restore_session(session_id).await?; - return Ok(session_id.to_string()); - } - - if let Some(session_id) = &self.session_options.session_id { - return self - .agent - .create_session_with_id(session_id.clone(), &self.agent_type) - .await; - } - - self.agent.ensure_session(&self.agent_type).await - } - - 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(()) - } - - fn emit_stream_error(&self, session_id: &str, message: &str) -> Result<()> { - let envelope = bitfun_events::AgenticEventEnvelope::new( - AgenticEvent::SystemError { - session_id: Some(session_id.to_string()), - error: message.to_string(), - recoverable: false, - }, - bitfun_events::AgenticEventPriority::Critical, - ); - self.emit_stream_envelope(&envelope) - } - - fn print_text(&self, f: impl FnOnce()) { - if self.output_format == ExecOutputFormat::Text { - f(); - } - } - - 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), - ); - }; - - 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}")), - ); - } - } - - 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 { - 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, - ) -> std::result::Result<(), RuntimeError> { - self.agent - .wait_for_turn_settlement(session_id, turn_id, 5_000) - .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<()> { - use std::path::Path; - - let path = Path::new(output_target); - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - } - std::fs::write(path, patch) -} - +mod lifecycle; +mod patch; #[cfg(test)] -mod patch_tests { - use std::process::Command; - - use super::{ - completed_turn_failure, effective_event_invocation, event_belongs_to_exec_turn, - event_turn_id, is_successful_exec_terminal, serialize_stream_envelope, settlement_failure, - write_patch_to_path, ExecApprovalMode, ExecJsonResult, ExecMode, ExecTokenUsage, ExitKind, - TOOL_START_INPUT_PREVIEW_CHARS, - }; - use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; - use bitfun_events::{ - AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity, - }; - use serde_json::json; - - #[test] - fn write_patch_to_path_creates_nested_parent_directories() { - let temp = tempfile::tempdir().expect("tempdir"); - let patch_path = temp.path().join("parent/child/out.patch"); - write_patch_to_path(patch_path.to_str().expect("utf8 path"), "diff content") - .expect("write patch"); - - let written = std::fs::read_to_string(&patch_path).expect("read patch"); - 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!({ - "image": { - "data_url": "data:image/png;base64,abc", - "name": "sample" - } - })); - - assert!(!preview.contains("data:image/png")); - assert!(preview.contains("\"has_data_url\":true")); - assert!(preview.contains("\"name\":\"sample\"")); - } - - #[test] - fn tool_input_preview_truncates_large_inputs() { - let preview = ExecMode::tool_input_preview(&json!({ - "content": "x".repeat(TOOL_START_INPUT_PREVIEW_CHARS + 100) - })); - - 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_config_id: "model-config".to_string(), - effective_model_name: "provider-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_config_id: "model-config".to_string(), - effective_model_name: "provider-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-config") - ); - } - - 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_config_id: "model-config".to_string(), - effective_model_name: "provider-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_config_id: "model-config".to_string(), - effective_model_name: "provider-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 successful_terminal_event_is_deferred_until_exec_settlement() { - let event = AgenticEvent::DialogTurnCompleted { - session_id: "session-1".to_string(), - turn_id: "turn-1".to_string(), - total_rounds: 1, - total_tools: 0, - duration_ms: 10, - partial_recovery_reason: None, - success: Some(true), - finish_reason: Some("complete".to_string()), - has_final_response: Some(true), - }; - - assert!(is_successful_exec_terminal(&event, "turn-1")); - assert!(!is_successful_exec_terminal(&event, "turn-other")); - } - - #[test] - fn settlement_failure_preserves_timeout_and_runtime_error_kinds() { - let timeout = RuntimeError::Port(PortError::new( - PortErrorKind::Timeout, - "turn did not settle", - )); - let backend = RuntimeError::Port(PortError::new( - PortErrorKind::Backend, - "settlement provider failed", - )); - - let (timeout_kind, timeout_message) = settlement_failure(timeout, "session-1", "turn-1"); - let (backend_kind, backend_message) = settlement_failure(backend, "session-1", "turn-1"); - - assert_eq!(timeout_kind, ExitKind::SettlementTimedOut); - assert!(timeout_message.starts_with("Timed out waiting")); - assert_eq!(backend_kind, ExitKind::SystemError); - assert!(backend_message.starts_with("Failed to wait")); - } - - #[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" - )); - } - - #[test] - fn deferred_exec_event_projects_effective_name_and_input() { - let identity = ToolEventIdentity::resolved( - "tool-1", - bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, - "CreatePlan", - ); - let wire_input = json!({ - "tool_name": "CreatePlan", - "args": { "title": "Ship deferred tools" } - }); - - let (tool_name, input) = effective_event_invocation(&identity, &wire_input); +mod tests; - assert_eq!(tool_name, "CreatePlan"); - assert_eq!(input, &json!({ "title": "Ship deferred tools" })); - assert_eq!(wire_input["tool_name"], "CreatePlan"); - } -} +pub(crate) use lifecycle::{ + emit_preflight_json_error, ExecApprovalMode, ExecMode, ExecOutputFormat, ExecSessionOptions, +}; diff --git a/src/apps/cli/src/modes/exec/lifecycle.rs b/src/apps/cli/src/modes/exec/lifecycle.rs new file mode 100644 index 000000000..d076265e2 --- /dev/null +++ b/src/apps/cli/src/modes/exec/lifecycle.rs @@ -0,0 +1,1296 @@ +/// Exec mode implementation +/// +/// Single command execution mode (non-interactive). +/// Observes core events through an independent runtime broadcast subscription. +use anyhow::Result; +use clap::ValueEnum; +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_agent_runtime::sdk::{PortErrorKind, RuntimeError}; +use bitfun_agent_tools::effective_tool_invocation; +use bitfun_events::{AgenticEvent, ToolEventIdentity}; +use tokio::time::Instant; + +use crate::agent::runtime_client::CliAgentRuntimeClient; +use crate::config::CliConfig; +use crate::diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}; +use crate::runtime::CliRuntimeContext; + +pub(super) const TOOL_START_INPUT_PREVIEW_CHARS: usize = 4_000; +const TURN_SETTLEMENT_TIMEOUT: Duration = Duration::from_secs(5); + +pub(super) fn effective_event_invocation<'a>( + identity: &'a ToolEventIdentity, + params: &'a serde_json::Value, +) -> (&'a str, &'a serde_json::Value) { + let (derived_name, effective_input) = effective_tool_invocation(&identity.tool_name, params); + debug_assert_eq!(identity.effective_name(), derived_name); + (derived_name, effective_input) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub(crate) enum ExecOutputFormat { + Text, + Json, + StreamJson, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum ExecApprovalMode { + #[default] + Reject, + Auto, +} + +impl ExecApprovalMode { + pub(super) const fn rejects_confirmation(self) -> bool { + matches!(self, Self::Reject) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(super) struct ExecTokenUsage { + pub(super) input_tokens: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) output_tokens: Option, + pub(super) total_tokens: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) 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)); + } + + pub(super) fn accumulate_event<'a>( + aggregate: &mut Option, + event: &'a AgenticEvent, + expected_turn_id: &str, + ) -> Option<&'a str> { + let AgenticEvent::TokenUsageUpdated { + turn_id, + model_config_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_config_id) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub(super) 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)] +pub(super) struct ExecPatchOutput { + pub(super) target: String, + pub(super) status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) patch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) bytes: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExecTerminalStatus { + Success, + Error, + Cancelled, +} + +struct ExecTerminalDecision { + status: ExecTerminalStatus, + message: Option, + exit_kind: Option, +} + +pub(super) 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, + } +} + +pub(super) 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, + } +} + +pub(super) 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}"), + }) +} + +fn exec_terminal_decision(event: &AgenticEvent, turn_id: &str) -> Option { + match event { + AgenticEvent::DialogTurnCompleted { + turn_id: event_turn_id, + success, + finish_reason, + has_final_response, + .. + } if event_turn_id == turn_id => { + match completed_turn_failure(*success, finish_reason.as_deref(), *has_final_response) { + Some(message) => Some(ExecTerminalDecision { + status: ExecTerminalStatus::Error, + message: Some(message), + exit_kind: Some(ExitKind::DialogTurnFailed), + }), + None => Some(ExecTerminalDecision { + status: ExecTerminalStatus::Success, + message: None, + exit_kind: None, + }), + } + } + AgenticEvent::DialogTurnFailed { + turn_id: event_turn_id, + error, + .. + } if event_turn_id == turn_id => Some(ExecTerminalDecision { + status: ExecTerminalStatus::Error, + message: Some(error.clone()), + exit_kind: Some(ExitKind::DialogTurnFailed), + }), + AgenticEvent::DialogTurnCancelled { + turn_id: event_turn_id, + .. + } if event_turn_id == turn_id => Some(ExecTerminalDecision { + status: ExecTerminalStatus::Cancelled, + message: Some("Execution cancelled".to_string()), + exit_kind: Some(ExitKind::Cancelled), + }), + AgenticEvent::SystemError { error, .. } => Some(ExecTerminalDecision { + status: ExecTerminalStatus::Error, + message: Some(error.clone()), + exit_kind: Some(ExitKind::SystemError), + }), + _ => None, + } +} + +pub(super) fn is_exec_terminal(event: &AgenticEvent, turn_id: &str) -> bool { + exec_terminal_decision(event, turn_id).is_some() +} + +pub(super) fn settlement_failure( + error: RuntimeError, + session_id: &str, + turn_id: &str, +) -> (ExitKind, String) { + let is_timeout = matches!( + &error, + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::Timeout + ); + let detail = error.into_message(); + if is_timeout { + ( + ExitKind::SettlementTimedOut, + format!( + "Timed out waiting for exec turn settlement: session_id={session_id}, turn_id={turn_id}: {detail}" + ), + ) + } else { + ( + ExitKind::SystemError, + format!( + "Failed to wait for exec turn settlement: session_id={session_id}, turn_id={turn_id}: {detail}" + ), + ) + } +} + +pub(super) fn resolve_cancelled_turn_observation( + observed_terminal: Result, + settlement: std::result::Result<(), RuntimeError>, + session_id: &str, + turn_id: &str, +) -> std::result::Result { + match settlement { + Err(error) => Err(settlement_failure(error, session_id, turn_id)), + Ok(()) => observed_terminal.map_err(|error| { + ( + ExitKind::SystemError, + format!("Failed to observe cancelled turn terminal event: {error}"), + ) + }), + } +} + +impl ExecJsonResult { + pub(super) 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) + } + + pub(super) fn preflight_error(result: impl Into) -> Self { + Self::new("error", true, None, None, result, None) + } + + pub(super) 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(super) 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, + pub continue_last: bool, + pub session_id: Option, + pub fork_session: bool, +} + +pub(crate) struct ExecMode { + #[allow(dead_code)] + config: CliConfig, + message: String, + agent_type: String, + agent: Arc, + _runtime: Arc, + pub(super) workspace_path: Option, + /// None: no patch output, Some("-"): output to stdout, Some(path): save to file + pub(super) output_patch: Option, + pub(super) output_format: ExecOutputFormat, + approval_mode: ExecApprovalMode, + session_options: ExecSessionOptions, +} + +impl ExecMode { + pub(crate) fn new( + config: CliConfig, + message: String, + agent_type: String, + 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(CliAgentRuntimeClient::new( + runtime.as_ref(), + workspace_path.clone(), + )); + + Self { + config, + message, + agent_type, + agent, + _runtime: runtime, + workspace_path, + output_patch, + output_format, + approval_mode, + session_options, + } + } + + pub(super) fn exit_context<'a>( + &'a self, + session_id: Option<&'a str>, + turn_id: Option<&'a str>, + ) -> ExitContext<'a> { + ExitContext { + session_id, + turn_id, + agent_type: Some(self.agent_type.as_str()), + workspace: self.workspace_path.as_deref(), + } + } + + fn workspace_display(&self) -> String { + self.workspace_path + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| { + std::env::current_dir() + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| ".".to_string()) + }) + } + + fn redact_large_inline_data(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + if map.remove("data_url").is_some() { + map.insert("has_data_url".to_string(), serde_json::json!(true)); + } + for child in map.values_mut() { + Self::redact_large_inline_data(child); + } + } + serde_json::Value::Array(items) => { + for child in items { + Self::redact_large_inline_data(child); + } + } + _ => {} + } + } + + pub(super) fn tool_input_preview(params: &serde_json::Value) -> String { + let mut redacted = params.clone(); + Self::redact_large_inline_data(&mut redacted); + let raw = + serde_json::to_string(&redacted).unwrap_or_else(|_| "".to_string()); + if raw.chars().count() <= TOOL_START_INPUT_PREVIEW_CHARS { + return raw; + } + + let preview: String = raw.chars().take(TOOL_START_INPUT_PREVIEW_CHARS).collect(); + format!("{preview}... [truncated]") + } + + fn print_tool_start_details(&self, tool_name: &str, tool_id: &str, params: &serde_json::Value) { + let started_at = chrono::Utc::now().to_rfc3339(); + let cwd = self.workspace_display(); + let input_preview = Self::tool_input_preview(params); + + self.print_text(|| { + eprintln!("\nTool call: {}", tool_name); + eprintln!(" Started at: {}", started_at); + eprintln!(" Tool ID: {}", tool_id); + eprintln!(" CWD: {}", cwd); + eprintln!(" Input: {}", input_preview); + }); + } + + pub(crate) async fn run(&mut self) -> Result<()> { + tracing::info!( + agent_type = %self.agent_type, + message_len = self.message.len(), + workspace = ?self.workspace_path, + "Executing command" + ); + + 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 mut event_rx = self.agent.event_source().subscribe(); + + self.print_text(|| { + eprintln!("Executing: {}", self.message); + eprintln!(); + eprintln!("Session: {}", session_id); + eprintln!("Thinking..."); + }); + + let turn_id = match self + .agent + .send_message(self.message.clone(), &self.agent_type) + .await + { + 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"); + + // Observe the shared Agentic event stream without consuming other clients' events. + let mut total_tool_calls = 0usize; + let mut subagent_parent_turns: HashMap = HashMap::new(); + let mut terminal_outcome: Option> = None; + let mut terminal_status: Option = None; + let mut terminal_message: Option = None; + let mut assistant_text = String::new(); + let mut usage: Option = None; + let mut deferred_terminal_envelope: Option = None; + let mut terminal_exit_kind: Option = None; + let mut final_stream_error: Option = None; + let mut cancellation_observation: Option< + Result<( + Vec, + bitfun_events::AgenticEventEnvelope, + )>, + > = None; + let mut cancellation_settlement: Option> = None; + let mut cancelled_terminal_override: Option<(ExecTerminalStatus, ExitKind, String)> = 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}")); + } + terminal_exit_kind = Some(ExitKind::EventStreamFailed); + final_stream_error = Some(message.clone()); + 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}")); + } + terminal_exit_kind = Some(ExitKind::EventStreamFailed); + final_stream_error = Some(message.clone()); + 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...")); + let (drain_result, settlement_result) = self + .observe_cancelled_turn_settlement( + &mut event_rx, + &session_id, + &turn_id, + ) + .await; + cancellation_observation = Some(drain_result); + cancellation_settlement = Some(settlement_result); + cancelled_terminal_override = Some(( + ExecTerminalStatus::Cancelled, + ExitKind::Cancelled, + message.clone(), + )); + } else { + final_stream_error = Some(message.clone()); + } + terminal_exit_kind = Some(if interrupted { + ExitKind::Cancelled + } else { + ExitKind::ExecError + }); + 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 + { + 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 { + 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_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 { + identity, params, .. + } => { + let (tool_name, input) = + effective_event_invocation(identity, params); + self.print_text(|| { + let started_at = chrono::Utc::now().to_rfc3339(); + let input_preview = Self::tool_input_preview(input); + eprintln!(" [subagent] {}", tool_name); + eprintln!(" Started at: {}", started_at); + eprintln!(" Tool ID: {}", identity.tool_id); + eprintln!(" CWD: {}", self.workspace_display()); + eprintln!(" Input: {}", input_preview); + }); + } + ToolEventData::Completed { + identity, + result_for_assistant, + result, + .. + } => { + let tool_name = identity.effective_name(); + let summary = result_for_assistant + .clone() + .unwrap_or_else(|| result.to_string()); + self.print_text(|| { + eprintln!( + " [subagent] {} completed: {}", + tool_name, summary + ) + }); + } + ToolEventData::Failed { + identity, error, .. + } => { + let tool_name = identity.effective_name(); + self.print_text(|| { + eprintln!(" [subagent] {} failed: {}", tool_name, error) + }); + } + _ => {} + } + } + } + continue; + } + + if !event_belongs_to_exec_turn(event, &session_id, &turn_id) { + continue; + } + + if let Some(decision) = exec_terminal_decision(event, &turn_id) { + deferred_terminal_envelope = Some(envelope.clone()); + self.print_exec_terminal(event, &decision, total_tool_calls); + terminal_status = Some(decision.status); + terminal_exit_kind = decision.exit_kind; + terminal_message = decision.message; + terminal_outcome = Some(match decision.status { + ExecTerminalStatus::Success => Ok(()), + ExecTerminalStatus::Error | ExecTerminalStatus::Cancelled => { + Err(anyhow::anyhow!(terminal_message.clone().unwrap_or_else( + || { "Execution ended unsuccessfully".to_string() } + ))) + } + }); + break; + } + + let confirmation = self + .project_exec_nonterminal_event( + &envelope, + &session_id, + &turn_id, + &mut assistant_text, + &mut usage, + &mut total_tool_calls, + ) + .await?; + if let Some((tool_id, tool_name)) = confirmation { + 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}")); + } + let (drain_result, settlement_result) = self + .observe_cancelled_turn_settlement(&mut event_rx, &session_id, &turn_id) + .await; + cancellation_observation = Some(drain_result); + cancellation_settlement = Some(settlement_result); + cancelled_terminal_override = Some(( + ExecTerminalStatus::Error, + ExitKind::PermissionRejected, + message.clone(), + )); + self.print_text(|| eprintln!("{message}")); + terminal_exit_kind = Some(ExitKind::PermissionRejected); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break; + } + 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}" + )); + } + let (drain_result, settlement_result) = self + .observe_cancelled_turn_settlement(&mut event_rx, &session_id, &turn_id) + .await; + cancellation_observation = Some(drain_result); + cancellation_settlement = Some(settlement_result); + cancelled_terminal_override = Some(( + ExecTerminalStatus::Error, + ExitKind::PermissionRejected, + message.clone(), + )); + terminal_exit_kind = Some(ExitKind::PermissionRejected); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + break; + } + } + } + + if terminal_outcome.is_some() { + break; + } + } + + let turn_settled = if let Some(settlement_result) = cancellation_settlement.take() { + let settled = settlement_result.is_ok(); + let observation = cancellation_observation.take().unwrap_or_else(|| { + Err(anyhow::anyhow!( + "cancelled turn observation ended without a terminal event" + )) + }); + let (buffered_events, observed_terminal) = match observation { + Ok((buffered_events, terminal)) => (buffered_events, Ok(terminal)), + Err(error) => (Vec::new(), Err(error)), + }; + for envelope in buffered_events { + let _ = self + .project_exec_nonterminal_event( + &envelope, + &session_id, + &turn_id, + &mut assistant_text, + &mut usage, + &mut total_tool_calls, + ) + .await?; + } + match resolve_cancelled_turn_observation( + observed_terminal, + settlement_result, + &session_id, + &turn_id, + ) { + Ok(envelope) => { + let mut decision = exec_terminal_decision(&envelope.event, &turn_id) + .expect("cancelled turn observation only returns terminal events"); + if decision.status == ExecTerminalStatus::Cancelled { + if let Some((status, exit_kind, message)) = + cancelled_terminal_override.take() + { + decision.status = status; + decision.exit_kind = Some(exit_kind); + decision.message = Some(message); + } + } + self.print_exec_terminal(&envelope.event, &decision, total_tool_calls); + terminal_status = Some(decision.status); + terminal_exit_kind = decision.exit_kind; + terminal_message = decision.message; + terminal_outcome = Some(match decision.status { + ExecTerminalStatus::Success => Ok(()), + ExecTerminalStatus::Error | ExecTerminalStatus::Cancelled => { + Err(anyhow::anyhow!(terminal_message.clone().unwrap_or_else( + || { "Execution ended unsuccessfully".to_string() } + ))) + } + }); + deferred_terminal_envelope = Some(envelope); + } + Err((exit_kind, observation_message)) => { + let local_message = cancelled_terminal_override + .take() + .map(|(_, _, message)| message) + .or_else(|| terminal_message.take()); + let message = match local_message { + Some(existing) => format!("{existing}; {observation_message}"), + None => observation_message, + }; + terminal_status = Some(ExecTerminalStatus::Error); + terminal_exit_kind = Some(exit_kind); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message.clone()))); + final_stream_error = Some(message); + } + } + settled + } else { + match self.wait_for_turn_settlement(&session_id, &turn_id).await { + Ok(()) => true, + Err(error) => { + let (exit_kind, settlement_message) = + settlement_failure(error, &session_id, &turn_id); + let message = match terminal_message.take() { + Some(existing) => format!("{existing}; {settlement_message}"), + None => settlement_message, + }; + terminal_status = Some(ExecTerminalStatus::Error); + terminal_exit_kind = Some(exit_kind); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message.clone()))); + final_stream_error = Some(message); + false + } + } + }; + let (patch, patch_error) = if turn_settled { + self.output_patch_if_needed() + } else { + (None, None) + }; + if let Some((exit_kind, error)) = patch_error { + let message = match terminal_message.take() { + Some(existing) => format!("{existing}; {error}"), + None => error.to_string(), + }; + terminal_status = Some(ExecTerminalStatus::Error); + terminal_exit_kind = Some(exit_kind); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message.clone()))); + final_stream_error = Some(message); + } + if terminal_outcome.is_none() { + let message = "Execution ended without a terminal event".to_string(); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_exit_kind = Some(ExitKind::ExecError); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message.clone()))); + final_stream_error = Some(message); + } + if let Some(message) = final_stream_error.as_deref() { + self.emit_stream_error(&session_id, message)?; + } else if let Some(envelope) = deferred_terminal_envelope.as_ref() { + self.emit_stream_envelope(envelope)?; + } + if let (Some(exit_kind), Some(message)) = (terminal_exit_kind, terminal_message.as_deref()) + { + emit_exit_diagnostic( + exit_kind, + message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + } + 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"))) + } + + fn print_exec_terminal( + &self, + event: &AgenticEvent, + decision: &ExecTerminalDecision, + tools: usize, + ) { + match event { + AgenticEvent::DialogTurnCompleted { .. } + if decision.status == ExecTerminalStatus::Success => + { + self.print_text(|| { + eprintln!("\n"); + eprintln!("Execution complete"); + if tools > 0 { + eprintln!("\nTool call statistics: {} tools invoked", tools); + } + }); + } + AgenticEvent::DialogTurnCompleted { .. } | AgenticEvent::DialogTurnFailed { .. } => { + if let Some(message) = decision.message.as_deref() { + self.print_text(|| eprintln!("\nExecution failed: {message}")); + } + } + AgenticEvent::DialogTurnCancelled { .. } => { + self.print_text(|| eprintln!("\nExecution cancelled")); + } + AgenticEvent::SystemError { .. } => { + if let Some(message) = decision.message.as_deref() { + self.print_text(|| eprintln!("\nSystem error: {message}")); + } + } + _ => {} + } + } + + async fn project_exec_nonterminal_event( + &self, + envelope: &bitfun_events::AgenticEventEnvelope, + session_id: &str, + turn_id: &str, + assistant_text: &mut String, + usage: &mut Option, + total_tool_calls: &mut usize, + ) -> Result> { + self.emit_stream_envelope(envelope)?; + let event = &envelope.event; + if let Some(model_config_id) = ExecTokenUsage::accumulate_event(usage, event, turn_id) { + self.record_resolved_model_config_id(session_id, model_config_id) + .await; + } + + match event { + AgenticEvent::ModelRoundStarted { + turn_id: event_turn_id, + model_config_id, + .. + } + | AgenticEvent::ModelRoundCompleted { + turn_id: event_turn_id, + model_config_id, + .. + } if event_turn_id == turn_id => { + self.record_resolved_model_config_id(session_id, model_config_id) + .await; + } + AgenticEvent::TextChunk { + turn_id: event_turn_id, + text, + .. + } if event_turn_id == turn_id => { + assistant_text.push_str(text); + self.print_text(|| { + print!("{}", text); + std::io::stdout().flush().ok(); + }); + } + AgenticEvent::ThinkingChunk { + turn_id: event_turn_id, + content, + .. + } if event_turn_id == turn_id => { + self.print_text(|| { + eprint!("\x1b[2m{}\x1b[0m", content); + std::io::stderr().flush().ok(); + }); + } + AgenticEvent::ToolEvent { + turn_id: event_turn_id, + tool_event, + .. + } if event_turn_id == turn_id => { + use bitfun_events::ToolEventData; + match tool_event { + ToolEventData::ConfirmationNeeded { identity, .. } => { + return Ok(Some(( + identity.tool_id.clone(), + identity.effective_name().to_string(), + ))); + } + ToolEventData::Started { + identity, params, .. + } => { + let (tool_name, input) = effective_event_invocation(identity, params); + self.print_tool_start_details(tool_name, &identity.tool_id, input); + *total_tool_calls += 1; + } + ToolEventData::Progress { message, .. } => { + self.print_text(|| eprintln!(" In progress: {}", message)); + } + ToolEventData::Completed { + identity, + result_for_assistant, + result, + duration_ms, + .. + } => { + let tool_name = identity.effective_name(); + let summary = result_for_assistant + .clone() + .unwrap_or_else(|| result.to_string()); + self.print_text(|| { + eprintln!(" [+] {} ({}ms): {}", tool_name, duration_ms, summary) + }); + } + ToolEventData::Failed { + identity, error, .. + } => { + let tool_name = identity.effective_name(); + self.print_text(|| eprintln!(" [x] {}: {}", tool_name, error)); + } + _ => {} + } + } + _ => {} + } + Ok(None) + } + + async fn record_resolved_model_config_id(&self, session_id: &str, model_config_id: &str) { + let trimmed = model_config_id.trim(); + if trimmed.is_empty() || matches!(trimmed, "auto" | "default" | "primary" | "fast") { + return; + } + + if let Err(error) = self.agent.update_session_model(session_id, trimmed).await { + tracing::debug!( + "Failed to persist resolved CLI model config id: session_id={}, model_config_id={}, error={}", + session_id, + trimmed, + error + ); + } + } + + async fn prepare_session(&self) -> Result { + let resume_id = self.session_options.resume.as_deref(); + + let resolved_resume = if self.session_options.continue_last || resume_id == Some("last") { + let sessions = self.agent.list_sessions().await?; + Some( + sessions + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions for current project"))?, + ) + } else { + resume_id.map(ToString::to_string) + }; + + if self.session_options.fork_session { + let source_session_id = resolved_resume + .clone() + .or_else(|| self.session_options.session_id.clone()) + .ok_or_else(|| { + anyhow::anyhow!("--fork-session requires --continue, --resume, or --session") + })?; + let result = self + .agent + .branch_session_at_latest_turn(&source_session_id) + .await?; + self.agent.restore_session(&result.session_id).await?; + return Ok(result.session_id); + } + + if let Some(session_id) = resolved_resume.as_deref() { + self.agent.restore_session(session_id).await?; + return Ok(session_id.to_string()); + } + + if let Some(session_id) = &self.session_options.session_id { + return self + .agent + .create_session_with_id(session_id.clone(), &self.agent_type) + .await; + } + + self.agent.ensure_session(&self.agent_type).await + } + + 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(()) + } + + fn emit_stream_error(&self, session_id: &str, message: &str) -> Result<()> { + let envelope = bitfun_events::AgenticEventEnvelope::new( + AgenticEvent::SystemError { + session_id: Some(session_id.to_string()), + error: message.to_string(), + recoverable: false, + }, + bitfun_events::AgenticEventPriority::Critical, + ); + self.emit_stream_envelope(&envelope) + } + + pub(super) fn print_text(&self, f: impl FnOnce()) { + if self.output_format == ExecOutputFormat::Text { + f(); + } + } + + async fn wait_for_turn_settlement( + &self, + session_id: &str, + turn_id: &str, + ) -> std::result::Result<(), RuntimeError> { + self.agent + .wait_for_turn_settlement( + session_id, + turn_id, + TURN_SETTLEMENT_TIMEOUT.as_millis() as u64, + ) + .await + } + + async fn observe_cancelled_turn_settlement( + &self, + event_rx: &mut tokio::sync::broadcast::Receiver, + session_id: &str, + turn_id: &str, + ) -> ( + Result<( + Vec, + bitfun_events::AgenticEventEnvelope, + )>, + std::result::Result<(), RuntimeError>, + ) { + tokio::join!( + drain_interrupted_turn_events(event_rx, session_id, turn_id), + self.wait_for_turn_settlement(session_id, turn_id), + ) + } +} + +pub(super) async fn drain_interrupted_turn_events( + event_rx: &mut tokio::sync::broadcast::Receiver, + session_id: &str, + turn_id: &str, +) -> Result<( + Vec, + bitfun_events::AgenticEventEnvelope, +)> { + let deadline = Instant::now() + TURN_SETTLEMENT_TIMEOUT; + let mut buffered = Vec::new(); + 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; + } + if is_exec_terminal(&envelope.event, turn_id) { + return Ok((buffered, envelope)); + } + buffered.push(envelope); + } +} diff --git a/src/apps/cli/src/modes/exec/patch.rs b/src/apps/cli/src/modes/exec/patch.rs new file mode 100644 index 000000000..b5ee0a7cf --- /dev/null +++ b/src/apps/cli/src/modes/exec/patch.rs @@ -0,0 +1,206 @@ +use std::path::PathBuf; + +use crate::diagnostics::ExitKind; + +use super::lifecycle::{ExecMode, ExecOutputFormat, ExecPatchOutput}; + +impl ExecMode { + 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()) + } + + pub(super) 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 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; + } + + 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(super) fn output_patch_if_needed( + &self, + ) -> (Option, Option<(ExitKind, anyhow::Error)>) { + 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" + ); + return ( + Some(ExecPatchOutput { + target: output_target.clone(), + status: "unavailable", + patch: None, + bytes: None, + }), + Some((ExitKind::PatchUnavailable, 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"); + return ( + Some(ExecPatchOutput { + target: output_target.clone(), + status: "unavailable", + patch: None, + bytes: None, + }), + Some((ExitKind::PatchUnavailable, error)), + ); + }; + + 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) { + eprintln!("Failed to save patch: {error}"); + return ( + Some(ExecPatchOutput { + target: output_target.clone(), + status: "write_failed", + patch: None, + bytes: Some(patch.len()), + }), + Some(( + ExitKind::PatchWriteFailed, + anyhow::anyhow!("Failed to save requested patch: {error}"), + )), + ); + } + } + + 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 { + 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, + ) + } +} + +pub(super) fn write_patch_to_path(output_target: &str, patch: &str) -> std::io::Result<()> { + use std::path::Path; + + let path = Path::new(output_target); + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + std::fs::write(path, patch) +} diff --git a/src/apps/cli/src/modes/exec/tests.rs b/src/apps/cli/src/modes/exec/tests.rs new file mode 100644 index 000000000..b66dfc214 --- /dev/null +++ b/src/apps/cli/src/modes/exec/tests.rs @@ -0,0 +1,620 @@ +use std::process::Command; + +use super::lifecycle::{ + completed_turn_failure, drain_interrupted_turn_events, effective_event_invocation, + event_belongs_to_exec_turn, event_turn_id, is_exec_terminal, + resolve_cancelled_turn_observation, serialize_stream_envelope, settlement_failure, + ExecApprovalMode, ExecJsonResult, ExecMode, ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, +}; +use super::patch::write_patch_to_path; +use crate::diagnostics::ExitKind; +use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; +use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity}; +use serde_json::json; + +#[test] +fn write_patch_to_path_creates_nested_parent_directories() { + let temp = tempfile::tempdir().expect("tempdir"); + let patch_path = temp.path().join("parent/child/out.patch"); + write_patch_to_path(patch_path.to_str().expect("utf8 path"), "diff content") + .expect("write patch"); + + let written = std::fs::read_to_string(&patch_path).expect("read patch"); + 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!({ + "image": { + "data_url": "data:image/png;base64,abc", + "name": "sample" + } + })); + + assert!(!preview.contains("data:image/png")); + assert!(preview.contains("\"has_data_url\":true")); + assert!(preview.contains("\"name\":\"sample\"")); +} + +#[test] +fn tool_input_preview_truncates_large_inputs() { + let preview = ExecMode::tool_input_preview(&json!({ + "content": "x".repeat(TOOL_START_INPUT_PREVIEW_CHARS + 100) + })); + + 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_config_id: "model-config".to_string(), + effective_model_name: "provider-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_config_id: "model-config".to_string(), + effective_model_name: "provider-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-config") + ); + } + + 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_config_id: "model-config".to_string(), + effective_model_name: "provider-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_config_id: "model-config".to_string(), + effective_model_name: "provider-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 every_exec_terminal_event_is_deferred_until_exec_settlement() { + let completed = AgenticEvent::DialogTurnCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), + has_final_response: Some(true), + }; + let failed = AgenticEvent::DialogTurnFailed { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + error: "provider failed".to_string(), + error_category: None, + error_detail: None, + }; + let system_error = AgenticEvent::SystemError { + session_id: Some("session-1".to_string()), + error: "runtime failed".to_string(), + recoverable: false, + }; + + assert!(is_exec_terminal(&completed, "turn-1")); + assert!(!is_exec_terminal(&completed, "turn-other")); + assert!(is_exec_terminal(&failed, "turn-1")); + assert!(is_exec_terminal(&system_error, "turn-1")); +} + +#[test] +fn settlement_failure_preserves_timeout_and_runtime_error_kinds() { + let timeout = RuntimeError::Port(PortError::new( + PortErrorKind::Timeout, + "turn did not settle", + )); + let backend = RuntimeError::Port(PortError::new( + PortErrorKind::Backend, + "settlement provider failed", + )); + + let (timeout_kind, timeout_message) = settlement_failure(timeout, "session-1", "turn-1"); + let (backend_kind, backend_message) = settlement_failure(backend, "session-1", "turn-1"); + + assert_eq!(timeout_kind, ExitKind::SettlementTimedOut); + assert!(timeout_message.starts_with("Timed out waiting")); + assert_eq!(backend_kind, ExitKind::SystemError); + assert!(backend_message.starts_with("Failed to wait")); +} + +#[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" + )); +} + +#[tokio::test] +async fn interrupted_turn_returns_a_delayed_terminal_without_emitting_it() { + let (event_tx, mut event_rx) = tokio::sync::broadcast::channel(4); + let sender = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; + event_tx + .send(AgenticEventEnvelope::new( + AgenticEvent::DialogTurnCancelled { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }, + AgenticEventPriority::Critical, + )) + .expect("send delayed cancellation terminal"); + }); + let (buffered, terminal) = drain_interrupted_turn_events(&mut event_rx, "session-1", "turn-1") + .await + .expect("delayed cancellation terminal must remain observable"); + sender.await.expect("join delayed terminal sender"); + + assert!( + buffered.is_empty(), + "terminal must be deferred until settlement" + ); + assert!(matches!( + terminal.event, + AgenticEvent::DialogTurnCancelled { .. } + )); +} + +#[tokio::test] +async fn interrupted_turn_treats_system_error_as_a_deferred_terminal() { + let (event_tx, mut event_rx) = tokio::sync::broadcast::channel(4); + event_tx + .send(AgenticEventEnvelope::new( + AgenticEvent::SystemError { + session_id: Some("session-1".to_string()), + error: "runtime cancellation failed".to_string(), + recoverable: false, + }, + AgenticEventPriority::Critical, + )) + .expect("send system error terminal"); + let (buffered, terminal) = drain_interrupted_turn_events(&mut event_rx, "session-1", "turn-1") + .await + .expect("system error must settle cancellation observation"); + + assert!(buffered.is_empty(), "terminal must not be emitted early"); + assert!(matches!(terminal.event, AgenticEvent::SystemError { .. })); +} + +#[tokio::test] +async fn interrupted_turn_buffers_tail_projection_before_a_completed_race() { + let (event_tx, mut event_rx) = tokio::sync::broadcast::channel(4); + event_tx + .send(AgenticEventEnvelope::new( + AgenticEvent::TextChunk { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + round_id: "round-1".to_string(), + attempt_id: None, + attempt_index: None, + text: "final answer".to_string(), + }, + AgenticEventPriority::Normal, + )) + .expect("send final text"); + event_tx + .send(AgenticEventEnvelope::new( + AgenticEvent::TokenUsageUpdated { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), + input_tokens: 10, + output_tokens: Some(5), + total_tokens: 15, + max_context_tokens: Some(200_000), + is_subagent: false, + cached_tokens: Some(3), + token_details: None, + }, + AgenticEventPriority::Normal, + )) + .expect("send final usage"); + event_tx + .send(AgenticEventEnvelope::new( + AgenticEvent::DialogTurnCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), + has_final_response: Some(true), + }, + AgenticEventPriority::Critical, + )) + .expect("send completed terminal"); + + let (buffered, terminal) = drain_interrupted_turn_events(&mut event_rx, "session-1", "turn-1") + .await + .expect("completed race must remain observable"); + + let mut assistant_text = String::new(); + let mut usage = None; + for envelope in &buffered { + if let AgenticEvent::TextChunk { text, .. } = &envelope.event { + assistant_text.push_str(text); + } + ExecTokenUsage::accumulate_event(&mut usage, &envelope.event, "turn-1"); + } + + assert_eq!(assistant_text, "final answer"); + assert_eq!(usage.expect("buffered final usage").total_tokens, 15); + assert!(matches!( + terminal.event, + AgenticEvent::DialogTurnCompleted { + success: Some(true), + .. + } + )); +} + +#[test] +fn settlement_failure_overrides_an_observed_cancellation_terminal() { + let terminal = AgenticEventEnvelope::new( + AgenticEvent::DialogTurnCancelled { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }, + AgenticEventPriority::Critical, + ); + let settlement = Err(RuntimeError::Port(PortError::new( + PortErrorKind::Timeout, + "turn did not settle", + ))); + + let result = + resolve_cancelled_turn_observation(Ok(terminal), settlement, "session-1", "turn-1"); + + let Err((kind, message)) = result else { + panic!("settlement failure must replace the observed terminal"); + }; + assert_eq!(kind, ExitKind::SettlementTimedOut); + assert!(message.contains("turn did not settle"), "{message}"); +} + +#[test] +fn deferred_exec_event_projects_effective_name_and_input() { + let identity = ToolEventIdentity::resolved( + "tool-1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + "CreatePlan", + ); + let wire_input = json!({ + "tool_name": "CreatePlan", + "args": { "title": "Ship deferred tools" } + }); + + let (tool_name, input) = effective_event_invocation(&identity, &wire_input); + + assert_eq!(tool_name, "CreatePlan"); + assert_eq!(input, &json!({ "title": "Ship deferred tools" })); + assert_eq!(wire_input["tool_name"], "CreatePlan"); +} diff --git a/src/apps/cli/tests/exec_cli_contracts.rs b/src/apps/cli/tests/exec_cli_contracts.rs index 845167eee..b1c0a88ce 100644 --- a/src/apps/cli/tests/exec_cli_contracts.rs +++ b/src/apps/cli/tests/exec_cli_contracts.rs @@ -1,7 +1,10 @@ mod support; use std::process::{Command, Output}; -use support::{CliTestEnvironment, MockOpenAiServer, STREAM_COMPLETED_MARKER}; +use support::{ + command_output_with_timeout, CliTestEnvironment, MockOpenAiServer, STREAM_COMPLETED_MARKER, + STREAM_START_MARKER, +}; fn run_cli(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_bitfun-cli")) @@ -35,6 +38,43 @@ fn is_terminal_event(value: &serde_json::Value) -> bool { ) } +#[test] +fn command_timeout_helper_returns_within_its_failure_budget() { + #[cfg(windows)] + let mut command = { + let mut command = Command::new("powershell.exe"); + command.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]); + command + }; + #[cfg(not(windows))] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "exec sleep 30"]); + command + }; + let started = std::time::Instant::now(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + command_output_with_timeout(&mut command, std::time::Duration::from_millis(50)) + })); + + let panic = result.expect_err("sleeping process must exceed the deadline"); + let panic_message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic payload"); + assert!( + panic_message.contains("CLI process exceeded"), + "unexpected timeout helper panic: {panic_message}" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(8), + "timeout helper exceeded its bounded cleanup budget: {:?}", + started.elapsed() + ); +} + #[test] fn exec_help_uses_competitor_aligned_output_and_approval_flags() { let output = run_cli(&["exec", "--help"]); @@ -182,18 +222,16 @@ fn stream_json_patch_write_failure_emits_error_without_success_terminal() { environment.initialize_git_repository(); environment.configure_mock_model(server.base_url()); let output_target = environment.workspace().to_string_lossy().into_owned(); - let output = environment - .std_command() - .args([ - "exec", - "exercise patch settlement", - "--output-format", - "stream-json", - "--output-patch", - &output_target, - ]) - .output() - .expect("run stream-json patch failure contract"); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise patch settlement", + "--output-format", + "stream-json", + "--output-patch", + &output_target, + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); @@ -270,18 +308,16 @@ fn stream_json_patch_success_emits_one_success_terminal() { .expect("workspace parent") .join("result.patch"); let output_target = output_patch.to_string_lossy().into_owned(); - let output = environment - .std_command() - .args([ - "exec", - "exercise successful patch settlement", - "--output-format", - "stream-json", - "--output-patch", - &output_target, - ]) - .output() - .expect("run stream-json patch success contract"); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise successful patch settlement", + "--output-format", + "stream-json", + "--output-patch", + &output_target, + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); let stdout = stdout(&output); assert!(output.status.success(), "{}\n{stdout}", stderr(&output)); @@ -314,3 +350,182 @@ fn stream_json_patch_success_emits_one_success_terminal() { "success terminal must be the final envelope: {stdout}" ); } + +#[test] +fn stream_json_provider_http_403_emits_one_error_terminal() { + let server = MockOpenAiServer::http_403("provider authorization denied"); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise provider failure contract", + "--output-format", + "stream-json", + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(1); + + let stdout = stdout(&output); + assert!(!output.status.success(), "{stdout}"); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + assert!( + stderr(&output) + .lines() + .any(|line| line.starts_with("BITFUN_EXIT: dialog_turn_failed:")), + "missing stable provider failure diagnostic: {}", + stderr(&output) + ); + let events = jsonl_events(&stdout); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "provider failure must emit exactly one terminal envelope: {stdout}" + ); + assert_eq!( + events.last().expect("provider failure terminal event")["event"]["type"], + "DialogTurnFailed", + "provider failure terminal must be last: {stdout}" + ); + let terminal_error = events.last().expect("provider failure terminal event")["event"]["error"] + .as_str() + .expect("provider failure error text"); + assert!( + terminal_error.contains("provider authorization denied"), + "provider failure reason was lost: {stdout}" + ); + assert!( + stderr(&output).contains("provider authorization denied"), + "provider failure diagnostic lost its reason: {}", + stderr(&output) + ); + assert!( + events.iter().all(|value| { + !(value["event"]["type"] == "DialogTurnCompleted" && value["event"]["success"] == true) + }), + "provider failure emitted a successful completion: {stdout}" + ); +} + +#[test] +fn stream_json_provider_and_patch_failures_publish_one_final_classification() { + let server = MockOpenAiServer::http_403("provider authorization denied"); + let environment = CliTestEnvironment::new(); + environment.initialize_git_repository(); + environment.configure_mock_model(server.base_url()); + let output_target = environment.workspace().to_string_lossy().into_owned(); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise combined provider and patch failure", + "--output-format", + "stream-json", + "--output-patch", + &output_target, + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(1); + + let stdout = stdout(&output); + let stderr = stderr(&output); + assert_eq!(output.status.code(), Some(1), "{stderr}\n{stdout}"); + let exit_diagnostics = stderr + .lines() + .filter(|line| line.starts_with("BITFUN_EXIT:")) + .collect::>(); + assert_eq!( + exit_diagnostics.len(), + 1, + "combined failure must have one stable classifier: {stderr}" + ); + assert!( + exit_diagnostics[0].starts_with("BITFUN_EXIT: patch_write_failed:"), + "patch delivery failure must be the final classifier: {stderr}" + ); + + let events = jsonl_events(&stdout); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "combined failure must publish exactly one terminal: {stdout}" + ); + let terminal = events.last().expect("combined failure terminal"); + assert_eq!(terminal["event"]["type"], "SystemError", "{stdout}"); + let error = terminal["event"]["error"] + .as_str() + .expect("combined failure error text"); + assert!(error.contains("provider authorization denied"), "{stdout}"); + assert!(error.contains("Failed to save requested patch"), "{stdout}"); +} + +#[test] +fn stream_json_disconnect_then_permanent_retry_failure_emits_one_error_terminal() { + let server = MockOpenAiServer::disconnect_then_http_403(); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise interrupted provider stream", + "--output-format", + "stream-json", + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(2); + + let stdout = stdout(&output); + assert!(!output.status.success(), "{stdout}"); + assert_eq!(output.status.code(), Some(1), "{}", stderr(&output)); + let events = jsonl_events(&stdout); + let stream_marker_index = events + .iter() + .position(|value| { + value["event"]["type"] == "TextChunk" + && value["event"]["text"] + .as_str() + .is_some_and(|text| text.contains(STREAM_START_MARKER)) + }) + .expect("interrupted provider stream marker"); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "provider disconnect must emit exactly one terminal envelope: {stdout}" + ); + assert_eq!( + events.last().expect("provider disconnect terminal event")["event"]["type"], + "DialogTurnFailed", + "provider disconnect terminal must be last: {stdout}" + ); + assert!( + stream_marker_index < events.len() - 1, + "provider stream must start before its retry failure: {stdout}" + ); + let terminal_error = events.last().expect("provider disconnect terminal event")["event"] + ["error"] + .as_str() + .expect("provider disconnect error text"); + assert!( + terminal_error.contains("provider stream remained unavailable"), + "provider retry failure reason was lost: {stdout}" + ); + assert!( + stderr(&output).contains("provider stream remained unavailable"), + "provider retry diagnostic lost its reason: {}", + stderr(&output) + ); + assert!( + events.iter().all(|value| { + !(value["event"]["type"] == "DialogTurnCompleted" && value["event"]["success"] == true) + }), + "provider disconnect emitted a successful completion: {stdout}" + ); +} diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index 98326d203..2ac24abb4 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -5,9 +5,9 @@ use serde_json::json; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc}; +use std::sync::{mpsc, Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -15,6 +15,81 @@ pub(crate) const STREAM_START_MARKER: &str = "ACTIVE_TURN_STREAM_MARKER"; pub(crate) const STREAM_RESIZED_MARKER: &str = "RESIZED_OK"; pub(crate) const STREAM_COMPLETED_MARKER: &str = "ACTIVE_TURN_STREAM_COMPLETED"; +pub(crate) fn command_output_with_timeout(command: &mut Command, timeout: Duration) -> Output { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = command.spawn().expect("spawn CLI process"); + let mut child_stdout = child.stdout.take().expect("capture CLI stdout"); + let mut child_stderr = child.stderr.take().expect("capture CLI stderr"); + let (stdout_tx, stdout_rx) = mpsc::sync_channel(1); + let (stderr_tx, stderr_rx) = mpsc::sync_channel(1); + thread::spawn(move || { + let mut output = Vec::new(); + let result = child_stdout.read_to_end(&mut output).map(|_| output); + let _ = stdout_tx.send(result); + }); + thread::spawn(move || { + let mut output = Vec::new(); + let result = child_stderr.read_to_end(&mut output).map(|_| output); + let _ = stderr_tx.send(result); + }); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait().expect("poll CLI process") { + break status; + } + if Instant::now() >= deadline { + let kill_error = child.kill().err(); + let termination_result = if kill_error.is_none() { + wait_for_child_exit(&mut child, Instant::now() + Duration::from_secs(2)) + } else { + None + }; + let output_deadline = Instant::now() + Duration::from_secs(2); + let stdout = recv_pipe_output(&stdout_rx, output_deadline, "stdout"); + let stderr = recv_pipe_output(&stderr_rx, output_deadline, "stderr"); + panic!( + "CLI process exceeded {timeout:?}; kill_error={kill_error:?}; termination_result={termination_result:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + } + thread::sleep(Duration::from_millis(10)); + }; + let output_deadline = Instant::now() + Duration::from_secs(2); + + Output { + status, + stdout: recv_pipe_output(&stdout_rx, output_deadline, "stdout"), + stderr: recv_pipe_output(&stderr_rx, output_deadline, "stderr"), + } +} + +fn wait_for_child_exit( + child: &mut std::process::Child, + deadline: Instant, +) -> Option> { + loop { + match child.try_wait() { + Ok(Some(status)) => return Some(Ok(status)), + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)), + Ok(None) => return None, + Err(error) => return Some(Err(error)), + } + } +} + +fn recv_pipe_output( + receiver: &mpsc::Receiver>>, + deadline: Instant, + stream: &str, +) -> Vec { + let remaining = deadline.saturating_duration_since(Instant::now()); + receiver + .recv_timeout(remaining) + .unwrap_or_else(|error| panic!("timed out collecting CLI {stream}: {error}")) + .unwrap_or_else(|error| panic!("failed to read CLI {stream}: {error}")) +} + pub(crate) struct CliTestEnvironment { _temp: tempfile::TempDir, workspace: PathBuf, @@ -153,17 +228,35 @@ pub(crate) struct MockOpenAiServer { base_url: String, release_stream: mpsc::Sender<()>, stream_disconnected: mpsc::Receiver<()>, + requests: Arc>>>, stop: Arc, thread: Option>, } +enum MockModelResponse { + Immediate, + Gated, + Http403 { reason: String }, + DisconnectThenHttp403, +} + impl MockOpenAiServer { pub(crate) fn gated() -> Self { - Self::spawn(true) + Self::spawn(MockModelResponse::Gated) } pub(crate) fn immediate() -> Self { - Self::spawn(false) + Self::spawn(MockModelResponse::Immediate) + } + + pub(crate) fn http_403(reason: impl Into) -> Self { + Self::spawn(MockModelResponse::Http403 { + reason: reason.into(), + }) + } + + pub(crate) fn disconnect_then_http_403() -> Self { + Self::spawn(MockModelResponse::DisconnectThenHttp403) } pub(crate) fn base_url(&self) -> &str { @@ -180,7 +273,28 @@ impl MockOpenAiServer { .expect("model stream remained connected after cancellation"); } - fn spawn(gated: bool) -> Self { + pub(crate) fn assert_chat_completion_requests(&self, expected_count: usize) { + let requests = self.requests.lock().expect("lock mock model requests"); + assert_eq!( + requests.len(), + expected_count, + "unexpected mock model request count" + ); + for request in requests.iter() { + let header_end = find_header_end(request).expect("mock request header terminator"); + let headers = String::from_utf8_lossy(&request[..header_end]); + assert_eq!( + headers.lines().next(), + Some("POST /v1/chat/completions HTTP/1.1"), + "unexpected mock model request target" + ); + let body: serde_json::Value = serde_json::from_slice(&request[header_end + 4..]) + .expect("parse mock model request body"); + assert_eq!(body["model"], "cli-e2e-model", "unexpected mock model id"); + } + } + + fn spawn(response: MockModelResponse) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock model server"); listener .set_nonblocking(true) @@ -188,17 +302,43 @@ impl MockOpenAiServer { let address = listener.local_addr().expect("mock model address"); let stop = Arc::new(AtomicBool::new(false)); let stop_for_thread = Arc::clone(&stop); + let requests = Arc::new(Mutex::new(Vec::new())); + let requests_for_thread = Arc::clone(&requests); let (release_tx, release_rx) = mpsc::channel(); let (disconnect_tx, disconnect_rx) = mpsc::channel(); let thread = thread::spawn(move || { let deadline = Instant::now() + Duration::from_secs(30); + let mut attempt = 0; loop { match listener.accept() { Ok((mut stream, _)) => { stream .set_nonblocking(false) .expect("configure accepted mock model connection"); - serve_model_response(&mut stream, gated, &release_rx, &disconnect_tx); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("configure mock request timeout"); + let request = + read_http_request(&mut stream).expect("read mock model request"); + requests_for_thread + .lock() + .expect("lock mock model requests") + .push(request); + serve_model_response( + &mut stream, + &response, + attempt, + &release_rx, + &disconnect_tx, + ); + attempt += 1; + if matches!( + response, + MockModelResponse::Http403 { .. } + | MockModelResponse::DisconnectThenHttp403 + ) { + continue; + } break; } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { @@ -216,6 +356,7 @@ impl MockOpenAiServer { base_url: format!("http://{address}"), release_stream: release_tx, stream_disconnected: disconnect_rx, + requests, stop, thread: Some(thread), } @@ -239,14 +380,20 @@ impl Drop for MockOpenAiServer { fn serve_model_response( stream: &mut TcpStream, - gated: bool, + response: &MockModelResponse, + attempt: usize, release_stream: &mpsc::Receiver<()>, stream_disconnected: &mpsc::Sender<()>, ) { - stream - .set_read_timeout(Some(Duration::from_secs(5))) - .expect("configure mock request timeout"); - read_http_request(stream).expect("read mock model request"); + if matches!(response, MockModelResponse::DisconnectThenHttp403) && attempt > 0 { + write_http_403(stream, "provider stream remained unavailable") + .expect("write post-disconnect HTTP error"); + return; + } + if let MockModelResponse::Http403 { reason } = response { + write_http_403(stream, reason).expect("write mock HTTP error"); + return; + } stream .write_all( b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n", @@ -286,11 +433,12 @@ fn serve_model_response( ) .expect("write mock streaming marker"); - if gated - && release_stream - .recv_timeout(Duration::from_secs(30)) - .is_err() - { + if matches!(response, MockModelResponse::DisconnectThenHttp403) { + return; + } + + let gated = matches!(response, MockModelResponse::Gated); + if gated && !wait_for_release_or_disconnect(stream, release_stream, stream_disconnected) { return; } @@ -346,6 +494,24 @@ fn serve_model_response( let _ = stream.flush(); } +fn write_http_403(stream: &mut TcpStream, reason: &str) -> std::io::Result<()> { + let body = json!({ + "error": { + "message": reason, + "type": "permission_error", + "param": null, + "code": "permission_denied" + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + stream.flush() +} + fn wait_for_release_or_disconnect( stream: &TcpStream, release_stream: &mpsc::Receiver<()>, diff --git a/src/apps/cli/tests/tui_terminal_process.rs b/src/apps/cli/tests/terminal_process_contracts.rs similarity index 66% rename from src/apps/cli/tests/tui_terminal_process.rs rename to src/apps/cli/tests/terminal_process_contracts.rs index a6227b9ec..df12fcb34 100644 --- a/src/apps/cli/tests/tui_terminal_process.rs +++ b/src/apps/cli/tests/terminal_process_contracts.rs @@ -19,6 +19,12 @@ const RESIZED_SIZE: PtySize = PtySize { pixel_width: 0, pixel_height: 0, }; +const EXEC_STREAM_SIZE: PtySize = PtySize { + rows: 30, + cols: 4096, + pixel_width: 0, + pixel_height: 0, +}; const STARTUP_INPUT: &[u8] = b"exercise active turn resize Q7Z9"; const STARTUP_INPUT_SENTINEL: &str = "Q7Z9"; const RECOVERY_INPUT: &[u8] = b"READY_AFTER_CANCEL K4W8"; @@ -173,6 +179,164 @@ fn active_turn_resize_can_be_cancelled_and_returns_to_editable_input() { assert!(output.contains("Goodbye!"), "{output}"); } +#[test] +fn exec_stream_json_ctrl_c_emits_one_cancelled_terminal_and_disconnects() { + let server = MockOpenAiServer::gated(); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.pty_command(); + command.args([ + "exec", + "exercise interrupt contract", + "--output-format", + "stream-json", + ]); + let mut process = PtyProcess::spawn(command, EXEC_STREAM_SIZE); + + process.expect_output( + STREAM_START_MARKER, + Duration::from_secs(30), + "exec model stream did not start", + ); + process.write(&[0x03]); + server.expect_stream_disconnect(Duration::from_secs(5)); + + let (status, output) = process.finish(Duration::from_secs(15)); + assert_eq!( + status.exit_code(), + 1, + "interrupt exit code changed:\n{output}" + ); + assert!( + output.contains("BITFUN_EXIT: cancelled:"), + "missing stable cancellation diagnostic:\n{output}" + ); + let events = strict_stream_json_events(&output); + let terminal_events = events + .iter() + .filter(|value| { + matches!( + value["event"]["type"].as_str(), + Some( + "DialogTurnCompleted" + | "DialogTurnCancelled" + | "DialogTurnFailed" + | "SystemError" + ) + ) + }) + .collect::>(); + assert_eq!( + terminal_events.len(), + 1, + "interrupt must emit exactly one terminal envelope:\n{output}" + ); + let raw_terminal_count = [ + "DialogTurnCompleted", + "DialogTurnCancelled", + "DialogTurnFailed", + "SystemError", + ] + .iter() + .map(|event_type| { + output + .matches(&format!("\"type\":\"{event_type}\"")) + .count() + }) + .sum::(); + assert_eq!( + raw_terminal_count, 1, + "raw PTY output contains a hidden or duplicate terminal envelope:\n{output}" + ); + assert_eq!( + terminal_events[0]["event"]["type"], "DialogTurnCancelled", + "interrupt must settle as cancelled:\n{output}" + ); + assert_eq!( + events.last().expect("stream-json cancellation event")["event"]["type"], + "DialogTurnCancelled", + "cancellation must be the final protocol envelope:\n{output}" + ); +} + +fn strict_stream_json_events(output: &str) -> Vec { + output + .lines() + .filter_map(|raw_line| { + let line = strip_terminal_sequences(raw_line); + let line = line.strip_prefix("^C").unwrap_or(&line); + let is_protocol_candidate = line.contains('{') + || line.contains("\"event\"") + || [ + "DialogTurnCompleted", + "DialogTurnCancelled", + "DialogTurnFailed", + "SystemError", + ] + .iter() + .any(|event_type| line.contains(event_type)); + if !is_protocol_candidate { + return None; + } + let value = serde_json::from_str::(&line).unwrap_or_else(|error| { + panic!("invalid stream-json PTY line {line:?}: {error}\nfull output:\n{output}") + }); + assert!( + value.get("event").is_some(), + "stream-json PTY record is not an Agentic envelope: {line:?}" + ); + Some(value) + }) + .collect() +} + +#[test] +fn stream_json_parser_accepts_the_echoed_ctrl_c_prefix_before_an_envelope() { + let events = strict_stream_json_events( + "^C{\"id\":\"event-1\",\"event\":{\"type\":\"SessionStateChanged\"}}\n", + ); + + assert_eq!(events.len(), 1); + assert_eq!(events[0]["event"]["type"], "SessionStateChanged"); +} + +fn strip_terminal_sequences(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(character) = chars.next() { + if character != '\x1b' { + if character != '\r' { + output.push(character); + } + continue; + } + match chars.next() { + Some('[') => { + for next in chars.by_ref() { + if next.is_ascii() && (0x40..=0x7e).contains(&(next as u8)) { + break; + } + } + } + Some(']') => { + while let Some(next) = chars.next() { + if next == '\x07' { + break; + } + if next == '\x1b' { + if chars.peek() == Some(&'\\') { + chars.next(); + } + break; + } + } + } + Some(_) | None => {} + } + } + output +} + fn captured_output(captured: &Arc>>) -> String { String::from_utf8_lossy(&captured.lock().expect("lock captured PTY output")).into_owned() }