diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md index 5199463a3d..bb2d417f9f 100644 --- a/docs/architecture/agent-runtime-deployment-design.md +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -42,7 +42,7 @@ flowchart TB | Session 写入 | BitFun Runtime 的持久化 Session 由 `SessionManager` 管理;同一存储位置中的同一 Session 同时只允许一个本机进程写入,list/view 等只读操作不受影响 | | 当前 HTTP Server | 只提供 health/info/WebSocket 外壳,未装配 Agent Runtime,因此不取得 workspace ownership;`bootstrap.rs` 仅保持 agent-enabled composition 的一致边界,不由当前入口启动 | | Shared local IPC | 未发布的本机协议已有 discovery、实例锁、严格握手、Session 控制权、有界事件流和 cleanup;唯一 consumer 是第一方交互式 TUI adapter | -| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,重命名当前 Session,读取 transcript,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | +| Shared TUI | `bitfun --shared` / `bitfun chat --shared` 可列出、创建、恢复 Session,删除未被控制的空闲非当前 Session,重命名当前 Session,读取 transcript,切换当前 Session 的 Agent mode/model,通过 `/reload [skills|instructions]` 刷新声明式上下文,通过 `/compact` 或 `/summarize` 压缩当前 Session 上下文,提交/取消 Turn,处理 Permission 和 UserInput;默认仍是 Embedded | | Shared GUI/Headless/ACP/SDK Host/Remote | 未交付,也不会由 `--shared` 隐式启用;Replay、Observer、Controller transfer、Session archive/fork 同样不在当前协议中 | 因此当前交付的是一条窄的、显式启用的 Shared TUI deployment,不是通用本机 Server。具体 `EventQueue` 仍由 Core 产品装配;IPC 只把当前 TUI 必需的强类型操作和事件映射到同一个 Runtime owner,没有事件重放或公开协议承诺。 @@ -199,6 +199,7 @@ sequenceDiagram C->>S: rename or update current Session C->>S: delete idle non-current Session C->>S: reload current Session context + C->>S: compact current Session context C->>S: submit/cancel Turn or answer Permission/UserInput S-->>C: Session-filtered authoritative events else invalid @@ -206,12 +207,12 @@ sequenceDiagram end ``` -当前私有协议(v7)只覆盖 TUI 已有用户旅程需要的窄操作: +当前私有协议(v8)只覆盖 TUI 已有用户旅程需要的窄操作: | 已支持 | 明确不支持 | |---|---| | Health、Session list/create、原子 restore(含 transcript 与 pending Permission)、删除未被控制的空闲 Session、当前 Session rename、Agent mode/model update、声明式上下文 reload | Session archive/fork、跨 workspace attach、transcript 分页、模型目录/默认值和 Agent/Subagent 管理 | -| Turn submit/cancel | replay、cursor、resume event stream | +| Turn submit/cancel、当前 Session 手动 context compaction | replay、cursor、resume event stream | | pending/respond Permission、submit UserInput answers | observer、controller transfer、多 Session multiplex | | 连接断开清理、Session-filtered events | detach/observer/controller transfer、SDK callbacks、GUI/Remote/Peer/ACP/Headless wire | @@ -228,7 +229,7 @@ sequenceDiagram - 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task; - 未知 frame/operation 信封字段、未知 operation、错误身份和不兼容版本 fail closed;复用的 Runtime DTO 按其既有反序列化契约处理字段; - 一个连接最多控制一个 Session、同时最多提交一个活动 Turn;一个 Session 同时只有一个 controller。create/restore 在完整结果通过大小检查后才原子切换控制权,失败时保留原 Session。活动 Turn 期间不能切换 Session,也不能修改其名称、Agent mode 或 model;删除只作用于非当前且未被任何连接控制的 Session。 -- Submit 使用调用方已有的 `turn_id` 标识不确定结果;若提交超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 +- Submit 与手动 context compaction 都使用调用方已有的 `turn_id` 标识不确定结果;若操作超时,返回 `outcome_unknown`、关闭连接并按该 ID 取消。手动 compaction 要求当前 controller 且 Session 空闲,由 Core 通过与普通对话 Turn 共用的原子准入路径创建一个可审计 maintenance Turn,并在取得所有权后读取压缩上下文:planning 阶段允许取消,atomic commit 开始后忽略晚到取消并保持 Processing 直至终态持久化完成。maintenance Turn 保留在权威 transcript 中但不进入模型上下文,live/restored payload 使用同一 compression ID 和 `applied` 事实;commit 后的持久化故障发布明确失败终态而不是遗留 Processing。断连取消只有得到确认后才释放 Session 控制权;无法确认时继续隔离该 Session,直到 Runtime 进程退出。 - Session delete/rename 和 Agent mode/model update 复用既有 Runtime 端口和校验,Runtime 对最终结果保持权威并拒绝无效目标。它们都是有副作用操作;发送前编码或 frame 上限失败表示请求未执行,连接仍可使用。rename 写入失败时恢复旧 metadata:确认恢复后返回明确失败,无法确认时返回 `outcome_unknown`。Shared Client 在请求写入后响应超时或丢失连接时也返回 `outcome_unknown` 并断开连接。两种情况都不自动重试:rename 由用户恢复 Session 并核对当前值;delete 由用户重新打开 `/sessions` 核对目标是否仍存在。模式与模型目录仍是同版本第一方产品事实,不加入 IPC。 - 声明式上下文 reload 只失效当前 Session 的 instructions 缓存,并按目标复用 Skill Registry 刷新;它可在活动 Turn 中执行但不改写该 Turn,generation 保护保证下一条消息重建上下文。它不引入 watcher、热替换或第二套 Runtime owner。 - Shared TUI 的模型选择器复用 Client 已有的只读产品配置来显示同版本模型目录;它只把选中的 model ID 通过 `update current Session model` 交给 Runtime。Client 不持有 Session 写入权,也不通过 IPC 管理模型目录或默认值。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index d643bab464..3b09138ae2 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -256,12 +256,12 @@ Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付 | 形态 | 默认部署 | 当前 Shared 范围 | |---|---|---| -| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、Turn submit/cancel、Permission 和 UserInput | +| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、当前 Session 手动 context compaction、Turn submit/cancel、Permission 和 UserInput | | `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 | | ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 | Shared TUI 不提供 Session delete/fork、模型目录/默认值、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 -Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model;`/reload [skills|instructions]` 刷新下一条消息使用的声明式上下文。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示 `/rename ` 和 `/reload`;在 slash menu 中选择 rename 只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权;单个连接已有活动 Turn 时拒绝重复提交以及 Session rename/mode/model update,但允许 reload 只影响下一条消息;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 +Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model;`/reload [skills|instructions]` 刷新下一条消息使用的声明式上下文;OpenCode 对齐的 `/compact` 及其 `/summarize` alias 以一个可取消的 maintenance Turn 压缩当前 Session 上下文,不增加自创命令或快捷键。该 Turn 与普通对话共用 Session 原子准入,取得所有权后再读取待压缩上下文;权威 transcript 保留完整 tool payload,但重建模型上下文时排除该 maintenance Turn。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示这些入口;在 slash menu 中选择 rename 只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换失败保留原控制权;单个连接已有活动 Turn 时拒绝重复提交、manual compaction 以及 Session rename/mode/model update,但允许 reload 只影响下一条消息;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 部署差异由 CLI Runtime client 封装。Embedded 以 Rust 类型直接调用 `AgentRuntime`,不初始化 IPC 或执行 JSON 编解码;Shared 将同一业务请求映射为一个有界本机 frame,Client/Server 各自只编码一次,再交给同一 Runtime owner。多 TUI 复用一个 Runtime 进程,连接和队列保持有界,不按 TUI 数量复制 Session owner。详细的 4+1 视图、帧上限和并发边界见 [`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 diff --git a/docs/superpowers/plans/2026-07-30-tui-manual-context-compaction.md b/docs/superpowers/plans/2026-07-30-tui-manual-context-compaction.md new file mode 100644 index 0000000000..32ee843002 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-tui-manual-context-compaction.md @@ -0,0 +1,313 @@ +# TUI Manual Context Compaction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add OpenCode-compatible `/compact` and `/summarize` commands to Embedded and Shared TUI through one runtime-owned, cancellable, audited manual-compaction lifecycle. + +**Architecture:** Add one stable session-compaction port to `bitfun-runtime-ports`, inject it into `AgentRuntime`, and implement it in Core by starting an owned maintenance task with a caller-supplied turn ID. Project that port through private Shared TUI IPC v8 and convert existing compression events into the existing TUI `ContextCompression` tool card. + +**Tech Stack:** Rust, Tokio, Serde, Ratatui, BitFun Agent Runtime ports, private local IPC. + +## Global Constraints + +- Primary command is `/compact`; the only compatibility alias is `/summarize`. +- Preserve idle-only admission and do not introduce queueing. +- Do not add a keyboard shortcut or multi-key keymap support. +- Do not change compression prompts, algorithms, automatic thresholds, or artifact schemas. +- Do not change Relay, Server, Peer, ACP, SDK Host, Web UI, extensions, or customization behavior. +- Shared IPC remains private to the first-party TUI and moves from protocol version 7 to 8. +- Caller-generated turn IDs must preserve disconnect cancellation and outcome-unknown behavior. +- Idle admission must be atomic with ordinary dialog-turn admission, and the compaction snapshot must be captured only after the maintenance turn owns the Session. +- Planning may be cancelled; once the atomic commit gate wins, context commit must finish without exposing a false idle state. +- The maintenance Turn is visible in the authoritative transcript but remains excluded from model context; live and restored tool payloads retain the same compression identity and `applied` state. +- Keep the combined PR below 8k changed lines and squash to one final commit. + +--- + +### Task 1: Add the narrow Runtime contract and facade + +**Files:** +- Modify: `src/crates/contracts/runtime-ports/src/lib.rs` +- Modify: `src/crates/execution/agent-runtime/src/runtime.rs` +- Modify: `src/crates/execution/agent-runtime/src/sdk.rs` + +**Interfaces:** +- Produces: `AgentSessionCompactionRequest`, `AgentSessionCompactionResult`, `AgentSessionCompactionPort`. +- Produces: `AgentRuntimeBuilder::with_session_compaction_port` and `AgentRuntime::start_session_compaction`. + +- [x] **Step 1: Write failing Runtime tests** + +Add a recording provider and tests proving that the runtime forwards exact session/turn identities and returns typed `NotAvailable` when the port is absent: + +```rust +#[derive(Default)] +struct RecordingCompactionPort { + requests: Mutex>, +} + +#[async_trait::async_trait] +impl AgentSessionCompactionPort for RecordingCompactionPort { + async fn start_session_compaction( + &self, + request: AgentSessionCompactionRequest, + ) -> PortResult { + self.requests.lock().unwrap().push(request.clone()); + Ok(AgentSessionCompactionResult { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } +} +``` + +- [x] **Step 2: Verify RED** + +Run: `cargo test -p bitfun-agent-runtime session_compaction -- --nocapture` + +Expected: compilation fails because the request, result, port, builder method, and runtime method do not exist. + +- [x] **Step 3: Implement the minimal contract and forwarding path** + +Add serializable camelCase DTOs and the narrow async trait in `runtime-ports`; add the optional port field, builder injection, debug projection, and forwarding method in `AgentRuntime`; re-export only these stable types through `sdk`. + +- [x] **Step 4: Verify GREEN** + +Run: + +```powershell +cargo test -p bitfun-runtime-ports +cargo test -p bitfun-agent-runtime session_compaction -- --nocapture +``` + +Expected: both commands pass. + +### Task 2: Move manual compaction into the owned Core turn lifecycle + +**Files:** +- Modify: `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` +- Modify: `src/crates/assembly/core/src/agentic/execution/execution_engine.rs` +- Modify: `src/crates/assembly/core/src/service_agent_runtime.rs` + +**Interfaces:** +- Consumes: `AgentSessionCompactionPort` and its request/result DTOs. +- Produces: one accepted maintenance task registered with active-session execution, settlement, cancellation, and an atomic planning/commit gate. +- Preserves: `ConversationCoordinator::compact_session_manually(String) -> BitFunResult<()>` for Desktop. + +- [x] **Step 1: Write failing gate and assembly tests** + +Add tests proving: + +```rust +let gate = ManualCompactionCommitGate::planning(); +assert!(gate.try_cancel()); +assert!(!gate.try_begin_commit()); + +let gate = ManualCompactionCommitGate::planning(); +assert!(gate.try_begin_commit()); +assert!(!gate.try_cancel()); +``` + +Add a source/assembly contract test proving all Core TUI-capable runtime builders register `with_session_compaction_port`. + +- [x] **Step 2: Verify RED** + +Run: `cargo test -p bitfun-core manual_compaction --features product-full -- --nocapture` + +Expected: fails because the commit gate and runtime-port implementation do not exist. + +- [x] **Step 3: Implement the start/task split** + +Refactor the current synchronous body into: + +```rust +async fn start_manual_compaction_task( + &self, + session_id: String, + requested_turn_id: Option, +) -> BitFunResult; + +pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()>; +``` + +`ManualCompactionTask` contains the accepted turn ID and a private oneshot completion receiver. The runtime port calls the start function with `Some(request.turn_id)` and drops the receiver; the Desktop compatibility method awaits it. + +Register before spawn: + +- `register_session_execution` lease; +- `turn_settlements.register_accepted` registration; +- `CancellationToken` in `ExecutionEngine`; +- `ManualCompactionCommitGate` in a coordinator map keyed by turn ID. + +The owned task persists completed, failed, or cancelled state exactly once, removes the gate and cancel token, and only then releases settlement/active-execution guards. Maintenance and ordinary dialog turns share one mutation-locked idle admission path; context is captured after that admission so a racing user turn cannot be omitted from the committed replacement. + +- [x] **Step 4: Make planning cancellation-aware** + +Pass the cancellation token and commit gate to `compact_session_context`. Wrap only `build_planned_compression_result` in cancellation selection. After planning, atomically call `try_begin_commit`; if cancellation already won, return `BitFunError::Cancelled`. Once commit wins, finish the existing replacement/persistence/event tail without another cancellation branch. + +Teach `cancel_dialog_turn` to consult the manual gate before changing state: planning cancellation follows the existing cancellation path; commit-winning turns ignore the late cancellation request and retain processing state until completion. + +- [x] **Step 5: Inject the port and verify GREEN** + +Register the coordinator as `AgentSessionCompactionPort` in the shared Core runtime builder paths, then run: + +```powershell +cargo test -p bitfun-core manual_compaction --features product-full -- --nocapture +cargo test -p bitfun-agent-runtime session_compaction -- --nocapture +``` + +Expected: tests pass with one terminal owner and no duplicate state transition. + +The completion finalizer treats post-commit turn/session persistence failures as an explicit failed terminal result while retaining an idle in-memory Session. Persisted transcript projection includes the maintenance Turn and its exact tool payload without restoring that Turn into model-visible context. + +### Task 3: Extend private Shared TUI IPC to protocol v8 + +**Files:** +- Modify: `src/crates/adapters/agent-runtime-ipc/src/protocol.rs` +- Modify: `src/crates/adapters/agent-runtime-ipc/src/operation.rs` +- Modify: `src/crates/adapters/agent-runtime-ipc/src/server.rs` +- Modify: `src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs` +- Modify: `src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs` +- Modify: `src/apps/cli/src/shared_runtime.rs` + +**Interfaces:** +- Consumes: `AgentSessionCompactionRequest` and `AgentRuntime::start_session_compaction`. +- Produces: `RuntimeIpcOperation::CompactSession`, returning the existing `TurnAccepted` result. + +- [x] **Step 1: Write failing protocol/rules/controller tests** + +Test JSON round-trip and exact operation rules: + +```rust +let operation = RuntimeIpcOperation::CompactSession { + request: AgentSessionCompactionRequest { + session_id: "session-1".into(), + turn_id: "turn-compact-1".into(), + }, +}; +let rules = operation.rules(); +assert_eq!(rules.session_requirement, RuntimeIpcSessionRequirement::CurrentController); +assert!(rules.requires_idle); +assert!(rules.side_effecting); +``` + +Extend the shared-controller fixture to prove the supplied turn ID becomes the connection's active turn and disconnect triggers cancellation. + +- [x] **Step 2: Verify RED** + +Run: `cargo test -p bitfun-agent-runtime-ipc compact -- --nocapture` + +Expected: compilation fails because `CompactSession` is not defined and protocol remains v7. + +- [x] **Step 3: Implement protocol v8 operation** + +Add the enum variant, current-controller/idle/side-effecting rules, `session_id` projection, provisional-turn extraction shared with `SubmitTurn`, handler dispatch to `AgentRuntime`, and `TurnAccepted` result. Increment `PROTOCOL_VERSION` to 8 and update explicit protocol assertions. + +- [x] **Step 4: Verify GREEN** + +Run: `cargo test -p bitfun-agent-runtime-ipc compact -- --nocapture` + +Expected: protocol, controller, disconnect, and cancellation tests pass. + +### Task 4: Add exact competitor-compatible TUI commands and feedback + +**Files:** +- Modify: `src/apps/cli/src/actions.rs` +- Modify: `src/apps/cli/src/agent/runtime_client.rs` +- Modify: `src/apps/cli/src/modes/chat/commands.rs` +- Modify: `src/apps/cli/src/modes/chat/run.rs` +- Modify: `src/apps/cli/src/chat_state.rs` +- Modify: `src/apps/cli/src/modes/chat/tests.rs` + +**Interfaces:** +- Consumes: runtime/IPC compaction start operation and authoritative `AgenticEvent` compression events. +- Produces: `ActionHandler::CompactSession`, aliases `/compact` and `/summarize`, and live `ContextCompression` tool-card projection. + +- [x] **Step 1: Write failing action and argument tests** + +Prove both aliases resolve to one idle-only action in Embedded and Shared modes, no invented alias exists, and non-empty arguments return `Usage: /compact` without starting runtime work. + +- [x] **Step 2: Write failing runtime-client parity tests** + +Add a focused test/source contract proving Embedded calls `runtime.start_session_compaction(request)` and Shared sends `RuntimeIpcOperation::CompactSession { request }`, both with a caller-generated stable turn ID. + +- [x] **Step 3: Write failing compression projection tests** + +Add a pure projection helper and tests proving: + +- `ContextCompressionStarted` creates a running `ContextCompression` tool card; +- `ContextCompressionCompleted` records tokens before/after, summary source, duration, and success; +- `ContextCompressionFailed` records failure; +- unrelated sessions/turns do not mutate current TUI state. + +- [x] **Step 4: Verify RED** + +Run: `cargo test -p bitfun-cli compact -- --nocapture` + +Expected: tests fail because the action, runtime client, and event projection do not exist. + +- [x] **Step 5: Implement the minimal TUI slice** + +Add the exact action aliases, call the runtime client through the existing synchronous dispatch boundary, and set an immediate accepted/error status. Convert compression events to existing `ToolEventData` values and feed `ChatState::handle_tool_event`; do not add another compaction UI model. + +- [x] **Step 6: Verify GREEN** + +Run: + +```powershell +cargo test -p bitfun-cli compact -- --nocapture +cargo test -p bitfun-cli +``` + +Expected: all CLI tests pass in both runtime projections. + +### Task 5: Align architecture constraints and validate the combined PR + +**Files:** +- Modify: `src/crates/adapters/agent-runtime-ipc/AGENTS.md` +- Modify: `docs/architecture/agent-runtime-deployment-design.md` +- Modify: `docs/architecture/cli-product-line-design.md` +- Modify: `docs/superpowers/plans/2026-07-30-tui-manual-context-compaction.md` + +- [x] **Step 1: Update the closed operation contract** + +Document protocol v8, the single TUI consumer, current-controller/idle requirements, caller-provided turn identity, disconnect cancellation, and explicit non-goals. Do not describe the private wire as a public SDK or server protocol. + +- [x] **Step 2: Mark completed plan steps and self-review the plan/spec** + +Check for placeholders, contradictory command names, protocol version drift, and scope leakage. + +- [x] **Step 3: Run required verification** + +```powershell +cargo test -p bitfun-runtime-ports +cargo test -p bitfun-agent-runtime +cargo test -p bitfun-agent-runtime-ipc +cargo test -p bitfun-cli +cargo check -p bitfun-core --features product-full +node scripts/check-core-boundaries.mjs +git diff --check +``` + +Expected: every command passes. If a broader pre-existing failure remains, capture exact evidence and ensure focused changed-path tests pass. + +- [x] **Step 4: Audit size and scope** + +Run: + +```powershell +git diff --stat gcwing/main...HEAD +git diff --numstat gcwing/main...HEAD +git status -sb +``` + +Expected: only PR1+PR2 files are present and total changed lines remain below 8k. + +- [x] **Step 5: Independent adversarial review and repair** + +Ask an isolated reviewer to inspect the combined diff for ownership leaks, cancellation/commit races, Shared IPC controller gaps, false terminal states, command incompatibility, transcript divergence, and unnecessary scope. Fix every actionable finding and rerun affected checks. + +Review repairs covered atomic dialog/maintenance admission, post-commit terminal finalization, persisted maintenance transcript restoration, and live/restored compression identity plus `applied` parity. + +- [x] **Step 6: Squash, push fork, and open Draft PR** + +Create one final conventional commit, push only to `origin` (`limityan/BitFun`), and open a Draft PR against `GCWing/BitFun:main` with design, impact, risk, and validation details. diff --git a/docs/superpowers/plans/2026-07-30-tui-session-context-status.md b/docs/superpowers/plans/2026-07-30-tui-session-context-status.md new file mode 100644 index 0000000000..2586755e93 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-tui-session-context-status.md @@ -0,0 +1,63 @@ +# TUI Session and Context Status Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make CLI TUI session navigation, fresh-session aliases, and model-context status truthful in both Embedded and Shared runtimes. + +**Architecture:** Keep the change inside the CLI adapter. Project the existing `TokenUsageUpdated` event into a small UI-owned snapshot for the latest primary-model request, render that snapshot without claiming it is cumulative session usage, and reuse the existing session picker for history. No new runtime owner, transport request, or persistence contract is introduced. + +**Tech Stack:** Rust, Ratatui, existing BitFun CLI action registry and agentic event contracts. + +## Global Constraints + +- Do not modify Relay, shared IPC, Core runtime behavior, Web UI, or extension/customization paths. +- Preserve the existing `/usage` command as the authoritative cumulative report where supported. +- Keep `/status` transient; it must not add a persisted conversation message. +- Treat subagent usage and last-round usage as distinct from session totals. +- Keep the total PR below 8k changed lines and prefer one final commit. + +--- + +### Task 1: Correct slash-command semantics + +**Files:** +- Modify: `src/apps/cli/src/actions.rs` +- Modify: `src/apps/cli/src/modes/chat/commands.rs` +- Modify: `src/apps/cli/src/ui/startup.rs` + +- [x] Add failing registry tests proving `/status` is available in Shared TUI, `/sessions` exposes OpenCode-compatible `/resume` and `/continue` aliases, `/history` resolves to the existing session picker for compatibility, and `/clear` resolves to the same new-session action as `/new`. +- [x] Run the focused action tests and confirm they fail for the missing behavior. +- [x] Add the `Status` handler, route `/history` through `Sessions`, add the OpenCode-compatible session aliases, and remove the misleading history-statistics action. +- [x] Remove the TUI-only clear-conversation action and make `/clear` an alias for `/new`, matching OpenCode's fresh-session semantics without inventing a new clear-screen command. +- [x] Run the focused action tests and confirm they pass. + +### Task 2: Preserve truthful primary-model context facts + +**Files:** +- Modify: `src/apps/cli/src/chat_state.rs` +- Modify: `src/apps/cli/src/modes/chat/run.rs` +- Add: `src/apps/cli/src/ui/chat/status.rs` +- Modify: `src/apps/cli/src/modes/chat.rs` +- Modify: `src/apps/cli/src/ui/chat.rs` + +- [x] Add failing unit tests for the latest primary-model usage snapshot and status text, including unknown context, known context percentage, and no session-total claim. +- [x] Run the focused tests and confirm they fail for the missing projection and formatter. +- [x] Replace `ChatMetadata::total_tokens` with a UI-owned `ModelTokenUsageSnapshot` containing only stable event facts used by the TUI. +- [x] Ignore subagent usage and unrelated turn events while retaining the latest primary-model request facts. +- [x] Add a pure transient `/status` formatter covering session, runtime, workspace, approval mode, and observed context facts. +- [x] Run the focused tests and confirm they pass. + +### Task 3: Wire the popup and status bar, then verify the PR + +**Files:** +- Modify: `src/apps/cli/src/modes/chat/commands.rs` +- Modify: `src/apps/cli/src/ui/chat/render.rs` +- Modify: `src/apps/cli/src/ui/command_menu.rs` +- Modify: `src/apps/cli/src/ui/command_palette.rs` + +- [x] Add failing tests for compact status-bar text with known, unknown, and missing context-window values. +- [x] Run the focused tests and confirm they fail for the old token-total display. +- [x] Wire `/status` to the existing transient info popup in both runtime modes. +- [x] Replace all `Tokens:` session-total labels with truthful latest-request context text, omitting unavailable data rather than showing zero. +- [x] Run `cargo test -p bitfun-cli`, `cargo check -p bitfun-cli`, and `git diff --check`; run `cargo fmt -p bitfun-cli -- --check` and record its unrelated baseline drift. +- [x] Adversarially review the full diff for runtime parity, active-turn behavior, misleading copy, accidental persistence, duplicate aliases, and out-of-scope files; fix all actionable findings. diff --git a/docs/superpowers/specs/2026-07-30-tui-manual-context-compaction-design.md b/docs/superpowers/specs/2026-07-30-tui-manual-context-compaction-design.md new file mode 100644 index 0000000000..d5d8063b9d --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-tui-manual-context-compaction-design.md @@ -0,0 +1,195 @@ +# TUI Manual Context Compaction Design + +**Status:** Approved for implementation by the 2026-07-30 request to complete PR2 and combine it with the existing TUI session/status work. + +## Goal + +Expose BitFun's existing manual context-compaction capability through both Embedded and Shared TUI without duplicating compression policy or weakening session ownership, cancellation, persistence, or audit semantics. + +The user entry points are `/compact` and the OpenCode-compatible alias `/summarize`. No BitFun-specific synonym or new shortcut is introduced. + +## Current State + +- Core already performs manual compaction as a persisted `ManualCompaction` maintenance turn and emits context-compression plus dialog-turn lifecycle events. +- Desktop already calls `ConversationCoordinator::compact_session_manually`. +- The CLI action registry has no manual-compaction action. +- `AgentRuntime`, runtime ports, and private Shared TUI IPC do not expose manual compaction. +- The Shared TUI protocol is version 7 and its documented closed operation set does not include compaction. +- Manual compaction currently waits for the model operation inline and is not registered in the same active-turn, settlement, and cancellation lifecycle used by TUI dialog turns. + +## Competitor Compatibility + +OpenCode uses `/compact` as the primary command and `/summarize` as an alias. Codex also exposes `/compact` as a dedicated runtime operation instead of sending it as an ordinary model prompt. + +BitFun will match those command names and dedicated-operation semantics. It will retain BitFun's current idle-only admission rule rather than introducing Codex-style queuing. OpenCode's `Ctrl+X C` shortcut is intentionally deferred because BitFun's keymap currently supports a single key chord; inventing a different shortcut or adding leader sequences would be unrelated scope. + +## Considered Approaches + +### 1. Send `/compact` as a normal prompt + +Rejected. This would make compression depend on model interpretation, pollute model-visible context, and bypass the existing maintenance-turn audit path. + +### 2. Call Core directly from the CLI + +Rejected. Embedded and Shared TUI would diverge, the CLI would depend on the concrete coordinator, and cancellation/disconnect ownership would remain incomplete. + +### 3. Add one narrow runtime-owned compaction capability + +Selected. A typed runtime port starts the existing Core maintenance operation, and the private Shared IPC projects exactly that capability to its only consumer, the first-party TUI. + +## Architecture + +```text +/compact | /summarize + | + v +CLI Action Registry + | + v +CliAgentRuntimeClient + | | + | Embedded | Shared + v v +AgentRuntime Runtime IPC v8 + \ / + v v +AgentSessionCompactionPort + | + v +ConversationCoordinator + | + v +ExecutionEngine compaction plan -> cancellation gate -> atomic commit tail + | + v +Authoritative events and persisted ManualCompaction turn +``` + +### Runtime contract + +`bitfun-runtime-ports` adds: + +```rust +pub struct AgentSessionCompactionRequest { + pub session_id: String, + pub turn_id: String, +} + +pub struct AgentSessionCompactionResult { + pub session_id: String, + pub turn_id: String, +} + +#[async_trait::async_trait] +pub trait AgentSessionCompactionPort: Send + Sync { + async fn start_session_compaction( + &self, + request: AgentSessionCompactionRequest, + ) -> PortResult; +} +``` + +The caller supplies a stable turn ID. This lets Shared IPC record the provisional active turn before executing the side effect, preserving disconnect cancellation and outcome-unknown handling. + +`AgentRuntime` stores the optional port, exposes `start_session_compaction`, and returns a typed `NotAvailable` error when a product assembly does not register it. + +### Core lifecycle + +The coordinator splits manual compaction into start and completion: + +1. Validate exact session/turn identities and require an idle, context-loaded session. +2. Atomically admit the persisted maintenance turn with the caller-provided turn ID through the same mutation lock used by ordinary dialog turns. +3. Read the authoritative context only after the maintenance turn owns the Session, so a racing dialog turn cannot be omitted. +4. Register active-session execution, exact turn settlement, a cancellation token, and a manual-compaction commit gate. +5. Emit `DialogTurnStarted` and return the accepted turn identity immediately. +6. Run compression in an owned task. +7. Persist exactly one terminal status and return the in-memory session to idle only when the owned task settles. + +The existing synchronous Desktop compatibility method starts the same task and awaits its private completion receiver. It does not create a second execution path. + +### Cancellation and commit safety + +Manual compaction has two phases: + +- **Planning:** cancellation wins through an atomic planning/cancelled transition and cancels the model future. The maintenance turn is persisted as cancelled. +- **Committing:** the compaction plan has already been accepted for context replacement. Cancellation no longer clears session state; the commit tail finishes and reports completion. + +The transition uses a small atomic gate shared by the coordinator and execution engine. A compare-and-swap prevents cancellation and commit from both winning. The gate is registered only for manual compaction and removed when the task settles. + +This avoids a state where the session appears idle while context replacement or persistence continues. + +The maintenance Turn is model-invisible but transcript-visible. Transcript reads project canonical persisted Turn records rather than reconstructing the maintenance entry from model context, retaining exact compression/tool identity and the `applied` fact after restart. If context commit succeeds but terminal Turn or idle-state persistence fails, the finalizer emits one explicit failed dialog terminal and returns an error that states the compaction was already applied; it never leaves Shared TUI waiting on a missing terminal event. + +### Shared IPC + +The private protocol moves from version 7 to version 8 and adds: + +```rust +RuntimeIpcOperation::CompactSession { + request: AgentSessionCompactionRequest, +} +``` + +Rules: + +- current controller only; +- current session must be idle; +- side-effecting; +- supplied turn ID becomes the provisional active turn; +- success reuses `RuntimeIpcOperationResult::TurnAccepted`; +- disconnect and explicit cancel reuse the existing turn-cancellation operation. + +No Server, Relay, Peer, ACP, SDK Host, or public wire protocol is changed. + +### TUI behavior + +- `/compact` is the primary entry; `/summarize` resolves to the same action. +- Both are shown only in chat/startup surfaces where the existing action projection permits session operations. +- Extra arguments are rejected with `Usage: /compact`. +- The action is idle-only in both Embedded and Shared modes. +- Acceptance shows immediate status while authoritative events drive processing state. +- Compression events are projected into the existing `ContextCompression` tool-card presentation, so live execution and restored transcript use the same visual vocabulary. +- Completion shows token reduction and source facts already carried by the event; failure and cancellation reuse existing turn terminal handling. + +## Error Handling + +- Missing runtime port: typed not-available error, surfaced in the TUI status line. +- Busy/error session: Core and IPC both fail closed; the action registry prevents the normal busy invocation path. +- Duplicate or invalid turn ID: rejected before starting a second maintenance turn. +- Shared request timeout after side-effect admission: existing outcome-unknown disconnect handling applies because the provisional turn ID is known. +- Event stream failure: existing CLI active-turn cancellation and embedded handoff guidance applies. +- Cancellation after commit wins: operation completes; no false cancelled terminal state is emitted. +- Post-commit terminal persistence failure: the Session returns to idle in memory, emits one failed terminal event, and reports that context replacement was already applied. + +## Scope Exclusions + +- Relay and remote protocol changes +- Extension or customization behavior +- Compression prompt, algorithm, automatic threshold, or artifact-schema changes +- Web UI behavior changes +- Public Server/API/SDK Host exposure +- Generic maintenance-command framework +- Busy-session queueing +- New keyboard shortcut or leader-sequence support + +## Verification + +- Runtime-port DTO and runtime forwarding tests +- Atomic cancellation/commit gate tests +- Core accepted-turn and terminal-settlement tests using existing coordinator fixtures where practical +- Atomic dialog/maintenance admission, post-commit failure finalization, and persisted transcript restoration tests +- IPC v8 serialization, rules, provisional active-turn, and disconnect-cancellation tests +- CLI action alias/availability/argument tests +- Embedded/Shared runtime-client equivalence tests +- TUI context-compression projection tests +- `cargo test -p bitfun-runtime-ports` +- `cargo test -p bitfun-agent-runtime` +- `cargo test -p bitfun-agent-runtime-ipc` +- `cargo test -p bitfun-cli` +- `cargo check -p bitfun-core --features product-full` +- `node scripts/check-core-boundaries.mjs` +- `git diff --check` + +## Delivery + +This work is combined with the existing TUI session aliases and truthful context-status changes in one PR. The final branch is rebased on current `gcwing/main`, reviewed as one diff, and squashed to one commit before pushing to `limityan/BitFun`. diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 231b64edf5..e5f61932a1 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -6,7 +6,7 @@ export const forbiddenContentRules = [ reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|AgentContextReloadRequest|AgentDialogTurnRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|AgentContextReloadRequest|AgentDialogTurnRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|SessionTranscript)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add archive, replay, observer, controller-transfer, fork, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 013ac0252a..0106c29591 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -66,7 +66,6 @@ const CHAT_ACTION_STATES: &[ActionState] = &[ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ActionHandler { Help, - ClearConversation, OpenAgentSelector, SwitchAgent, SwitchAgentReverse, @@ -85,7 +84,8 @@ pub(crate) enum ActionHandler { ExternalHooks, AcpHelp, Init, - History, + Status, + CompactSession, Usage, ToggleAutoApprove, ToggleWorktree, @@ -115,7 +115,7 @@ pub(crate) enum ActionHandler { pub(crate) const SHARED_TUI_EMBEDDED_HANDOFF: &str = "Exit all Shared TUI clients, wait up to 30 seconds for their Runtime to stop, then use default Embedded `bitfun chat`"; pub(crate) const SHARED_TUI_HELP_NOTE: &str = - "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/rename ` to rename the current Session, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; + "Shared TUI: start with `bitfun chat --shared`. Multiple TUI processes reuse one workspace Runtime, while each TUI controls at most one Session and each Session has one controller. Use `/sessions` and Ctrl+D to delete an idle, non-current Session; use `/rename ` to rename the current Session, `/compact` to compact its context, `/agent`, Tab, or Shift+Tab to change its Agent mode, `/models` to change its model, and `/reload [skills|instructions]` to refresh declarative context for the next message. Model configuration, Agent/Subagent management, MCP, extension, account-sync, usage, and other management remain Embedded. Exit all Shared TUI clients and wait up to 30 seconds before returning to default Embedded `bitfun chat`."; impl ActionHandler { pub(crate) const fn available_in_shared_tui(self, context: ActionContext) -> bool { @@ -123,14 +123,14 @@ impl ActionHandler { || matches!( self, Self::Help - | Self::ClearConversation | Self::SelectTheme | Self::NewSession | Self::Sessions | Self::RenameSession | Self::AcpHelp | Self::Init - | Self::History + | Self::Status + | Self::CompactSession | Self::ToggleAutoApprove | Self::OpenAgentSelector | Self::SwitchAgent @@ -239,21 +239,6 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: true, }, - ActionSpec { - id: "clear_conversation", - name: "Clear conversation", - aliases: &["/clear"], - description: "Clear conversation", - contexts: CHAT, - availability: ActionAvailability::Always, - handler: ActionHandler::ClearConversation, - default_bindings: &[], - fallback_bindings: &[], - shortcut_field: None, - palette: None, - shortcut_label: None, - slash_on_startup: false, - }, ActionSpec { id: "switch_agent", name: "Agent", @@ -347,8 +332,8 @@ static ACTION_SPECS: &[ActionSpec] = &[ ActionSpec { id: "new_session", name: "New session", - aliases: &["/new"], - description: "Start a new conversation", + aliases: &["/new", "/clear"], + description: "Start a fresh conversation session", contexts: BOTH, availability: ActionAvailability::Idle, handler: ActionHandler::NewSession, @@ -362,7 +347,7 @@ static ACTION_SPECS: &[ActionSpec] = &[ ActionSpec { id: "sessions", name: "Sessions", - aliases: &["/sessions"], + aliases: &["/sessions", "/resume", "/continue", "/history"], description: "Browse and switch sessions", contexts: BOTH, availability: ActionAvailability::Idle, @@ -525,17 +510,32 @@ static ACTION_SPECS: &[ActionSpec] = &[ slash_on_startup: true, }, ActionSpec { - id: "history", - name: "History", - aliases: &["/history"], - description: "Show history", + id: "status", + name: "View status", + aliases: &["/status"], + description: "Show current session and runtime status", contexts: CHAT, availability: ActionAvailability::Always, - handler: ActionHandler::History, + handler: ActionHandler::Status, default_bindings: &[], fallback_bindings: &[], shortcut_field: None, - palette: None, + palette: palette("System", false), + shortcut_label: None, + slash_on_startup: false, + }, + ActionSpec { + id: "compact_session", + name: "Compact context", + aliases: &["/compact", "/summarize"], + description: "Compact the current session context", + contexts: CHAT, + availability: ActionAvailability::Idle, + handler: ActionHandler::CompactSession, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Session", false), shortcut_label: None, slash_on_startup: false, }, @@ -1876,6 +1876,69 @@ mod tests { assert!(action_by_id("rename_session", ActionContext::Startup).is_none()); } + #[test] + fn compact_uses_opencode_commands_without_an_invented_shortcut() { + let action = action_by_id("compact_session", ActionContext::Chat) + .expect("current session compaction action"); + + assert_eq!(action.aliases, &["/compact", "/summarize"]); + assert_eq!(action.handler, ActionHandler::CompactSession); + assert_eq!(action.availability, ActionAvailability::Idle); + assert!(action.default_bindings.is_empty()); + assert!(action.fallback_bindings.is_empty()); + assert!(action.shortcut_field.is_none()); + assert!(action.available(ActionState::chat(false, false).for_shared_tui())); + assert!(!action.available(ActionState::chat(true, false).for_shared_tui())); + assert!(action_for_alias("/compress", ActionContext::Chat).is_none()); + } + + #[test] + fn status_is_a_shared_chat_action() { + let action = action_for_alias("/status", ActionContext::Chat) + .expect("current session status action"); + + assert_eq!(action.id, "status"); + assert!(action.available(ActionState::chat(false, false))); + assert!(action.available(ActionState::chat(true, false))); + assert!(action.available(ActionState::chat(false, false).for_shared_tui())); + assert!(action_by_id("status", ActionContext::Startup).is_none()); + } + + #[test] + fn session_browser_uses_opencode_aliases_and_keeps_history_compatible() { + let sessions = + action_for_alias("/sessions", ActionContext::Chat).expect("session browser action"); + let resume = action_for_alias("/resume", ActionContext::Chat) + .expect("OpenCode-compatible resume alias"); + let continue_session = action_for_alias("/continue", ActionContext::Chat) + .expect("OpenCode-compatible continue alias"); + let history = action_for_alias("/history", ActionContext::Chat) + .expect("existing history compatibility alias"); + + assert_eq!(resume.id, sessions.id); + assert_eq!(continue_session.id, sessions.id); + assert_eq!(history.id, sessions.id); + assert_eq!(history.handler, ActionHandler::Sessions); + assert_eq!(history.availability, ActionAvailability::Idle); + } + + #[test] + fn clear_alias_matches_opencode_new_session_semantics() { + let new_session = + action_for_alias("/new", ActionContext::Chat).expect("new session action"); + let clear = action_for_alias("/clear", ActionContext::Chat) + .expect("OpenCode-compatible clear alias"); + + assert_eq!(clear.id, new_session.id); + assert_eq!(clear.handler, ActionHandler::NewSession); + assert_eq!(clear.availability, ActionAvailability::Idle); + assert!(clear.description.contains("fresh conversation")); + assert!(clear.available(ActionState::chat(false, false))); + assert!(clear.available(ActionState::chat(false, false).for_shared_tui())); + assert!(!clear.available(ActionState::chat(true, false))); + assert!(action_for_alias("/clear-screen", ActionContext::Chat).is_none()); + } + #[test] fn shared_tui_projections_hide_embedded_management_actions() { let state = ActionState::chat(false, false).for_shared_tui(); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 4e6037b2e0..04e33db967 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -13,13 +13,13 @@ use tokio::sync::{broadcast, Mutex}; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentEventReceiver, AgentLocalCommandTurnRecordRequest, AgentRuntime, - AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkRequest, - AgentSessionForkResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRestoreRequest, - AgentSessionUsageRequest, AgentTurnCancellationRequest, AgentTurnSettlementRequest, - AgentUserAnswersRequest, PermissionReply, PermissionRequest, PermissionRequestEventReceiver, - PortError, PortErrorKind, RuntimeError, SessionTranscript, SessionTranscriptRequest, - SessionUsageReport, + AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, + AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest, + AgentSessionRestoreRequest, AgentSessionUsageRequest, AgentTurnCancellationRequest, + AgentTurnSettlementRequest, AgentUserAnswersRequest, PermissionReply, PermissionRequest, + PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, + SessionTranscriptRequest, SessionUsageReport, }; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, @@ -939,6 +939,49 @@ impl CliAgentRuntimeClient { Ok(id) } + pub(crate) async fn start_session_compaction(&self, session_id: &str) -> Result { + let turn_id = uuid::Uuid::new_v4().to_string(); + let request = AgentSessionCompactionRequest { + session_id: session_id.to_string(), + turn_id: turn_id.clone(), + }; + *self.current_turn_id.lock().await = Some(turn_id.clone()); + + let submission: Result = async { + match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => { + let accepted = runtime + .start_session_compaction(request) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))?; + if accepted.session_id != session_id || accepted.turn_id != turn_id { + return Err(anyhow::anyhow!( + "Runtime accepted manual compaction with an unexpected identity" + )); + } + Ok(accepted.turn_id) + } + CliAgentRuntimeBackend::Shared(client) => match client + .request(RuntimeIpcOperation::CompactSession { request }) + .await? + { + RuntimeIpcOperationResult::TurnAccepted { + session_id: accepted_session, + turn_id: accepted_turn, + } if accepted_session == session_id && accepted_turn == turn_id => { + Ok(accepted_turn) + } + _ => return Err(unexpected_shared_result("compact_session")), + }, + } + } + .await; + if submission.is_err() { + *self.current_turn_id.lock().await = None; + } + submission + } + pub(crate) async fn send_message(&self, message: String, agent_type: &str) -> Result { let session_id = self.ensure_session(agent_type).await?; tracing::info!("Sending message to session {}: {}", session_id, message); @@ -1491,6 +1534,25 @@ mod tests { assert!(!rename.contains("serde_json::from_value")); } + #[test] + fn session_compaction_uses_direct_runtime_or_private_shared_ipc() { + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let compact = source + .split_once("pub(crate) async fn start_session_compaction(") + .expect("compaction method") + .1 + .split_once("pub(crate) async fn send_message(") + .expect("compaction method boundary") + .0; + + assert!(compact.contains("CliAgentRuntimeBackend::Embedded(runtime)")); + assert!(compact.contains(".start_session_compaction(request)")); + assert!(compact.contains("RuntimeIpcOperation::CompactSession { request }")); + assert!(compact.contains("RuntimeIpcOperationResult::TurnAccepted")); + assert!(!compact.contains("serde_json::to_value")); + assert!(!compact.contains("serde_json::from_value")); + } + #[test] fn session_delete_uses_direct_runtime_or_private_shared_ipc() { let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 8aba9c1257..73e0a1883a 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -291,7 +291,21 @@ pub(crate) struct ChatMetadata { pub message_count: usize, pub tool_calls: usize, pub total_rounds: usize, +} + +/// Facts from the latest primary-model request observed by this TUI. +/// +/// This is intentionally not a cumulative session-usage aggregate. The +/// authoritative cumulative report remains owned by the runtime `/usage` path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelTokenUsageSnapshot { + pub model_config_id: String, + pub effective_model_name: String, + pub input_tokens: usize, + pub output_tokens: Option, pub total_tokens: usize, + pub max_context_tokens: Option, + pub cached_tokens: Option, } // ============ ChatState ============ @@ -328,6 +342,8 @@ pub(crate) struct ChatState { pub messages: Vec, /// Session statistics pub metadata: ChatMetadata, + /// Latest primary-model request observed by this TUI, if any. + pub last_primary_model_usage: Option, // -- Streaming state (transient, not persisted) -- /// Current turn ID being processed @@ -386,6 +402,7 @@ impl ChatState { auto_approve_ask: false, messages: Vec::new(), metadata: ChatMetadata::default(), + last_primary_model_usage: None, current_turn_id: None, current_flow_items: Vec::new(), tool_index: HashMap::new(), @@ -1146,12 +1163,12 @@ impl ChatState { self.question_prompt = None; } - /// Handle token usage update - pub(crate) fn handle_token_usage(&mut self, total_tokens: usize) { - self.metadata.total_tokens = total_tokens; + /// Record the latest primary-model request observed by this TUI. + pub(crate) fn handle_primary_model_usage(&mut self, usage: ModelTokenUsageSnapshot) { + self.last_primary_model_usage = Some(usage); } - /// Add a system message (for commands like /help, /clear, etc.) + /// Add a system message for commands that intentionally enter the transcript. pub(crate) fn add_system_message(&mut self, content: String) { self.messages.push(ChatMessage { id: uuid::Uuid::new_v4().to_string(), @@ -1181,11 +1198,6 @@ impl ChatState { }); } - /// Clear all messages (for /clear command) - pub(crate) fn clear_messages(&mut self) { - self.messages.clear(); - } - /// Get the current turn ID (if processing) pub(crate) fn current_turn_id(&self) -> Option<&str> { self.current_turn_id.as_deref() @@ -1375,7 +1387,7 @@ fn truncate_string(s: &str, max_len: usize) -> String { #[cfg(test)] mod tests { - use super::{ChatState, FlowItem, ToolDisplayStatus}; + use super::{ChatState, FlowItem, ModelTokenUsageSnapshot, ToolDisplayStatus}; use bitfun_agent_runtime::sdk::{ PermissionDelegationContext, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, SessionTranscript, TranscriptContent, TranscriptMessage, @@ -1449,6 +1461,31 @@ mod tests { assert!(state.has_conversation_history()); } + #[test] + fn latest_primary_model_usage_keeps_round_facts_without_claiming_a_session_total() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("/tmp/project".to_string()), + ); + let usage = ModelTokenUsageSnapshot { + model_config_id: "model-config-1".to_string(), + effective_model_name: "example-model".to_string(), + input_tokens: 80_000, + output_tokens: Some(2_000), + total_tokens: 82_000, + max_context_tokens: Some(128_000), + cached_tokens: Some(10_000), + }; + + state.handle_primary_model_usage(usage.clone()); + + assert_eq!(state.last_primary_model_usage.as_ref(), Some(&usage)); + assert_eq!(state.metadata.message_count, 0); + assert_eq!(state.metadata.tool_calls, 0); + } + #[test] fn worktree_preference_is_visible_before_materialization() { let mut state = ChatState::new( diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index fc587f4d09..ff9e749573 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -20,7 +20,7 @@ use tokio::sync::broadcast::error::TryRecvError; use bitfun_agent_runtime::sdk::{ AgentLocalCommandTurnRecordRequest, AgentSessionUsageRequest, SessionUsageReport, }; -use bitfun_events::AgenticEvent; +use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use resize::ResizeRedrawState; use crate::actions::{ @@ -30,10 +30,10 @@ use crate::actions::{ }; use crate::agent::context_reload_client::CliContextReloadClient; use crate::agent::runtime_client::{CliAgentRuntimeClient, SessionOperationError}; -use crate::chat_state::ChatState; +use crate::chat_state::{ChatState, ModelTokenUsageSnapshot}; use crate::config::CliConfig; use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; -use crate::ui::chat::{ChatView, MouseGestureOutcome}; +use crate::ui::chat::{session_status_text, ChatView, MouseGestureOutcome}; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use crate::ui::command_palette::PaletteAction; use crate::ui::login_form::LoginFormAction; diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 83ff6eba20..fe277c6273 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -41,7 +41,10 @@ fn pending_session_operation_blocks_runtime_action( && pending_for_current_session && matches!( handler, - ActionHandler::Sessions | ActionHandler::RenameSession | ActionHandler::Init + ActionHandler::Sessions + | ActionHandler::RenameSession + | ActionHandler::CompactSession + | ActionHandler::Init ) } @@ -78,6 +81,17 @@ fn builtin_arguments_route(route: CommandRoute, handler: ActionHandler) -> bool route == CommandRoute::Builtin && handler == ActionHandler::RenameSession } +fn compact_arguments_error( + route: CommandRoute, + handler: ActionHandler, + arguments: &str, +) -> Option<&'static str> { + (route == CommandRoute::Builtin + && handler == ActionHandler::CompactSession + && !arguments.trim().is_empty()) + .then_some("Usage: /compact") +} + fn selected_command_prefill(handler: ActionHandler) -> Option<&'static str> { match handler { ActionHandler::RenameSession => Some("/rename "), @@ -343,6 +357,12 @@ impl ChatMode { if self.agent.is_shared() { if let Some(action) = builtin_action { let state = self.action_state(chat_state.is_processing, false); + if let Some(usage) = + compact_arguments_error(CommandRoute::Builtin, action.handler, arguments) + { + chat_view.set_status(Some(usage.to_string())); + return Ok(None); + } if builtin_arguments_route(CommandRoute::Builtin, action.handler) { if !action.available(state) { chat_view.set_status(Some(action.unavailable_message(state))); @@ -411,6 +431,12 @@ impl ChatMode { builtin_reconfirmation_required, ) }; + if let Some(action) = builtin_action { + if let Some(usage) = compact_arguments_error(route, action.handler, arguments) { + chat_view.set_status(Some(usage.to_string())); + return Ok(None); + } + } if let Some(action) = builtin_action { if builtin_arguments_route(route, action.handler) { let state = self.action_state(chat_state.is_processing, false); @@ -850,17 +876,6 @@ impl ChatMode { } chat_view.show_info_popup(help); } - ActionHandler::ClearConversation => { - if chat_state.is_processing { - self.cancel_active_turn(chat_view, rt_handle); - if self.agent.is_shared() { - return Ok(None); - } - } - chat_state.clear_messages(); - chat_view.clear_screen(); - chat_view.set_status(Some("Conversation cleared".to_string())); - } ActionHandler::OpenAgentSelector => { self.show_agent_selector(chat_view, chat_state, rt_handle); } @@ -929,16 +944,11 @@ impl ChatMode { .to_string(), ), }, - ActionHandler::History => { - chat_state.add_system_message(format!( - "Current session statistics:\n\ - • Messages: {}\n\ - • Tool calls: {}\n\ - • Tokens: {}", - chat_state.metadata.message_count, - chat_state.metadata.tool_calls, - chat_state.metadata.total_tokens - )); + ActionHandler::Status => { + chat_view.show_info_popup(session_status_text(chat_state, self.agent.is_shared())); + } + ActionHandler::CompactSession => { + self.start_session_compaction(chat_view, chat_state, rt_handle); } ActionHandler::Usage => self.show_usage_report(chat_view, chat_state, rt_handle), ActionHandler::ToggleAutoApprove => {} @@ -1029,6 +1039,23 @@ impl ChatMode { Ok(None) } + fn start_session_compaction( + &self, + chat_view: &mut ChatView, + chat_state: &ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let agent = self.agent.clone(); + let session_id = chat_state.core_session_id.clone(); + chat_view.set_status(Some("Compacting context...".to_string())); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async move { agent.start_session_compaction(&session_id).await }) + }); + if let Err(error) = result { + chat_view.set_status(Some(format!("Could not compact context: {error}"))); + } + } + fn start_session_rename( &mut self, arguments: &str, diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index f602e6b1ad..a2a481426e 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -1,3 +1,133 @@ +fn primary_model_usage_for_active_turn( + event: &AgenticEvent, + chat_state: &ChatState, +) -> Option { + let AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + model_config_id, + effective_model_name, + input_tokens, + output_tokens, + total_tokens, + max_context_tokens, + is_subagent, + cached_tokens, + .. + } = event + else { + return None; + }; + if *is_subagent + || session_id != &chat_state.core_session_id + || chat_state.current_turn_id() != Some(turn_id.as_str()) + { + return None; + } + + Some(ModelTokenUsageSnapshot { + model_config_id: model_config_id.clone(), + effective_model_name: effective_model_name.clone(), + input_tokens: *input_tokens, + output_tokens: *output_tokens, + total_tokens: *total_tokens, + max_context_tokens: *max_context_tokens, + cached_tokens: *cached_tokens, + }) +} + +fn context_compression_tool_event( + event: &AgenticEvent, + chat_state: &ChatState, +) -> Option { + let (session_id, turn_id) = match event { + AgenticEvent::ContextCompressionStarted { + session_id, + turn_id, + .. + } + | AgenticEvent::ContextCompressionCompleted { + session_id, + turn_id, + .. + } + | AgenticEvent::ContextCompressionFailed { + session_id, + turn_id, + .. + } => (session_id, turn_id), + _ => return None, + }; + if session_id != &chat_state.core_session_id + || chat_state.current_turn_id() != Some(turn_id.as_str()) + { + return None; + } + + match event { + AgenticEvent::ContextCompressionStarted { + compression_id, + trigger, + tokens_before, + context_window, + .. + } => Some(ToolEventData::Started { + identity: ToolEventIdentity::direct(compression_id, "ContextCompression"), + params: serde_json::json!({ + "trigger": trigger, + "tokens_before": tokens_before, + "context_window": context_window, + }), + timeout_seconds: None, + }), + AgenticEvent::ContextCompressionCompleted { + compression_id, + compression_count, + tokens_before, + tokens_after, + compression_ratio, + duration_ms, + has_summary, + summary_source, + applied, + .. + } => Some(ToolEventData::Completed { + identity: ToolEventIdentity::direct(compression_id, "ContextCompression"), + result: serde_json::json!({ + "compression_count": compression_count, + "tokens_before": tokens_before, + "tokens_after": tokens_after, + "compression_ratio": compression_ratio, + "duration": duration_ms, + "applied": applied, + "has_summary": has_summary, + "summary_source": summary_source, + }), + result_for_assistant: None, + image_attachments: None, + duration_ms: *duration_ms, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(*duration_ms), + }), + AgenticEvent::ContextCompressionFailed { + compression_id, + error, + .. + } => Some(ToolEventData::Failed { + identity: ToolEventIdentity::direct(compression_id, "ContextCompression"), + error: error.clone(), + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }), + _ => None, + } +} + impl ChatMode { pub(crate) fn run( &mut self, @@ -641,6 +771,20 @@ impl ChatMode { needs_redraw = true; } + AgenticEvent::ContextCompressionStarted { .. } + | AgenticEvent::ContextCompressionCompleted { .. } + | AgenticEvent::ContextCompressionFailed { .. } => { + if let Some(tool_event) = context_compression_tool_event(event, &chat_state) + { + if matches!(event, AgenticEvent::ContextCompressionStarted { .. }) { + chat_view.set_status(Some("Compacting context...".to_string())); + } + chat_state.handle_tool_event(&tool_event); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + } + AgenticEvent::DialogTurnCompleted { turn_id, total_rounds, @@ -698,13 +842,10 @@ impl ChatMode { } } - AgenticEvent::TokenUsageUpdated { - turn_id, - total_tokens, - .. - } => { - if chat_state.current_turn_id() == Some(turn_id.as_str()) { - chat_state.handle_token_usage(*total_tokens); + AgenticEvent::TokenUsageUpdated { .. } => { + if let Some(usage) = primary_model_usage_for_active_turn(event, &chat_state) + { + chat_state.handle_primary_model_usage(usage); needs_redraw = true; } } diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 77eba10a0d..9e76066986 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -7,7 +7,8 @@ mod tests { apply_model_selection_feedback, apply_session_model_migration, apply_session_rename_feedback, begin_slash_menu_selection, builtin_arguments_route, builtin_command_reconfirmation, clear_selected_native_command_prefill, - cli_native_prompt_command_descriptors, command_route, consume_selected_native_command_once, + cli_native_prompt_command_descriptors, command_route, compact_arguments_error, + consume_selected_native_command_once, context_compression_tool_event, extension_command_help_request, external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, external_agent_review_text, external_command_projections, external_control_review_text, @@ -20,8 +21,9 @@ mod tests { parse_external_agent_review_action, parse_external_control_action, parse_external_tool_review_action, parse_hook_management_action, parse_reload_invocation, parse_reload_target, pending_session_operation_blocks_runtime_action, - previous_session_update_status, render_external_hook_catalog, render_native_hook_overview, - requested_session_name, retain_selected_native_command_for_input, selected_command_prefill, + previous_session_update_status, primary_model_usage_for_active_turn, + render_external_hook_catalog, render_native_hook_overview, requested_session_name, + retain_selected_native_command_for_input, selected_command_prefill, session_command_help_note, session_delete_allowed, session_delete_feedback, session_update_allowed, session_update_blocks_typed_submission, session_update_completion_should_exit, shared_session_change_is_blocked, CommandRoute, @@ -48,6 +50,7 @@ mod tests { use bitfun_core::native_hooks::{ NativeHookFileView, NativeHookHandlerView, NativeHookOverview, NativeHookRuleView, }; + use bitfun_events::{AgenticEvent, ToolEventData}; use bitfun_product_domains::external_sources::ExternalSourceScope; use bitfun_runtime_ports::AgentContextReloadTarget; use std::collections::{BTreeMap, BTreeSet}; @@ -1164,6 +1167,30 @@ mod tests { } } + #[test] + fn compact_rejects_arguments_only_after_the_builtin_route_wins() { + assert_eq!( + compact_arguments_error( + CommandRoute::Builtin, + ActionHandler::CompactSession, + "unexpected" + ), + Some("Usage: /compact") + ); + assert_eq!( + compact_arguments_error(CommandRoute::Builtin, ActionHandler::CompactSession, " "), + None + ); + assert_eq!( + compact_arguments_error( + CommandRoute::External, + ActionHandler::CompactSession, + "unexpected" + ), + None + ); + } + #[test] fn native_choice_is_reused_when_multiple_external_candidates_remain_unresolved() { let selected_native = "bitfun.cli:help"; @@ -1283,6 +1310,174 @@ mod tests { assert!(!state.is_processing); } + #[test] + fn primary_model_usage_projection_rejects_other_sessions_turns_and_subagents() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "hello"); + + let event = + |session_id: &str, turn_id: &str, is_subagent: bool| AgenticEvent::TokenUsageUpdated { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "example-model".to_string(), + input_tokens: 80_000, + output_tokens: Some(2_000), + total_tokens: 82_000, + max_context_tokens: Some(128_000), + is_subagent, + cached_tokens: Some(10_000), + token_details: None, + }; + + let usage = primary_model_usage_for_active_turn(&event("session", "turn", false), &state) + .expect("matching primary-model event"); + assert_eq!(usage.total_tokens, 82_000); + assert_eq!(usage.effective_model_name, "example-model"); + + assert!(primary_model_usage_for_active_turn( + &event("other-session", "turn", false), + &state + ) + .is_none()); + assert!(primary_model_usage_for_active_turn( + &event("session", "other-turn", false), + &state + ) + .is_none()); + assert!( + primary_model_usage_for_active_turn(&event("session", "turn", true), &state).is_none() + ); + } + + #[test] + fn context_compression_projection_is_scoped_to_the_active_session_turn() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "/compact"); + + let started = AgenticEvent::ContextCompressionStarted { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + compression_id: "compression".to_string(), + trigger: "manual".to_string(), + tokens_before: 80_000, + context_window: 128_000, + }; + let projected = + context_compression_tool_event(&started, &state).expect("active compression event"); + match projected { + ToolEventData::Started { + identity, params, .. + } => { + assert_eq!(identity.tool_id, "compression"); + assert_eq!(identity.effective_name(), "ContextCompression"); + assert_eq!(params["trigger"], "manual"); + assert_eq!(params["tokens_before"], 80_000); + } + _ => panic!("expected started tool event"), + } + + let other_turn = AgenticEvent::ContextCompressionStarted { + session_id: "session".to_string(), + turn_id: "other-turn".to_string(), + compression_id: "other-compression".to_string(), + trigger: "manual".to_string(), + tokens_before: 80_000, + context_window: 128_000, + }; + assert!(context_compression_tool_event(&other_turn, &state).is_none()); + + let other_session = AgenticEvent::ContextCompressionStarted { + session_id: "other-session".to_string(), + turn_id: "turn".to_string(), + compression_id: "other-compression".to_string(), + trigger: "manual".to_string(), + tokens_before: 80_000, + context_window: 128_000, + }; + assert!(context_compression_tool_event(&other_session, &state).is_none()); + } + + #[test] + fn context_compression_completion_projects_the_persisted_tool_shape() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "/compact"); + let completed = AgenticEvent::ContextCompressionCompleted { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + compression_id: "compression".to_string(), + compression_count: 2, + tokens_before: 80_000, + tokens_after: 20_000, + compression_ratio: 0.25, + duration_ms: 42, + has_summary: true, + summary_source: "model".to_string(), + applied: true, + }; + + match context_compression_tool_event(&completed, &state) + .expect("active compression completion") + { + ToolEventData::Completed { + identity, + result, + duration_ms, + .. + } => { + assert_eq!(identity.tool_id, "compression"); + assert_eq!(result["tokens_before"], 80_000); + assert_eq!(result["tokens_after"], 20_000); + assert_eq!(result["applied"], true); + assert_eq!(result["summary_source"], "model"); + assert_eq!(duration_ms, 42); + } + _ => panic!("expected completed tool event"), + } + } + + #[test] + fn context_compression_failure_projects_the_existing_tool_error_shape() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "/compact"); + let failed = AgenticEvent::ContextCompressionFailed { + session_id: "session".to_string(), + turn_id: "turn".to_string(), + compression_id: "compression".to_string(), + error: "summary request failed".to_string(), + }; + + match context_compression_tool_event(&failed, &state).expect("active compression failure") { + ToolEventData::Failed { + identity, error, .. + } => { + assert_eq!(identity.tool_id, "compression"); + assert_eq!(error, "summary request failed"); + } + _ => panic!("expected failed tool event"), + } + } + #[test] fn model_selection_commits_only_the_current_session_state_after_runtime_success() { let mut state = ChatState::new( diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 933f7dca2c..e09429913d 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -294,6 +294,15 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .map_err(core_ipc_error)?; Ok(RuntimeIpcOperationResult::Unit) } + RuntimeIpcOperation::CompactSession { request } => self + .runtime + .start_session_compaction(request) + .await + .map(|result| RuntimeIpcOperationResult::TurnAccepted { + session_id: result.session_id, + turn_id: result.turn_id, + }) + .map_err(runtime_ipc_error), RuntimeIpcOperation::SubmitTurn { request } => { let outcome = self .runtime diff --git a/src/apps/cli/src/ui/chat.rs b/src/apps/cli/src/ui/chat.rs index c391065b38..34f54a4bcb 100644 --- a/src/apps/cli/src/ui/chat.rs +++ b/src/apps/cli/src/ui/chat.rs @@ -3,6 +3,7 @@ //! This module is split across multiple files under `ui/chat/` to keep individual files manageable. include!("chat/state.rs"); +include!("chat/status.rs"); include!("chat/tools.rs"); include!("chat/input.rs"); include!("chat/popups.rs"); diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index c4768b2430..6164a1d93d 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -812,7 +812,9 @@ impl ChatView { // Show thinking spinner when processing self.spinner.tick(); let loading_text = format!(" {} Thinking...", self.spinner.current()); - let stats_text = format!("Tokens: {} ", chat_state.metadata.total_tokens); + let stats_text = context_status_text(chat_state.last_primary_model_usage.as_ref()) + .map(|status| format!("{status} ")) + .unwrap_or_default(); let padding_len = (area.width as usize).saturating_sub(loading_text.len() + stats_text.len()); @@ -832,12 +834,7 @@ impl ChatView { let status_text = if let Some(status) = &self.status { format!(" {}", status) } else { - format!( - " Messages: {} | Tool calls: {} | Tokens: {}", - chat_state.metadata.message_count, - chat_state.metadata.tool_calls, - chat_state.metadata.total_tokens, - ) + format!(" {}", default_chat_status_text(chat_state)) }; let paragraph = Paragraph::new(status_text) @@ -1008,12 +1005,7 @@ impl ChatView { let status_text = if let Some(status_text) = status { format!(" {}", status_text) } else { - format!( - " Messages: {} | Tool calls: {} | Tokens: {}", - chat_state.metadata.message_count, - chat_state.metadata.tool_calls, - chat_state.metadata.total_tokens, - ) + format!(" {}", default_chat_status_text(chat_state)) }; let width = available_width as usize; diff --git a/src/apps/cli/src/ui/chat/status.rs b/src/apps/cli/src/ui/chat/status.rs new file mode 100644 index 0000000000..6125937457 --- /dev/null +++ b/src/apps/cli/src/ui/chat/status.rs @@ -0,0 +1,267 @@ +use crate::chat_state::ModelTokenUsageSnapshot; + +fn format_token_count(value: usize) -> String { + let digits = value.to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index) % 3 == 0 { + formatted.push(','); + } + formatted.push(digit); + } + formatted +} + +fn context_window_metrics(usage: &ModelTokenUsageSnapshot) -> Option<(usize, f64)> { + let max_context_tokens = usage.max_context_tokens.filter(|limit| *limit > 0)?; + let percentage = usage.total_tokens as f64 / max_context_tokens as f64 * 100.0; + Some((max_context_tokens, percentage)) +} + +fn context_window_text(usage: &ModelTokenUsageSnapshot) -> Option { + let (max_context_tokens, percentage) = context_window_metrics(usage)?; + Some(format!( + "{} / {} ({percentage:.1}%)", + format_token_count(usage.total_tokens), + format_token_count(max_context_tokens), + )) +} + +fn context_window_detail(usage: &ModelTokenUsageSnapshot) -> Option { + let (max_context_tokens, percentage) = context_window_metrics(usage)?; + Some(format!( + "{} / {} tokens ({percentage:.1}%)", + format_token_count(usage.total_tokens), + format_token_count(max_context_tokens), + )) +} + +pub(crate) fn context_status_text(usage: Option<&ModelTokenUsageSnapshot>) -> Option { + let usage = usage?; + Some(match context_window_text(usage) { + Some(context) => format!("Context: {context}"), + None => format!( + "Last request: {} tokens", + format_token_count(usage.total_tokens) + ), + }) +} + +pub(crate) fn default_chat_status_text(chat_state: &ChatState) -> String { + let mut status = format!( + "Messages: {} | Tool calls: {}", + chat_state.metadata.message_count, chat_state.metadata.tool_calls + ); + if let Some(context) = context_status_text(chat_state.last_primary_model_usage.as_ref()) { + status.push_str(" | "); + status.push_str(&context); + } + status +} + +fn optional_token_count(value: Option) -> String { + value + .map(|tokens| format!("{} tokens", format_token_count(tokens))) + .unwrap_or_else(|| "unavailable".to_string()) +} + +pub(crate) fn session_status_text(chat_state: &ChatState, shared_tui: bool) -> String { + let runtime = if shared_tui { + "Shared TUI" + } else { + "Embedded TUI" + }; + let processing = if chat_state.is_processing { + "Processing" + } else { + "Idle" + }; + let approval = if chat_state.auto_approve_ask { + "Auto" + } else { + "Ask" + }; + let workspace = chat_state + .workspace + .as_deref() + .filter(|path| !path.trim().is_empty()) + .unwrap_or("unavailable"); + let model = (!chat_state.current_model_name.trim().is_empty()) + .then_some(chat_state.current_model_name.as_str()) + .or(chat_state.current_model_id.as_deref()) + .unwrap_or("unavailable"); + + let mut lines = vec![ + "Status".to_string(), + String::new(), + "Session".to_string(), + format!(" Session: {}", chat_state.session_name), + format!(" ID: {}", chat_state.core_session_id), + format!(" Runtime: {runtime}"), + format!(" State: {processing}"), + format!(" Agent: {}", chat_state.agent_type), + format!(" Model: {model}"), + format!(" Approval: {approval}"), + String::new(), + "Workspace".to_string(), + format!(" Path: {workspace}"), + format!(" Branch: {}", chat_state.branch_label()), + format!(" Worktree: {}", chat_state.worktree_status_label()), + String::new(), + "Last primary model request".to_string(), + ]; + + if let Some(usage) = chat_state.last_primary_model_usage.as_ref() { + lines.extend([ + format!(" Effective model: {}", usage.effective_model_name), + format!(" Model config: {}", usage.model_config_id), + format!(" Input: {} tokens", format_token_count(usage.input_tokens)), + format!(" Output: {}", optional_token_count(usage.output_tokens)), + format!( + " Cached input: {}", + optional_token_count(usage.cached_tokens) + ), + format!(" Total: {} tokens", format_token_count(usage.total_tokens)), + format!( + " Context window: {}", + context_window_detail(usage).unwrap_or_else(|| "unavailable".to_string()) + ), + ]); + } else { + lines.push(" Latest request: Not observed by this TUI".to_string()); + } + + lines.push(String::new()); + if shared_tui { + lines.push("Cumulative session usage is unavailable in Shared TUI.".to_string()); + } else { + lines.push("For cumulative session usage, use /usage.".to_string()); + } + lines.join("\n") +} + +#[cfg(test)] +mod status_tests { + use super::*; + use crate::chat_state::ModelTokenUsageSnapshot; + + fn usage(max_context_tokens: Option) -> ModelTokenUsageSnapshot { + ModelTokenUsageSnapshot { + model_config_id: "model-config-1".to_string(), + effective_model_name: "example-model".to_string(), + input_tokens: 80_000, + output_tokens: Some(2_000), + total_tokens: 82_000, + max_context_tokens, + cached_tokens: Some(10_000), + } + } + + #[test] + fn compact_context_status_uses_the_latest_request_not_a_session_total() { + assert_eq!(context_status_text(None), None); + assert_eq!( + context_status_text(Some(&usage(Some(128_000)))), + Some("Context: 82,000 / 128,000 (64.1%)".to_string()) + ); + assert_eq!( + context_status_text(Some(&usage(None))), + Some("Last request: 82,000 tokens".to_string()) + ); + } + + #[test] + fn default_status_bar_omits_unknown_usage_instead_of_showing_zero_tokens() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + state.metadata.message_count = 3; + state.metadata.tool_calls = 2; + + assert_eq!( + default_chat_status_text(&state), + "Messages: 3 | Tool calls: 2" + ); + + state.last_primary_model_usage = Some(usage(Some(128_000))); + assert_eq!( + default_chat_status_text(&state), + "Messages: 3 | Tool calls: 2 | Context: 82,000 / 128,000 (64.1%)" + ); + } + + #[test] + fn embedded_status_reports_observed_facts_and_points_to_cumulative_usage() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("/tmp/project".to_string()), + ); + state.current_model_name = "Example Model".to_string(); + state.set_git_repository_status(true, Some("main".to_string())); + state.last_primary_model_usage = Some(usage(Some(128_000))); + + let status = session_status_text(&state, false); + + for expected in [ + "Status", + "Session: Session", + "ID: session-1", + "Runtime: Embedded TUI", + "State: Idle", + "Agent: agentic", + "Model: Example Model", + "Approval: Ask", + "Path: /tmp/project", + "Branch: main", + "Worktree: off", + "Last primary model request", + "Effective model: example-model", + "Model config: model-config-1", + "Input: 80,000 tokens", + "Output: 2,000 tokens", + "Cached input: 10,000 tokens", + "Total: 82,000 tokens", + "Context window: 82,000 / 128,000 tokens (64.1%)", + "For cumulative session usage, use /usage.", + ] { + assert!( + status.contains(expected), + "missing {expected:?} in:\n{status}" + ); + } + assert!(!status.contains("Session total")); + } + + #[test] + fn shared_status_marks_unobserved_and_unavailable_facts() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + state.is_processing = true; + state.auto_approve_ask = true; + + let status = session_status_text(&state, true); + + for expected in [ + "Runtime: Shared TUI", + "State: Processing", + "Approval: Auto", + "Path: unavailable", + "Latest request: Not observed by this TUI", + "Cumulative session usage is unavailable in Shared TUI.", + ] { + assert!( + status.contains(expected), + "missing {expected:?} in:\n{status}" + ); + } + } +} diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index dc9afabdd6..748e1a1277 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -473,8 +473,8 @@ mod tests { fn slash_lists_all_actions_for_the_current_context() { let mut chat = CommandMenuState::new(ActionState::chat(false, false)); chat.update("/", 1); - assert!(names(&chat).contains(&"/clear")); assert!(names(&chat).contains(&"/new")); + assert!(!names(&chat).contains(&"/clear")); let mut startup = CommandMenuState::new(ActionState::startup(false)); startup.update("/", 1); @@ -508,6 +508,12 @@ mod tests { menu.update("/mcp", 4); assert_eq!(names(&menu), vec!["/mcp"]); + + menu.update("/clear", 6); + assert_eq!(names(&menu), vec!["/clear"]); + let selection = menu.apply_selection_with_name().unwrap(); + assert_eq!(selection.action_id, "new_session"); + assert_eq!(selection.command_name, "clear"); } #[test] diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 8c1004324a..7e90c751cb 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -42,6 +42,7 @@ pub(crate) enum PaletteAction { const DEFAULT_ITEM_ORDER: &[&str] = &[ "new_session", "sessions", + "compact_session", "usage", "toggle_auto_approve", "toggle_worktree", @@ -57,6 +58,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "hooks_external", "login", "logout", + "status", "help", "exit", ]; diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index b2a98450fc..46edeaf7fe 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1066,14 +1066,14 @@ impl StartupPage { } ActionHandler::ClosePopups => self.close_all_popups(), ActionHandler::NavigateBack => self.navigate_back(), - ActionHandler::ClearConversation - | ActionHandler::RenameSession + ActionHandler::RenameSession | ActionHandler::Reload | ActionHandler::Tools | ActionHandler::Extensions | ActionHandler::NativeHooks | ActionHandler::ExternalHooks - | ActionHandler::History + | ActionHandler::Status + | ActionHandler::CompactSession | ActionHandler::ToggleAutoApprove | ActionHandler::ToggleWorktree | ActionHandler::Interrupt diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index b4ee8c78e5..78cff8b520 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,7 +15,7 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、Session list/create/restore/delete(restore 结果包含 transcript)、当前 Session rename 和 Agent mode/model update、声明式上下文 reload、Turn submit/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、fork、replay、observer、controller transfer、Tool/MCP/Hook 管理或其他产品配置。 +- 封闭 operation 范围为 Health、Session list/create/restore/delete(restore 结果包含 transcript)、当前 Session rename、Agent mode/model update 和手动 context compaction、声明式上下文 reload、Turn submit/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、fork、replay、observer、controller transfer、Tool/MCP/Hook 管理或其他产品配置。 - 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 - 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 - 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index 8a79ddb0be..2df15f6f79 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -22,8 +22,9 @@ session controller leases, event delivery, connection bounds, and cleanup. It is - Export only the exact workspace-private API needed by the CLI adapter. Do not publish this crate or expose its wire as an SDK contract. -- The closed operation budget is Health, Session list/create/restore/delete (including transcript on restore), current-Session rename and Agent mode/model update, +- The closed operation budget is Health, Session list/create/restore/delete (including transcript on restore), current-Session rename, Agent mode/model update and manual context compaction, declarative context reload, Turn submit/cancel, pending/respond Permission, and UserInput answers. Delete is limited to an idle Session not controlled by any client. + Manual compaction is a current-controller, idle-only Turn operation. The client supplies its exact Turn ID before admission so timeout or disconnect cleanup can cancel the same owned task; once Core begins the atomic context commit, a late cancellation does not expose a false idle state. Context reload may run during an active Turn, does not rewrite that Turn, and guards the cache so the next message reads invalidated instructions. Disconnect cleanup is internal lifecycle, not a detach operation. Model catalogs and defaults remain product configuration outside this wire. Do not add archive, fork, replay, observer, diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index bf31b4492f..ed148727d7 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,9 +1,9 @@ use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ - AgentContextReloadRequest, AgentDialogTurnRequest, AgentSessionCreateRequest, - AgentSessionCreateResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, - AgentSessionModelUpdateRequest, AgentSessionSummary, AgentTurnCancellationRequest, - AgentTurnCancellationResult, SessionTranscript, + AgentContextReloadRequest, AgentDialogTurnRequest, AgentSessionCompactionRequest, + AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionListRequest, + AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionSummary, + AgentTurnCancellationRequest, AgentTurnCancellationResult, SessionTranscript, }; use serde::{Deserialize, Serialize}; @@ -62,6 +62,9 @@ pub enum RuntimeIpcOperation { ReloadSessionContext { request: AgentContextReloadRequest, }, + CompactSession { + request: AgentSessionCompactionRequest, + }, SubmitTurn { request: AgentDialogTurnRequest, }, @@ -90,6 +93,7 @@ impl RuntimeIpcOperation { Self::UpdateSessionModel { request } => Some(&request.session_id), Self::RenameSession { request } => Some(&request.session_id), Self::ReloadSessionContext { request } => Some(&request.session_id), + Self::CompactSession { request } => Some(&request.session_id), Self::SubmitTurn { request } => Some(&request.session_id), Self::CancelTurn { request } => Some(&request.session_id), Self::PendingPermissions { session_id } @@ -118,6 +122,7 @@ impl RuntimeIpcOperation { Self::UpdateSessionMode { .. } | Self::UpdateSessionModel { .. } | Self::RenameSession { .. } + | Self::CompactSession { .. } | Self::SubmitTurn { .. } => { RuntimeIpcOperationRules::new(CurrentController, true, false, true) } diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 8036245015..8e67b98576 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index 41d3d9b81a..3a4f92a144 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -463,6 +463,11 @@ async fn run_initialized_connection( *active_turn_id = Some(turn_id.clone()); Some(turn_id) } + RuntimeIpcOperation::CompactSession { request } => { + let turn_id = request.turn_id.clone(); + *active_turn_id = Some(turn_id.clone()); + Some(turn_id) + } _ => None, }; let side_effecting = rules.side_effecting; @@ -581,7 +586,7 @@ async fn run_initialized_connection( config.request_timeout, Some(request_id), RuntimeIpcErrorCode::Internal, - "runtime returned a different turn id than the submitted operation", + "runtime returned a different turn id than the accepted turn operation", ) .await?; return Err(RuntimeIpcServerError::Disconnected); diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 5977c7b2d1..c91e2c9789 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -8,8 +8,8 @@ use crate::{ use bitfun_product_domains::tool_permissions::PermissionReply; use bitfun_runtime_ports::{ AgentContextReloadRequest, AgentContextReloadTarget, AgentDialogTurnRequest, - AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSubmissionSource, - DialogSubmissionPolicy, + AgentSessionCompactionRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, + AgentSubmissionSource, DialogSubmissionPolicy, }; use serde_json::{json, Map}; @@ -118,7 +118,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 7); + assert_eq!(PROTOCOL_VERSION, 8); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { @@ -149,6 +149,41 @@ fn protocol_round_trips_the_current_session_rename_operation() { ); } +#[test] +fn protocol_round_trips_manual_compaction_as_an_idle_controller_turn() { + let operation = RuntimeIpcOperation::CompactSession { + request: AgentSessionCompactionRequest { + session_id: "session-1".to_string(), + turn_id: "turn-compact-1".to_string(), + }, + }; + + let encoded = serde_json::to_value(&operation).expect("serialize compaction"); + assert_eq!( + encoded, + json!({ + "operation": "compact_session", + "request": { + "sessionId": "session-1", + "turnId": "turn-compact-1" + } + }) + ); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize compaction"); + + assert_eq!(decoded, operation); + assert_eq!(decoded.session_id(), Some("session-1")); + let rules = decoded.rules(); + assert_eq!( + rules.session_requirement, + RuntimeIpcSessionRequirement::CurrentController + ); + assert!(rules.requires_idle); + assert!(!rules.serializes_session_selection); + assert!(rules.side_effecting); +} + #[test] fn protocol_round_trips_session_delete_and_not_found() { let operation = RuntimeIpcOperation::DeleteSession { diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index e248516e8b..96faefab89 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -8,9 +8,9 @@ use crate::{ use async_trait::async_trait; use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionCreateResult, - AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionSummary, - AgentSubmissionSource, DialogSubmissionPolicy, SessionTranscript, + AgentDialogTurnRequest, AgentSessionCompactionRequest, AgentSessionCreateRequest, + AgentSessionCreateResult, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, + AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy, SessionTranscript, }; use serde_json::Map; use std::path::Path; @@ -212,6 +212,12 @@ impl RuntimeIpcRequestHandler for FakeHandler { turn_id: request.turn_id.expect("test turn id"), }) } + RuntimeIpcOperation::CompactSession { request } => { + Ok(RuntimeIpcOperationResult::TurnAccepted { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } RuntimeIpcOperation::CancelTurn { request } => { if self.settle_cancel { let _ = self.events.send(test_agent_event( @@ -513,6 +519,15 @@ fn submit_operation(workspace: &Path, session_id: &str, turn_id: &str) -> Runtim } } +fn compact_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { + RuntimeIpcOperation::CompactSession { + request: AgentSessionCompactionRequest { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + }, + } +} + fn update_mode_operation(session_id: &str, mode_id: &str) -> RuntimeIpcOperation { RuntimeIpcOperation::UpdateSessionMode { request: AgentSessionModeUpdateRequest { @@ -746,6 +761,55 @@ async fn one_connection_rejects_a_second_turn_until_the_first_finishes() { server.finish().await; } +#[tokio::test] +async fn manual_compaction_owns_the_supplied_turn_until_disconnect_cancels_it() { + let handler = Arc::new(FakeHandler::default()); + let server = TestServer::start(server_config(), handler.clone()).await; + let mut client = server.connect("compact-controller").await; + expect_response( + &mut client, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_response( + &mut client, + 3, + compact_operation("session-a", "turn-compact-a"), + ) + .await; + expect_error( + &mut client, + 4, + compact_operation("session-a", "turn-compact-b"), + RuntimeIpcErrorCode::SessionInUse, + ) + .await; + + drop(client); + wait_for_calls(&handler, |calls| { + let compacted = calls.iter().any(|call| { + matches!( + call, + RuntimeIpcOperation::CompactSession { request } + if request.session_id == "session-a" + && request.turn_id == "turn-compact-a" + ) + }); + let cancelled = calls.iter().any(|call| { + matches!( + call, + RuntimeIpcOperation::CancelTurn { request } + if request.session_id == "session-a" + && request.turn_id.as_deref() == Some("turn-compact-a") + ) + }); + compacted && cancelled + }) + .await; + server.finish().await; +} + #[tokio::test] async fn mode_update_requires_the_controlled_idle_session() { let handler = Arc::new(FakeHandler::default()); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index e201ebe3da..67bbb74965 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -24,6 +24,7 @@ use crate::agentic::events::{ }; use crate::agentic::execution::{ ContextCompactionOutcome, ExecutionContext, ExecutionEngine, ExecutionResult, + ManualCompactionCommitGate, }; use crate::agentic::fork_agent::ForkAgentContextSnapshot; use crate::agentic::goal_mode::{ @@ -60,7 +61,8 @@ use crate::service::config::{ }; use crate::service::remote_ssh::normalize_remote_workspace_path; use crate::service::session::{ - SessionMemoryMode, SessionRelationship, SessionRelationshipKind, SessionStatus, + DialogTurnData, SessionMemoryMode, SessionRelationship, SessionRelationshipKind, SessionStatus, + ToolItemIdentityExt, }; use crate::service::workspace::{ get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceInfo, @@ -91,7 +93,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::sync::OnceLock; -use tokio::sync::{mpsc, watch, OwnedSemaphorePermit, RwLock, Semaphore}; +use tokio::sync::{mpsc, oneshot, watch, OwnedSemaphorePermit, RwLock, Semaphore}; use tokio::time::{sleep, Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -664,12 +666,34 @@ struct SessionExecutionLease { active_counter: Arc, } +struct ManualCompactionTask { + turn_id: String, + completion: oneshot::Receiver>, +} + +struct ManualCompactionControlGuard { + execution_engine: Arc, + controls: Arc>>, + turn_id: String, +} + impl Drop for SessionExecutionLease { fn drop(&mut self) { self.active_counter.fetch_sub(1, Ordering::SeqCst); } } +impl Drop for ManualCompactionControlGuard { + fn drop(&mut self) { + self.controls.remove(&self.turn_id); + let execution_engine = Arc::clone(&self.execution_engine); + let turn_id = self.turn_id.clone(); + tokio::spawn(async move { + execution_engine.cleanup_cancel_token(&turn_id).await; + }); + } +} + impl Drop for CancelTokenGuard { fn drop(&mut self) { let execution_engine = self.execution_engine.clone(); @@ -924,6 +948,9 @@ pub struct ConversationCoordinator { /// active through persistence finalization, not merely until session state /// changes to Idle. turn_settlements: Arc, + /// Manual-compaction turns need an atomic planning/cancel/commit decision + /// before the normal cancellation path may expose the Session as idle. + manual_compaction_controls: Arc>>, thread_goal_runtime: Arc, terminal_port: OnceLock>, remote_exec_port: OnceLock>, @@ -1556,7 +1583,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet id: compression_id, }, tool_result: Some(ToolResultData { - result: serde_json::Value::Null, + result: serde_json::json!({ "error": error }), success: false, result_for_assistant: None, image_attachments: None, @@ -1654,6 +1681,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet round_injection_source: OnceLock::new(), active_turns_per_session: Arc::new(DashMap::new()), turn_settlements: Arc::new(TurnSettlementTracker::default()), + manual_compaction_controls: Arc::new(DashMap::new()), thread_goal_runtime: Arc::new(ThreadGoalRuntime::new()), terminal_port: OnceLock::new(), remote_exec_port: OnceLock::new(), @@ -3587,14 +3615,25 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(plan) } - /// Compact the active session context as a persisted maintenance turn. - pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()> { - let session = self + async fn start_manual_compaction_task( + &self, + session_id: String, + requested_turn_id: Option, + ) -> BitFunResult { + bitfun_core_types::validate_session_id(&session_id).map_err(BitFunError::Validation)?; + if requested_turn_id + .as_deref() + .is_some_and(|turn_id| turn_id.trim().is_empty()) + { + return Err(BitFunError::Validation( + "Manual compaction turn_id must not be empty".to_string(), + )); + } + let initial_session = self .session_manager .get_session(&session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; - - match &session.state { + match &initial_session.state { SessionState::Idle => {} SessionState::Processing { current_turn_id, @@ -3617,34 +3656,50 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .get_context_messages(&session_id) .await?; - let needs_restore = if context_messages.is_empty() { - true - } else { - context_messages.len() == 1 && !session.dialog_turn_ids.is_empty() - }; - - if needs_restore { - let restore_path = self.restore_path_for_existing_session(&session_id).await?; - self.session_manager - .restore_session_from_storage_path(&restore_path, &session_id) - .await?; + if context_messages.is_empty() && !initial_session.dialog_turn_ids.is_empty() { + return Err(BitFunError::Validation(format!( + "Session context is not loaded; restore the session before manual compaction: {session_id}" + ))); } - let context_messages = self - .session_manager - .get_context_messages(&session_id) - .await?; - let turn_index = self.session_manager.get_turn_count(&session_id); let user_message_metadata = Some(Self::manual_compaction_metadata()); let turn_id = self .session_manager .start_maintenance_turn( &session_id, MANUAL_COMPACTION_COMMAND.to_string(), - None, + requested_turn_id, user_message_metadata.clone(), ) .await?; + // Once the maintenance turn owns Processing, competing dialog turns + // can no longer mutate context. Capture the authoritative context only + // after that atomic admission so a just-completed turn cannot be lost. + let context_messages = self + .session_manager + .get_context_messages(&session_id) + .await?; + let session = self + .session_manager + .get_session(&session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + let turn_index = session.dialog_turn_ids.len().saturating_sub(1); + + let execution_lease = self.register_session_execution(&session_id); + let settlement = self + .turn_settlements + .register_accepted(session_id.clone(), turn_id.clone()); + let cancellation_token = CancellationToken::new(); + self.execution_engine + .register_cancel_token(&turn_id, cancellation_token.clone()); + let commit_gate = Arc::new(ManualCompactionCommitGate::planning()); + self.manual_compaction_controls + .insert(turn_id.clone(), Arc::clone(&commit_gate)); + let control_guard = ManualCompactionControlGuard { + execution_engine: Arc::clone(&self.execution_engine), + controls: Arc::clone(&self.manual_compaction_controls), + turn_id: turn_id.clone(), + }; self.emit_event(AgenticEvent::DialogTurnStarted { session_id: session_id.clone(), @@ -3656,6 +3711,139 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }) .await; + let session_manager = Arc::clone(&self.session_manager); + let execution_engine = Arc::clone(&self.execution_engine); + let event_queue = Arc::clone(&self.event_queue); + let terminal_port = self.terminal_port(); + let remote_exec_port = self.remote_exec_port(); + let session_id_for_task = session_id.clone(); + let turn_id_for_task = turn_id.clone(); + let (completion_tx, completion) = oneshot::channel(); + + tokio::spawn(async move { + let _execution_lease = execution_lease; + let _settlement = settlement; + let _control_guard = control_guard; + let result = Self::execute_manual_compaction_task( + session_manager, + execution_engine, + event_queue, + session, + context_messages, + session_id_for_task, + turn_id_for_task, + turn_index, + terminal_port, + remote_exec_port, + cancellation_token, + commit_gate, + ) + .await; + let _ = completion_tx.send(result); + }); + + Ok(ManualCompactionTask { + turn_id, + completion, + }) + } + + #[allow(clippy::too_many_arguments)] + async fn finalize_manual_compaction_success( + session_manager: &SessionManager, + event_queue: &EventQueue, + session_id: &str, + turn_id: &str, + outcome: &ContextCompactionOutcome, + context_window: usize, + ) -> BitFunResult<()> { + let model_round = + Self::build_manual_compaction_round_completed(turn_id, outcome, context_window); + let turn_persistence = session_manager + .complete_maintenance_turn( + session_id, + turn_id, + vec![model_round.clone()], + outcome.duration_ms, + ) + .await; + let idle_persistence = session_manager + .update_session_state_for_turn_if_processing(session_id, turn_id, SessionState::Idle) + .await; + + let finalization_error = match (turn_persistence, idle_persistence) { + (Ok(()), Ok(true)) => None, + (Ok(()), Ok(false)) => Some(BitFunError::Session(format!( + "Manual compaction was applied, but turn ownership changed before finalization: session_id={session_id}, turn_id={turn_id}" + ))), + (Err(turn_error), Ok(_)) => Some(BitFunError::Session(format!( + "Manual compaction was applied, but the completed turn could not be persisted: {turn_error}" + ))), + (Ok(()), Err(state_error)) => Some(BitFunError::Session(format!( + "Manual compaction was applied, but the idle session state could not be persisted: {state_error}" + ))), + (Err(turn_error), Err(state_error)) => Some(BitFunError::Session(format!( + "Manual compaction was applied, but turn and idle-state persistence failed: turn_error={turn_error}; state_error={state_error}" + ))), + }; + + if let Some(error) = finalization_error { + // Preserve the applied tool payload if a transient storage failure + // allows this best-effort retry to succeed. The turn itself remains + // failed because its terminal durability was not guaranteed. + let _ = session_manager + .fail_maintenance_turn(session_id, turn_id, error.to_string(), vec![model_round]) + .await; + let _ = event_queue + .enqueue( + AgenticEvent::DialogTurnFailed { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + error: error.to_string(), + error_category: Some(error.error_category()), + error_detail: Some(error.error_detail()), + }, + Some(EventPriority::High), + ) + .await; + return Err(error); + } + + let _ = event_queue + .enqueue( + AgenticEvent::DialogTurnCompleted { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + total_rounds: 1, + total_tools: 1, + duration_ms: outcome.duration_ms, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), + has_final_response: Some(true), + }, + Some(EventPriority::Normal), + ) + .await; + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn execute_manual_compaction_task( + session_manager: Arc, + execution_engine: Arc, + event_queue: Arc, + session: Session, + context_messages: Vec, + session_id: String, + turn_id: String, + turn_index: usize, + terminal_port: Option>, + remote_exec_port: Option>, + cancellation_token: CancellationToken, + commit_gate: Arc, + ) -> BitFunResult<()> { let manual_workspace = Self::build_workspace_binding(&session.config).await; let manual_workspace_services = Self::build_workspace_services(&manual_workspace).await; let manual_execution_context = ExecutionContext { @@ -3671,8 +3859,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet delegation_policy: DelegationPolicy::top_level(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), workspace_services: manual_workspace_services, - terminal_port: self.terminal_port(), - remote_exec_port: self.remote_exec_port(), + terminal_port, + remote_exec_port, round_injection: None, emit_lifecycle_events: true, recover_partial_on_cancel: false, @@ -3695,61 +3883,62 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Some(mcw) => mcw.min(session_max_tokens), None => session_max_tokens, }; - match self - .execution_engine + let compression_id = format!("compression_{}", uuid::Uuid::new_v4()); + match execution_engine .compact_session_context( session_id.clone(), turn_id.clone(), + compression_id.clone(), manual_execution_context, context_messages, "manual", + cancellation_token, + commit_gate, ) .await { Ok(outcome) => { - let model_round = Self::build_manual_compaction_round_completed( + Self::finalize_manual_compaction_success( + session_manager.as_ref(), + event_queue.as_ref(), + &session_id, &turn_id, &outcome, context_window, + ) + .await + } + Err(err @ BitFunError::Cancelled(_)) => { + let error_text = err.to_string(); + let model_round = Self::build_manual_compaction_round_failed( + &turn_id, + compression_id.clone(), + &error_text, + context_window, ); - self.session_manager - .complete_maintenance_turn( - &session_id, - &turn_id, - vec![model_round], - outcome.duration_ms, - ) - .await?; - self.session_manager - .update_session_state(&session_id, SessionState::Idle) - .await?; - - self.emit_event(AgenticEvent::DialogTurnCompleted { - session_id, - turn_id, - total_rounds: 1, - total_tools: 1, - duration_ms: outcome.duration_ms, - partial_recovery_reason: None, - success: Some(true), - finish_reason: Some("complete".to_string()), - has_final_response: Some(true), - }) + let _ = session_manager + .fail_maintenance_turn(&session_id, &turn_id, error_text, vec![model_round]) + .await; + Self::persist_cancelled_dialog_turn( + event_queue.as_ref(), + session_manager.as_ref(), + None, + &session_id, + &turn_id, + true, + ) .await; - - Ok(()) + Err(err) } Err(err) => { let error_text = err.to_string(); - let compression_id = format!("compression_{}", uuid::Uuid::new_v4()); let model_round = Self::build_manual_compaction_round_failed( &turn_id, - compression_id, + compression_id.clone(), &error_text, context_window, ); - let _ = self - .session_manager + let _ = session_manager .fail_maintenance_turn( &session_id, &turn_id, @@ -3757,23 +3946,43 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet vec![model_round], ) .await; - let _ = self - .session_manager - .update_session_state(&session_id, SessionState::Idle) + let _ = session_manager + .update_session_state_for_turn_if_processing( + &session_id, + &turn_id, + SessionState::Idle, + ) + .await; + let _ = event_queue + .enqueue( + AgenticEvent::DialogTurnFailed { + session_id, + turn_id, + error: error_text.clone(), + error_category: Some(err.error_category()), + error_detail: Some(err.error_detail()), + }, + Some(EventPriority::Normal), + ) .await; - self.emit_event(AgenticEvent::DialogTurnFailed { - session_id, - turn_id, - error: error_text.clone(), - error_category: Some(err.error_category()), - error_detail: Some(err.error_detail()), - }) - .await; Err(err) } } } + /// Compact the active session context through the same owned maintenance + /// task used by Agent Runtime callers, then await its terminal result for + /// the existing Desktop compatibility API. + pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()> { + let task = self.start_manual_compaction_task(session_id, None).await?; + task.completion.await.map_err(|_| { + BitFunError::Service(format!( + "Manual compaction task ended without a terminal result: {}", + task.turn_id + )) + })? + } + #[allow(clippy::too_many_arguments)] async fn start_dialog_turn_internal( &self, @@ -4875,6 +5084,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id, session_id ); + if let Some(control) = self.manual_compaction_controls.get(dialog_turn_id) { + if !control.try_cancel() && control.commit_started() { + info!( + "Ignoring late manual compaction cancellation after commit began: session_id={}, dialog_turn_id={}", + session_id, dialog_turn_id + ); + return Ok(()); + } + } + abort_thread_goal_continuation_for_session(session_id); let old_state = self @@ -8891,6 +9110,178 @@ fn runtime_session_time_ms(time: std::time::SystemTime) -> u64 { .unwrap_or_default() } +fn runtime_transcript_message_from_message( + message: Message, +) -> bitfun_runtime_ports::TranscriptMessage { + let role = match message.role { + crate::agentic::core::MessageRole::User => "user", + crate::agentic::core::MessageRole::Assistant => "assistant", + crate::agentic::core::MessageRole::Tool => "tool", + crate::agentic::core::MessageRole::System => "system", + } + .to_string(); + + let content = match message.content { + MessageContent::Text(text) => bitfun_runtime_ports::TranscriptContent::Text(text), + MessageContent::Multimodal { text, images } => { + bitfun_runtime_ports::TranscriptContent::Multimodal { + text, + image_count: images.len(), + } + } + MessageContent::ToolResult { + tool_id, + tool_name, + effective_tool_name, + result, + is_error, + .. + } => bitfun_runtime_ports::TranscriptContent::ToolResult { + tool_id, + tool_name, + effective_tool_name, + result, + is_error, + }, + MessageContent::Mixed { + reasoning_content, + text, + tool_calls, + } => bitfun_runtime_ports::TranscriptContent::Mixed { + reasoning_content, + text, + tool_calls: tool_calls + .into_iter() + .map(|tool_call| bitfun_runtime_ports::TranscriptToolCall { + tool_id: tool_call.tool_id, + tool_name: tool_call.tool_name, + arguments: tool_call.arguments, + }) + .collect(), + }, + }; + + bitfun_runtime_ports::TranscriptMessage { + id: Some(message.id), + role, + turn_id: message.metadata.turn_id, + timestamp_ms: Some(runtime_session_time_ms(message.timestamp)), + content, + } +} + +fn runtime_transcript_messages_from_turns( + turns: &[DialogTurnData], + requested_turn_id: Option<&str>, +) -> Vec { + let mut messages = Vec::new(); + for turn in turns.iter().filter(|turn| { + turn.kind.is_transcript_visible() + && requested_turn_id.is_none_or(|turn_id| turn.turn_id == turn_id) + }) { + let image_count = turn + .user_message + .metadata + .as_ref() + .and_then(|metadata| metadata.get("images")) + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + messages.push(bitfun_runtime_ports::TranscriptMessage { + id: Some(turn.user_message.id.clone()), + role: "user".to_string(), + turn_id: Some(turn.turn_id.clone()), + timestamp_ms: Some(turn.user_message.timestamp), + content: if image_count == 0 { + bitfun_runtime_ports::TranscriptContent::Text(turn.user_message.content.clone()) + } else { + bitfun_runtime_ports::TranscriptContent::Multimodal { + text: turn.user_message.content.clone(), + image_count, + } + }, + }); + + for (round_index, round) in turn.model_rounds.iter().enumerate() { + let mut text = round + .text_items + .iter() + .map(|item| item.content.clone()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n"); + if turn.status == crate::service::session::TurnStatus::Error + && round_index + 1 == turn.model_rounds.len() + { + if let Some(error) = turn.error.as_deref() { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(&format!("[Error: {error}]")); + } + } + let reasoning_content = round + .thinking_items + .iter() + .map(|item| item.content.clone()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join("\n\n"); + let tool_calls = round + .tool_items + .iter() + .map(|item| bitfun_runtime_ports::TranscriptToolCall { + tool_id: item.tool_call.id.clone(), + tool_name: item.effective_name().to_string(), + arguments: item.effective_input().clone(), + }) + .collect::>(); + if !text.is_empty() || !reasoning_content.is_empty() || !tool_calls.is_empty() { + messages.push(bitfun_runtime_ports::TranscriptMessage { + id: Some(round.id.clone()), + role: "assistant".to_string(), + turn_id: Some(turn.turn_id.clone()), + timestamp_ms: Some(round.timestamp), + content: bitfun_runtime_ports::TranscriptContent::Mixed { + reasoning_content: (!reasoning_content.is_empty()) + .then_some(reasoning_content), + text, + tool_calls, + }, + }); + } + + for item in &round.tool_items { + let Some(tool_result) = item.tool_result.as_ref() else { + continue; + }; + let effective_name = item.effective_name(); + let result = if tool_result.success || !tool_result.result.is_null() { + tool_result.result.clone() + } else { + serde_json::json!({ + "error": tool_result.error.as_deref().unwrap_or("Tool execution failed") + }) + }; + messages.push(bitfun_runtime_ports::TranscriptMessage { + id: Some(format!("{}-result", item.id)), + role: "tool".to_string(), + turn_id: Some(turn.turn_id.clone()), + timestamp_ms: item.end_time.or(Some(item.start_time)), + content: bitfun_runtime_ports::TranscriptContent::ToolResult { + tool_id: item.tool_call.id.clone(), + tool_name: item.tool_name.clone(), + effective_tool_name: (effective_name != item.tool_name) + .then(|| effective_name.to_string()), + result, + is_error: !tool_result.success, + }, + }); + } + } + } + messages +} + fn runtime_session_summary(session: SessionSummary) -> bitfun_runtime_ports::AgentSessionSummary { bitfun_runtime_ports::AgentSessionSummary { session_id: session.session_id, @@ -9350,6 +9741,26 @@ impl bitfun_runtime_ports::AgentThreadGoalManagementPort for ConversationCoordin } } +#[async_trait::async_trait] +impl bitfun_runtime_ports::AgentSessionCompactionPort for ConversationCoordinator { + async fn start_session_compaction( + &self, + request: bitfun_runtime_ports::AgentSessionCompactionRequest, + ) -> bitfun_runtime_ports::PortResult { + let session_id = request.session_id; + let task = self + .start_manual_compaction_task(session_id.clone(), Some(request.turn_id)) + .await + .map_err(runtime_port_error_preserving_message)?; + let turn_id = task.turn_id.clone(); + drop(task.completion); + Ok(bitfun_runtime_ports::AgentSessionCompactionResult { + session_id, + turn_id, + }) + } +} + #[async_trait::async_trait] impl bitfun_runtime_ports::AgentTurnCancellationPort for ConversationCoordinator { async fn cancel_turn( @@ -9447,77 +9858,27 @@ impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator { &self, request: bitfun_runtime_ports::SessionTranscriptRequest, ) -> bitfun_runtime_ports::PortResult { - let messages = self - .get_messages(&request.session_id) + let messages = match self + .session_manager + .load_persisted_transcript_turns(&request.session_id) .await - .map_err(runtime_port_error_preserving_message)?; - - let messages = messages - .into_iter() - .filter(|message| match request.turn_id.as_ref() { - Some(turn_id) => message.metadata.turn_id.as_ref() == Some(turn_id), - None => true, - }) - .map(|message| { - let role = match message.role { - crate::agentic::core::MessageRole::User => "user", - crate::agentic::core::MessageRole::Assistant => "assistant", - crate::agentic::core::MessageRole::Tool => "tool", - crate::agentic::core::MessageRole::System => "system", - } - .to_string(); - - let content = match message.content { - MessageContent::Text(text) => { - bitfun_runtime_ports::TranscriptContent::Text(text) - } - MessageContent::Multimodal { text, images } => { - bitfun_runtime_ports::TranscriptContent::Multimodal { - text, - image_count: images.len(), - } - } - MessageContent::ToolResult { - tool_id, - tool_name, - effective_tool_name, - result, - is_error, - .. - } => bitfun_runtime_ports::TranscriptContent::ToolResult { - tool_id, - tool_name, - effective_tool_name, - result, - is_error, - }, - MessageContent::Mixed { - reasoning_content, - text, - tool_calls, - } => bitfun_runtime_ports::TranscriptContent::Mixed { - reasoning_content, - text, - tool_calls: tool_calls - .into_iter() - .map(|tool_call| bitfun_runtime_ports::TranscriptToolCall { - tool_id: tool_call.tool_id, - tool_name: tool_call.tool_name, - arguments: tool_call.arguments, - }) - .collect(), - }, - }; - - bitfun_runtime_ports::TranscriptMessage { - id: Some(message.id), - role, - turn_id: message.metadata.turn_id, - timestamp_ms: Some(runtime_session_time_ms(message.timestamp)), - content, - } - }) - .collect(); + .map_err(runtime_port_error_preserving_message)? + { + Some(turns) => { + runtime_transcript_messages_from_turns(&turns, request.turn_id.as_deref()) + } + None => self + .get_messages(&request.session_id) + .await + .map_err(runtime_port_error_preserving_message)? + .into_iter() + .filter(|message| match request.turn_id.as_ref() { + Some(turn_id) => message.metadata.turn_id.as_ref() == Some(turn_id), + None => true, + }) + .map(runtime_transcript_message_from_message) + .collect(), + }; Ok(bitfun_runtime_ports::SessionTranscript { session_id: request.session_id, @@ -9632,10 +9993,11 @@ mod tests { normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, resolve_subagent_model_selection, runtime_port_error_preserving_message, runtime_session_summary, - runtime_tool_restrictions_for_session_lifetime, session_storage_workspace_locator, - turn_review_manifest_for_agent, BackgroundSubagentWaitMode, ConversationCoordinator, - SessionMemoryMode, SessionReferenceLocator, SessionRelationshipKind, - SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, + runtime_tool_restrictions_for_session_lifetime, runtime_transcript_messages_from_turns, + session_storage_workspace_locator, turn_review_manifest_for_agent, + BackgroundSubagentWaitMode, ContextCompactionOutcome, ConversationCoordinator, + ManualCompactionCommitGate, SessionMemoryMode, SessionReferenceLocator, + SessionRelationshipKind, SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::coordination::coordination_store::{ BackgroundTaskRegistration, RegisteredBackgroundTask, @@ -9643,8 +10005,9 @@ mod tests { use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageRole, MessageSemanticKind, SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, + SessionState, }; - use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; + use crate::agentic::events::{AgenticEvent, EventQueue, EventQueueConfig, EventRouter}; use crate::agentic::execution::{ ExecutionEngine, ExecutionEngineConfig, RoundExecutor, StreamProcessor, }; @@ -9684,7 +10047,9 @@ mod tests { use crate::runtime_ownership::CoreRuntimeOwnership; use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; - use crate::service::session::{SessionMetadata, SessionStatus}; + use crate::service::session::{ + DialogTurnData, DialogTurnKind, SessionMetadata, SessionStatus, TurnStatus, UserMessageData, + }; use crate::service::workspace::WorkspaceKind; use bitfun_agent_runtime::permission::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_core_types::{ @@ -9703,6 +10068,232 @@ mod tests { use std::time::Duration; use tokio_util::sync::CancellationToken; + #[test] + fn manual_compaction_cancellation_wins_before_commit() { + let gate = ManualCompactionCommitGate::planning(); + + assert!(gate.try_cancel()); + assert!(!gate.try_begin_commit()); + } + + #[test] + fn manual_compaction_commit_rejects_late_cancellation() { + let gate = ManualCompactionCommitGate::planning(); + + assert!(gate.try_begin_commit()); + assert!(!gate.try_cancel()); + } + + #[test] + fn manual_compaction_transcript_restores_user_and_tool_payload() { + let outcome = ContextCompactionOutcome { + compression_id: "compression-1".to_string(), + compression_count: 2, + tokens_before: 80_000, + tokens_after: 20_000, + compression_ratio: 0.25, + duration_ms: 42, + has_summary: true, + summary_source: "model".to_string(), + applied: true, + }; + let mut turn = DialogTurnData::new_with_kind( + DialogTurnKind::ManualCompaction, + "compact-turn".to_string(), + 1, + "session".to_string(), + None, + UserMessageData { + id: "compact-user".to_string(), + content: "/compact".to_string(), + timestamp: 10, + metadata: Some(ConversationCoordinator::manual_compaction_metadata()), + }, + ); + turn.model_rounds = vec![ + ConversationCoordinator::build_manual_compaction_round_completed( + &turn.turn_id, + &outcome, + 128_000, + ), + ]; + turn.status = TurnStatus::Completed; + + let transcript = runtime_transcript_messages_from_turns(&[turn.clone()], None); + + assert_eq!(transcript.len(), 3); + assert_eq!(transcript[0].role, "user"); + assert_eq!(transcript[0].turn_id.as_deref(), Some("compact-turn")); + match &transcript[1].content { + bitfun_runtime_ports::TranscriptContent::Mixed { tool_calls, .. } => { + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].tool_id, "compression-1"); + assert_eq!(tool_calls[0].tool_name, "ContextCompression"); + } + other => panic!("expected restored tool call, got {other:?}"), + } + match &transcript[2].content { + bitfun_runtime_ports::TranscriptContent::ToolResult { + tool_id, + result, + is_error, + .. + } => { + assert_eq!(tool_id, "compression-1"); + assert_eq!(result["applied"], true); + assert!(!is_error); + } + other => panic!("expected restored tool result, got {other:?}"), + } + + turn.status = TurnStatus::Error; + turn.error = + Some("Manual compaction was applied, but terminal persistence failed".to_string()); + let failed_transcript = runtime_transcript_messages_from_turns(&[turn.clone()], None); + match &failed_transcript[1].content { + bitfun_runtime_ports::TranscriptContent::Mixed { text, .. } => { + assert_eq!( + text, + "[Error: Manual compaction was applied, but terminal persistence failed]" + ); + } + other => panic!("expected restored failure text, got {other:?}"), + } + + turn.status = TurnStatus::Cancelled; + let cancelled_transcript = runtime_transcript_messages_from_turns(&[turn], None); + match &cancelled_transcript[1].content { + bitfun_runtime_ports::TranscriptContent::Mixed { text, .. } => { + assert!( + text.is_empty(), + "cancelled turns must not restore their internal failure marker" + ); + } + other => panic!("expected restored cancelled tool call, got {other:?}"), + } + } + + #[test] + fn manual_compaction_failure_round_preserves_runtime_identity_and_error() { + let round = ConversationCoordinator::build_manual_compaction_round_failed( + "compact-turn", + "compression-runtime".to_string(), + "summary request failed", + 128_000, + ); + + assert_eq!(round.tool_items.len(), 1); + let tool = &round.tool_items[0]; + assert_eq!(tool.id, "compression-runtime"); + assert_eq!(tool.tool_call.id, "compression-runtime"); + let result = tool.tool_result.as_ref().expect("failure result"); + assert!(!result.success); + assert_eq!(result.result["error"], "summary request failed"); + assert_eq!(result.error.as_deref(), Some("summary request failed")); + } + + #[tokio::test] + async fn applied_manual_compaction_emits_failed_terminal_when_turn_persistence_fails() { + let root = tempfile::tempdir().expect("test root"); + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace should exist"); + let path_manager = Arc::new(PathManager::with_user_root_for_tests( + root.path().join("user-root"), + )); + let persistence = + Arc::new(PersistenceManager::new(path_manager.clone()).expect("persistence manager")); + let session_manager = SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence, + SessionManagerConfig { + max_active_sessions: 8, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: true, + prompt_cache_policy: PromptCachePolicy::default(), + }, + ); + let session = session_manager + .create_session( + "Persistence failure".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let turn_id = session_manager + .start_maintenance_turn( + &session.session_id, + "/compact".to_string(), + Some("compact-turn".to_string()), + Some(ConversationCoordinator::manual_compaction_metadata()), + ) + .await + .expect("maintenance turn should start"); + + let turns_dir = path_manager + .project_sessions_dir(&workspace) + .join(&session.session_id) + .join("turns"); + std::fs::remove_dir_all(&turns_dir).expect("turn directory should be removable"); + std::fs::write(&turns_dir, b"block turn persistence") + .expect("turn path should become a file"); + + let event_queue = EventQueue::new(EventQueueConfig::default()); + let result = ConversationCoordinator::finalize_manual_compaction_success( + &session_manager, + &event_queue, + &session.session_id, + &turn_id, + &ContextCompactionOutcome { + compression_id: "compression-1".to_string(), + compression_count: 1, + tokens_before: 80_000, + tokens_after: 20_000, + compression_ratio: 0.25, + duration_ms: 42, + has_summary: true, + summary_source: "model".to_string(), + applied: true, + }, + 128_000, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + session_manager + .get_session(&session.session_id) + .expect("session should remain available") + .state, + SessionState::Idle + )); + let events = event_queue.dequeue_batch(10).await; + let terminal_events = events + .iter() + .filter(|envelope| { + matches!( + envelope.event, + AgenticEvent::DialogTurnCompleted { .. } + | AgenticEvent::DialogTurnFailed { .. } + | AgenticEvent::DialogTurnCancelled { .. } + ) + }) + .collect::>(); + assert_eq!(terminal_events.len(), 1); + assert!(matches!( + terminal_events[0].event, + AgenticEvent::DialogTurnFailed { + ref turn_id, + ref error, + .. + } if turn_id == "compact-turn" && error.contains("was applied") + )); + } + #[test] fn worktree_execution_root_is_a_legacy_alias_for_project_storage() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 932b520f65..3fa620af91 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -58,6 +58,7 @@ use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; @@ -92,6 +93,59 @@ pub struct ContextCompactionOutcome { pub applied: bool, } +const MANUAL_COMPACTION_PLANNING: u8 = 0; +const MANUAL_COMPACTION_CANCELLED: u8 = 1; +const MANUAL_COMPACTION_COMMITTING: u8 = 2; + +/// Arbitrates the only race that matters for manual compaction: cancellation +/// may win while the model is planning, but context commit must be atomic once +/// it begins. +#[derive(Debug)] +pub(crate) struct ManualCompactionCommitGate { + state: AtomicU8, +} + +impl ManualCompactionCommitGate { + pub(crate) fn planning() -> Self { + Self { + state: AtomicU8::new(MANUAL_COMPACTION_PLANNING), + } + } + + pub(crate) fn try_cancel(&self) -> bool { + self.state + .compare_exchange( + MANUAL_COMPACTION_PLANNING, + MANUAL_COMPACTION_CANCELLED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub(crate) fn try_begin_commit(&self) -> bool { + self.state + .compare_exchange( + MANUAL_COMPACTION_PLANNING, + MANUAL_COMPACTION_COMMITTING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + pub(crate) fn commit_started(&self) -> bool { + self.state.load(Ordering::Acquire) == MANUAL_COMPACTION_COMMITTING + } +} + +fn manual_compaction_terminal_error(error: BitFunError) -> BitFunError { + match error { + error @ BitFunError::Cancelled(_) => error, + error => BitFunError::Session(error.to_string()), + } +} + struct CompressionRuntimeScaffold { ai_client: Arc, tool_definitions: Option>, @@ -2417,6 +2471,7 @@ impl ExecutionEngine { duration_ms, has_summary: compression_result.has_model_summary, summary_source: summary_source.to_string(), + applied: true, }, EventPriority::Normal, ) @@ -2457,20 +2512,22 @@ impl ExecutionEngine { /// Compact the current session context outside the normal dialog execution loop. /// Always emits compression started/completed/failed events for the provided turn. #[allow(clippy::too_many_arguments)] - pub async fn compact_session_context( + pub(crate) async fn compact_session_context( &self, session_id: String, dialog_turn_id: String, + compression_id: String, context: ExecutionContext, messages: Vec, trigger: &str, + cancellation_token: CancellationToken, + commit_gate: Arc, ) -> BitFunResult { let mut session = self .session_manager .get_session(&session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; let start_time = std::time::Instant::now(); - let compression_id = format!("compression_{}", uuid::Uuid::new_v4()); let scaffold = self .resolve_compression_runtime_scaffold(&session, &context) .await?; @@ -2534,8 +2591,12 @@ impl ExecutionEngine { scaffold.ai_client.as_ref(), ) .await; - let planned_result = self - .build_planned_compression_result( + let planned_result = tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + Err(BitFunError::Cancelled("Manual context compaction cancelled".to_string())) + } + result = self.build_planned_compression_result( &session_id, &dialog_turn_id, &runtime_messages, @@ -2547,8 +2608,15 @@ impl ExecutionEngine { scaffold.primary_supports_image_understanding, context.workspace.as_ref(), trace_config, - ) - .await; + ) => result, + }; + let planned_result = match planned_result { + Ok(result) if commit_gate.try_begin_commit() => Ok(result), + Ok(_) => Err(BitFunError::Cancelled( + "Manual context compaction cancelled".to_string(), + )), + Err(error) => Err(error), + }; match planned_result { Ok(Some(mut compression_result)) => { let boundary_turn_index = self @@ -2668,6 +2736,7 @@ impl ExecutionEngine { } else { "local_fallback".to_string() }, + applied: true, }, EventPriority::Normal, ) @@ -2724,6 +2793,7 @@ impl ExecutionEngine { duration_ms, has_summary: false, summary_source: "none".to_string(), + applied: false, }, EventPriority::Normal, ) @@ -2752,7 +2822,7 @@ impl ExecutionEngine { ) .await; - Err(BitFunError::Session(err.to_string())) + Err(manual_compaction_terminal_error(err)) } } } @@ -4416,7 +4486,10 @@ impl ExecutionEngine { #[cfg(test)] mod tests { - use super::{ContextHealthSnapshot, ExecutionEngine, TurnPromptScaffold}; + use super::{ + manual_compaction_terminal_error, ContextHealthSnapshot, ExecutionEngine, + TurnPromptScaffold, + }; use crate::agentic::agents::{ PrependedPromptReminders, PromptBuilderContext, UserContextPolicy, }; @@ -4436,6 +4509,15 @@ mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; + #[test] + fn manual_compaction_preserves_cancellation_as_a_terminal_cancellation() { + let error = manual_compaction_terminal_error(crate::BitFunError::Cancelled( + "cancelled by user".to_string(), + )); + + assert!(matches!(error, crate::BitFunError::Cancelled(_))); + } + #[derive(Clone)] struct InstructionWorkspaceFs { operation_count: Arc, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index b828673deb..87cd1505c2 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5577,6 +5577,25 @@ impl SessionManager { let session = self .get_session(session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + match &session.state { + SessionState::Idle => {} + SessionState::Processing { + current_turn_id, + phase, + } => { + return Err(BitFunError::Validation(format!( + "Session is still processing: current_turn_id={}, phase={:?}", + current_turn_id, phase + ))); + } + SessionState::Error { .. } if kind == DialogTurnKind::UserDialog => {} + SessionState::Error { error, .. } => { + return Err(BitFunError::Validation(format!( + "Session must be idle before starting a turn: {}", + error + ))); + } + } let workspace_path = self .effective_storage_path_for_config(&session.config) .await @@ -5589,6 +5608,15 @@ impl SessionManager { let turn_index = session.dialog_turn_ids.len(); let turn_id = new_turn_id(turn_id); + if session + .dialog_turn_ids + .iter() + .any(|existing| existing == &turn_id) + { + return Err(BitFunError::Validation(format!( + "Dialog turn already exists: {turn_id}" + ))); + } if let Some(mut session) = self.sessions.get_mut(session_id) { session.dialog_turn_ids.push(turn_id.clone()); @@ -6463,6 +6491,7 @@ impl SessionManager { .as_millis() as u64; turn.model_rounds = model_rounds; turn.status = TurnStatus::Error; + turn.error = Some(error.clone()); turn.duration_ms = Some(completion_timestamp.saturating_sub(turn.start_time)); turn.end_time = Some(completion_timestamp); @@ -6507,6 +6536,27 @@ impl SessionManager { Ok(self.context_store.get_context_messages(session_id)) } + /// Load canonical persisted turns for a user-facing transcript. + /// + /// This is intentionally separate from `get_messages`: runtime context + /// reconstruction excludes model-invisible maintenance turns, while a + /// transcript must be able to project those turns for the UI. + pub(crate) async fn load_persisted_transcript_turns( + &self, + session_id: &str, + ) -> BitFunResult>> { + if !self.config.enable_persistence { + return Ok(None); + } + let Some(workspace_path) = self.effective_session_storage_path(session_id).await else { + return Ok(None); + }; + self.persistence_manager + .load_session_turns(&workspace_path, session_id) + .await + .map(Some) + } + /// Get a paginated best-effort message view for the session. pub async fn get_messages_paginated( &self, @@ -9877,6 +9927,167 @@ mod tests { assert_eq!(runtime_context.len(), seeded_messages.len()); } + #[tokio::test] + async fn dialog_and_maintenance_turn_admission_is_atomic() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Atomic admission".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let dialog = manager.start_dialog_turn( + &session.session_id, + "agentic".to_string(), + "new user input".to_string(), + Some("dialog-turn".to_string()), + None, + None, + ); + let maintenance = manager.start_maintenance_turn( + &session.session_id, + "/compact".to_string(), + Some("compact-turn".to_string()), + None, + ); + let (dialog_result, maintenance_result) = tokio::join!(dialog, maintenance); + + assert_eq!( + usize::from(dialog_result.is_ok()) + usize::from(maintenance_result.is_ok()), + 1, + "only one competing turn may acquire an idle session" + ); + let active = manager + .get_session(&session.session_id) + .expect("session should remain available"); + assert_eq!(active.dialog_turn_ids.len(), 1); + let accepted_turn = dialog_result + .ok() + .or_else(|| maintenance_result.ok()) + .expect("one turn should be admitted"); + assert!(matches!( + active.state, + SessionState::Processing { + ref current_turn_id, + .. + } if current_turn_id == &accepted_turn + )); + } + + #[tokio::test] + async fn user_dialog_retry_is_allowed_from_error_but_maintenance_is_not() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Retry admission".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + { + let mut active = manager + .sessions + .get_mut(&session.session_id) + .expect("session should remain available"); + active.state = SessionState::Error { + error: "retryable failure".to_string(), + recoverable: true, + }; + } + + let maintenance = manager + .start_maintenance_turn( + &session.session_id, + "/compact".to_string(), + Some("compact-turn".to_string()), + None, + ) + .await; + assert!( + maintenance.is_err(), + "maintenance work must remain idle-only" + ); + + let retry_turn = manager + .start_dialog_turn( + &session.session_id, + "agentic".to_string(), + "retry after failure".to_string(), + Some("retry-turn".to_string()), + None, + None, + ) + .await + .expect("ordinary dialog should preserve error-state retry semantics"); + assert_eq!(retry_turn, "retry-turn"); + } + + #[tokio::test] + async fn maintenance_failure_persists_its_terminal_error() { + let workspace = TestWorkspace::new(); + let persistence_manager = + Arc::new(PersistenceManager::new(workspace.path_manager()).expect("persistence")); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Failed maintenance".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + let turn_id = manager + .start_maintenance_turn( + &session.session_id, + "/compact".to_string(), + Some("compact-turn".to_string()), + None, + ) + .await + .expect("maintenance turn should start"); + + manager + .fail_maintenance_turn( + &session.session_id, + &turn_id, + "terminal persistence failed".to_string(), + Vec::new(), + ) + .await + .expect("maintenance failure should persist"); + + let turns = manager + .load_persisted_transcript_turns(&session.session_id) + .await + .expect("turns should load") + .expect("persistence should be enabled"); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].status, TurnStatus::Error); + assert_eq!( + turns[0].error.as_deref(), + Some("terminal persistence failed") + ); + } + #[tokio::test] async fn restore_session_view_preserves_full_visible_tool_result_payload() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 2f14a42916..d824c272a4 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -7,7 +7,7 @@ use bitfun_agent_runtime::sdk::{ AgentEventSource, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, - AgentSessionForkPort, AgentSessionModePort, AgentSessionModelPort, + AgentSessionCompactionPort, AgentSessionForkPort, AgentSessionModePort, AgentSessionModelPort, AgentSessionModelUpdateRequest, AgentSessionRestorePort, AgentSessionUsagePort, AgentTurnSettlementPort, RuntimeError, }; @@ -411,6 +411,7 @@ fn core_agent_runtime_builder( session_management: Arc, session_mode: Arc, session_model: Arc, + session_compaction: Arc, session_restore: Arc, local_command_turn: Arc, transcript_reader: Arc, @@ -425,6 +426,7 @@ fn core_agent_runtime_builder( .with_session_management_port(session_management) .with_session_mode_port(session_mode) .with_session_model_port(session_model) + .with_session_compaction_port(session_compaction) .with_session_restore_port(session_restore) .with_local_command_turn_port(local_command_turn) .with_session_transcript_reader(transcript_reader) @@ -944,12 +946,14 @@ impl CoreServiceAgentRuntime { coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); + let session_compaction: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; core_agent_runtime_builder( submission, session_management, session_mode, session_model, + session_compaction, session_restore, local_command_turn, transcript_reader, @@ -977,6 +981,7 @@ impl CoreServiceAgentRuntime { coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); + let session_compaction: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; let dialog_turn: Arc = scheduler.clone(); let lifecycle_delivery: Arc = scheduler; @@ -985,6 +990,7 @@ impl CoreServiceAgentRuntime { session_management, session_mode, session_model, + session_compaction, session_restore, local_command_turn, transcript_reader, @@ -1014,6 +1020,7 @@ impl CoreServiceAgentRuntime { coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); + let session_compaction: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; let lifecycle_delivery: Arc = scheduler; core_agent_runtime_builder( @@ -1021,6 +1028,7 @@ impl CoreServiceAgentRuntime { session_management, session_mode, session_model, + session_compaction, session_restore, local_command_turn, transcript_reader, @@ -1045,6 +1053,7 @@ impl CoreServiceAgentRuntime { let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); let session_model: Arc = coordinator.clone(); + let session_compaction: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; let dialog_turn: Arc = scheduler.clone(); let cancellation: Arc = scheduler; @@ -1053,6 +1062,7 @@ impl CoreServiceAgentRuntime { .with_submission_port(submission) .with_session_management_port(session_management) .with_session_model_port(session_model) + .with_session_compaction_port(session_compaction) .with_dialog_turn_port(dialog_turn) .with_cancellation_port(cancellation) .with_interaction_response_port(interaction_response) @@ -1079,6 +1089,7 @@ impl CoreServiceAgentRuntime { let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); + let session_compaction: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let dialog_turn: Arc = scheduler.clone(); @@ -1088,6 +1099,7 @@ impl CoreServiceAgentRuntime { session_management, session_mode, session_model, + session_compaction, session_restore, local_command_turn, transcript_reader, @@ -1193,6 +1205,7 @@ impl CoreServiceAgentRuntime { let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); + let session_compaction: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let lifecycle_delivery: Arc = scheduler; @@ -1202,6 +1215,7 @@ impl CoreServiceAgentRuntime { session_management, session_mode, session_model, + session_compaction, session_restore, local_command_turn, transcript_reader, @@ -1920,6 +1934,7 @@ mod tests { where T: AgentSubmissionPort + AgentInteractionResponsePort + + AgentSessionCompactionPort + AgentSessionManagementPort + AgentThreadGoalManagementPort + AgentTurnCancellationPort diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 3a50b580ee..3d6ba336eb 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -4,6 +4,10 @@ use bitfun_core_types::{SessionExecutionTarget, ToolImageAttachment}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; +fn context_compression_applied_by_default() -> bool { + true +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum AgenticEventPriority { Critical = 0, // Immediately send (error, cancellation) @@ -224,6 +228,8 @@ pub enum AgenticEvent { duration_ms: u64, has_summary: bool, summary_source: String, + #[serde(default = "context_compression_applied_by_default")] + applied: bool, }, ContextCompressionFailed { @@ -765,6 +771,29 @@ mod tests { } } + #[test] + fn legacy_context_compression_completion_defaults_to_applied() { + let event: AgenticEvent = serde_json::from_value(json!({ + "type": "ContextCompressionCompleted", + "session_id": "session-1", + "turn_id": "turn-1", + "compression_id": "compression-1", + "compression_count": 1, + "tokens_before": 100, + "tokens_after": 20, + "compression_ratio": 0.2, + "duration_ms": 5, + "has_summary": true, + "summary_source": "model" + })) + .expect("legacy completion event"); + + assert!(matches!( + event, + AgenticEvent::ContextCompressionCompleted { applied: true, .. } + )); + } + #[test] fn token_usage_updated_serializes_optional_cache_and_detail_fields() { let event = AgenticEvent::TokenUsageUpdated { diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index e0a9df005e..a9fb3ca0cd 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -310,6 +310,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( "agentic://context-compression-completed", json!({ @@ -323,6 +324,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option PortResult<()>; } +#[async_trait::async_trait] +pub trait AgentSessionCompactionPort: Send + Sync { + async fn start_session_compaction( + &self, + request: AgentSessionCompactionRequest, + ) -> PortResult; +} + #[async_trait::async_trait] pub trait AgentSessionForkPort: Send + Sync { async fn fork_session( diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 7443e98daf..90a80dcd7c 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -13,7 +13,8 @@ use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, AgentSessionArchiveRequest, - AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCreateRequest, + AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCompactionPort, + AgentSessionCompactionRequest, AgentSessionCompactionResult, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, @@ -187,6 +188,7 @@ pub struct AgentRuntime { session_close: Option>, session_mode: Option>, session_model: Option>, + session_compaction: Option>, session_fork: Option>, session_usage: Option>, turn_settlement: Option>, @@ -241,6 +243,13 @@ impl std::fmt::Debug for AgentRuntime { .as_ref() .map(|_| ""), ) + .field( + "session_compaction", + &self + .session_compaction + .as_ref() + .map(|_| ""), + ) .field( "session_fork", &self @@ -371,6 +380,7 @@ pub struct AgentRuntimeBuilder { session_close: Option>, session_mode: Option>, session_model: Option>, + session_compaction: Option>, session_fork: Option>, session_usage: Option>, turn_settlement: Option>, @@ -426,6 +436,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_compaction_port( + mut self, + port: Arc, + ) -> Self { + self.session_compaction = Some(port); + self + } + pub fn with_session_fork_port(mut self, port: Arc) -> Self { self.session_fork = Some(port); self @@ -551,6 +569,7 @@ impl AgentRuntimeBuilder { session_close, session_mode, session_model, + session_compaction, session_fork, session_usage, turn_settlement, @@ -583,6 +602,7 @@ impl AgentRuntimeBuilder { session_close, session_mode, session_model, + session_compaction, session_fork, session_usage, turn_settlement, @@ -1058,6 +1078,21 @@ impl AgentRuntime { .map_err(RuntimeError::from) } + pub async fn start_session_compaction( + &self, + request: AgentSessionCompactionRequest, + ) -> Result { + let port = self.session_compaction.as_ref().ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent session compaction port is not registered", + )) + })?; + port.start_session_compaction(request) + .await + .map_err(RuntimeError::from) + } + pub async fn fork_session( &self, request: AgentSessionForkRequest, @@ -1368,6 +1403,7 @@ mod tests { use crate::session_state::SessionState; use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnRequest, AgentLifecycleDeliveryPort, + AgentSessionCompactionPort, AgentSessionCompactionRequest, AgentSessionCompactionResult, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, AgentSessionSummary, AgentSessionWorkspaceRequest, AgentSubmissionResult, @@ -1387,6 +1423,7 @@ mod tests { exact_session_result_id: Mutex>, submitted_messages: Mutex>, cancelled_turns: Mutex>, + compaction_requests: Mutex>, listed_sessions: Mutex>, deleted_sessions: Mutex>, renamed_sessions: Mutex>, @@ -1540,6 +1577,23 @@ mod tests { } } + #[async_trait::async_trait] + impl AgentSessionCompactionPort for FakeAgentRuntimePorts { + async fn start_session_compaction( + &self, + request: AgentSessionCompactionRequest, + ) -> PortResult { + self.compaction_requests + .lock() + .unwrap() + .push(request.clone()); + Ok(AgentSessionCompactionResult { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } + } + #[async_trait::async_trait] impl AgentLocalCommandTurnPort for FakeAgentRuntimePorts { async fn record_completed_local_command_turn( @@ -1752,6 +1806,62 @@ mod tests { assert_eq!(err, RuntimeBuildError::MissingSubmissionPort); } + #[tokio::test] + async fn session_compaction_requires_registered_port() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .start_session_compaction(AgentSessionCompactionRequest { + session_id: "session-1".to_string(), + turn_id: "turn-compact-1".to_string(), + }) + .await + .expect_err("missing compaction port must fail closed"); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::NotAvailable, + .. + }) + )); + } + + #[tokio::test] + async fn session_compaction_forwards_exact_session_and_turn_identity() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_session_compaction_port(ports.clone()) + .build() + .expect("runtime"); + let request = AgentSessionCompactionRequest { + session_id: "session-1".to_string(), + turn_id: "turn-compact-1".to_string(), + }; + + let result = runtime + .start_session_compaction(request.clone()) + .await + .expect("start compaction"); + + assert_eq!( + result, + AgentSessionCompactionResult { + session_id: "session-1".to_string(), + turn_id: "turn-compact-1".to_string(), + } + ); + assert_eq!( + ports.compaction_requests.lock().unwrap().as_slice(), + &[request] + ); + } + #[tokio::test] async fn builder_keeps_plugin_runtime_disabled_by_default() { let ports = Arc::new(FakeAgentRuntimePorts::default()); diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 86f7d4825a..7c06aee5fd 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -61,7 +61,8 @@ pub use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, AgentSessionArchiveRequest, - AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCreateRequest, + AgentSessionArchiveStateRequest, AgentSessionClosePort, AgentSessionCompactionPort, + AgentSessionCompactionRequest, AgentSessionCompactionResult, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModePort, AgentSessionModeUpdateRequest, @@ -142,6 +143,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_compaction_port( + mut self, + port: Arc, + ) -> Self { + self.inner = self.inner.with_session_compaction_port(port); + self + } + pub fn with_session_fork_port(mut self, port: Arc) -> Self { self.inner = self.inner.with_session_fork_port(port); self @@ -461,6 +470,13 @@ impl AgentRuntime { self.inner.update_session_mode(request).await } + pub async fn start_session_compaction( + &self, + request: AgentSessionCompactionRequest, + ) -> Result { + self.inner.start_session_compaction(request).await + } + pub async fn fork_session( &self, request: AgentSessionForkRequest, diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 9e58454a1f..e00281685a 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -494,6 +494,10 @@ impl DialogTurnKind { pub fn is_model_visible(self) -> bool { matches!(self, Self::UserDialog) } + + pub fn is_transcript_visible(self) -> bool { + matches!(self, Self::UserDialog | Self::ManualCompaction) + } } /// User message data @@ -1159,6 +1163,9 @@ mod tests { #[test] fn manual_compaction_turn_is_model_invisible() { assert!(!DialogTurnKind::ManualCompaction.is_model_visible()); + assert!(DialogTurnKind::ManualCompaction.is_transcript_visible()); + assert!(DialogTurnKind::UserDialog.is_transcript_visible()); + assert!(!DialogTurnKind::LocalCommand.is_transcript_visible()); } #[test]