From 82ffa23b119413d5c22e2deef38bbb21b0f43875 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 18 Jul 2026 19:42:01 +0800 Subject: [PATCH] refactor(cli): isolate exec lifecycle and failure contracts Split the exec owner into focused private modules, defer structured terminal publication until settlement and Patch delivery complete, and add deterministic provider, cancellation, resize, and process-timeout contracts. --- .github/workflows/ci.yml | 3 +- docs/architecture/cli-product-line-design.md | 17 +- docs/plans/core-decomposition-plan.md | 9 +- src/apps/cli/AGENTS.md | 4 + src/apps/cli/src/modes/exec.rs | 1836 +---------------- src/apps/cli/src/modes/exec/lifecycle.rs | 1296 ++++++++++++ src/apps/cli/src/modes/exec/patch.rs | 206 ++ src/apps/cli/src/modes/exec/tests.rs | 620 ++++++ src/apps/cli/tests/exec_cli_contracts.rs | 265 ++- src/apps/cli/tests/support/mod.rs | 198 +- ...ocess.rs => terminal_process_contracts.rs} | 164 ++ 11 files changed, 2734 insertions(+), 1884 deletions(-) create mode 100644 src/apps/cli/src/modes/exec/lifecycle.rs create mode 100644 src/apps/cli/src/modes/exec/patch.rs create mode 100644 src/apps/cli/src/modes/exec/tests.rs rename src/apps/cli/tests/{tui_terminal_process.rs => terminal_process_contracts.rs} (66%) 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() }